├── .editorconfig ├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ ├── ci.yaml │ └── release.yaml ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Cargo.lock ├── Cargo.toml ├── GRAMMAR.md ├── LICENSE ├── README.md ├── README.中文.md ├── Vagrantfile ├── bin ├── forbid └── package ├── book ├── en │ └── book.toml └── zh │ └── book.toml ├── clippy.toml ├── contrib └── just.sh ├── crates-io-readme.md ├── crates ├── generate-book │ ├── Cargo.toml │ └── src │ │ └── main.rs └── update-contributors │ ├── Cargo.toml │ └── src │ └── main.rs ├── examples ├── cross-platform.just ├── keybase.just ├── kitchen-sink.just ├── powershell.just ├── pre-commit.just └── screenshot.just ├── fuzz ├── Cargo.lock ├── Cargo.toml └── fuzz_targets │ └── compile.rs ├── icon.png ├── justfile ├── rustfmt.toml ├── screenshot.png ├── src ├── alias.rs ├── alias_style.rs ├── analyzer.rs ├── argument_parser.rs ├── assignment.rs ├── assignment_resolver.rs ├── ast.rs ├── attribute.rs ├── attribute_set.rs ├── binding.rs ├── color.rs ├── color_display.rs ├── command_color.rs ├── command_ext.rs ├── compilation.rs ├── compile_error.rs ├── compile_error_kind.rs ├── compiler.rs ├── completions.rs ├── condition.rs ├── conditional_operator.rs ├── config.rs ├── config_error.rs ├── constants.rs ├── count.rs ├── delimiter.rs ├── dependency.rs ├── dump_format.rs ├── enclosure.rs ├── error.rs ├── evaluator.rs ├── execution_context.rs ├── executor.rs ├── expression.rs ├── fragment.rs ├── function.rs ├── fuzzing.rs ├── interpreter.rs ├── item.rs ├── justfile.rs ├── keyed.rs ├── keyword.rs ├── lexer.rs ├── lib.rs ├── line.rs ├── list.rs ├── load_dotenv.rs ├── loader.rs ├── main.rs ├── module_path.rs ├── name.rs ├── namepath.rs ├── node.rs ├── ordinal.rs ├── output_error.rs ├── parameter.rs ├── parameter_kind.rs ├── parser.rs ├── platform.rs ├── platform │ ├── unix.rs │ └── windows.rs ├── platform_interface.rs ├── position.rs ├── positional.rs ├── ran.rs ├── range_ext.rs ├── recipe.rs ├── recipe_resolver.rs ├── recipe_signature.rs ├── request.rs ├── run.rs ├── scope.rs ├── search.rs ├── search_config.rs ├── search_error.rs ├── set.rs ├── setting.rs ├── settings.rs ├── shebang.rs ├── show_whitespace.rs ├── signal.rs ├── signal_handler.rs ├── signals.rs ├── source.rs ├── string_delimiter.rs ├── string_kind.rs ├── string_literal.rs ├── subcommand.rs ├── suggestion.rs ├── summary.rs ├── table.rs ├── testing.rs ├── thunk.rs ├── token.rs ├── token_kind.rs ├── tree.rs ├── unindent.rs ├── unresolved_dependency.rs ├── unresolved_recipe.rs ├── unstable_feature.rs ├── use_color.rs ├── variables.rs ├── verbosity.rs ├── warning.rs └── which.rs ├── tests ├── alias.rs ├── alias_style.rs ├── allow_duplicate_recipes.rs ├── allow_duplicate_variables.rs ├── allow_missing.rs ├── assert_stdout.rs ├── assert_success.rs ├── assertions.rs ├── assignment.rs ├── attributes.rs ├── backticks.rs ├── byte_order_mark.rs ├── changelog.rs ├── choose.rs ├── command.rs ├── completions.rs ├── completions │ ├── just.bash │ ├── justfile │ └── subdir │ │ └── justfile ├── conditional.rs ├── confirm.rs ├── constants.rs ├── datetime.rs ├── delimiters.rs ├── directories.rs ├── dotenv.rs ├── edit.rs ├── equals.rs ├── error_messages.rs ├── evaluate.rs ├── examples.rs ├── explain.rs ├── export.rs ├── fallback.rs ├── format.rs ├── functions.rs ├── global.rs ├── groups.rs ├── ignore_comments.rs ├── imports.rs ├── init.rs ├── invocation_directory.rs ├── json.rs ├── lib.rs ├── line_prefixes.rs ├── list.rs ├── logical_operators.rs ├── man.rs ├── misc.rs ├── modules.rs ├── multibyte_char.rs ├── newline_escape.rs ├── no_aliases.rs ├── no_cd.rs ├── no_dependencies.rs ├── no_exit_message.rs ├── os_attributes.rs ├── parameters.rs ├── parser.rs ├── positional_arguments.rs ├── private.rs ├── quiet.rs ├── quote.rs ├── readme.rs ├── recursion_limit.rs ├── regexes.rs ├── request.rs ├── run.rs ├── script.rs ├── search.rs ├── search_arguments.rs ├── shadowing_parameters.rs ├── shebang.rs ├── shell.rs ├── shell_expansion.rs ├── show.rs ├── signals.rs ├── slash_operator.rs ├── string.rs ├── subsequents.rs ├── summary.rs ├── tempdir.rs ├── test.rs ├── timestamps.rs ├── undefined_variables.rs ├── unexport.rs ├── unstable.rs ├── which_function.rs ├── windows.rs ├── windows_shell.rs └── working_directory.rs └── www ├── .nojekyll ├── CNAME ├── favicon.ico ├── index.css ├── index.html ├── install.sh └── man ├── en └── zh /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | # Text is UTF-8 8 | charset = utf-8 9 | # Unix-style newlines 10 | end_of_line = lf 11 | # Newline ending every file 12 | insert_final_newline = true 13 | # Soft tabs 14 | indent_style = space 15 | # Two-space indentation 16 | indent_size = 2 17 | # Trim trailing whitespace 18 | trim_trailing_whitespace = true 19 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * -text 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | groups: 6 | GitHub_Actions: 7 | patterns: 8 | - "*" # open a single pull request to update all actions 9 | schedule: 10 | interval: weekly 11 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - '*' 7 | push: 8 | branches: 9 | - master 10 | 11 | defaults: 12 | run: 13 | shell: bash 14 | 15 | env: 16 | RUSTFLAGS: --deny warnings 17 | 18 | jobs: 19 | lint: 20 | runs-on: ubuntu-latest 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | 25 | - uses: Swatinem/rust-cache@v2 26 | 27 | - name: Clippy 28 | run: cargo clippy --all --all-targets 29 | 30 | - name: Format 31 | run: cargo fmt --all -- --check 32 | 33 | - name: Install Dependencies 34 | run: | 35 | sudo apt-get update 36 | sudo apt-get install ripgrep shellcheck 37 | 38 | - name: Check for Forbidden Words 39 | run: ./bin/forbid 40 | 41 | - name: Check Install Script 42 | run: shellcheck www/install.sh 43 | 44 | msrv: 45 | runs-on: ubuntu-latest 46 | 47 | steps: 48 | - uses: actions/checkout@v4 49 | 50 | - uses: actions-rust-lang/setup-rust-toolchain@v1 51 | with: 52 | toolchain: 1.77 53 | 54 | - uses: Swatinem/rust-cache@v2 55 | 56 | - name: Check 57 | run: cargo check 58 | 59 | pages: 60 | runs-on: ubuntu-latest 61 | 62 | steps: 63 | - uses: actions/checkout@v4 64 | 65 | - uses: Swatinem/rust-cache@v2 66 | 67 | - name: Install `mdbook` 68 | run: cargo install mdbook 69 | 70 | - name: Install `mdbook-linkcheck` 71 | run: | 72 | mkdir -p mdbook-linkcheck 73 | cd mdbook-linkcheck 74 | wget https://github.com/Michael-F-Bryan/mdbook-linkcheck/releases/latest/download/mdbook-linkcheck.x86_64-unknown-linux-gnu.zip 75 | unzip mdbook-linkcheck.x86_64-unknown-linux-gnu.zip 76 | chmod +x mdbook-linkcheck 77 | pwd >> $GITHUB_PATH 78 | 79 | - name: Build book 80 | run: | 81 | cargo run --package generate-book 82 | mdbook build book/en 83 | mdbook build book/zh 84 | 85 | test: 86 | strategy: 87 | matrix: 88 | os: 89 | - ubuntu-latest 90 | - macos-latest 91 | - windows-latest 92 | 93 | runs-on: ${{matrix.os}} 94 | 95 | steps: 96 | - uses: actions/checkout@v4 97 | 98 | - name: Remove Broken WSL bash executable 99 | if: ${{ matrix.os == 'windows-latest' }} 100 | shell: cmd 101 | run: | 102 | takeown /F C:\Windows\System32\bash.exe 103 | icacls C:\Windows\System32\bash.exe /grant administrators:F 104 | del C:\Windows\System32\bash.exe 105 | 106 | - uses: Swatinem/rust-cache@v2 107 | 108 | - name: Test 109 | run: cargo test --all 110 | 111 | - name: Test install.sh 112 | run: | 113 | bash www/install.sh --to /tmp --tag 1.25.0 114 | /tmp/just --version 115 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | /.vagrant 4 | /.vscode 5 | /README.html 6 | /book/en/build 7 | /book/en/src 8 | /book/zh/build 9 | /book/zh/src 10 | /fuzz/artifacts 11 | /fuzz/corpus 12 | /fuzz/target 13 | /man 14 | /target 15 | /test-utilities/Cargo.lock 16 | /test-utilities/target 17 | /tmp 18 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | Unless you explicitly state otherwise, any contribution intentionally submitted 5 | for inclusion in the work by you shall be licensed as in [LICENSE](LICENSE), 6 | without any additional terms or conditions. 7 | 8 | See [the readme](README.md#contributing) for contribution workflow suggestions. 9 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "just" 3 | version = "1.40.0" 4 | authors = ["Casey Rodarmor "] 5 | autotests = false 6 | categories = ["command-line-utilities", "development-tools"] 7 | description = "🤖 Just a command runner" 8 | edition = "2021" 9 | exclude = ["/book", "/icon.png", "/screenshot.png", "/www"] 10 | homepage = "https://github.com/casey/just" 11 | keywords = ["command-line", "task", "runner", "development", "utility"] 12 | license = "CC0-1.0" 13 | readme = "crates-io-readme.md" 14 | repository = "https://github.com/casey/just" 15 | rust-version = "1.77" 16 | 17 | [workspace] 18 | members = [".", "crates/*"] 19 | 20 | [dependencies] 21 | ansi_term = "0.12.0" 22 | blake3 = { version = "1.5.0", features = ["rayon", "mmap"] } 23 | camino = "1.0.4" 24 | chrono = "0.4.38" 25 | clap = { version = "4.0.0", features = ["derive", "env", "wrap_help"] } 26 | clap_complete = "4.0.0" 27 | clap_mangen = "0.2.20" 28 | ctrlc = { version = "3.1.1", features = ["termination"] } 29 | derive-where = "1.2.7" 30 | dirs = "6.0.0" 31 | dotenvy = "0.15" 32 | edit-distance = "2.0.0" 33 | heck = "0.5.0" 34 | is_executable = "1.0.4" 35 | lexiclean = "0.0.1" 36 | libc = "0.2.0" 37 | num_cpus = "1.15.0" 38 | once_cell = "1.19.0" 39 | percent-encoding = "2.3.1" 40 | rand = "0.9.0" 41 | regex = "1.10.4" 42 | rustversion = "1.0.18" 43 | semver = "1.0.20" 44 | serde = { version = "1.0.130", features = ["derive", "rc"] } 45 | serde_json = "1.0.68" 46 | sha2 = "0.10" 47 | shellexpand = "3.1.0" 48 | similar = { version = "2.1.0", features = ["unicode"] } 49 | snafu = "0.8.0" 50 | strum = { version = "0.27.1", features = ["derive"] } 51 | target = "2.0.0" 52 | tempfile = "3.0.0" 53 | typed-arena = "2.0.1" 54 | unicode-width = "0.2.0" 55 | uuid = { version = "1.0.0", features = ["v4"] } 56 | 57 | [target.'cfg(unix)'.dependencies] 58 | nix = { version = "0.29.0", features = ["user"] } 59 | 60 | [target.'cfg(windows)'.dependencies] 61 | ctrlc = { version = "3.1.1", features = ["termination"] } 62 | 63 | [dev-dependencies] 64 | executable-path = "1.0.0" 65 | pretty_assertions = "1.0.0" 66 | temptree = "0.2.0" 67 | which = "7.0.0" 68 | 69 | [lints.rust] 70 | unexpected_cfgs = { level = "warn", check-cfg = ['cfg(fuzzing)'] } 71 | 72 | [lints.clippy] 73 | all = { level = "deny", priority = -1 } 74 | arbitrary-source-item-ordering = "deny" 75 | enum_glob_use = "allow" 76 | needless_pass_by_value = "allow" 77 | pedantic = { level = "deny", priority = -1 } 78 | similar_names = "allow" 79 | struct_excessive_bools = "allow" 80 | struct_field_names = "allow" 81 | too_many_arguments = "allow" 82 | too_many_lines = "allow" 83 | undocumented_unsafe_blocks = "deny" 84 | unnecessary_wraps = "allow" 85 | wildcard_imports = "allow" 86 | 87 | [lib] 88 | doctest = false 89 | 90 | [[bin]] 91 | path = "src/main.rs" 92 | name = "just" 93 | test = false 94 | 95 | # The public documentation is minimal and doesn't change between 96 | # platforms, so we only build them for linux on docs.rs to save 97 | # their build machines some cycles. 98 | [package.metadata.docs.rs] 99 | targets = ["x86_64-unknown-linux-gnu"] 100 | 101 | [profile.release] 102 | lto = true 103 | codegen-units = 1 104 | 105 | [[test]] 106 | name = "integration" 107 | path = "tests/lib.rs" 108 | -------------------------------------------------------------------------------- /Vagrantfile: -------------------------------------------------------------------------------- 1 | Vagrant.configure(2) do |config| 2 | config.vm.box = 'debian/jessie64' 3 | 4 | config.vm.provision "shell", inline: <<-EOS 5 | apt-get -y update 6 | apt-get install -y clang git vim curl 7 | EOS 8 | 9 | config.vm.provision "shell", privileged: false, inline: <<-EOS 10 | curl https://sh.rustup.rs -sSf > install-rustup 11 | chmod +x install-rustup 12 | ./install-rustup -y 13 | source ~/.cargo/env 14 | rustup target add x86_64-unknown-linux-musl 15 | cargo install -f just 16 | git clone https://github.com/casey/just.git 17 | EOS 18 | end 19 | -------------------------------------------------------------------------------- /bin/forbid: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | if ! which rg > /dev/null; then 6 | echo 'error: `rg` not found, please install ripgrep: https://github.com/BurntSushi/ripgrep/' 7 | exit 1 8 | fi 9 | 10 | ! rg \ 11 | --glob !CHANGELOG.md \ 12 | --glob !bin/forbid \ 13 | --ignore-case \ 14 | 'dbg!|fixme|todo|xxx' \ 15 | . 16 | -------------------------------------------------------------------------------- /bin/package: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euxo pipefail 4 | 5 | VERSION=${REF#"refs/tags/"} 6 | DIST=`pwd`/dist 7 | 8 | echo "Packaging just $VERSION for $TARGET..." 9 | 10 | test -f Cargo.lock || cargo generate-lockfile 11 | 12 | echo "Installing rust toolchain for $TARGET..." 13 | rustup target add $TARGET 14 | 15 | if [[ $TARGET == aarch64-unknown-linux-musl ]]; then 16 | export CC=aarch64-linux-gnu-gcc 17 | fi 18 | 19 | echo "Building just..." 20 | RUSTFLAGS="--deny warnings --codegen target-feature=+crt-static $TARGET_RUSTFLAGS" \ 21 | cargo build --bin just --target $TARGET --release 22 | EXECUTABLE=target/$TARGET/release/just 23 | 24 | if [[ $OS == windows-latest ]]; then 25 | EXECUTABLE=$EXECUTABLE.exe 26 | fi 27 | 28 | echo "Copying release files..." 29 | mkdir dist 30 | cp -r \ 31 | $EXECUTABLE \ 32 | Cargo.lock \ 33 | Cargo.toml \ 34 | GRAMMAR.md \ 35 | LICENSE \ 36 | README.md \ 37 | completions \ 38 | man/just.1 \ 39 | $DIST 40 | 41 | cd $DIST 42 | echo "Creating release archive..." 43 | case $OS in 44 | ubuntu-latest | macos-latest) 45 | ARCHIVE=just-$VERSION-$TARGET.tar.gz 46 | tar czf $ARCHIVE * 47 | echo "archive=$DIST/$ARCHIVE" >> $GITHUB_OUTPUT 48 | ;; 49 | windows-latest) 50 | ARCHIVE=just-$VERSION-$TARGET.zip 51 | 7z a $ARCHIVE * 52 | echo "archive=`pwd -W`/$ARCHIVE" >> $GITHUB_OUTPUT 53 | ;; 54 | esac 55 | -------------------------------------------------------------------------------- /book/en/book.toml: -------------------------------------------------------------------------------- 1 | [book] 2 | language = "en" 3 | src = "src" 4 | title = "Just Programmer's Manual" 5 | 6 | [build] 7 | build-dir = "build" 8 | 9 | [output.html] 10 | git-repository-url = "https://github.com/casey/just" 11 | site-url = "/man/en/" 12 | 13 | [output.linkcheck] 14 | -------------------------------------------------------------------------------- /book/zh/book.toml: -------------------------------------------------------------------------------- 1 | [book] 2 | language = "zh" 3 | src = "src" 4 | title = "Just 用户指南" 5 | 6 | [build] 7 | build-dir = "build" 8 | 9 | [output.html] 10 | git-repository-url = "https://github.com/casey/just" 11 | site-url = "/man/zh/" 12 | 13 | [output.linkcheck] 14 | -------------------------------------------------------------------------------- /clippy.toml: -------------------------------------------------------------------------------- 1 | cognitive-complexity-threshold = 1337 2 | source-item-ordering = ['enum', 'struct', 'trait'] 3 | -------------------------------------------------------------------------------- /contrib/just.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # cd upwards to the justfile 4 | while [[ ! -e justfile ]]; do 5 | if [[ $PWD = / ]] || [[ $PWD = $JUSTSTOP ]] || [[ -e juststop ]]; then 6 | echo 'No justfile found.' 7 | exit 1 8 | fi 9 | cd .. 10 | done 11 | 12 | # prefer gmake if it exists 13 | if command -v gmake > /dev/null; then 14 | MAKE=gmake 15 | else 16 | MAKE=make 17 | fi 18 | 19 | declare -a RECIPES 20 | for ARG in "$@"; do 21 | test $ARG = '--' && shift && break 22 | RECIPES+=($ARG) && shift 23 | done 24 | 25 | # export arguments after '--' so they can be used in recipes 26 | I=0 27 | for ARG in "$@"; do 28 | export ARG$I=$ARG 29 | I=$((I + 1)) 30 | done 31 | 32 | # go! 33 | exec $MAKE MAKEFLAGS='' --always-make --no-print-directory -f justfile ${RECIPES[*]} 34 | -------------------------------------------------------------------------------- /crates-io-readme.md: -------------------------------------------------------------------------------- 1 | `just` is a handy way to save and run project-specific commands. 2 | 3 | Commands are stored in a file called `justfile` or `Justfile` with syntax 4 | inspired by `make`: 5 | 6 | ```make 7 | build: 8 | cc *.c -o main 9 | 10 | # test everything 11 | test-all: build 12 | ./test --all 13 | 14 | # run a specific test 15 | test TEST: build 16 | ./test --test {{TEST}} 17 | ``` 18 | 19 | `just` produces detailed error messages and avoids `make`'s idiosyncrasies, so 20 | debugging a justfile is easier and less surprising than debugging a makefile. 21 | 22 | It works on all operating systems supported by Rust. 23 | 24 | Read more on [GitHub](https://github.com/casey/just). 25 | -------------------------------------------------------------------------------- /crates/generate-book/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "generate-book" 3 | version = "0.0.0" 4 | edition = "2018" 5 | publish = false 6 | 7 | [dependencies] 8 | pulldown-cmark = "0.9.1" 9 | pulldown-cmark-to-cmark = "10.0.1" 10 | -------------------------------------------------------------------------------- /crates/update-contributors/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "update-contributors" 3 | version = "0.0.0" 4 | edition = "2021" 5 | publish = false 6 | 7 | [dependencies] 8 | regex = "1.5.4" 9 | -------------------------------------------------------------------------------- /crates/update-contributors/src/main.rs: -------------------------------------------------------------------------------- 1 | use { 2 | regex::{Captures, Regex}, 3 | std::{fs, process::Command, str}, 4 | }; 5 | 6 | fn author(pr: u64) -> String { 7 | eprintln!("#{pr}"); 8 | let output = Command::new("sh") 9 | .args([ 10 | "-c", 11 | &format!("gh pr view {pr} --json author | jq -r .author.login"), 12 | ]) 13 | .output() 14 | .unwrap(); 15 | 16 | assert!( 17 | output.status.success(), 18 | "{}", 19 | String::from_utf8_lossy(&output.stderr) 20 | ); 21 | 22 | str::from_utf8(&output.stdout).unwrap().trim().to_owned() 23 | } 24 | 25 | fn main() { 26 | fs::write( 27 | "CHANGELOG.md", 28 | &*Regex::new(r"\(#(\d+)( by @[a-z]+)?\)") 29 | .unwrap() 30 | .replace_all( 31 | &fs::read_to_string("CHANGELOG.md").unwrap(), 32 | |captures: &Captures| { 33 | let pr = captures[1].parse().unwrap(); 34 | let contributor = author(pr); 35 | format!("([#{pr}](https://github.com/casey/just/pull/{pr}) by [{contributor}](https://github.com/{contributor}))") 36 | }, 37 | ), 38 | ) 39 | .unwrap(); 40 | } 41 | -------------------------------------------------------------------------------- /examples/cross-platform.just: -------------------------------------------------------------------------------- 1 | # use with https://github.com/casey/just 2 | # 3 | # Example cross-platform Python project 4 | # 5 | 6 | python_dir := if os_family() == "windows" { "./.venv/Scripts" } else { "./.venv/bin" } 7 | python := python_dir + if os_family() == "windows" { "/python.exe" } else { "/python3" } 8 | system_python := if os_family() == "windows" { "py.exe -3.9" } else { "python3.9" } 9 | 10 | # Set up development environment 11 | bootstrap: 12 | if test ! -e .venv; then {{ system_python }} -m venv .venv; fi 13 | {{ python }} -m pip install --upgrade pip wheel pip-tools 14 | {{ python_dir }}/pip-sync 15 | 16 | # Upgrade Python dependencies 17 | upgrade-deps: && bootstrap 18 | {{ python_dir }}/pip-compile --upgrade 19 | 20 | # Sample project script 1 21 | script1: 22 | {{ python }} script1.py 23 | 24 | # Sample project script 2 25 | script2 *ARGS: 26 | {{ python }} script2.py {{ ARGS }} 27 | -------------------------------------------------------------------------------- /examples/keybase.just: -------------------------------------------------------------------------------- 1 | # use with https://github.com/casey/just 2 | 3 | # Be inspired to use just to notify a chat 4 | # channel, this examples shows use with keybase 5 | # since it - practically - authenticates at the 6 | # device level and needs no additional secrets 7 | 8 | # notify update in keybase 9 | notify m="": 10 | keybase chat send --topic-type "chat" --channel "upd(): {{m}}" 11 | -------------------------------------------------------------------------------- /examples/powershell.just: -------------------------------------------------------------------------------- 1 | # Cross platform shebang: 2 | shebang := if os() == 'windows' { 3 | 'powershell.exe' 4 | } else { 5 | '/usr/bin/env pwsh' 6 | } 7 | 8 | # Set shell for non-Windows OSs: 9 | set shell := ["powershell", "-c"] 10 | 11 | # Set shell for Windows OSs: 12 | set windows-shell := ["powershell.exe", "-NoLogo", "-Command"] 13 | 14 | # If you have PowerShell Core installed and want to use it, 15 | # use `pwsh.exe` instead of `powershell.exe` 16 | 17 | linewise: 18 | Write-Host "Hello, world!" 19 | 20 | shebang: 21 | #!{{shebang}} 22 | $PSV = $PSVersionTable.PSVersion | % {"$_" -split "\." } 23 | $psver = $PSV[0] + "." + $PSV[1] 24 | if ($PSV[2].Length -lt 4) { 25 | $psver += "." + $PSV[2] + " Core" 26 | } else { 27 | $psver += " Desktop" 28 | } 29 | echo "PowerShell $psver" 30 | -------------------------------------------------------------------------------- /examples/pre-commit.just: -------------------------------------------------------------------------------- 1 | # use with https://github.com/casey/just 2 | 3 | # Example combining just + pre-commit 4 | # pre-commit: https://pre-commit.com/ 5 | # > A framework for managing and maintaining 6 | # > multi-language pre-commit hooks. 7 | 8 | # pre-commit brings about encapsulation of your 9 | # most common repo scripting tasks. It is perfectly 10 | # usable without actually setting up precommit hooks. 11 | # If you chose to, this justfiles includes shorthands 12 | # for git commit and amend to keep pre-commit out of 13 | # the way when in flow on a feature branch. 14 | 15 | # uses: https://github.com/tekwizely/pre-commit-golang 16 | # uses: https://github.com/prettier/prettier (pre-commit hook) 17 | # configures: https://www.git-town.com/ (setup receipt) 18 | 19 | # fix auto-fixable lint issues in staged files 20 | fix: 21 | pre-commit run go-returns # fixes all Go lint issues 22 | pre-commit run prettier # fixes all Markdown (& other) lint issues 23 | 24 | # lint most common issues in - or due - to staged files 25 | lint: 26 | pre-commit run go-vet-mod || true # runs go vet 27 | pre-commit run go-lint || true # runs golint 28 | pre-commit run go-critic || true # runs gocritic 29 | 30 | # lint all issues in - or due - to staged files: 31 | lint-all: 32 | pre-commit run golangci-lint-mod || true # runs golangci-lint 33 | 34 | # run tests in - or due - to staged files 35 | test: 36 | pre-commit run go-test-mod || true # runs go test 37 | 38 | # commit skipping pre-commit hooks 39 | commit m: 40 | git commit --no-verify -m "{{m}}" 41 | 42 | # amend skipping pre-commit hooks 43 | amend: 44 | git commit --amend --no-verify 45 | 46 | # install/update code automation (prettier, pre-commit, goreturns, lintpack, gocritic, golangci-lint) 47 | install: 48 | npm i -g prettier 49 | curl https://pre-commit.com/install-local.py | python3 - 50 | go get github.com/sqs/goreturns 51 | go get github.com/go-lintpack/lintpack/... 52 | go get github.com/go-critic/go-critic/... 53 | curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh| sh -s -- -b $(go env GOPATH)/bin v1.27.0 54 | 55 | # setup/update pre-commit hooks (optional) 56 | setup: 57 | pre-commit install --install-hooks # uninstall: `pre-commit uninstall` 58 | git config git-town.code-hosting-driver gitea # setup git-town with gitea 59 | git config git-town.code-hosting-origin-hostname gitea.example.org # setup git-town origin hostname 60 | -------------------------------------------------------------------------------- /examples/screenshot.just: -------------------------------------------------------------------------------- 1 | alias b := build 2 | 3 | host := `uname -a` 4 | 5 | # build main 6 | build: 7 | cc *.c -o main 8 | 9 | # test everything 10 | test-all: build 11 | ./test --all 12 | 13 | # run a specific test 14 | test TEST: build 15 | ./test --test {{TEST}} 16 | -------------------------------------------------------------------------------- /fuzz/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "just-fuzz" 3 | version = "0.0.0" 4 | authors = ["Automatically generated"] 5 | publish = false 6 | edition = "2018" 7 | 8 | [package.metadata] 9 | cargo-fuzz = true 10 | 11 | [dependencies] 12 | libfuzzer-sys = "0.4" 13 | 14 | [dependencies.just] 15 | path = ".." 16 | 17 | # Prevent this from interfering with workspaces 18 | [workspace] 19 | members = ["."] 20 | 21 | [[bin]] 22 | name = "compile" 23 | path = "fuzz_targets/compile.rs" 24 | test = false 25 | doc = false 26 | 27 | [profile.release] 28 | debug = true 29 | -------------------------------------------------------------------------------- /fuzz/fuzz_targets/compile.rs: -------------------------------------------------------------------------------- 1 | #![no_main] 2 | use libfuzzer_sys::fuzz_target; 3 | 4 | fuzz_target!(|src: &str| { 5 | let _ = just::fuzzing::compile(src); 6 | }); 7 | -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/casey/just/515e806b5121a4696113ef15b5f0b12e69854570/icon.png -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | edition = "2018" 2 | max_width = 100 3 | newline_style = "Unix" 4 | tab_spaces = 2 5 | use_field_init_shorthand = true 6 | use_try_shorthand = true 7 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/casey/just/515e806b5121a4696113ef15b5f0b12e69854570/screenshot.png -------------------------------------------------------------------------------- /src/alias.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | /// An alias, e.g. `name := target` 4 | #[derive(Debug, PartialEq, Clone, Serialize)] 5 | pub(crate) struct Alias<'src, T = Rc>> { 6 | pub(crate) attributes: AttributeSet<'src>, 7 | pub(crate) name: Name<'src>, 8 | #[serde( 9 | bound(serialize = "T: Keyed<'src>"), 10 | serialize_with = "keyed::serialize" 11 | )] 12 | pub(crate) target: T, 13 | } 14 | 15 | impl<'src> Alias<'src, Namepath<'src>> { 16 | pub(crate) fn resolve(self, target: Rc>) -> Alias<'src> { 17 | assert!(self.target.last().lexeme() == target.namepath.last().lexeme()); 18 | 19 | Alias { 20 | attributes: self.attributes, 21 | name: self.name, 22 | target, 23 | } 24 | } 25 | } 26 | 27 | impl Alias<'_> { 28 | pub(crate) fn is_private(&self) -> bool { 29 | self.name.lexeme().starts_with('_') || self.attributes.contains(AttributeDiscriminant::Private) 30 | } 31 | } 32 | 33 | impl<'src, T> Keyed<'src> for Alias<'src, T> { 34 | fn key(&self) -> &'src str { 35 | self.name.lexeme() 36 | } 37 | } 38 | 39 | impl<'src> Display for Alias<'src, Namepath<'src>> { 40 | fn fmt(&self, f: &mut Formatter) -> fmt::Result { 41 | write!(f, "alias {} := {}", self.name.lexeme(), self.target) 42 | } 43 | } 44 | 45 | impl Display for Alias<'_> { 46 | fn fmt(&self, f: &mut Formatter) -> fmt::Result { 47 | write!( 48 | f, 49 | "alias {} := {}", 50 | self.name.lexeme(), 51 | self.target.name.lexeme() 52 | ) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/alias_style.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[derive(Debug, PartialEq, Clone, ValueEnum)] 4 | pub(crate) enum AliasStyle { 5 | Left, 6 | Right, 7 | Separate, 8 | } 9 | -------------------------------------------------------------------------------- /src/assignment.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | /// An assignment, e.g `foo := bar` 4 | pub(crate) type Assignment<'src> = Binding<'src, Expression<'src>>; 5 | 6 | impl Display for Assignment<'_> { 7 | fn fmt(&self, f: &mut Formatter) -> fmt::Result { 8 | if self.private { 9 | writeln!(f, "[private]")?; 10 | } 11 | 12 | if self.export { 13 | write!(f, "export ")?; 14 | } 15 | 16 | write!(f, "{} := {}", self.name, self.value) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/ast.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | /// The top-level type produced by the parser. Not all successful parses result 4 | /// in valid justfiles, so additional consistency checks and name resolution 5 | /// are performed by the `Analyzer`, which produces a `Justfile` from an `Ast`. 6 | #[derive(Debug, Clone)] 7 | pub(crate) struct Ast<'src> { 8 | pub(crate) items: Vec>, 9 | pub(crate) unstable_features: BTreeSet, 10 | pub(crate) warnings: Vec, 11 | pub(crate) working_directory: PathBuf, 12 | } 13 | 14 | impl Display for Ast<'_> { 15 | fn fmt(&self, f: &mut Formatter) -> fmt::Result { 16 | let mut iter = self.items.iter().peekable(); 17 | 18 | while let Some(item) = iter.next() { 19 | writeln!(f, "{item}")?; 20 | 21 | if let Some(next_item) = iter.peek() { 22 | if matches!(item, Item::Recipe(_)) 23 | || mem::discriminant(item) != mem::discriminant(next_item) 24 | { 25 | writeln!(f)?; 26 | } 27 | } 28 | } 29 | 30 | Ok(()) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/attribute_set.rs: -------------------------------------------------------------------------------- 1 | use {super::*, std::collections}; 2 | 3 | #[derive(Default, Debug, Clone, PartialEq, Serialize)] 4 | pub(crate) struct AttributeSet<'src>(BTreeSet>); 5 | 6 | impl<'src> AttributeSet<'src> { 7 | pub(crate) fn len(&self) -> usize { 8 | self.0.len() 9 | } 10 | 11 | pub(crate) fn contains(&self, target: AttributeDiscriminant) -> bool { 12 | self.0.iter().any(|attr| attr.discriminant() == target) 13 | } 14 | 15 | pub(crate) fn get(&self, discriminant: AttributeDiscriminant) -> Option<&Attribute<'src>> { 16 | self 17 | .0 18 | .iter() 19 | .find(|attr| discriminant == attr.discriminant()) 20 | } 21 | 22 | pub(crate) fn iter<'a>(&'a self) -> collections::btree_set::Iter<'a, Attribute<'src>> { 23 | self.0.iter() 24 | } 25 | 26 | pub(crate) fn ensure_valid_attributes( 27 | &self, 28 | item_kind: &'static str, 29 | item_token: Token<'src>, 30 | valid: &[AttributeDiscriminant], 31 | ) -> Result<(), CompileError<'src>> { 32 | for attribute in &self.0 { 33 | let discriminant = attribute.discriminant(); 34 | if !valid.contains(&discriminant) { 35 | return Err(item_token.error(CompileErrorKind::InvalidAttribute { 36 | item_kind, 37 | item_name: item_token.lexeme(), 38 | attribute: attribute.clone(), 39 | })); 40 | } 41 | } 42 | Ok(()) 43 | } 44 | } 45 | 46 | impl<'src> FromIterator> for AttributeSet<'src> { 47 | fn from_iter>>(iter: T) -> Self { 48 | Self(iter.into_iter().collect()) 49 | } 50 | } 51 | 52 | impl<'src, 'a> IntoIterator for &'a AttributeSet<'src> { 53 | type Item = &'a Attribute<'src>; 54 | 55 | type IntoIter = collections::btree_set::Iter<'a, Attribute<'src>>; 56 | 57 | fn into_iter(self) -> Self::IntoIter { 58 | self.0.iter() 59 | } 60 | } 61 | 62 | impl<'src> IntoIterator for AttributeSet<'src> { 63 | type Item = Attribute<'src>; 64 | 65 | type IntoIter = collections::btree_set::IntoIter>; 66 | 67 | fn into_iter(self) -> Self::IntoIter { 68 | self.0.into_iter() 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/binding.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | /// A binding of `name` to `value` 4 | #[derive(Debug, Clone, PartialEq, Serialize)] 5 | pub(crate) struct Binding<'src, V = String> { 6 | #[serde(skip)] 7 | pub(crate) constant: bool, 8 | pub(crate) export: bool, 9 | #[serde(skip)] 10 | pub(crate) file_depth: u32, 11 | pub(crate) name: Name<'src>, 12 | pub(crate) private: bool, 13 | pub(crate) value: V, 14 | } 15 | 16 | impl<'src, V> Keyed<'src> for Binding<'src, V> { 17 | fn key(&self) -> &'src str { 18 | self.name.lexeme() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/color_display.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | pub(crate) trait ColorDisplay { 4 | fn color_display(&self, color: Color) -> Wrapper 5 | where 6 | Self: Sized, 7 | { 8 | Wrapper(self, color) 9 | } 10 | 11 | fn fmt(&self, f: &mut Formatter, color: Color) -> fmt::Result; 12 | } 13 | 14 | pub(crate) struct Wrapper<'a>(&'a dyn ColorDisplay, Color); 15 | 16 | impl Display for Wrapper<'_> { 17 | fn fmt(&self, f: &mut Formatter) -> fmt::Result { 18 | self.0.fmt(f, self.1) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/command_color.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[derive(Copy, Clone, ValueEnum)] 4 | pub(crate) enum CommandColor { 5 | Black, 6 | Blue, 7 | Cyan, 8 | Green, 9 | Purple, 10 | Red, 11 | Yellow, 12 | } 13 | 14 | impl From for ansi_term::Color { 15 | fn from(command_color: CommandColor) -> Self { 16 | match command_color { 17 | CommandColor::Black => Self::Black, 18 | CommandColor::Blue => Self::Blue, 19 | CommandColor::Cyan => Self::Cyan, 20 | CommandColor::Green => Self::Green, 21 | CommandColor::Purple => Self::Purple, 22 | CommandColor::Red => Self::Red, 23 | CommandColor::Yellow => Self::Yellow, 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/command_ext.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | pub(crate) trait CommandExt { 4 | fn export( 5 | &mut self, 6 | settings: &Settings, 7 | dotenv: &BTreeMap, 8 | scope: &Scope, 9 | unexports: &HashSet, 10 | ) -> &mut Command; 11 | 12 | fn export_scope(&mut self, settings: &Settings, scope: &Scope, unexports: &HashSet); 13 | 14 | fn output_guard(self) -> (io::Result, Option); 15 | 16 | fn output_guard_stdout(self) -> Result; 17 | 18 | fn status_guard(self) -> (io::Result, Option); 19 | } 20 | 21 | impl CommandExt for Command { 22 | fn export( 23 | &mut self, 24 | settings: &Settings, 25 | dotenv: &BTreeMap, 26 | scope: &Scope, 27 | unexports: &HashSet, 28 | ) -> &mut Command { 29 | for (name, value) in dotenv { 30 | self.env(name, value); 31 | } 32 | 33 | if let Some(parent) = scope.parent() { 34 | self.export_scope(settings, parent, unexports); 35 | } 36 | 37 | self 38 | } 39 | 40 | fn export_scope(&mut self, settings: &Settings, scope: &Scope, unexports: &HashSet) { 41 | if let Some(parent) = scope.parent() { 42 | self.export_scope(settings, parent, unexports); 43 | } 44 | 45 | for unexport in unexports { 46 | self.env_remove(unexport); 47 | } 48 | 49 | for binding in scope.bindings() { 50 | if binding.export || (settings.export && !binding.constant) { 51 | self.env(binding.name.lexeme(), &binding.value); 52 | } 53 | } 54 | } 55 | 56 | fn output_guard(self) -> (io::Result, Option) { 57 | SignalHandler::spawn(self, process::Child::wait_with_output) 58 | } 59 | 60 | fn output_guard_stdout(self) -> Result { 61 | let (result, caught) = self.output_guard(); 62 | 63 | let output = result.map_err(OutputError::Io)?; 64 | 65 | OutputError::result_from_exit_status(output.status)?; 66 | 67 | let output = str::from_utf8(&output.stdout).map_err(OutputError::Utf8)?; 68 | 69 | if let Some(signal) = caught { 70 | return Err(OutputError::Interrupted(signal)); 71 | } 72 | 73 | Ok( 74 | output 75 | .strip_suffix("\r\n") 76 | .or_else(|| output.strip_suffix("\n")) 77 | .unwrap_or(output) 78 | .into(), 79 | ) 80 | } 81 | 82 | fn status_guard(self) -> (io::Result, Option) { 83 | SignalHandler::spawn(self, |mut child| child.wait()) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/compilation.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[derive(Debug)] 4 | pub(crate) struct Compilation<'src> { 5 | pub(crate) asts: HashMap>, 6 | pub(crate) justfile: Justfile<'src>, 7 | pub(crate) root: PathBuf, 8 | pub(crate) srcs: HashMap, 9 | } 10 | 11 | impl<'src> Compilation<'src> { 12 | pub(crate) fn root_ast(&self) -> &Ast<'src> { 13 | self.asts.get(&self.root).unwrap() 14 | } 15 | 16 | pub(crate) fn root_src(&self) -> &'src str { 17 | self.srcs.get(&self.root).unwrap() 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/condition.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[derive(PartialEq, Debug, Clone)] 4 | pub(crate) struct Condition<'src> { 5 | pub(crate) lhs: Box>, 6 | pub(crate) operator: ConditionalOperator, 7 | pub(crate) rhs: Box>, 8 | } 9 | 10 | impl Display for Condition<'_> { 11 | fn fmt(&self, f: &mut Formatter) -> fmt::Result { 12 | write!(f, "{} {} {}", self.lhs, self.operator, self.rhs) 13 | } 14 | } 15 | 16 | impl Serialize for Condition<'_> { 17 | fn serialize(&self, serializer: S) -> Result 18 | where 19 | S: Serializer, 20 | { 21 | let mut seq = serializer.serialize_seq(None)?; 22 | seq.serialize_element(&self.operator.to_string())?; 23 | seq.serialize_element(&self.lhs)?; 24 | seq.serialize_element(&self.rhs)?; 25 | seq.end() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/conditional_operator.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | /// A conditional expression operator. 4 | #[derive(PartialEq, Debug, Copy, Clone)] 5 | pub(crate) enum ConditionalOperator { 6 | /// `==` 7 | Equality, 8 | /// `!=` 9 | Inequality, 10 | /// `=~` 11 | RegexMatch, 12 | /// `!~` 13 | RegexMismatch, 14 | } 15 | 16 | impl Display for ConditionalOperator { 17 | fn fmt(&self, f: &mut Formatter) -> fmt::Result { 18 | match self { 19 | Self::Equality => write!(f, "=="), 20 | Self::Inequality => write!(f, "!="), 21 | Self::RegexMatch => write!(f, "=~"), 22 | Self::RegexMismatch => write!(f, "!~"), 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/config_error.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[derive(Debug, Snafu)] 4 | #[snafu(visibility(pub(crate)), context(suffix(Context)))] 5 | pub(crate) enum ConfigError { 6 | #[snafu(display("Failed to get current directory: {}", source))] 7 | CurrentDir { source: io::Error }, 8 | #[snafu(display( 9 | "Internal config error, this may indicate a bug in just: {message} \ 10 | consider filing an issue: https://github.com/casey/just/issues/new", 11 | ))] 12 | Internal { message: String }, 13 | #[snafu(display("Invalid module path `{}`", path.join(" ")))] 14 | ModulePath { path: Vec }, 15 | #[snafu(display("Failed to parse request: {source}"))] 16 | RequestParse { source: serde_json::Error }, 17 | #[snafu(display( 18 | "Path-prefixed recipes may not be used with `--working-directory` or `--justfile`." 19 | ))] 20 | SearchDirConflict, 21 | #[snafu(display( 22 | "`--{}` used with unexpected {}: {}", 23 | subcommand.to_lowercase(), 24 | Count("argument", arguments.len()), 25 | List::and_ticked(arguments) 26 | ))] 27 | SubcommandArguments { 28 | subcommand: &'static str, 29 | arguments: Vec, 30 | }, 31 | #[snafu(display( 32 | "`--{}` used with unexpected overrides: {}", 33 | subcommand.to_lowercase(), 34 | List::and_ticked(overrides.iter().map(|(key, value)| format!("{key}={value}"))), 35 | ))] 36 | SubcommandOverrides { 37 | subcommand: &'static str, 38 | overrides: BTreeMap, 39 | }, 40 | #[snafu(display( 41 | "`--{}` used with unexpected overrides: {}; and arguments: {}", 42 | subcommand.to_lowercase(), 43 | List::and_ticked(overrides.iter().map(|(key, value)| format!("{key}={value}"))), 44 | List::and_ticked(arguments))) 45 | ] 46 | SubcommandOverridesAndArguments { 47 | subcommand: &'static str, 48 | overrides: BTreeMap, 49 | arguments: Vec, 50 | }, 51 | } 52 | 53 | impl ConfigError { 54 | pub(crate) fn internal(message: impl Into) -> Self { 55 | Self::Internal { 56 | message: message.into(), 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/constants.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | const CONSTANTS: [(&str, &str, &str); 27] = [ 4 | ("HEX", "0123456789abcdef", "1.27.0"), 5 | ("HEXLOWER", "0123456789abcdef", "1.27.0"), 6 | ("HEXUPPER", "0123456789ABCDEF", "1.27.0"), 7 | ("CLEAR", "\x1bc", "master"), 8 | ("NORMAL", "\x1b[0m", "master"), 9 | ("BOLD", "\x1b[1m", "master"), 10 | ("ITALIC", "\x1b[3m", "master"), 11 | ("UNDERLINE", "\x1b[4m", "master"), 12 | ("INVERT", "\x1b[7m", "master"), 13 | ("HIDE", "\x1b[8m", "master"), 14 | ("STRIKETHROUGH", "\x1b[9m", "master"), 15 | ("BLACK", "\x1b[30m", "master"), 16 | ("RED", "\x1b[31m", "master"), 17 | ("GREEN", "\x1b[32m", "master"), 18 | ("YELLOW", "\x1b[33m", "master"), 19 | ("BLUE", "\x1b[34m", "master"), 20 | ("MAGENTA", "\x1b[35m", "master"), 21 | ("CYAN", "\x1b[36m", "master"), 22 | ("WHITE", "\x1b[37m", "master"), 23 | ("BG_BLACK", "\x1b[40m", "master"), 24 | ("BG_RED", "\x1b[41m", "master"), 25 | ("BG_GREEN", "\x1b[42m", "master"), 26 | ("BG_YELLOW", "\x1b[43m", "master"), 27 | ("BG_BLUE", "\x1b[44m", "master"), 28 | ("BG_MAGENTA", "\x1b[45m", "master"), 29 | ("BG_CYAN", "\x1b[46m", "master"), 30 | ("BG_WHITE", "\x1b[47m", "master"), 31 | ]; 32 | 33 | pub(crate) fn constants() -> &'static HashMap<&'static str, &'static str> { 34 | static MAP: OnceLock> = OnceLock::new(); 35 | MAP.get_or_init(|| { 36 | CONSTANTS 37 | .into_iter() 38 | .map(|(name, value, _version)| (name, value)) 39 | .collect() 40 | }) 41 | } 42 | 43 | #[cfg(test)] 44 | mod tests { 45 | use super::*; 46 | 47 | #[test] 48 | fn readme_table() { 49 | println!("| Name | Value |"); 50 | println!("|------|-------------|"); 51 | for (name, value, version) in CONSTANTS { 52 | println!( 53 | "| `{name}`{version} | `\"{}\"` |", 54 | value.replace('\x1b', "\\e") 55 | ); 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/count.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | pub struct Count(pub T, pub usize); 4 | 5 | impl Display for Count { 6 | fn fmt(&self, f: &mut Formatter) -> fmt::Result { 7 | if self.1 == 1 { 8 | write!(f, "{}", self.0) 9 | } else { 10 | write!(f, "{}s", self.0) 11 | } 12 | } 13 | } 14 | 15 | #[cfg(test)] 16 | mod tests { 17 | use super::*; 18 | 19 | #[test] 20 | fn count() { 21 | assert_eq!(Count("dog", 0).to_string(), "dogs"); 22 | assert_eq!(Count("dog", 1).to_string(), "dog"); 23 | assert_eq!(Count("dog", 2).to_string(), "dogs"); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/delimiter.rs: -------------------------------------------------------------------------------- 1 | #[derive(PartialEq, Eq, Debug, Copy, Clone)] 2 | pub(crate) enum Delimiter { 3 | Brace, 4 | Bracket, 5 | Paren, 6 | } 7 | 8 | impl Delimiter { 9 | pub(crate) fn open(self) -> char { 10 | match self { 11 | Self::Brace => '{', 12 | Self::Bracket => '[', 13 | Self::Paren => '(', 14 | } 15 | } 16 | 17 | pub(crate) fn close(self) -> char { 18 | match self { 19 | Self::Brace => '}', 20 | Self::Bracket => ']', 21 | Self::Paren => ')', 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/dependency.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[derive(PartialEq, Debug, Serialize)] 4 | pub(crate) struct Dependency<'src> { 5 | pub(crate) arguments: Vec>, 6 | #[serde(serialize_with = "keyed::serialize")] 7 | pub(crate) recipe: Rc>, 8 | } 9 | 10 | impl Display for Dependency<'_> { 11 | fn fmt(&self, f: &mut Formatter) -> fmt::Result { 12 | if self.arguments.is_empty() { 13 | write!(f, "{}", self.recipe.name()) 14 | } else { 15 | write!(f, "({}", self.recipe.name())?; 16 | 17 | for argument in &self.arguments { 18 | write!(f, " {argument}")?; 19 | } 20 | 21 | write!(f, ")") 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/dump_format.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[derive(Debug, PartialEq, Clone, ValueEnum)] 4 | pub(crate) enum DumpFormat { 5 | Json, 6 | Just, 7 | } 8 | -------------------------------------------------------------------------------- /src/enclosure.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | pub struct Enclosure { 4 | enclosure: &'static str, 5 | value: T, 6 | } 7 | 8 | impl Enclosure { 9 | pub fn tick(value: T) -> Enclosure { 10 | Self { 11 | enclosure: "`", 12 | value, 13 | } 14 | } 15 | } 16 | 17 | impl Display for Enclosure { 18 | fn fmt(&self, f: &mut Formatter) -> fmt::Result { 19 | write!(f, "{}{}{}", self.enclosure, self.value, self.enclosure) 20 | } 21 | } 22 | 23 | #[cfg(test)] 24 | mod tests { 25 | use super::*; 26 | 27 | #[test] 28 | fn tick() { 29 | assert_eq!(Enclosure::tick("foo").to_string(), "`foo`"); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/execution_context.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[derive(Copy, Clone)] 4 | pub(crate) struct ExecutionContext<'src: 'run, 'run> { 5 | pub(crate) config: &'run Config, 6 | pub(crate) dotenv: &'run BTreeMap, 7 | pub(crate) module: &'run Justfile<'src>, 8 | pub(crate) scope: &'run Scope<'src, 'run>, 9 | pub(crate) search: &'run Search, 10 | } 11 | 12 | impl<'src: 'run, 'run> ExecutionContext<'src, 'run> { 13 | pub(crate) fn working_directory(&self) -> PathBuf { 14 | let base = if self.module.is_submodule() { 15 | &self.module.working_directory 16 | } else { 17 | &self.search.working_directory 18 | }; 19 | 20 | if let Some(setting) = &self.module.settings.working_directory { 21 | base.join(setting) 22 | } else { 23 | base.into() 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/fragment.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | /// A line fragment consisting either of… 4 | #[derive(PartialEq, Debug, Clone)] 5 | pub(crate) enum Fragment<'src> { 6 | /// …an interpolation containing `expression`. 7 | Interpolation { expression: Expression<'src> }, 8 | /// …raw text… 9 | Text { token: Token<'src> }, 10 | } 11 | 12 | impl Serialize for Fragment<'_> { 13 | fn serialize(&self, serializer: S) -> Result 14 | where 15 | S: Serializer, 16 | { 17 | match self { 18 | Self::Text { token } => serializer.serialize_str(token.lexeme()), 19 | Self::Interpolation { expression } => { 20 | let mut seq = serializer.serialize_seq(None)?; 21 | seq.serialize_element(expression)?; 22 | seq.end() 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/fuzzing.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | pub fn compile(text: &str) { 4 | let _ = testing::compile(text); 5 | } 6 | -------------------------------------------------------------------------------- /src/interpreter.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)] 4 | pub(crate) struct Interpreter<'src> { 5 | pub(crate) arguments: Vec>, 6 | pub(crate) command: StringLiteral<'src>, 7 | } 8 | 9 | impl Interpreter<'_> { 10 | pub(crate) fn default_script_interpreter() -> &'static Interpreter<'static> { 11 | static INSTANCE: Lazy> = Lazy::new(|| Interpreter { 12 | arguments: vec![StringLiteral::from_raw("-eu")], 13 | command: StringLiteral::from_raw("sh"), 14 | }); 15 | &INSTANCE 16 | } 17 | } 18 | 19 | impl Display for Interpreter<'_> { 20 | fn fmt(&self, f: &mut Formatter) -> fmt::Result { 21 | write!(f, "{}", self.command)?; 22 | 23 | for argument in &self.arguments { 24 | write!(f, ", {argument}")?; 25 | } 26 | 27 | Ok(()) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/item.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | /// A single top-level item 4 | #[derive(Debug, Clone)] 5 | pub(crate) enum Item<'src> { 6 | Alias(Alias<'src, Namepath<'src>>), 7 | Assignment(Assignment<'src>), 8 | Comment(&'src str), 9 | Import { 10 | absolute: Option, 11 | optional: bool, 12 | path: Token<'src>, 13 | relative: StringLiteral<'src>, 14 | }, 15 | Module { 16 | absolute: Option, 17 | doc: Option, 18 | groups: Vec, 19 | name: Name<'src>, 20 | optional: bool, 21 | relative: Option>, 22 | }, 23 | Recipe(UnresolvedRecipe<'src>), 24 | Set(Set<'src>), 25 | Unexport { 26 | name: Name<'src>, 27 | }, 28 | } 29 | 30 | impl Display for Item<'_> { 31 | fn fmt(&self, f: &mut Formatter) -> fmt::Result { 32 | match self { 33 | Self::Alias(alias) => write!(f, "{alias}"), 34 | Self::Assignment(assignment) => write!(f, "{assignment}"), 35 | Self::Comment(comment) => write!(f, "{comment}"), 36 | Self::Import { 37 | relative, optional, .. 38 | } => { 39 | write!(f, "import")?; 40 | 41 | if *optional { 42 | write!(f, "?")?; 43 | } 44 | 45 | write!(f, " {relative}") 46 | } 47 | Self::Module { 48 | name, 49 | relative, 50 | optional, 51 | .. 52 | } => { 53 | write!(f, "mod")?; 54 | 55 | if *optional { 56 | write!(f, "?")?; 57 | } 58 | 59 | write!(f, " {name}")?; 60 | 61 | if let Some(path) = relative { 62 | write!(f, " {path}")?; 63 | } 64 | 65 | Ok(()) 66 | } 67 | Self::Recipe(recipe) => write!(f, "{}", recipe.color_display(Color::never())), 68 | Self::Set(set) => write!(f, "{set}"), 69 | Self::Unexport { name } => write!(f, "unexport {name}"), 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/keyed.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | pub(crate) trait Keyed<'key> { 4 | fn key(&self) -> &'key str; 5 | } 6 | 7 | impl<'key, T: Keyed<'key>> Keyed<'key> for Rc { 8 | fn key(&self) -> &'key str { 9 | self.as_ref().key() 10 | } 11 | } 12 | 13 | pub(crate) fn serialize<'src, S, K>(keyed: &K, serializer: S) -> Result 14 | where 15 | S: Serializer, 16 | K: Keyed<'src>, 17 | { 18 | serializer.serialize_str(keyed.key()) 19 | } 20 | 21 | #[rustversion::attr(since(1.83), allow(clippy::ref_option))] 22 | pub(crate) fn serialize_option<'src, S, K>( 23 | recipe: &Option, 24 | serializer: S, 25 | ) -> Result 26 | where 27 | S: Serializer, 28 | K: Keyed<'src>, 29 | { 30 | match recipe { 31 | None => serializer.serialize_none(), 32 | Some(keyed) => serialize(keyed, serializer), 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/keyword.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[derive(Debug, Eq, PartialEq, IntoStaticStr, Display, Copy, Clone, EnumString)] 4 | #[strum(serialize_all = "kebab_case")] 5 | pub(crate) enum Keyword { 6 | Alias, 7 | AllowDuplicateRecipes, 8 | AllowDuplicateVariables, 9 | Assert, 10 | DotenvFilename, 11 | DotenvLoad, 12 | DotenvPath, 13 | DotenvRequired, 14 | Else, 15 | Export, 16 | Fallback, 17 | False, 18 | If, 19 | IgnoreComments, 20 | Import, 21 | Mod, 22 | NoExitMessage, 23 | PositionalArguments, 24 | Quiet, 25 | ScriptInterpreter, 26 | Set, 27 | Shell, 28 | Tempdir, 29 | True, 30 | Unexport, 31 | Unstable, 32 | WindowsPowershell, 33 | WindowsShell, 34 | WorkingDirectory, 35 | X, 36 | } 37 | 38 | impl Keyword { 39 | pub(crate) fn from_lexeme(lexeme: &str) -> Option { 40 | lexeme.parse().ok() 41 | } 42 | 43 | pub(crate) fn lexeme(self) -> &'static str { 44 | self.into() 45 | } 46 | } 47 | 48 | impl<'a> PartialEq<&'a str> for Keyword { 49 | fn eq(&self, other: &&'a str) -> bool { 50 | self.lexeme() == *other 51 | } 52 | } 53 | 54 | #[cfg(test)] 55 | mod tests { 56 | use super::*; 57 | 58 | #[test] 59 | fn keyword_case() { 60 | assert_eq!(Keyword::X.lexeme(), "x"); 61 | assert_eq!(Keyword::IgnoreComments.lexeme(), "ignore-comments"); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/line.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | /// A single line in a recipe body, consisting of any number of `Fragment`s. 4 | #[derive(Debug, Clone, PartialEq, Serialize)] 5 | #[serde(transparent)] 6 | pub(crate) struct Line<'src> { 7 | pub(crate) fragments: Vec>, 8 | #[serde(skip)] 9 | pub(crate) number: usize, 10 | } 11 | 12 | impl Line<'_> { 13 | fn first(&self) -> Option<&str> { 14 | if let Fragment::Text { token } = self.fragments.first()? { 15 | Some(token.lexeme()) 16 | } else { 17 | None 18 | } 19 | } 20 | 21 | pub(crate) fn is_comment(&self) -> bool { 22 | self.first().is_some_and(|text| text.starts_with('#')) 23 | } 24 | 25 | pub(crate) fn is_continuation(&self) -> bool { 26 | matches!( 27 | self.fragments.last(), 28 | Some(Fragment::Text { token }) if token.lexeme().ends_with('\\'), 29 | ) 30 | } 31 | 32 | pub(crate) fn is_empty(&self) -> bool { 33 | self.fragments.is_empty() 34 | } 35 | 36 | pub(crate) fn is_infallible(&self) -> bool { 37 | self 38 | .first() 39 | .is_some_and(|text| text.starts_with('-') || text.starts_with("@-")) 40 | } 41 | 42 | pub(crate) fn is_quiet(&self) -> bool { 43 | self 44 | .first() 45 | .is_some_and(|text| text.starts_with('@') || text.starts_with("-@")) 46 | } 47 | 48 | pub(crate) fn is_shebang(&self) -> bool { 49 | self.first().is_some_and(|text| text.starts_with("#!")) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/list.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | pub struct List + Clone> { 4 | conjunction: &'static str, 5 | values: I, 6 | } 7 | 8 | impl + Clone> List { 9 | pub fn or>(values: II) -> Self { 10 | Self { 11 | conjunction: "or", 12 | values: values.into_iter(), 13 | } 14 | } 15 | 16 | pub fn and>(values: II) -> Self { 17 | Self { 18 | conjunction: "and", 19 | values: values.into_iter(), 20 | } 21 | } 22 | 23 | pub fn or_ticked>( 24 | values: II, 25 | ) -> List, impl Iterator> + Clone> { 26 | List::or(values.into_iter().map(Enclosure::tick)) 27 | } 28 | 29 | pub fn and_ticked>( 30 | values: II, 31 | ) -> List, impl Iterator> + Clone> { 32 | List::and(values.into_iter().map(Enclosure::tick)) 33 | } 34 | } 35 | 36 | impl + Clone> Display for List { 37 | fn fmt(&self, f: &mut Formatter) -> fmt::Result { 38 | let mut values = self.values.clone().fuse(); 39 | 40 | if let Some(first) = values.next() { 41 | write!(f, "{first}")?; 42 | } else { 43 | return Ok(()); 44 | } 45 | 46 | let second = values.next(); 47 | 48 | if second.is_none() { 49 | return Ok(()); 50 | } 51 | 52 | let third = values.next(); 53 | 54 | if let (Some(second), None) = (second.as_ref(), third.as_ref()) { 55 | write!(f, " {} {second}", self.conjunction)?; 56 | return Ok(()); 57 | } 58 | 59 | let mut current = second; 60 | let mut next = third; 61 | 62 | loop { 63 | match (current, next) { 64 | (Some(c), Some(n)) => { 65 | write!(f, ", {c}")?; 66 | current = Some(n); 67 | next = values.next(); 68 | } 69 | (Some(c), None) => { 70 | write!(f, ", {} {c}", self.conjunction)?; 71 | return Ok(()); 72 | } 73 | _ => unreachable!("Iterator was fused, but returned Some after None"), 74 | } 75 | } 76 | } 77 | } 78 | 79 | #[cfg(test)] 80 | mod tests { 81 | use super::*; 82 | 83 | #[test] 84 | fn or() { 85 | assert_eq!("1", List::or(&[1]).to_string()); 86 | assert_eq!("1 or 2", List::or(&[1, 2]).to_string()); 87 | assert_eq!("1, 2, or 3", List::or(&[1, 2, 3]).to_string()); 88 | assert_eq!("1, 2, 3, or 4", List::or(&[1, 2, 3, 4]).to_string()); 89 | } 90 | 91 | #[test] 92 | fn and() { 93 | assert_eq!("1", List::and(&[1]).to_string()); 94 | assert_eq!("1 and 2", List::and(&[1, 2]).to_string()); 95 | assert_eq!("1, 2, and 3", List::and(&[1, 2, 3]).to_string()); 96 | assert_eq!("1, 2, 3, and 4", List::and(&[1, 2, 3, 4]).to_string()); 97 | } 98 | 99 | #[test] 100 | fn or_ticked() { 101 | assert_eq!("`1`", List::or_ticked(&[1]).to_string()); 102 | assert_eq!("`1` or `2`", List::or_ticked(&[1, 2]).to_string()); 103 | assert_eq!("`1`, `2`, or `3`", List::or_ticked(&[1, 2, 3]).to_string()); 104 | assert_eq!( 105 | "`1`, `2`, `3`, or `4`", 106 | List::or_ticked(&[1, 2, 3, 4]).to_string() 107 | ); 108 | } 109 | 110 | #[test] 111 | fn and_ticked() { 112 | assert_eq!("`1`", List::and_ticked(&[1]).to_string()); 113 | assert_eq!("`1` and `2`", List::and_ticked(&[1, 2]).to_string()); 114 | assert_eq!( 115 | "`1`, `2`, and `3`", 116 | List::and_ticked(&[1, 2, 3]).to_string() 117 | ); 118 | assert_eq!( 119 | "`1`, `2`, `3`, and `4`", 120 | List::and_ticked(&[1, 2, 3, 4]).to_string() 121 | ); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/load_dotenv.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | pub(crate) fn load_dotenv( 4 | config: &Config, 5 | settings: &Settings, 6 | working_directory: &Path, 7 | ) -> RunResult<'static, BTreeMap> { 8 | let dotenv_filename = config 9 | .dotenv_filename 10 | .as_ref() 11 | .or(settings.dotenv_filename.as_ref()); 12 | 13 | let dotenv_path = config 14 | .dotenv_path 15 | .as_ref() 16 | .or(settings.dotenv_path.as_ref()); 17 | 18 | if !settings.dotenv_load 19 | && dotenv_filename.is_none() 20 | && dotenv_path.is_none() 21 | && !settings.dotenv_required 22 | { 23 | return Ok(BTreeMap::new()); 24 | } 25 | 26 | if let Some(path) = dotenv_path { 27 | let path = working_directory.join(path); 28 | if path.is_file() { 29 | return load_from_file(&path); 30 | } 31 | } 32 | 33 | let filename = dotenv_filename.map_or(".env", |s| s.as_str()); 34 | 35 | for directory in working_directory.ancestors() { 36 | let path = directory.join(filename); 37 | if path.is_file() { 38 | return load_from_file(&path); 39 | } 40 | } 41 | 42 | if settings.dotenv_required { 43 | Err(Error::DotenvRequired) 44 | } else { 45 | Ok(BTreeMap::new()) 46 | } 47 | } 48 | 49 | fn load_from_file(path: &Path) -> RunResult<'static, BTreeMap> { 50 | let iter = dotenvy::from_path_iter(path)?; 51 | let mut dotenv = BTreeMap::new(); 52 | for result in iter { 53 | let (key, value) = result?; 54 | if env::var_os(&key).is_none() { 55 | dotenv.insert(key, value); 56 | } 57 | } 58 | Ok(dotenv) 59 | } 60 | -------------------------------------------------------------------------------- /src/loader.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | pub(crate) struct Loader { 4 | paths: Arena, 5 | srcs: Arena, 6 | } 7 | 8 | impl Loader { 9 | pub(crate) fn new() -> Self { 10 | Self { 11 | srcs: Arena::new(), 12 | paths: Arena::new(), 13 | } 14 | } 15 | 16 | pub(crate) fn load<'src>( 17 | &'src self, 18 | root: &Path, 19 | path: &Path, 20 | ) -> RunResult<'src, (&'src Path, &'src str)> { 21 | let src = fs::read_to_string(path).map_err(|io_error| Error::Load { 22 | path: path.into(), 23 | io_error, 24 | })?; 25 | 26 | let relative = path.strip_prefix(root.parent().unwrap()).unwrap_or(path); 27 | 28 | Ok((self.paths.alloc(relative.into()), self.srcs.alloc(src))) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | if let Err(code) = just::run(std::env::args_os()) { 3 | std::process::exit(code); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/module_path.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[derive(Debug, PartialEq, Clone)] 4 | pub(crate) struct ModulePath { 5 | pub(crate) path: Vec, 6 | pub(crate) spaced: bool, 7 | } 8 | 9 | impl TryFrom<&[&str]> for ModulePath { 10 | type Error = (); 11 | 12 | fn try_from(path: &[&str]) -> Result { 13 | let spaced = path.len() > 1; 14 | 15 | let path = if path.len() == 1 { 16 | let first = path[0]; 17 | 18 | if first.starts_with(':') || first.ends_with(':') || first.contains(":::") { 19 | return Err(()); 20 | } 21 | 22 | first 23 | .split("::") 24 | .map(str::to_string) 25 | .collect::>() 26 | } else { 27 | path.iter().map(|s| (*s).to_string()).collect() 28 | }; 29 | 30 | for name in &path { 31 | if name.is_empty() { 32 | return Err(()); 33 | } 34 | 35 | for (i, c) in name.chars().enumerate() { 36 | if i == 0 { 37 | if !Lexer::is_identifier_start(c) { 38 | return Err(()); 39 | } 40 | } else if !Lexer::is_identifier_continue(c) { 41 | return Err(()); 42 | } 43 | } 44 | } 45 | 46 | Ok(Self { path, spaced }) 47 | } 48 | } 49 | 50 | impl Display for ModulePath { 51 | fn fmt(&self, f: &mut Formatter) -> fmt::Result { 52 | for (i, name) in self.path.iter().enumerate() { 53 | if i > 0 { 54 | if self.spaced { 55 | write!(f, " ")?; 56 | } else { 57 | write!(f, "::")?; 58 | } 59 | } 60 | write!(f, "{name}")?; 61 | } 62 | Ok(()) 63 | } 64 | } 65 | 66 | #[cfg(test)] 67 | mod tests { 68 | use super::*; 69 | 70 | #[test] 71 | fn try_from_ok() { 72 | #[track_caller] 73 | fn case(path: &[&str], expected: &[&str], display: &str) { 74 | let actual = ModulePath::try_from(path).unwrap(); 75 | assert_eq!(actual.path, expected); 76 | assert_eq!(actual.to_string(), display); 77 | } 78 | 79 | case(&[], &[], ""); 80 | case(&["foo"], &["foo"], "foo"); 81 | case(&["foo0"], &["foo0"], "foo0"); 82 | case(&["foo", "bar"], &["foo", "bar"], "foo bar"); 83 | case(&["foo::bar"], &["foo", "bar"], "foo::bar"); 84 | } 85 | 86 | #[test] 87 | fn try_from_err() { 88 | #[track_caller] 89 | fn case(path: &[&str]) { 90 | assert!(ModulePath::try_from(path).is_err()); 91 | } 92 | 93 | case(&[":foo"]); 94 | case(&["foo:"]); 95 | case(&["foo:::bar"]); 96 | case(&["0foo"]); 97 | case(&["f$oo"]); 98 | case(&[""]); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/name.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | /// A name. This is just a `Token` of kind `Identifier`, but we give it its own 4 | /// type for clarity. 5 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Ord, PartialOrd)] 6 | pub(crate) struct Name<'src> { 7 | pub(crate) token: Token<'src>, 8 | } 9 | 10 | impl<'src> Name<'src> { 11 | pub(crate) fn from_identifier(token: Token<'src>) -> Self { 12 | assert_eq!(token.kind, TokenKind::Identifier); 13 | Self { token } 14 | } 15 | } 16 | 17 | impl<'src> Deref for Name<'src> { 18 | type Target = Token<'src>; 19 | 20 | fn deref(&self) -> &Self::Target { 21 | &self.token 22 | } 23 | } 24 | 25 | impl Display for Name<'_> { 26 | fn fmt(&self, f: &mut Formatter) -> fmt::Result { 27 | write!(f, "{}", self.lexeme()) 28 | } 29 | } 30 | 31 | impl Serialize for Name<'_> { 32 | fn serialize(&self, serializer: S) -> Result 33 | where 34 | S: Serializer, 35 | { 36 | serializer.serialize_str(self.lexeme()) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/namepath.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[derive(Clone, Debug, PartialEq, Eq, Ord, PartialOrd)] 4 | pub(crate) struct Namepath<'src>(Vec>); 5 | 6 | impl<'src> Namepath<'src> { 7 | pub(crate) fn join(&self, name: Name<'src>) -> Self { 8 | Self(self.0.iter().copied().chain(iter::once(name)).collect()) 9 | } 10 | 11 | pub(crate) fn spaced(&self) -> ModulePath { 12 | ModulePath { 13 | path: self.0.iter().map(|name| name.lexeme().into()).collect(), 14 | spaced: true, 15 | } 16 | } 17 | 18 | pub(crate) fn push(&mut self, name: Name<'src>) { 19 | self.0.push(name); 20 | } 21 | 22 | pub(crate) fn last(&self) -> &Name<'src> { 23 | self.0.last().unwrap() 24 | } 25 | 26 | pub(crate) fn split_last(&self) -> (&Name<'src>, &[Name<'src>]) { 27 | self.0.split_last().unwrap() 28 | } 29 | 30 | #[cfg(test)] 31 | pub(crate) fn iter(&self) -> slice::Iter<'_, Name<'src>> { 32 | self.0.iter() 33 | } 34 | 35 | #[cfg(test)] 36 | pub(crate) fn len(&self) -> usize { 37 | self.0.len() 38 | } 39 | } 40 | 41 | impl Display for Namepath<'_> { 42 | fn fmt(&self, f: &mut Formatter) -> fmt::Result { 43 | for (i, name) in self.0.iter().enumerate() { 44 | if i > 0 { 45 | write!(f, "::")?; 46 | } 47 | write!(f, "{name}")?; 48 | } 49 | Ok(()) 50 | } 51 | } 52 | 53 | impl<'src> From> for Namepath<'src> { 54 | fn from(name: Name<'src>) -> Self { 55 | Self(vec![name]) 56 | } 57 | } 58 | 59 | impl Serialize for Namepath<'_> { 60 | fn serialize(&self, serializer: S) -> Result 61 | where 62 | S: Serializer, 63 | { 64 | serializer.serialize_str(&format!("{self}")) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/ordinal.rs: -------------------------------------------------------------------------------- 1 | pub(crate) trait Ordinal { 2 | /// Convert an index starting at 0 to an ordinal starting at 1 3 | fn ordinal(self) -> Self; 4 | } 5 | 6 | impl Ordinal for usize { 7 | fn ordinal(self) -> Self { 8 | self + 1 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/output_error.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[derive(Debug)] 4 | pub(crate) enum OutputError { 5 | /// Non-zero exit code 6 | Code(i32), 7 | /// Interrupted by signal 8 | Interrupted(Signal), 9 | /// IO error 10 | Io(io::Error), 11 | /// Terminated by signal 12 | Signal(i32), 13 | /// Unknown failure 14 | Unknown, 15 | /// Stdout not UTF-8 16 | Utf8(str::Utf8Error), 17 | } 18 | 19 | impl OutputError { 20 | pub(crate) fn result_from_exit_status(exit_status: ExitStatus) -> Result<(), OutputError> { 21 | match exit_status.code() { 22 | Some(0) => Ok(()), 23 | Some(code) => Err(Self::Code(code)), 24 | None => match Platform::signal_from_exit_status(exit_status) { 25 | Some(signal) => Err(Self::Signal(signal)), 26 | None => Err(Self::Unknown), 27 | }, 28 | } 29 | } 30 | } 31 | 32 | impl Display for OutputError { 33 | fn fmt(&self, f: &mut Formatter) -> fmt::Result { 34 | match *self { 35 | Self::Code(code) => write!(f, "Process exited with status code {code}"), 36 | Self::Interrupted(signal) => write!( 37 | f, 38 | "Process succeded but `just` was interrupted by signal {signal}" 39 | ), 40 | Self::Io(ref io_error) => write!(f, "Error executing process: {io_error}"), 41 | Self::Signal(signal) => write!(f, "Process terminated by signal {signal}"), 42 | Self::Unknown => write!(f, "Process experienced an unknown failure"), 43 | Self::Utf8(ref err) => write!(f, "Could not convert process stdout to UTF-8: {err}"), 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/parameter.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | /// A single function parameter 4 | #[derive(PartialEq, Debug, Clone, Serialize)] 5 | pub(crate) struct Parameter<'src> { 6 | /// An optional default expression 7 | pub(crate) default: Option>, 8 | /// Export parameter as environment variable 9 | pub(crate) export: bool, 10 | /// The kind of parameter 11 | pub(crate) kind: ParameterKind, 12 | /// The parameter name 13 | pub(crate) name: Name<'src>, 14 | } 15 | 16 | impl Parameter<'_> { 17 | pub(crate) fn is_required(&self) -> bool { 18 | self.default.is_none() && self.kind != ParameterKind::Star 19 | } 20 | } 21 | 22 | impl ColorDisplay for Parameter<'_> { 23 | fn fmt(&self, f: &mut Formatter, color: Color) -> fmt::Result { 24 | if let Some(prefix) = self.kind.prefix() { 25 | write!(f, "{}", color.annotation().paint(prefix))?; 26 | } 27 | if self.export { 28 | write!(f, "$")?; 29 | } 30 | write!(f, "{}", color.parameter().paint(self.name.lexeme()))?; 31 | if let Some(ref default) = self.default { 32 | write!(f, "={}", color.string().paint(&default.to_string()))?; 33 | } 34 | Ok(()) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/parameter_kind.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | /// Parameters can either be… 4 | #[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize)] 5 | #[serde(rename_all = "snake_case")] 6 | pub(crate) enum ParameterKind { 7 | /// …variadic, accepting one or more arguments 8 | Plus, 9 | /// …singular, accepting a single argument 10 | Singular, 11 | /// …variadic, accepting zero or more arguments 12 | Star, 13 | } 14 | 15 | impl ParameterKind { 16 | pub(crate) fn prefix(self) -> Option<&'static str> { 17 | match self { 18 | Self::Singular => None, 19 | Self::Plus => Some("+"), 20 | Self::Star => Some("*"), 21 | } 22 | } 23 | 24 | pub(crate) fn is_variadic(self) -> bool { 25 | self != Self::Singular 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/platform.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | pub(crate) struct Platform; 4 | 5 | #[cfg(unix)] 6 | mod unix; 7 | 8 | #[cfg(windows)] 9 | mod windows; 10 | -------------------------------------------------------------------------------- /src/platform/unix.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | impl PlatformInterface for Platform { 4 | fn make_shebang_command( 5 | path: &Path, 6 | working_directory: Option<&Path>, 7 | _shebang: Shebang, 8 | ) -> Result { 9 | // shebang scripts can be executed directly on unix 10 | let mut command = Command::new(path); 11 | 12 | if let Some(working_directory) = working_directory { 13 | command.current_dir(working_directory); 14 | } 15 | 16 | Ok(command) 17 | } 18 | 19 | fn set_execute_permission(path: &Path) -> io::Result<()> { 20 | use std::os::unix::fs::PermissionsExt; 21 | 22 | // get current permissions 23 | let mut permissions = fs::metadata(path)?.permissions(); 24 | 25 | // set the execute bit 26 | let current_mode = permissions.mode(); 27 | permissions.set_mode(current_mode | 0o100); 28 | 29 | // set the new permissions 30 | fs::set_permissions(path, permissions) 31 | } 32 | 33 | fn signal_from_exit_status(exit_status: ExitStatus) -> Option { 34 | use std::os::unix::process::ExitStatusExt; 35 | exit_status.signal() 36 | } 37 | 38 | fn convert_native_path(_working_directory: &Path, path: &Path) -> FunctionResult { 39 | path 40 | .to_str() 41 | .map(str::to_string) 42 | .ok_or_else(|| String::from("Error getting current directory: unicode decode error")) 43 | } 44 | 45 | fn install_signal_handler(handler: T) -> RunResult<'static> { 46 | let signals = crate::signals::Signals::new()?; 47 | 48 | std::thread::Builder::new() 49 | .name("signal handler".into()) 50 | .spawn(move || { 51 | for signal in signals { 52 | match signal { 53 | Ok(signal) => handler(signal), 54 | Err(io_error) => eprintln!("warning: I/O error reading from signal pipe: {io_error}"), 55 | } 56 | } 57 | }) 58 | .map_err(|io_error| Error::SignalHandlerSpawnThread { io_error })?; 59 | 60 | Ok(()) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/platform/windows.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | impl PlatformInterface for Platform { 4 | fn make_shebang_command( 5 | path: &Path, 6 | working_directory: Option<&Path>, 7 | shebang: Shebang, 8 | ) -> Result { 9 | use std::borrow::Cow; 10 | 11 | // If the path contains forward slashes… 12 | let command = if shebang.interpreter.contains('/') { 13 | // …translate path to the interpreter from unix style to windows style. 14 | let mut cygpath = Command::new("cygpath"); 15 | 16 | if let Some(working_directory) = working_directory { 17 | cygpath.current_dir(working_directory); 18 | } 19 | 20 | cygpath 21 | .arg("--windows") 22 | .arg(shebang.interpreter) 23 | .stdin(Stdio::null()) 24 | .stdout(Stdio::piped()) 25 | .stderr(Stdio::piped()); 26 | 27 | Cow::Owned(cygpath.output_guard_stdout()?) 28 | } else { 29 | // …otherwise use it as-is. 30 | Cow::Borrowed(shebang.interpreter) 31 | }; 32 | 33 | let mut cmd = Command::new(command.as_ref()); 34 | 35 | if let Some(working_directory) = working_directory { 36 | cmd.current_dir(working_directory); 37 | } 38 | 39 | if let Some(argument) = shebang.argument { 40 | cmd.arg(argument); 41 | } 42 | 43 | cmd.arg(path); 44 | Ok(cmd) 45 | } 46 | 47 | fn set_execute_permission(_path: &Path) -> io::Result<()> { 48 | // it is not necessary to set an execute permission on a script on windows, so 49 | // this is a nop 50 | Ok(()) 51 | } 52 | 53 | fn signal_from_exit_status(_exit_status: process::ExitStatus) -> Option { 54 | // The rust standard library does not expose a way to extract a signal from a 55 | // windows process exit status, so just return None 56 | None 57 | } 58 | 59 | fn convert_native_path(working_directory: &Path, path: &Path) -> FunctionResult { 60 | // Translate path from windows style to unix style 61 | let mut cygpath = Command::new("cygpath"); 62 | 63 | cygpath 64 | .current_dir(working_directory) 65 | .arg("--unix") 66 | .arg(path) 67 | .stdin(Stdio::null()) 68 | .stdout(Stdio::piped()) 69 | .stderr(Stdio::piped()); 70 | 71 | match cygpath.output_guard_stdout() { 72 | Ok(shell_path) => Ok(shell_path), 73 | Err(_) => path 74 | .to_str() 75 | .map(str::to_string) 76 | .ok_or_else(|| String::from("Error getting current directory: unicode decode error")), 77 | } 78 | } 79 | 80 | fn install_signal_handler(handler: T) -> RunResult<'static> { 81 | ctrlc::set_handler(move || handler(Signal::Interrupt)) 82 | .map_err(|source| Error::SignalHandlerInstall { source }) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/platform_interface.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | pub(crate) trait PlatformInterface { 4 | /// translate path from "native" path to path interpreter expects 5 | fn convert_native_path(working_directory: &Path, path: &Path) -> FunctionResult; 6 | 7 | /// install handler, may only be called once 8 | fn install_signal_handler(handler: T) -> RunResult<'static>; 9 | 10 | /// construct command equivalent to running script at `path` with shebang 11 | /// line `shebang` 12 | fn make_shebang_command( 13 | path: &Path, 14 | working_directory: Option<&Path>, 15 | shebang: Shebang, 16 | ) -> Result; 17 | 18 | /// set the execute permission on file pointed to by `path` 19 | fn set_execute_permission(path: &Path) -> io::Result<()>; 20 | 21 | /// extract signal from process exit status 22 | fn signal_from_exit_status(exit_status: ExitStatus) -> Option; 23 | } 24 | -------------------------------------------------------------------------------- /src/position.rs: -------------------------------------------------------------------------------- 1 | /// Source position 2 | #[derive(Copy, Clone, PartialEq, Debug)] 3 | pub(crate) struct Position { 4 | pub(crate) column: usize, 5 | pub(crate) line: usize, 6 | pub(crate) offset: usize, 7 | } 8 | -------------------------------------------------------------------------------- /src/ran.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[derive(Default)] 4 | pub(crate) struct Ran<'src>(BTreeMap, BTreeSet>>); 5 | 6 | impl<'src> Ran<'src> { 7 | pub(crate) fn has_run(&self, recipe: &Namepath<'src>, arguments: &[String]) -> bool { 8 | self 9 | .0 10 | .get(recipe) 11 | .is_some_and(|ran| ran.contains(arguments)) 12 | } 13 | 14 | pub(crate) fn ran(&mut self, recipe: &Namepath<'src>, arguments: Vec) { 15 | self.0.entry(recipe.clone()).or_default().insert(arguments); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/range_ext.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | pub(crate) trait RangeExt { 4 | fn display(&self) -> DisplayRange<&Self> { 5 | DisplayRange(self) 6 | } 7 | 8 | fn range_contains(&self, i: &T) -> bool; 9 | } 10 | 11 | pub(crate) struct DisplayRange(T); 12 | 13 | impl Display for DisplayRange<&RangeInclusive> { 14 | fn fmt(&self, f: &mut Formatter) -> fmt::Result { 15 | if self.0.start() == self.0.end() { 16 | write!(f, "{}", self.0.start())?; 17 | } else if *self.0.end() == usize::MAX { 18 | write!(f, "{} or more", self.0.start())?; 19 | } else { 20 | write!(f, "{} to {}", self.0.start(), self.0.end())?; 21 | } 22 | Ok(()) 23 | } 24 | } 25 | 26 | impl RangeExt for Range 27 | where 28 | T: PartialOrd, 29 | { 30 | fn range_contains(&self, i: &T) -> bool { 31 | i >= &self.start && i < &self.end 32 | } 33 | } 34 | 35 | impl RangeExt for RangeInclusive 36 | where 37 | T: PartialOrd, 38 | { 39 | fn range_contains(&self, i: &T) -> bool { 40 | i >= self.start() && i <= self.end() 41 | } 42 | } 43 | 44 | #[cfg(test)] 45 | mod tests { 46 | use super::*; 47 | 48 | #[test] 49 | fn exclusive() { 50 | assert!(!(0..0).range_contains(&0)); 51 | assert!(!(1..10).range_contains(&0)); 52 | assert!(!(1..10).range_contains(&10)); 53 | assert!(!(1..10).range_contains(&0)); 54 | assert!((0..1).range_contains(&0)); 55 | assert!((10..20).range_contains(&15)); 56 | } 57 | 58 | #[test] 59 | fn inclusive() { 60 | assert!(!(0..=10).range_contains(&11)); 61 | assert!(!(1..=10).range_contains(&0)); 62 | assert!(!(5..=10).range_contains(&4)); 63 | assert!((0..=0).range_contains(&0)); 64 | assert!((0..=1).range_contains(&0)); 65 | assert!((0..=10).range_contains(&0)); 66 | assert!((0..=10).range_contains(&10)); 67 | assert!((0..=10).range_contains(&7)); 68 | assert!((1..=10).range_contains(&10)); 69 | assert!((10..=20).range_contains(&15)); 70 | } 71 | 72 | #[test] 73 | fn display() { 74 | assert!(!(1..1).contains(&1)); 75 | assert!((1..1).is_empty()); 76 | assert!((5..5).is_empty()); 77 | assert_eq!((0..=0).display().to_string(), "0"); 78 | assert_eq!((1..=1).display().to_string(), "1"); 79 | assert_eq!((5..=5).display().to_string(), "5"); 80 | assert_eq!((5..=9).display().to_string(), "5 to 9"); 81 | assert_eq!((1..=usize::MAX).display().to_string(), "1 or more"); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/recipe_signature.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | pub(crate) struct RecipeSignature<'a> { 4 | pub(crate) name: &'a str, 5 | pub(crate) recipe: &'a Recipe<'a>, 6 | } 7 | 8 | impl ColorDisplay for RecipeSignature<'_> { 9 | fn fmt(&self, f: &mut Formatter, color: Color) -> fmt::Result { 10 | write!(f, "{}", self.name)?; 11 | for parameter in &self.recipe.parameters { 12 | write!(f, " {}", parameter.color_display(color))?; 13 | } 14 | Ok(()) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/request.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[derive(Clone, Debug, Deserialize, PartialEq)] 4 | #[serde(rename_all = "kebab-case")] 5 | pub enum Request { 6 | EnvironmentVariable(String), 7 | #[cfg(not(windows))] 8 | Signal, 9 | } 10 | 11 | #[derive(Debug, Deserialize, PartialEq, Serialize)] 12 | #[serde(rename_all = "kebab-case")] 13 | pub enum Response { 14 | EnvironmentVariable(Option), 15 | #[cfg(not(windows))] 16 | Signal(String), 17 | } 18 | -------------------------------------------------------------------------------- /src/run.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | /// Main entry point into `just`. Parse arguments from `args` and run. 4 | #[allow(clippy::missing_errors_doc)] 5 | pub fn run(args: impl Iterator + Clone>) -> Result<(), i32> { 6 | #[cfg(windows)] 7 | ansi_term::enable_ansi_support().ok(); 8 | 9 | let app = Config::app(); 10 | 11 | let matches = app.try_get_matches_from(args).map_err(|err| { 12 | err.print().ok(); 13 | err.exit_code() 14 | })?; 15 | 16 | let config = Config::from_matches(&matches).map_err(Error::from); 17 | 18 | let (color, verbosity) = config 19 | .as_ref() 20 | .map(|config| (config.color, config.verbosity)) 21 | .unwrap_or_default(); 22 | 23 | let loader = Loader::new(); 24 | 25 | config 26 | .and_then(|config| { 27 | SignalHandler::install(config.verbosity)?; 28 | config.subcommand.execute(&config, &loader) 29 | }) 30 | .map_err(|error| { 31 | if !verbosity.quiet() && error.print_message() { 32 | eprintln!("{}", error.color_display(color.stderr())); 33 | } 34 | error.code().unwrap_or(EXIT_FAILURE) 35 | }) 36 | } 37 | -------------------------------------------------------------------------------- /src/scope.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[derive(Debug)] 4 | pub(crate) struct Scope<'src: 'run, 'run> { 5 | bindings: Table<'src, Binding<'src, String>>, 6 | parent: Option<&'run Self>, 7 | } 8 | 9 | impl<'src, 'run> Scope<'src, 'run> { 10 | pub(crate) fn child(&'run self) -> Self { 11 | Self { 12 | parent: Some(self), 13 | bindings: Table::new(), 14 | } 15 | } 16 | 17 | pub(crate) fn root() -> Self { 18 | let mut root = Self { 19 | parent: None, 20 | bindings: Table::new(), 21 | }; 22 | 23 | for (key, value) in constants() { 24 | root.bind(Binding { 25 | constant: true, 26 | export: false, 27 | file_depth: 0, 28 | name: Name { 29 | token: Token { 30 | column: 0, 31 | kind: TokenKind::Identifier, 32 | length: key.len(), 33 | line: 0, 34 | offset: 0, 35 | path: Path::new("PRELUDE"), 36 | src: key, 37 | }, 38 | }, 39 | private: false, 40 | value: (*value).into(), 41 | }); 42 | } 43 | 44 | root 45 | } 46 | 47 | pub(crate) fn bind(&mut self, binding: Binding<'src>) { 48 | self.bindings.insert(binding); 49 | } 50 | 51 | pub(crate) fn bound(&self, name: &str) -> bool { 52 | self.bindings.contains_key(name) 53 | } 54 | 55 | pub(crate) fn value(&self, name: &str) -> Option<&str> { 56 | if let Some(binding) = self.bindings.get(name) { 57 | Some(binding.value.as_ref()) 58 | } else { 59 | self.parent?.value(name) 60 | } 61 | } 62 | 63 | pub(crate) fn bindings(&self) -> impl Iterator> { 64 | self.bindings.values() 65 | } 66 | 67 | pub(crate) fn names(&self) -> impl Iterator { 68 | self.bindings.keys().copied() 69 | } 70 | 71 | pub(crate) fn parent(&self) -> Option<&'run Self> { 72 | self.parent 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/search_config.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | /// Controls how `just` will search for the justfile. 4 | #[derive(Debug, PartialEq)] 5 | pub(crate) enum SearchConfig { 6 | /// Recursively search for the justfile upwards from the invocation directory 7 | /// to the root, setting the working directory to the directory in which the 8 | /// justfile is found. 9 | FromInvocationDirectory, 10 | /// As in `Invocation`, but start from `search_directory`. 11 | FromSearchDirectory { search_directory: PathBuf }, 12 | /// Search for global justfile 13 | GlobalJustfile, 14 | /// Use user-specified justfile, with the working directory set to the 15 | /// directory that contains it. 16 | WithJustfile { justfile: PathBuf }, 17 | /// Use user-specified justfile and working directory. 18 | WithJustfileAndWorkingDirectory { 19 | justfile: PathBuf, 20 | working_directory: PathBuf, 21 | }, 22 | } 23 | -------------------------------------------------------------------------------- /src/search_error.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[derive(Debug, Snafu)] 4 | #[snafu(visibility(pub(crate)))] 5 | pub(crate) enum SearchError { 6 | #[snafu(display("Cannot initialize global justfile"))] 7 | GlobalJustfileInit, 8 | #[snafu(display("Global justfile not found"))] 9 | GlobalJustfileNotFound, 10 | #[snafu(display( 11 | "I/O error reading directory `{}`: {}", 12 | directory.display(), 13 | io_error 14 | ))] 15 | Io { 16 | directory: PathBuf, 17 | io_error: io::Error, 18 | }, 19 | #[snafu(display("Justfile path had no parent: {}", path.display()))] 20 | JustfileHadNoParent { path: PathBuf }, 21 | #[snafu(display( 22 | "Multiple candidate justfiles found in `{}`: {}", 23 | candidates.iter().next().unwrap().parent().unwrap().display(), 24 | List::and_ticked( 25 | candidates 26 | .iter() 27 | .map(|candidate| candidate.file_name().unwrap().to_string_lossy()) 28 | ), 29 | ))] 30 | MultipleCandidates { candidates: BTreeSet }, 31 | #[snafu(display("No justfile found"))] 32 | NotFound, 33 | } 34 | 35 | #[cfg(test)] 36 | mod tests { 37 | use super::*; 38 | 39 | #[test] 40 | fn multiple_candidates_formatting() { 41 | let error = SearchError::MultipleCandidates { 42 | candidates: [Path::new("/foo/justfile"), Path::new("/foo/JUSTFILE")] 43 | .iter() 44 | .map(|path| path.to_path_buf()) 45 | .collect(), 46 | }; 47 | 48 | assert_eq!( 49 | error.to_string(), 50 | "Multiple candidate justfiles found in `/foo`: `JUSTFILE` and `justfile`" 51 | ); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/set.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[derive(Debug, Clone)] 4 | pub(crate) struct Set<'src> { 5 | pub(crate) name: Name<'src>, 6 | pub(crate) value: Setting<'src>, 7 | } 8 | 9 | impl<'src> Keyed<'src> for Set<'src> { 10 | fn key(&self) -> &'src str { 11 | self.name.lexeme() 12 | } 13 | } 14 | 15 | impl Display for Set<'_> { 16 | fn fmt(&self, f: &mut Formatter) -> fmt::Result { 17 | write!(f, "set {} := {}", self.name, self.value) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/setting.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[derive(Debug, Clone)] 4 | pub(crate) enum Setting<'src> { 5 | AllowDuplicateRecipes(bool), 6 | AllowDuplicateVariables(bool), 7 | DotenvFilename(StringLiteral<'src>), 8 | DotenvLoad(bool), 9 | DotenvPath(StringLiteral<'src>), 10 | DotenvRequired(bool), 11 | Export(bool), 12 | Fallback(bool), 13 | IgnoreComments(bool), 14 | NoExitMessage(bool), 15 | PositionalArguments(bool), 16 | Quiet(bool), 17 | ScriptInterpreter(Interpreter<'src>), 18 | Shell(Interpreter<'src>), 19 | Tempdir(StringLiteral<'src>), 20 | Unstable(bool), 21 | WindowsPowerShell(bool), 22 | WindowsShell(Interpreter<'src>), 23 | WorkingDirectory(StringLiteral<'src>), 24 | } 25 | 26 | impl Display for Setting<'_> { 27 | fn fmt(&self, f: &mut Formatter) -> fmt::Result { 28 | match self { 29 | Self::AllowDuplicateRecipes(value) 30 | | Self::AllowDuplicateVariables(value) 31 | | Self::DotenvLoad(value) 32 | | Self::DotenvRequired(value) 33 | | Self::Export(value) 34 | | Self::Fallback(value) 35 | | Self::IgnoreComments(value) 36 | | Self::NoExitMessage(value) 37 | | Self::PositionalArguments(value) 38 | | Self::Quiet(value) 39 | | Self::Unstable(value) 40 | | Self::WindowsPowerShell(value) => write!(f, "{value}"), 41 | Self::ScriptInterpreter(shell) | Self::Shell(shell) | Self::WindowsShell(shell) => { 42 | write!(f, "[{shell}]") 43 | } 44 | Self::DotenvFilename(value) 45 | | Self::DotenvPath(value) 46 | | Self::Tempdir(value) 47 | | Self::WorkingDirectory(value) => { 48 | write!(f, "{value}") 49 | } 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/show_whitespace.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | /// String wrapper that uses nonblank characters to display spaces and tabs 4 | pub struct ShowWhitespace<'str>(pub &'str str); 5 | 6 | impl Display for ShowWhitespace<'_> { 7 | fn fmt(&self, f: &mut Formatter) -> fmt::Result { 8 | for c in self.0.chars() { 9 | match c { 10 | '\t' => write!(f, "␉")?, 11 | ' ' => write!(f, "␠")?, 12 | _ => write!(f, "{c}")?, 13 | } 14 | } 15 | 16 | Ok(()) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/signals.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::*, 3 | nix::{ 4 | errno::Errno, 5 | sys::signal::{SaFlags, SigAction, SigHandler, SigSet}, 6 | }, 7 | std::{ 8 | fs::File, 9 | os::fd::{BorrowedFd, IntoRawFd}, 10 | sync::atomic::{self, AtomicI32}, 11 | }, 12 | }; 13 | 14 | const INVALID_FILENO: i32 = -1; 15 | 16 | static WRITE: AtomicI32 = AtomicI32::new(INVALID_FILENO); 17 | 18 | fn die(message: &str) -> ! { 19 | // SAFETY: 20 | // 21 | // Standard error is open for the duration of the program. 22 | const STDERR: BorrowedFd = unsafe { BorrowedFd::borrow_raw(libc::STDERR_FILENO) }; 23 | 24 | let mut i = 0; 25 | let mut buffer = [0; 512]; 26 | 27 | let mut append = |s: &str| { 28 | let remaining = buffer.len() - i; 29 | let n = s.len().min(remaining); 30 | let end = i + n; 31 | buffer[i..end].copy_from_slice(&s.as_bytes()[0..n]); 32 | i = end; 33 | }; 34 | 35 | append("error: "); 36 | append(message); 37 | append("\n"); 38 | 39 | nix::unistd::write(STDERR, &buffer[0..i]).ok(); 40 | 41 | process::abort(); 42 | } 43 | 44 | extern "C" fn handler(signal: libc::c_int) { 45 | let errno = Errno::last(); 46 | 47 | let Ok(signal) = u8::try_from(signal) else { 48 | die("unexpected signal"); 49 | }; 50 | 51 | // SAFETY: 52 | // 53 | // `WRITE` is initialized before the signal handler can run and remains open 54 | // for the duration of the program. 55 | let fd = unsafe { BorrowedFd::borrow_raw(WRITE.load(atomic::Ordering::Relaxed)) }; 56 | 57 | if let Err(err) = nix::unistd::write(fd, &[signal]) { 58 | die(err.desc()); 59 | } 60 | 61 | errno.set(); 62 | } 63 | 64 | pub(crate) struct Signals(File); 65 | 66 | impl Signals { 67 | pub(crate) fn new() -> RunResult<'static, Self> { 68 | let (read, write) = nix::unistd::pipe().map_err(|errno| Error::SignalHandlerPipeOpen { 69 | io_error: errno.into(), 70 | })?; 71 | 72 | if WRITE 73 | .compare_exchange( 74 | INVALID_FILENO, 75 | write.into_raw_fd(), 76 | atomic::Ordering::Relaxed, 77 | atomic::Ordering::Relaxed, 78 | ) 79 | .is_err() 80 | { 81 | panic!("signal iterator cannot be initialized twice"); 82 | } 83 | 84 | let sa = SigAction::new( 85 | SigHandler::Handler(handler), 86 | SaFlags::SA_RESTART, 87 | SigSet::empty(), 88 | ); 89 | 90 | for &signal in Signal::ALL { 91 | // SAFETY: 92 | // 93 | // This is the only place we modify signal handlers, and 94 | // `nix::sys::signal::sigaction` is unsafe only if an invalid signal 95 | // handler has already been installed. 96 | unsafe { 97 | nix::sys::signal::sigaction(signal.into(), &sa).map_err(|errno| { 98 | Error::SignalHandlerSigaction { 99 | signal, 100 | io_error: errno.into(), 101 | } 102 | })?; 103 | } 104 | } 105 | 106 | Ok(Self(File::from(read))) 107 | } 108 | } 109 | 110 | impl Iterator for Signals { 111 | type Item = io::Result; 112 | 113 | fn next(&mut self) -> Option { 114 | let mut signal = [0]; 115 | Some( 116 | self 117 | .0 118 | .read_exact(&mut signal) 119 | .and_then(|()| Signal::try_from(signal[0])), 120 | ) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/source.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[derive(Debug)] 4 | pub(crate) struct Source<'src> { 5 | pub(crate) file_depth: u32, 6 | pub(crate) file_path: Vec, 7 | pub(crate) import_offsets: Vec, 8 | pub(crate) namepath: Option>, 9 | pub(crate) path: PathBuf, 10 | pub(crate) working_directory: PathBuf, 11 | } 12 | 13 | impl<'src> Source<'src> { 14 | pub(crate) fn root(path: &Path) -> Self { 15 | Self { 16 | file_depth: 0, 17 | file_path: vec![path.into()], 18 | import_offsets: Vec::new(), 19 | namepath: None, 20 | path: path.into(), 21 | working_directory: path.parent().unwrap().into(), 22 | } 23 | } 24 | 25 | pub(crate) fn import(&self, path: PathBuf, import_offset: usize) -> Self { 26 | Self { 27 | file_depth: self.file_depth + 1, 28 | file_path: self 29 | .file_path 30 | .clone() 31 | .into_iter() 32 | .chain(iter::once(path.clone())) 33 | .collect(), 34 | import_offsets: self 35 | .import_offsets 36 | .iter() 37 | .copied() 38 | .chain(iter::once(import_offset)) 39 | .collect(), 40 | namepath: self.namepath.clone(), 41 | path, 42 | working_directory: self.working_directory.clone(), 43 | } 44 | } 45 | 46 | pub(crate) fn module(&self, name: Name<'src>, path: PathBuf) -> Self { 47 | Self { 48 | file_depth: self.file_depth + 1, 49 | file_path: self 50 | .file_path 51 | .clone() 52 | .into_iter() 53 | .chain(iter::once(path.clone())) 54 | .collect(), 55 | import_offsets: Vec::new(), 56 | namepath: Some( 57 | self 58 | .namepath 59 | .as_ref() 60 | .map_or_else(|| name.into(), |namepath| namepath.join(name)), 61 | ), 62 | path: path.clone(), 63 | working_directory: path.parent().unwrap().into(), 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/string_delimiter.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug, PartialEq, Clone, Copy, Ord, PartialOrd, Eq)] 2 | pub(crate) enum StringDelimiter { 3 | Backtick, 4 | QuoteDouble, 5 | QuoteSingle, 6 | } 7 | -------------------------------------------------------------------------------- /src/string_kind.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[derive(Debug, PartialEq, Clone, Copy, Ord, PartialOrd, Eq)] 4 | pub(crate) struct StringKind { 5 | pub(crate) delimiter: StringDelimiter, 6 | pub(crate) indented: bool, 7 | } 8 | 9 | impl StringKind { 10 | // Indented values must come before un-indented values, or else 11 | // `Self::from_token_start` will incorrectly return indented = false 12 | // for indented strings. 13 | const ALL: &'static [Self] = &[ 14 | Self::new(StringDelimiter::Backtick, true), 15 | Self::new(StringDelimiter::Backtick, false), 16 | Self::new(StringDelimiter::QuoteDouble, true), 17 | Self::new(StringDelimiter::QuoteDouble, false), 18 | Self::new(StringDelimiter::QuoteSingle, true), 19 | Self::new(StringDelimiter::QuoteSingle, false), 20 | ]; 21 | 22 | const fn new(delimiter: StringDelimiter, indented: bool) -> Self { 23 | Self { 24 | delimiter, 25 | indented, 26 | } 27 | } 28 | 29 | pub(crate) fn delimiter(self) -> &'static str { 30 | match (self.delimiter, self.indented) { 31 | (StringDelimiter::Backtick, false) => "`", 32 | (StringDelimiter::Backtick, true) => "```", 33 | (StringDelimiter::QuoteDouble, false) => "\"", 34 | (StringDelimiter::QuoteDouble, true) => "\"\"\"", 35 | (StringDelimiter::QuoteSingle, false) => "'", 36 | (StringDelimiter::QuoteSingle, true) => "'''", 37 | } 38 | } 39 | 40 | pub(crate) fn delimiter_len(self) -> usize { 41 | self.delimiter().len() 42 | } 43 | 44 | pub(crate) fn token_kind(self) -> TokenKind { 45 | match self.delimiter { 46 | StringDelimiter::QuoteDouble | StringDelimiter::QuoteSingle => TokenKind::StringToken, 47 | StringDelimiter::Backtick => TokenKind::Backtick, 48 | } 49 | } 50 | 51 | pub(crate) fn unterminated_error_kind(self) -> CompileErrorKind<'static> { 52 | match self.delimiter { 53 | StringDelimiter::QuoteDouble | StringDelimiter::QuoteSingle => { 54 | CompileErrorKind::UnterminatedString 55 | } 56 | StringDelimiter::Backtick => CompileErrorKind::UnterminatedBacktick, 57 | } 58 | } 59 | 60 | pub(crate) fn processes_escape_sequences(self) -> bool { 61 | match self.delimiter { 62 | StringDelimiter::QuoteDouble => true, 63 | StringDelimiter::Backtick | StringDelimiter::QuoteSingle => false, 64 | } 65 | } 66 | 67 | pub(crate) fn indented(self) -> bool { 68 | self.indented 69 | } 70 | 71 | pub(crate) fn from_string_or_backtick(token: Token) -> CompileResult { 72 | Self::from_token_start(token.lexeme()).ok_or_else(|| { 73 | token.error(CompileErrorKind::Internal { 74 | message: "StringKind::from_token: Expected String or Backtick".to_owned(), 75 | }) 76 | }) 77 | } 78 | 79 | pub(crate) fn from_token_start(token_start: &str) -> Option { 80 | Self::ALL 81 | .iter() 82 | .find(|&&kind| token_start.starts_with(kind.delimiter())) 83 | .copied() 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/string_literal.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[derive(PartialEq, Debug, Clone, Ord, Eq, PartialOrd)] 4 | pub(crate) struct StringLiteral<'src> { 5 | pub(crate) cooked: String, 6 | pub(crate) expand: bool, 7 | pub(crate) kind: StringKind, 8 | pub(crate) raw: &'src str, 9 | } 10 | 11 | impl<'src> StringLiteral<'src> { 12 | pub(crate) fn from_raw(raw: &'src str) -> Self { 13 | Self { 14 | cooked: raw.into(), 15 | expand: false, 16 | kind: StringKind { 17 | delimiter: StringDelimiter::QuoteSingle, 18 | indented: false, 19 | }, 20 | raw, 21 | } 22 | } 23 | } 24 | 25 | impl Display for StringLiteral<'_> { 26 | fn fmt(&self, f: &mut Formatter) -> fmt::Result { 27 | if self.expand { 28 | write!(f, "x")?; 29 | } 30 | 31 | write!( 32 | f, 33 | "{}{}{}", 34 | self.kind.delimiter(), 35 | self.raw, 36 | self.kind.delimiter() 37 | ) 38 | } 39 | } 40 | 41 | impl Serialize for StringLiteral<'_> { 42 | fn serialize(&self, serializer: S) -> Result 43 | where 44 | S: Serializer, 45 | { 46 | serializer.serialize_str(&self.cooked) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/suggestion.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[derive(Clone, Copy, Debug, PartialEq)] 4 | pub(crate) struct Suggestion<'src> { 5 | pub(crate) name: &'src str, 6 | pub(crate) target: Option<&'src str>, 7 | } 8 | 9 | impl Display for Suggestion<'_> { 10 | fn fmt(&self, f: &mut Formatter) -> fmt::Result { 11 | write!(f, "Did you mean `{}`", self.name)?; 12 | if let Some(target) = self.target { 13 | write!(f, ", an alias for `{target}`")?; 14 | } 15 | write!(f, "?") 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/table.rs: -------------------------------------------------------------------------------- 1 | use {super::*, std::collections::btree_map}; 2 | 3 | #[derive(Debug, PartialEq, Serialize)] 4 | #[serde(transparent)] 5 | pub(crate) struct Table<'key, V: Keyed<'key>> { 6 | map: BTreeMap<&'key str, V>, 7 | } 8 | 9 | impl<'key, V: Keyed<'key>> Table<'key, V> { 10 | pub(crate) fn new() -> Self { 11 | Self { 12 | map: BTreeMap::new(), 13 | } 14 | } 15 | 16 | pub(crate) fn insert(&mut self, value: V) { 17 | self.map.insert(value.key(), value); 18 | } 19 | 20 | pub(crate) fn len(&self) -> usize { 21 | self.map.len() 22 | } 23 | 24 | pub(crate) fn get(&self, key: &str) -> Option<&V> { 25 | self.map.get(key) 26 | } 27 | 28 | pub(crate) fn is_empty(&self) -> bool { 29 | self.map.is_empty() 30 | } 31 | 32 | pub(crate) fn values(&self) -> btree_map::Values<&'key str, V> { 33 | self.map.values() 34 | } 35 | 36 | pub(crate) fn contains_key(&self, key: &str) -> bool { 37 | self.map.contains_key(key) 38 | } 39 | 40 | pub(crate) fn keys(&self) -> btree_map::Keys<&'key str, V> { 41 | self.map.keys() 42 | } 43 | 44 | pub(crate) fn iter(&self) -> btree_map::Iter<&'key str, V> { 45 | self.map.iter() 46 | } 47 | 48 | pub(crate) fn pop(&mut self) -> Option { 49 | let key = self.map.keys().next().copied()?; 50 | self.map.remove(key) 51 | } 52 | 53 | pub(crate) fn remove(&mut self, key: &str) -> Option { 54 | self.map.remove(key) 55 | } 56 | } 57 | 58 | impl<'key, V: Keyed<'key>> Default for Table<'key, V> { 59 | fn default() -> Self { 60 | Self::new() 61 | } 62 | } 63 | 64 | impl<'key, V: Keyed<'key>> FromIterator for Table<'key, V> { 65 | fn from_iter>(iter: I) -> Self { 66 | Self { 67 | map: iter.into_iter().map(|value| (value.key(), value)).collect(), 68 | } 69 | } 70 | } 71 | 72 | impl<'key, V: Keyed<'key>> Index<&'key str> for Table<'key, V> { 73 | type Output = V; 74 | 75 | #[inline] 76 | fn index(&self, key: &str) -> &V { 77 | self.map.get(key).expect("no entry found for key") 78 | } 79 | } 80 | 81 | impl<'key, V: Keyed<'key>> IntoIterator for Table<'key, V> { 82 | type IntoIter = btree_map::IntoIter<&'key str, V>; 83 | type Item = (&'key str, V); 84 | 85 | fn into_iter(self) -> btree_map::IntoIter<&'key str, V> { 86 | self.map.into_iter() 87 | } 88 | } 89 | 90 | impl<'table, V: Keyed<'table> + 'table> IntoIterator for &'table Table<'table, V> { 91 | type IntoIter = btree_map::Iter<'table, &'table str, V>; 92 | type Item = (&'table &'table str, &'table V); 93 | 94 | fn into_iter(self) -> btree_map::Iter<'table, &'table str, V> { 95 | self.map.iter() 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/token.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Ord, PartialOrd)] 4 | pub(crate) struct Token<'src> { 5 | pub(crate) column: usize, 6 | pub(crate) kind: TokenKind, 7 | pub(crate) length: usize, 8 | pub(crate) line: usize, 9 | pub(crate) offset: usize, 10 | pub(crate) path: &'src Path, 11 | pub(crate) src: &'src str, 12 | } 13 | 14 | impl<'src> Token<'src> { 15 | pub(crate) fn lexeme(&self) -> &'src str { 16 | &self.src[self.offset..self.offset + self.length] 17 | } 18 | 19 | pub(crate) fn error(&self, kind: CompileErrorKind<'src>) -> CompileError<'src> { 20 | CompileError::new(*self, kind) 21 | } 22 | } 23 | 24 | impl ColorDisplay for Token<'_> { 25 | fn fmt(&self, f: &mut Formatter, color: Color) -> fmt::Result { 26 | let width = if self.length == 0 { 1 } else { self.length }; 27 | 28 | let line_number = self.line.ordinal(); 29 | match self.src.lines().nth(self.line) { 30 | Some(line) => { 31 | let mut i = 0; 32 | let mut space_column = 0; 33 | let mut space_line = String::new(); 34 | let mut space_width = 0; 35 | for c in line.chars() { 36 | if c == '\t' { 37 | space_line.push_str(" "); 38 | if i < self.column { 39 | space_column += 4; 40 | } 41 | if i >= self.column && i < self.column + width { 42 | space_width += 4; 43 | } 44 | } else { 45 | if i < self.column { 46 | space_column += UnicodeWidthChar::width(c).unwrap_or(0); 47 | } 48 | if i >= self.column && i < self.column + width { 49 | space_width += UnicodeWidthChar::width(c).unwrap_or(0); 50 | } 51 | space_line.push(c); 52 | } 53 | i += c.len_utf8(); 54 | } 55 | let line_number_width = line_number.to_string().len(); 56 | writeln!( 57 | f, 58 | "{:width$}{} {}:{}:{}", 59 | "", 60 | color.context().paint("——▶"), 61 | self.path.display(), 62 | line_number, 63 | self.column.ordinal(), 64 | width = line_number_width 65 | )?; 66 | writeln!( 67 | f, 68 | "{:width$} {}", 69 | "", 70 | color.context().paint("│"), 71 | width = line_number_width 72 | )?; 73 | writeln!( 74 | f, 75 | "{} {space_line}", 76 | color.context().paint(&format!("{line_number} │")) 77 | )?; 78 | write!( 79 | f, 80 | "{:width$} {}", 81 | "", 82 | color.context().paint("│"), 83 | width = line_number_width 84 | )?; 85 | write!( 86 | f, 87 | " {0:1$}{2}{3:^<4$}{5}", 88 | "", 89 | space_column, 90 | color.prefix(), 91 | "", 92 | space_width.max(1), 93 | color.suffix() 94 | )?; 95 | } 96 | None => { 97 | if self.offset != self.src.len() { 98 | write!( 99 | f, 100 | "internal error: Error has invalid line number: {line_number}" 101 | )?; 102 | } 103 | } 104 | } 105 | 106 | Ok(()) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/token_kind.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[derive(Debug, PartialEq, Clone, Copy, Ord, PartialOrd, Eq)] 4 | pub(crate) enum TokenKind { 5 | AmpersandAmpersand, 6 | Asterisk, 7 | At, 8 | Backtick, 9 | BangEquals, 10 | BangTilde, 11 | BarBar, 12 | BraceL, 13 | BraceR, 14 | BracketL, 15 | BracketR, 16 | ByteOrderMark, 17 | Colon, 18 | ColonColon, 19 | ColonEquals, 20 | Comma, 21 | Comment, 22 | Dedent, 23 | Dollar, 24 | Eof, 25 | Eol, 26 | Equals, 27 | EqualsEquals, 28 | EqualsTilde, 29 | Identifier, 30 | Indent, 31 | InterpolationEnd, 32 | InterpolationStart, 33 | ParenL, 34 | ParenR, 35 | Plus, 36 | QuestionMark, 37 | Slash, 38 | StringToken, 39 | Text, 40 | Unspecified, 41 | Whitespace, 42 | } 43 | 44 | impl Display for TokenKind { 45 | fn fmt(&self, f: &mut Formatter) -> fmt::Result { 46 | use TokenKind::*; 47 | write!( 48 | f, 49 | "{}", 50 | match *self { 51 | AmpersandAmpersand => "'&&'", 52 | Asterisk => "'*'", 53 | At => "'@'", 54 | Backtick => "backtick", 55 | BangEquals => "'!='", 56 | BangTilde => "'!~'", 57 | BarBar => "'||'", 58 | BraceL => "'{'", 59 | BraceR => "'}'", 60 | BracketL => "'['", 61 | BracketR => "']'", 62 | ByteOrderMark => "byte order mark", 63 | Colon => "':'", 64 | ColonColon => "'::'", 65 | ColonEquals => "':='", 66 | Comma => "','", 67 | Comment => "comment", 68 | Dedent => "dedent", 69 | Dollar => "'$'", 70 | Eof => "end of file", 71 | Eol => "end of line", 72 | Equals => "'='", 73 | EqualsEquals => "'=='", 74 | EqualsTilde => "'=~'", 75 | Identifier => "identifier", 76 | Indent => "indent", 77 | InterpolationEnd => "'}}'", 78 | InterpolationStart => "'{{'", 79 | ParenL => "'('", 80 | ParenR => "')'", 81 | Plus => "'+'", 82 | QuestionMark => "?", 83 | Slash => "'/'", 84 | StringToken => "string", 85 | Text => "command text", 86 | Unspecified => "unspecified", 87 | Whitespace => "whitespace", 88 | } 89 | ) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/tree.rs: -------------------------------------------------------------------------------- 1 | use {super::*, std::borrow::Cow}; 2 | 3 | /// Construct a `Tree` from a symbolic expression literal. This macro, and the 4 | /// Tree type, are only used in the Parser unit tests, providing a concise 5 | /// notation for representing the expected results of parsing a given string. 6 | macro_rules! tree { 7 | { ($($child:tt)*) } => { 8 | $crate::tree::Tree::List(vec![$(tree!($child),)*]) 9 | }; 10 | 11 | { $atom:ident } => { 12 | $crate::tree::Tree::atom(stringify!($atom)) 13 | }; 14 | 15 | { $atom:literal } => { 16 | $crate::tree::Tree::atom(format!("\"{}\"", $atom)) 17 | }; 18 | 19 | { # } => { 20 | $crate::tree::Tree::atom("#") 21 | }; 22 | 23 | { ? } => { 24 | $crate::tree::Tree::atom("?") 25 | }; 26 | 27 | { + } => { 28 | $crate::tree::Tree::atom("+") 29 | }; 30 | 31 | { * } => { 32 | $crate::tree::Tree::atom("*") 33 | }; 34 | 35 | { && } => { 36 | $crate::tree::Tree::atom("&&") 37 | }; 38 | 39 | { == } => { 40 | $crate::tree::Tree::atom("==") 41 | }; 42 | 43 | { != } => { 44 | $crate::tree::Tree::atom("!=") 45 | }; 46 | } 47 | 48 | /// A `Tree` is either… 49 | #[derive(Debug, PartialEq)] 50 | pub(crate) enum Tree<'text> { 51 | /// …an atom containing text, or… 52 | Atom(Cow<'text, str>), 53 | /// …a list containing zero or more `Tree`s. 54 | List(Vec), 55 | } 56 | 57 | impl<'text> Tree<'text> { 58 | /// Construct an Atom from a text scalar 59 | pub(crate) fn atom(text: impl Into>) -> Self { 60 | Self::Atom(text.into()) 61 | } 62 | 63 | /// Construct a List from an iterable of trees 64 | pub(crate) fn list(children: impl IntoIterator) -> Self { 65 | Self::List(children.into_iter().collect()) 66 | } 67 | 68 | /// Convenience function to create an atom containing quoted text 69 | pub(crate) fn string(contents: impl AsRef) -> Self { 70 | Self::atom(format!("\"{}\"", contents.as_ref())) 71 | } 72 | 73 | /// Push a child node into self, turning it into a List if it was an Atom 74 | pub(crate) fn push(self, tree: impl Into) -> Self { 75 | match self { 76 | Self::List(mut children) => { 77 | children.push(tree.into()); 78 | Self::List(children) 79 | } 80 | Self::Atom(text) => Self::List(vec![Self::Atom(text), tree.into()]), 81 | } 82 | } 83 | 84 | /// Extend a self with a tail of Trees, turning self into a List if it was an 85 | /// Atom 86 | pub(crate) fn extend(self, tail: I) -> Self 87 | where 88 | I: IntoIterator, 89 | T: Into, 90 | { 91 | // Tree::List(children.into_iter().collect()) 92 | let mut head = match self { 93 | Self::List(children) => children, 94 | Self::Atom(text) => vec![Self::Atom(text)], 95 | }; 96 | 97 | for child in tail { 98 | head.push(child.into()); 99 | } 100 | 101 | Self::List(head) 102 | } 103 | 104 | /// Like `push`, but modify self in-place 105 | pub(crate) fn push_mut(&mut self, tree: impl Into) { 106 | *self = mem::replace(self, Self::List(Vec::new())).push(tree.into()); 107 | } 108 | } 109 | 110 | impl Display for Tree<'_> { 111 | fn fmt(&self, f: &mut Formatter) -> fmt::Result { 112 | match self { 113 | Self::List(children) => { 114 | write!(f, "(")?; 115 | 116 | for (i, child) in children.iter().enumerate() { 117 | if i > 0 { 118 | write!(f, " ")?; 119 | } 120 | write!(f, "{child}")?; 121 | } 122 | 123 | write!(f, ")") 124 | } 125 | Self::Atom(text) => write!(f, "{text}"), 126 | } 127 | } 128 | } 129 | 130 | impl<'text, T> From for Tree<'text> 131 | where 132 | T: Into>, 133 | { 134 | fn from(text: T) -> Self { 135 | Self::Atom(text.into()) 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/unindent.rs: -------------------------------------------------------------------------------- 1 | #[must_use] 2 | pub fn unindent(text: &str) -> String { 3 | // find line start and end indices 4 | let mut lines = Vec::new(); 5 | let mut start = 0; 6 | for (i, c) in text.char_indices() { 7 | if c == '\n' || i == text.len() - c.len_utf8() { 8 | let end = i + c.len_utf8(); 9 | lines.push(&text[start..end]); 10 | start = end; 11 | } 12 | } 13 | 14 | let common_indentation = lines 15 | .iter() 16 | .filter(|line| !blank(line)) 17 | .copied() 18 | .map(indentation) 19 | .fold( 20 | None, 21 | |common_indentation, line_indentation| match common_indentation { 22 | Some(common_indentation) => Some(common(common_indentation, line_indentation)), 23 | None => Some(line_indentation), 24 | }, 25 | ) 26 | .unwrap_or(""); 27 | 28 | let mut replacements = Vec::with_capacity(lines.len()); 29 | 30 | for (i, line) in lines.iter().enumerate() { 31 | let blank = blank(line); 32 | let first = i == 0; 33 | let last = i == lines.len() - 1; 34 | 35 | let replacement = match (blank, first, last) { 36 | (true, false, false) => "\n", 37 | (true, _, _) => "", 38 | (false, _, _) => &line[common_indentation.len()..], 39 | }; 40 | 41 | replacements.push(replacement); 42 | } 43 | 44 | replacements.into_iter().collect() 45 | } 46 | 47 | fn indentation(line: &str) -> &str { 48 | let i = line 49 | .char_indices() 50 | .take_while(|(_, c)| matches!(c, ' ' | '\t')) 51 | .map(|(i, _)| i + 1) 52 | .last() 53 | .unwrap_or(0); 54 | 55 | &line[..i] 56 | } 57 | 58 | fn blank(line: &str) -> bool { 59 | line.chars().all(|c| matches!(c, ' ' | '\t' | '\r' | '\n')) 60 | } 61 | 62 | fn common<'s>(a: &'s str, b: &'s str) -> &'s str { 63 | let i = a 64 | .char_indices() 65 | .zip(b.chars()) 66 | .take_while(|((_, ac), bc)| ac == bc) 67 | .map(|((i, c), _)| i + c.len_utf8()) 68 | .last() 69 | .unwrap_or(0); 70 | 71 | &a[0..i] 72 | } 73 | 74 | #[cfg(test)] 75 | mod tests { 76 | use super::*; 77 | 78 | #[test] 79 | fn unindents() { 80 | assert_eq!(unindent("foo"), "foo"); 81 | assert_eq!(unindent("foo\nbar\nbaz\n"), "foo\nbar\nbaz\n"); 82 | assert_eq!(unindent(""), ""); 83 | assert_eq!(unindent(" foo\n bar"), "foo\nbar"); 84 | assert_eq!(unindent(" foo\n bar\n\n"), "foo\nbar\n"); 85 | 86 | assert_eq!( 87 | unindent( 88 | " 89 | hello 90 | bar 91 | " 92 | ), 93 | "hello\nbar\n" 94 | ); 95 | 96 | assert_eq!(unindent("hello\n bar\n foo"), "hello\n bar\n foo"); 97 | 98 | assert_eq!( 99 | unindent( 100 | " 101 | 102 | hello 103 | bar 104 | 105 | " 106 | ), 107 | "\nhello\nbar\n\n" 108 | ); 109 | } 110 | 111 | #[test] 112 | fn indentations() { 113 | assert_eq!(indentation(""), ""); 114 | assert_eq!(indentation("foo"), ""); 115 | assert_eq!(indentation(" foo"), " "); 116 | assert_eq!(indentation("\t\tfoo"), "\t\t"); 117 | assert_eq!(indentation("\t \t foo"), "\t \t "); 118 | } 119 | 120 | #[test] 121 | fn blanks() { 122 | assert!(blank(" \n")); 123 | assert!(!blank(" foo\n")); 124 | assert!(blank("\t\t\n")); 125 | } 126 | 127 | #[test] 128 | fn commons() { 129 | assert_eq!(common("foo", "foobar"), "foo"); 130 | assert_eq!(common("foo", "bar"), ""); 131 | assert_eq!(common("", ""), ""); 132 | assert_eq!(common("", "bar"), ""); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/unresolved_dependency.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[derive(PartialEq, Debug, Clone)] 4 | pub(crate) struct UnresolvedDependency<'src> { 5 | pub(crate) arguments: Vec>, 6 | pub(crate) recipe: Name<'src>, 7 | } 8 | 9 | impl Display for UnresolvedDependency<'_> { 10 | fn fmt(&self, f: &mut Formatter) -> fmt::Result { 11 | if self.arguments.is_empty() { 12 | write!(f, "{}", self.recipe) 13 | } else { 14 | write!(f, "({}", self.recipe)?; 15 | 16 | for argument in &self.arguments { 17 | write!(f, " {argument}")?; 18 | } 19 | 20 | write!(f, ")") 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/unresolved_recipe.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | pub(crate) type UnresolvedRecipe<'src> = Recipe<'src, UnresolvedDependency<'src>>; 4 | 5 | impl<'src> UnresolvedRecipe<'src> { 6 | pub(crate) fn resolve( 7 | self, 8 | resolved: Vec>>, 9 | ) -> CompileResult<'src, Recipe<'src>> { 10 | assert_eq!( 11 | self.dependencies.len(), 12 | resolved.len(), 13 | "UnresolvedRecipe::resolve: dependency count not equal to resolved count: {} != {}", 14 | self.dependencies.len(), 15 | resolved.len() 16 | ); 17 | 18 | for (unresolved, resolved) in self.dependencies.iter().zip(&resolved) { 19 | assert_eq!(unresolved.recipe.lexeme(), resolved.name.lexeme()); 20 | if !resolved 21 | .argument_range() 22 | .contains(&unresolved.arguments.len()) 23 | { 24 | return Err( 25 | unresolved 26 | .recipe 27 | .error(CompileErrorKind::DependencyArgumentCountMismatch { 28 | dependency: unresolved.recipe.lexeme(), 29 | found: unresolved.arguments.len(), 30 | min: resolved.min_arguments(), 31 | max: resolved.max_arguments(), 32 | }), 33 | ); 34 | } 35 | } 36 | 37 | let dependencies = self 38 | .dependencies 39 | .into_iter() 40 | .zip(resolved) 41 | .map(|(unresolved, resolved)| Dependency { 42 | recipe: resolved, 43 | arguments: unresolved.arguments, 44 | }) 45 | .collect(); 46 | 47 | Ok(Recipe { 48 | attributes: self.attributes, 49 | body: self.body, 50 | dependencies, 51 | doc: self.doc, 52 | file_depth: self.file_depth, 53 | import_offsets: self.import_offsets, 54 | name: self.name, 55 | namepath: self.namepath, 56 | parameters: self.parameters, 57 | priors: self.priors, 58 | private: self.private, 59 | quiet: self.quiet, 60 | shebang: self.shebang, 61 | }) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/unstable_feature.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[derive(Copy, Clone, Debug, PartialEq, Ord, Eq, PartialOrd)] 4 | pub(crate) enum UnstableFeature { 5 | FormatSubcommand, 6 | LogicalOperators, 7 | ScriptAttribute, 8 | ScriptInterpreterSetting, 9 | WhichFunction, 10 | } 11 | 12 | impl Display for UnstableFeature { 13 | fn fmt(&self, f: &mut Formatter) -> fmt::Result { 14 | match self { 15 | Self::FormatSubcommand => write!(f, "The `--fmt` command is currently unstable."), 16 | Self::LogicalOperators => write!( 17 | f, 18 | "The logical operators `&&` and `||` are currently unstable." 19 | ), 20 | Self::ScriptAttribute => write!(f, "The `[script]` attribute is currently unstable."), 21 | Self::ScriptInterpreterSetting => { 22 | write!(f, "The `script-interpreter` setting is currently unstable.") 23 | } 24 | Self::WhichFunction => write!(f, "The `which()` function is currently unstable."), 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/use_color.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[derive(Copy, Clone, Debug, PartialEq, ValueEnum)] 4 | pub(crate) enum UseColor { 5 | Always, 6 | Auto, 7 | Never, 8 | } 9 | -------------------------------------------------------------------------------- /src/variables.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | pub(crate) struct Variables<'expression, 'src> { 4 | stack: Vec<&'expression Expression<'src>>, 5 | } 6 | 7 | impl<'expression, 'src> Variables<'expression, 'src> { 8 | pub(crate) fn new(root: &'expression Expression<'src>) -> Self { 9 | Self { stack: vec![root] } 10 | } 11 | } 12 | 13 | impl<'src> Iterator for Variables<'_, 'src> { 14 | type Item = Token<'src>; 15 | 16 | fn next(&mut self) -> Option> { 17 | loop { 18 | match self.stack.pop()? { 19 | Expression::And { lhs, rhs } | Expression::Or { lhs, rhs } => { 20 | self.stack.push(lhs); 21 | self.stack.push(rhs); 22 | } 23 | Expression::Assert { 24 | condition: 25 | Condition { 26 | lhs, 27 | rhs, 28 | operator: _, 29 | }, 30 | error, 31 | } => { 32 | self.stack.push(error); 33 | self.stack.push(rhs); 34 | self.stack.push(lhs); 35 | } 36 | Expression::Backtick { .. } | Expression::StringLiteral { .. } => {} 37 | Expression::Call { thunk } => match thunk { 38 | Thunk::Nullary { .. } => {} 39 | Thunk::Unary { arg, .. } => self.stack.push(arg), 40 | Thunk::UnaryOpt { 41 | args: (a, opt_b), .. 42 | } => { 43 | self.stack.push(a); 44 | if let Some(b) = opt_b.as_ref() { 45 | self.stack.push(b); 46 | } 47 | } 48 | Thunk::UnaryPlus { 49 | args: (a, rest), .. 50 | } => { 51 | let first: &[&Expression] = &[a]; 52 | for arg in first.iter().copied().chain(rest).rev() { 53 | self.stack.push(arg); 54 | } 55 | } 56 | Thunk::Binary { args, .. } => { 57 | for arg in args.iter().rev() { 58 | self.stack.push(arg); 59 | } 60 | } 61 | Thunk::BinaryPlus { 62 | args: ([a, b], rest), 63 | .. 64 | } => { 65 | let first: &[&Expression] = &[a, b]; 66 | for arg in first.iter().copied().chain(rest).rev() { 67 | self.stack.push(arg); 68 | } 69 | } 70 | Thunk::Ternary { args, .. } => { 71 | for arg in args.iter().rev() { 72 | self.stack.push(arg); 73 | } 74 | } 75 | }, 76 | Expression::Concatenation { lhs, rhs } => { 77 | self.stack.push(rhs); 78 | self.stack.push(lhs); 79 | } 80 | Expression::Conditional { 81 | condition: 82 | Condition { 83 | lhs, 84 | rhs, 85 | operator: _, 86 | }, 87 | then, 88 | otherwise, 89 | } => { 90 | self.stack.push(otherwise); 91 | self.stack.push(then); 92 | self.stack.push(rhs); 93 | self.stack.push(lhs); 94 | } 95 | Expression::Group { contents } => { 96 | self.stack.push(contents); 97 | } 98 | Expression::Join { lhs, rhs } => { 99 | self.stack.push(rhs); 100 | if let Some(lhs) = lhs { 101 | self.stack.push(lhs); 102 | } 103 | } 104 | Expression::Variable { name, .. } => return Some(name.token), 105 | } 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/verbosity.rs: -------------------------------------------------------------------------------- 1 | #[allow(clippy::arbitrary_source_item_ordering)] 2 | #[derive(Copy, Clone, Debug, PartialEq, PartialOrd)] 3 | pub(crate) enum Verbosity { 4 | Quiet, 5 | Taciturn, 6 | Loquacious, 7 | Grandiloquent, 8 | } 9 | 10 | impl Verbosity { 11 | pub(crate) fn from_flag_occurrences(flag_occurrences: u8) -> Self { 12 | match flag_occurrences { 13 | 0 => Self::Taciturn, 14 | 1 => Self::Loquacious, 15 | _ => Self::Grandiloquent, 16 | } 17 | } 18 | 19 | pub(crate) fn quiet(self) -> bool { 20 | self == Self::Quiet 21 | } 22 | 23 | pub(crate) fn loud(self) -> bool { 24 | !self.quiet() 25 | } 26 | 27 | pub(crate) fn loquacious(self) -> bool { 28 | self >= Self::Loquacious 29 | } 30 | 31 | pub(crate) fn grandiloquent(self) -> bool { 32 | self >= Self::Grandiloquent 33 | } 34 | 35 | pub const fn default() -> Self { 36 | Self::Taciturn 37 | } 38 | } 39 | 40 | impl Default for Verbosity { 41 | fn default() -> Self { 42 | Self::default() 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/warning.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[derive(Clone, Debug, PartialEq)] 4 | pub(crate) enum Warning {} 5 | 6 | impl Warning { 7 | #[allow(clippy::unused_self)] 8 | fn context(&self) -> Option<&Token> { 9 | None 10 | } 11 | } 12 | 13 | impl ColorDisplay for Warning { 14 | fn fmt(&self, f: &mut Formatter, color: Color) -> fmt::Result { 15 | let warning = color.warning(); 16 | let message = color.message(); 17 | 18 | write!(f, "{} {}", warning.paint("warning:"), message.prefix())?; 19 | 20 | write!(f, "{}", message.suffix())?; 21 | 22 | if let Some(token) = self.context() { 23 | writeln!(f)?; 24 | write!(f, "{}", token.color_display(color))?; 25 | } 26 | 27 | Ok(()) 28 | } 29 | } 30 | 31 | impl Serialize for Warning { 32 | fn serialize(&self, serializer: S) -> Result 33 | where 34 | S: Serializer, 35 | { 36 | let mut map = serializer.serialize_map(None)?; 37 | 38 | map.serialize_entry("message", &self.color_display(Color::never()).to_string())?; 39 | 40 | map.end() 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/which.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | pub(crate) fn which(context: function::Context, name: &str) -> Result, String> { 4 | let name = Path::new(name); 5 | 6 | let candidates = match name.components().count() { 7 | 0 => return Err("empty command".into()), 8 | 1 => { 9 | // cmd is a regular command 10 | env::split_paths(&env::var_os("PATH").ok_or("`PATH` environment variable not set")?) 11 | .map(|path| path.join(name)) 12 | .collect() 13 | } 14 | _ => { 15 | // cmd contains a path separator, treat it as a path 16 | vec![name.into()] 17 | } 18 | }; 19 | 20 | for mut candidate in candidates { 21 | if candidate.is_relative() { 22 | // This candidate is a relative path, either because the user invoked `which("rel/path")`, 23 | // or because there was a relative path in `PATH`. Resolve it to an absolute path, 24 | // relative to the working directory of the just invocation. 25 | candidate = context 26 | .evaluator 27 | .context 28 | .working_directory() 29 | .join(candidate); 30 | } 31 | 32 | candidate = candidate.lexiclean(); 33 | 34 | if is_executable::is_executable(&candidate) { 35 | return candidate 36 | .to_str() 37 | .map(|candidate| Some(candidate.into())) 38 | .ok_or_else(|| { 39 | format!( 40 | "Executable path is not valid unicode: {}", 41 | candidate.display() 42 | ) 43 | }); 44 | } 45 | } 46 | 47 | Ok(None) 48 | } 49 | -------------------------------------------------------------------------------- /tests/alias.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn alias_nested_module() { 5 | Test::new() 6 | .write("foo.just", "mod bar\nbaz: \n @echo FOO") 7 | .write("bar.just", "baz:\n @echo BAZ") 8 | .justfile( 9 | " 10 | mod foo 11 | 12 | alias b := foo::bar::baz 13 | 14 | baz: 15 | @echo 'HERE' 16 | ", 17 | ) 18 | .arg("b") 19 | .stdout("BAZ\n") 20 | .run(); 21 | } 22 | 23 | #[test] 24 | fn unknown_nested_alias() { 25 | Test::new() 26 | .write("foo.just", "baz: \n @echo FOO") 27 | .justfile( 28 | " 29 | mod foo 30 | 31 | alias b := foo::bar::baz 32 | ", 33 | ) 34 | .arg("b") 35 | .stderr( 36 | "\ 37 | error: Alias `b` has an unknown target `foo::bar::baz` 38 | ——▶ justfile:3:7 39 | │ 40 | 3 │ alias b := foo::bar::baz 41 | │ ^ 42 | ", 43 | ) 44 | .status(EXIT_FAILURE) 45 | .run(); 46 | } 47 | 48 | #[test] 49 | fn alias_in_submodule() { 50 | Test::new() 51 | .write( 52 | "foo.just", 53 | " 54 | alias b := bar 55 | 56 | bar: 57 | @echo BAR 58 | ", 59 | ) 60 | .justfile( 61 | " 62 | mod foo 63 | ", 64 | ) 65 | .arg("foo::b") 66 | .stdout("BAR\n") 67 | .run(); 68 | } 69 | -------------------------------------------------------------------------------- /tests/alias_style.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn default() { 5 | Test::new() 6 | .justfile( 7 | " 8 | alias f := foo 9 | 10 | # comment 11 | foo: 12 | 13 | bar: 14 | ", 15 | ) 16 | .args(["--list"]) 17 | .stdout( 18 | " 19 | Available recipes: 20 | bar 21 | foo # comment [alias: f] 22 | ", 23 | ) 24 | .run(); 25 | } 26 | 27 | #[test] 28 | fn multiple() { 29 | Test::new() 30 | .justfile( 31 | " 32 | alias a := foo 33 | alias b := foo 34 | 35 | # comment 36 | foo: 37 | 38 | bar: 39 | ", 40 | ) 41 | .args(["--list"]) 42 | .stdout( 43 | " 44 | Available recipes: 45 | bar 46 | foo # comment [aliases: a, b] 47 | ", 48 | ) 49 | .run(); 50 | } 51 | 52 | #[test] 53 | fn right() { 54 | Test::new() 55 | .justfile( 56 | " 57 | alias f := foo 58 | 59 | # comment 60 | foo: 61 | 62 | bar: 63 | ", 64 | ) 65 | .args(["--alias-style=right", "--list"]) 66 | .stdout( 67 | " 68 | Available recipes: 69 | bar 70 | foo # comment [alias: f] 71 | ", 72 | ) 73 | .run(); 74 | } 75 | 76 | #[test] 77 | fn left() { 78 | Test::new() 79 | .justfile( 80 | " 81 | alias f := foo 82 | 83 | # comment 84 | foo: 85 | 86 | bar: 87 | ", 88 | ) 89 | .args(["--alias-style=left", "--list"]) 90 | .stdout( 91 | " 92 | Available recipes: 93 | bar 94 | foo # [alias: f] comment 95 | ", 96 | ) 97 | .run(); 98 | } 99 | 100 | #[test] 101 | fn separate() { 102 | Test::new() 103 | .justfile( 104 | " 105 | alias f := foo 106 | 107 | # comment 108 | foo: 109 | 110 | bar: 111 | ", 112 | ) 113 | .args(["--alias-style=separate", "--list"]) 114 | .stdout( 115 | " 116 | Available recipes: 117 | bar 118 | foo # comment 119 | f # alias for `foo` 120 | ", 121 | ) 122 | .run(); 123 | } 124 | -------------------------------------------------------------------------------- /tests/allow_duplicate_recipes.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn allow_duplicate_recipes() { 5 | Test::new() 6 | .justfile( 7 | " 8 | b: 9 | echo foo 10 | b: 11 | echo bar 12 | 13 | set allow-duplicate-recipes 14 | ", 15 | ) 16 | .stdout("bar\n") 17 | .stderr("echo bar\n") 18 | .run(); 19 | } 20 | 21 | #[test] 22 | fn allow_duplicate_recipes_with_args() { 23 | Test::new() 24 | .justfile( 25 | " 26 | b a: 27 | echo foo 28 | b c d: 29 | echo bar {{c}} {{d}} 30 | 31 | set allow-duplicate-recipes 32 | ", 33 | ) 34 | .args(["b", "one", "two"]) 35 | .stdout("bar one two\n") 36 | .stderr("echo bar one two\n") 37 | .run(); 38 | } 39 | -------------------------------------------------------------------------------- /tests/allow_duplicate_variables.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn allow_duplicate_variables() { 5 | Test::new() 6 | .justfile( 7 | " 8 | a := 'foo' 9 | a := 'bar' 10 | 11 | set allow-duplicate-variables 12 | 13 | b: 14 | echo {{a}} 15 | ", 16 | ) 17 | .arg("b") 18 | .stdout("bar\n") 19 | .stderr("echo bar\n") 20 | .run(); 21 | } 22 | -------------------------------------------------------------------------------- /tests/allow_missing.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn allow_missing_recipes_in_run_invocation() { 5 | Test::new() 6 | .arg("foo") 7 | .stderr("error: Justfile does not contain recipe `foo`\n") 8 | .status(EXIT_FAILURE) 9 | .run(); 10 | 11 | Test::new().args(["--allow-missing", "foo"]).run(); 12 | } 13 | 14 | #[test] 15 | fn allow_missing_modules_in_run_invocation() { 16 | Test::new() 17 | .arg("foo::bar") 18 | .stderr("error: Justfile does not contain submodule `foo`\n") 19 | .status(EXIT_FAILURE) 20 | .run(); 21 | 22 | Test::new().args(["--allow-missing", "foo::bar"]).run(); 23 | } 24 | 25 | #[test] 26 | fn allow_missing_does_not_apply_to_compilation_errors() { 27 | Test::new() 28 | .justfile("bar: foo") 29 | .args(["--allow-missing", "foo"]) 30 | .stderr( 31 | " 32 | error: Recipe `bar` has unknown dependency `foo` 33 | ——▶ justfile:1:6 34 | │ 35 | 1 │ bar: foo 36 | │ ^^^ 37 | ", 38 | ) 39 | .status(EXIT_FAILURE) 40 | .run(); 41 | } 42 | 43 | #[test] 44 | fn allow_missing_does_not_apply_to_other_subcommands() { 45 | Test::new() 46 | .args(["--allow-missing", "--show", "foo"]) 47 | .stderr("error: Justfile does not contain recipe `foo`\n") 48 | .status(EXIT_FAILURE) 49 | .run(); 50 | } 51 | -------------------------------------------------------------------------------- /tests/assert_stdout.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | pub(crate) fn assert_stdout(output: &std::process::Output, stdout: &str) { 4 | assert_success(output); 5 | assert_eq!(String::from_utf8_lossy(&output.stdout), stdout); 6 | } 7 | -------------------------------------------------------------------------------- /tests/assert_success.rs: -------------------------------------------------------------------------------- 1 | #[track_caller] 2 | pub(crate) fn assert_success(output: &std::process::Output) { 3 | if !output.status.success() { 4 | eprintln!("stderr: {}", String::from_utf8_lossy(&output.stderr)); 5 | eprintln!("stdout: {}", String::from_utf8_lossy(&output.stdout)); 6 | panic!("{}", output.status); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tests/assertions.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn assert_pass() { 5 | Test::new() 6 | .justfile( 7 | " 8 | foo: 9 | {{ assert('a' == 'a', 'error message') }} 10 | ", 11 | ) 12 | .run(); 13 | } 14 | 15 | #[test] 16 | fn assert_fail() { 17 | Test::new() 18 | .justfile( 19 | " 20 | foo: 21 | {{ assert('a' != 'a', 'error message') }} 22 | ", 23 | ) 24 | .stderr("error: Assert failed: error message\n") 25 | .status(EXIT_FAILURE) 26 | .run(); 27 | } 28 | -------------------------------------------------------------------------------- /tests/assignment.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn set_export_parse_error() { 5 | Test::new() 6 | .justfile( 7 | " 8 | set export := fals 9 | ", 10 | ) 11 | .stderr( 12 | " 13 | error: Expected keyword `true` or `false` but found identifier `fals` 14 | ——▶ justfile:1:15 15 | │ 16 | 1 │ set export := fals 17 | │ ^^^^ 18 | ", 19 | ) 20 | .status(EXIT_FAILURE) 21 | .run(); 22 | } 23 | 24 | #[test] 25 | fn set_export_parse_error_eol() { 26 | Test::new() 27 | .justfile( 28 | " 29 | set export := 30 | ", 31 | ) 32 | .stderr( 33 | " 34 | error: Expected identifier, but found end of line 35 | ——▶ justfile:1:14 36 | │ 37 | 1 │ set export := 38 | │ ^ 39 | ", 40 | ) 41 | .status(EXIT_FAILURE) 42 | .run(); 43 | } 44 | 45 | #[test] 46 | fn invalid_attributes_are_an_error() { 47 | Test::new() 48 | .justfile( 49 | " 50 | [group: 'bar'] 51 | x := 'foo' 52 | ", 53 | ) 54 | .args(["--evaluate", "x"]) 55 | .stderr( 56 | " 57 | error: Assignment `x` has invalid attribute `group` 58 | ——▶ justfile:2:1 59 | │ 60 | 2 │ x := 'foo' 61 | │ ^ 62 | ", 63 | ) 64 | .status(EXIT_FAILURE) 65 | .run(); 66 | } 67 | -------------------------------------------------------------------------------- /tests/backticks.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn trailing_newlines_are_stripped() { 5 | Test::new() 6 | .shell(false) 7 | .args(["--evaluate", "foos"]) 8 | .justfile( 9 | " 10 | set shell := ['python3', '-c'] 11 | 12 | foos := `print('foo' * 4)` 13 | ", 14 | ) 15 | .stdout("foofoofoofoo") 16 | .run(); 17 | } 18 | -------------------------------------------------------------------------------- /tests/byte_order_mark.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn ignore_leading_byte_order_mark() { 5 | Test::new() 6 | .justfile( 7 | " 8 | \u{feff}foo: 9 | echo bar 10 | ", 11 | ) 12 | .stderr("echo bar\n") 13 | .stdout("bar\n") 14 | .run(); 15 | } 16 | 17 | #[test] 18 | fn non_leading_byte_order_mark_produces_error() { 19 | Test::new() 20 | .justfile( 21 | " 22 | foo: 23 | echo bar 24 | \u{feff} 25 | ", 26 | ) 27 | .stderr( 28 | " 29 | error: Expected \'@\', \'[\', comment, end of file, end of line, or identifier, but found byte order mark 30 | ——▶ justfile:3:1 31 | │ 32 | 3 │ \u{feff} 33 | │ ^ 34 | ") 35 | .status(EXIT_FAILURE) 36 | .run(); 37 | } 38 | 39 | #[test] 40 | fn dont_mention_byte_order_mark_in_errors() { 41 | Test::new() 42 | .justfile("{") 43 | .stderr( 44 | " 45 | error: Expected '@', '[', comment, end of file, end of line, or identifier, but found '{' 46 | ——▶ justfile:1:1 47 | │ 48 | 1 │ { 49 | │ ^ 50 | ", 51 | ) 52 | .status(EXIT_FAILURE) 53 | .run(); 54 | } 55 | -------------------------------------------------------------------------------- /tests/changelog.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn print_changelog() { 5 | Test::new() 6 | .args(["--changelog"]) 7 | .stdout(fs::read_to_string("CHANGELOG.md").unwrap()) 8 | .run(); 9 | } 10 | -------------------------------------------------------------------------------- /tests/completions.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | #[cfg(target_os = "linux")] 5 | fn bash() { 6 | let output = Command::new(executable_path("just")) 7 | .args(["--completions", "bash"]) 8 | .output() 9 | .unwrap(); 10 | 11 | assert!(output.status.success()); 12 | 13 | let script = str::from_utf8(&output.stdout).unwrap(); 14 | 15 | let tempdir = tempdir(); 16 | 17 | let path = tempdir.path().join("just.bash"); 18 | 19 | fs::write(&path, script).unwrap(); 20 | 21 | let status = Command::new("./tests/completions/just.bash") 22 | .arg(path) 23 | .status() 24 | .unwrap(); 25 | 26 | assert!(status.success()); 27 | } 28 | 29 | #[test] 30 | fn replacements() { 31 | for shell in ["bash", "elvish", "fish", "nushell", "powershell", "zsh"] { 32 | let output = Command::new(executable_path("just")) 33 | .args(["--completions", shell]) 34 | .output() 35 | .unwrap(); 36 | assert!( 37 | output.status.success(), 38 | "shell completion generation for {shell} failed: {}\n{}", 39 | output.status, 40 | String::from_utf8_lossy(&output.stderr), 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tests/completions/just.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # --- Shared functions --- 4 | reply_equals() { 5 | local reply=$(declare -p COMPREPLY) 6 | local expected=$1 7 | 8 | if [ "$reply" = "$expected" ]; then 9 | echo "${FUNCNAME[1]}: ok" 10 | else 11 | exit_code=1 12 | echo >&2 "${FUNCNAME[1]}: failed! Completion for \`${COMP_WORDS[*]}\` does not match." 13 | 14 | echo 15 | diff -U3 --label expected <(echo "$expected") --label actual <(echo "$reply") >&2 16 | echo 17 | fi 18 | } 19 | 20 | # --- Initial Setup --- 21 | source "$1" 22 | cd tests/completions 23 | cargo build 24 | PATH="$(git rev-parse --show-toplevel)/target/debug:$PATH" 25 | exit_code=0 26 | 27 | # --- Tests --- 28 | test_complete_all_recipes() { 29 | COMP_WORDS=(just) 30 | COMP_CWORD=1 _just just 31 | reply_equals 'declare -a COMPREPLY=([0]="deploy" [1]="install" [2]="publish" [3]="push" [4]="test")' 32 | } 33 | test_complete_all_recipes 34 | 35 | test_complete_recipes_starting_with_i() { 36 | COMP_WORDS=(just i) 37 | COMP_CWORD=1 _just just 38 | reply_equals 'declare -a COMPREPLY=([0]="install")' 39 | } 40 | test_complete_recipes_starting_with_i 41 | 42 | test_complete_recipes_starting_with_p() { 43 | COMP_WORDS=(just p) 44 | COMP_CWORD=1 _just just 45 | reply_equals 'declare -a COMPREPLY=([0]="publish" [1]="push")' 46 | } 47 | test_complete_recipes_starting_with_p 48 | 49 | test_complete_recipes_from_subdirs() { 50 | COMP_WORDS=(just subdir/) 51 | COMP_CWORD=1 _just just 52 | reply_equals 'declare -a COMPREPLY=([0]="subdir/special" [1]="subdir/surprise")' 53 | } 54 | test_complete_recipes_from_subdirs 55 | 56 | # --- Conclusion --- 57 | if [ $exit_code = 0 ]; then 58 | echo "All tests passed." 59 | else 60 | echo "Some test[s] failed." 61 | fi 62 | 63 | exit $exit_code 64 | -------------------------------------------------------------------------------- /tests/completions/justfile: -------------------------------------------------------------------------------- 1 | install: 2 | test: 3 | deploy: 4 | push: 5 | publish: 6 | -------------------------------------------------------------------------------- /tests/completions/subdir/justfile: -------------------------------------------------------------------------------- 1 | special: 2 | surprise: 3 | -------------------------------------------------------------------------------- /tests/constants.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn constants_are_defined() { 5 | assert_eval_eq("HEX", "0123456789abcdef"); 6 | } 7 | 8 | #[test] 9 | fn constants_are_defined_in_recipe_bodies() { 10 | Test::new() 11 | .justfile( 12 | " 13 | @foo: 14 | echo {{HEX}} 15 | ", 16 | ) 17 | .stdout("0123456789abcdef\n") 18 | .run(); 19 | } 20 | 21 | #[test] 22 | fn constants_are_defined_in_recipe_parameters() { 23 | Test::new() 24 | .justfile( 25 | " 26 | @foo hex=HEX: 27 | echo {{hex}} 28 | ", 29 | ) 30 | .stdout("0123456789abcdef\n") 31 | .run(); 32 | } 33 | 34 | #[test] 35 | fn constants_can_be_redefined() { 36 | Test::new() 37 | .justfile( 38 | " 39 | HEX := 'foo' 40 | ", 41 | ) 42 | .args(["--evaluate", "HEX"]) 43 | .stdout("foo") 44 | .run(); 45 | } 46 | 47 | #[test] 48 | fn constants_are_not_exported() { 49 | Test::new() 50 | .justfile( 51 | r#" 52 | set export 53 | 54 | foo: 55 | @'{{just_executable()}}' --request '{"environment-variable": "HEXUPPER"}' 56 | "#, 57 | ) 58 | .response(Response::EnvironmentVariable(None)) 59 | .run(); 60 | } 61 | -------------------------------------------------------------------------------- /tests/datetime.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn datetime() { 5 | Test::new() 6 | .justfile( 7 | " 8 | x := datetime('%Y-%m-%d %z') 9 | ", 10 | ) 11 | .args(["--eval", "x"]) 12 | .stdout_regex(r"\d\d\d\d-\d\d-\d\d [+-]\d\d\d\d") 13 | .run(); 14 | } 15 | 16 | #[test] 17 | fn datetime_utc() { 18 | Test::new() 19 | .justfile( 20 | " 21 | x := datetime_utc('%Y-%m-%d %Z') 22 | ", 23 | ) 24 | .args(["--eval", "x"]) 25 | .stdout_regex(r"\d\d\d\d-\d\d-\d\d UTC") 26 | .run(); 27 | } 28 | -------------------------------------------------------------------------------- /tests/delimiters.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn mismatched_delimiter() { 5 | Test::new() 6 | .justfile("(]") 7 | .stderr( 8 | " 9 | error: Mismatched closing delimiter `]`. (Did you mean to close the `(` on line 1?) 10 | ——▶ justfile:1:2 11 | │ 12 | 1 │ (] 13 | │ ^ 14 | ", 15 | ) 16 | .status(EXIT_FAILURE) 17 | .run(); 18 | } 19 | 20 | #[test] 21 | fn unexpected_delimiter() { 22 | Test::new() 23 | .justfile("]") 24 | .stderr( 25 | " 26 | error: Unexpected closing delimiter `]` 27 | ——▶ justfile:1:1 28 | │ 29 | 1 │ ] 30 | │ ^ 31 | ", 32 | ) 33 | .status(EXIT_FAILURE) 34 | .run(); 35 | } 36 | 37 | #[test] 38 | fn paren_continuation() { 39 | Test::new() 40 | .justfile( 41 | " 42 | x := ( 43 | 'a' 44 | + 45 | 'b' 46 | ) 47 | 48 | foo: 49 | echo {{x}} 50 | ", 51 | ) 52 | .stdout("ab\n") 53 | .stderr("echo ab\n") 54 | .run(); 55 | } 56 | 57 | #[test] 58 | fn brace_continuation() { 59 | Test::new() 60 | .justfile( 61 | " 62 | x := if '' == '' { 63 | 'a' 64 | } else { 65 | 'b' 66 | } 67 | 68 | foo: 69 | echo {{x}} 70 | ", 71 | ) 72 | .stdout("a\n") 73 | .stderr("echo a\n") 74 | .run(); 75 | } 76 | 77 | #[test] 78 | fn bracket_continuation() { 79 | Test::new() 80 | .justfile( 81 | " 82 | set shell := [ 83 | 'sh', 84 | '-cu', 85 | ] 86 | 87 | foo: 88 | echo foo 89 | ", 90 | ) 91 | .stdout("foo\n") 92 | .stderr("echo foo\n") 93 | .run(); 94 | } 95 | 96 | #[test] 97 | fn dependency_continuation() { 98 | Test::new() 99 | .justfile( 100 | " 101 | foo: ( 102 | bar 'bar' 103 | ) 104 | echo foo 105 | 106 | bar x: 107 | echo {{x}} 108 | ", 109 | ) 110 | .stdout("bar\nfoo\n") 111 | .stderr("echo bar\necho foo\n") 112 | .run(); 113 | } 114 | 115 | #[test] 116 | fn no_interpolation_continuation() { 117 | Test::new() 118 | .justfile( 119 | " 120 | foo: 121 | echo {{ ( 122 | 'a' + 'b')}} 123 | ", 124 | ) 125 | .stderr( 126 | " 127 | error: Unterminated interpolation 128 | ——▶ justfile:2:8 129 | │ 130 | 2 │ echo {{ ( 131 | │ ^^ 132 | ", 133 | ) 134 | .status(EXIT_FAILURE) 135 | .run(); 136 | } 137 | -------------------------------------------------------------------------------- /tests/directories.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn cache_directory() { 5 | Test::new() 6 | .justfile("x := cache_directory()") 7 | .args(["--evaluate", "x"]) 8 | .stdout(dirs::cache_dir().unwrap_or_default().to_string_lossy()) 9 | .run(); 10 | } 11 | 12 | #[test] 13 | fn config_directory() { 14 | Test::new() 15 | .justfile("x := config_directory()") 16 | .args(["--evaluate", "x"]) 17 | .stdout(dirs::config_dir().unwrap_or_default().to_string_lossy()) 18 | .run(); 19 | } 20 | 21 | #[test] 22 | fn config_local_directory() { 23 | Test::new() 24 | .justfile("x := config_local_directory()") 25 | .args(["--evaluate", "x"]) 26 | .stdout( 27 | dirs::config_local_dir() 28 | .unwrap_or_default() 29 | .to_string_lossy(), 30 | ) 31 | .run(); 32 | } 33 | 34 | #[test] 35 | fn data_directory() { 36 | Test::new() 37 | .justfile("x := data_directory()") 38 | .args(["--evaluate", "x"]) 39 | .stdout(dirs::data_dir().unwrap_or_default().to_string_lossy()) 40 | .run(); 41 | } 42 | 43 | #[test] 44 | fn data_local_directory() { 45 | Test::new() 46 | .justfile("x := data_local_directory()") 47 | .args(["--evaluate", "x"]) 48 | .stdout(dirs::data_local_dir().unwrap_or_default().to_string_lossy()) 49 | .run(); 50 | } 51 | 52 | #[test] 53 | fn executable_directory() { 54 | if let Some(executable_dir) = dirs::executable_dir() { 55 | Test::new() 56 | .justfile("x := executable_directory()") 57 | .args(["--evaluate", "x"]) 58 | .stdout(executable_dir.to_string_lossy()) 59 | .run(); 60 | } else { 61 | Test::new() 62 | .justfile("x := executable_directory()") 63 | .args(["--evaluate", "x"]) 64 | .stderr( 65 | " 66 | error: Call to function `executable_directory` failed: executable directory not found 67 | ——▶ justfile:1:6 68 | │ 69 | 1 │ x := executable_directory() 70 | │ ^^^^^^^^^^^^^^^^^^^^ 71 | ", 72 | ) 73 | .status(EXIT_FAILURE) 74 | .run(); 75 | } 76 | } 77 | 78 | #[test] 79 | fn home_directory() { 80 | Test::new() 81 | .justfile("x := home_directory()") 82 | .args(["--evaluate", "x"]) 83 | .stdout(dirs::home_dir().unwrap_or_default().to_string_lossy()) 84 | .run(); 85 | } 86 | -------------------------------------------------------------------------------- /tests/equals.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn export_recipe() { 5 | Test::new() 6 | .justfile( 7 | " 8 | export foo='bar': 9 | echo {{foo}} 10 | ", 11 | ) 12 | .stdout("bar\n") 13 | .stderr("echo bar\n") 14 | .run(); 15 | } 16 | 17 | #[test] 18 | fn alias_recipe() { 19 | Test::new() 20 | .justfile( 21 | " 22 | alias foo='bar': 23 | echo {{foo}} 24 | ", 25 | ) 26 | .stdout("bar\n") 27 | .stderr("echo bar\n") 28 | .run(); 29 | } 30 | -------------------------------------------------------------------------------- /tests/error_messages.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn invalid_alias_attribute() { 5 | Test::new() 6 | .justfile("[private]\n[linux]\nalias t := test\n\ntest:\n") 7 | .stderr( 8 | " 9 | error: Alias `t` has invalid attribute `linux` 10 | ——▶ justfile:3:7 11 | │ 12 | 3 │ alias t := test 13 | │ ^ 14 | ", 15 | ) 16 | .status(EXIT_FAILURE) 17 | .run(); 18 | } 19 | 20 | #[test] 21 | fn expected_keyword() { 22 | Test::new() 23 | .justfile("foo := if '' == '' { '' } arlo { '' }") 24 | .stderr( 25 | " 26 | error: Expected keyword `else` but found identifier `arlo` 27 | ——▶ justfile:1:27 28 | │ 29 | 1 │ foo := if '' == '' { '' } arlo { '' } 30 | │ ^^^^ 31 | ", 32 | ) 33 | .status(EXIT_FAILURE) 34 | .run(); 35 | } 36 | 37 | #[test] 38 | fn unexpected_character() { 39 | Test::new() 40 | .justfile("&~") 41 | .stderr( 42 | " 43 | error: Expected character `&` 44 | ——▶ justfile:1:2 45 | │ 46 | 1 │ &~ 47 | │ ^ 48 | ", 49 | ) 50 | .status(EXIT_FAILURE) 51 | .run(); 52 | } 53 | 54 | #[test] 55 | fn argument_count_mismatch() { 56 | Test::new() 57 | .justfile("foo a b:") 58 | .args(["foo"]) 59 | .stderr( 60 | " 61 | error: Recipe `foo` got 0 arguments but takes 2 62 | usage: 63 | just foo a b 64 | ", 65 | ) 66 | .status(EXIT_FAILURE) 67 | .run(); 68 | } 69 | 70 | #[test] 71 | fn file_path_is_indented_if_justfile_is_long() { 72 | Test::new() 73 | .justfile("\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nfoo") 74 | .status(EXIT_FAILURE) 75 | .stderr( 76 | " 77 | error: Expected '*', ':', '$', identifier, or '+', but found end of file 78 | ——▶ justfile:20:4 79 | │ 80 | 20 │ foo 81 | │ ^ 82 | ", 83 | ) 84 | .run(); 85 | } 86 | 87 | #[test] 88 | fn file_paths_are_relative() { 89 | Test::new() 90 | .justfile("import 'foo/bar.just'") 91 | .write("foo/bar.just", "baz") 92 | .status(EXIT_FAILURE) 93 | .stderr(format!( 94 | " 95 | error: Expected '*', ':', '$', identifier, or '+', but found end of file 96 | ——▶ foo{MAIN_SEPARATOR}bar.just:1:4 97 | │ 98 | 1 │ baz 99 | │ ^ 100 | ", 101 | )) 102 | .run(); 103 | } 104 | 105 | #[test] 106 | #[cfg(not(windows))] 107 | fn file_paths_not_in_subdir_are_absolute() { 108 | Test::new() 109 | .write("foo/justfile", "import '../bar.just'") 110 | .write("bar.just", "baz") 111 | .no_justfile() 112 | .args(["--justfile", "foo/justfile"]) 113 | .status(EXIT_FAILURE) 114 | .stderr_regex( 115 | r"error: Expected '\*', ':', '\$', identifier, or '\+', but found end of file 116 | ——▶ /.*/bar.just:1:4 117 | │ 118 | 1 │ baz 119 | │ \^ 120 | ", 121 | ) 122 | .run(); 123 | } 124 | 125 | #[test] 126 | fn redefinition_errors_properly_swap_types() { 127 | Test::new() 128 | .write("foo.just", "foo:") 129 | .justfile("foo:\n echo foo\n\nmod foo 'foo.just'") 130 | .status(EXIT_FAILURE) 131 | .stderr( 132 | " 133 | error: Recipe `foo` defined on line 1 is redefined as a module on line 4 134 | ——▶ justfile:4:5 135 | │ 136 | 4 │ mod foo 'foo.just' 137 | │ ^^^ 138 | ", 139 | ) 140 | .run(); 141 | } 142 | -------------------------------------------------------------------------------- /tests/evaluate.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn evaluate() { 5 | Test::new() 6 | .arg("--evaluate") 7 | .justfile( 8 | r#" 9 | foo := "a\t" 10 | hello := "c" 11 | bar := "b\t" 12 | ab := foo + bar + hello 13 | 14 | wut: 15 | touch /this/is/not/a/file 16 | "#, 17 | ) 18 | .stdout( 19 | r#"ab := "a b c" 20 | bar := "b " 21 | foo := "a " 22 | hello := "c" 23 | "#, 24 | ) 25 | .run(); 26 | } 27 | 28 | #[test] 29 | fn evaluate_empty() { 30 | Test::new() 31 | .arg("--evaluate") 32 | .justfile( 33 | " 34 | a := 'foo' 35 | ", 36 | ) 37 | .stdout( 38 | r#" 39 | a := "foo" 40 | "#, 41 | ) 42 | .run(); 43 | } 44 | 45 | #[test] 46 | fn evaluate_multiple() { 47 | Test::new() 48 | .arg("--evaluate") 49 | .arg("a") 50 | .arg("c") 51 | .justfile( 52 | " 53 | a := 'x' 54 | b := 'y' 55 | c := 'z' 56 | ", 57 | ) 58 | .stderr("error: `--evaluate` used with unexpected argument: `c`\n") 59 | .status(EXIT_FAILURE) 60 | .run(); 61 | } 62 | 63 | #[test] 64 | fn evaluate_single_free() { 65 | Test::new() 66 | .arg("--evaluate") 67 | .arg("b") 68 | .justfile( 69 | " 70 | a := 'x' 71 | b := 'y' 72 | c := 'z' 73 | ", 74 | ) 75 | .stdout("y") 76 | .run(); 77 | } 78 | 79 | #[test] 80 | fn evaluate_no_suggestion() { 81 | Test::new() 82 | .arg("--evaluate") 83 | .arg("aby") 84 | .justfile( 85 | " 86 | abc := 'x' 87 | ", 88 | ) 89 | .status(EXIT_FAILURE) 90 | .stderr( 91 | " 92 | error: Justfile does not contain variable `aby`. 93 | Did you mean `abc`? 94 | ", 95 | ) 96 | .run(); 97 | } 98 | 99 | #[test] 100 | fn evaluate_suggestion() { 101 | Test::new() 102 | .arg("--evaluate") 103 | .arg("goodbye") 104 | .justfile( 105 | " 106 | hello := 'x' 107 | ", 108 | ) 109 | .status(EXIT_FAILURE) 110 | .stderr( 111 | " 112 | error: Justfile does not contain variable `goodbye`. 113 | ", 114 | ) 115 | .run(); 116 | } 117 | 118 | #[test] 119 | fn evaluate_private() { 120 | Test::new() 121 | .arg("--evaluate") 122 | .justfile( 123 | " 124 | [private] 125 | foo := 'one' 126 | bar := 'two' 127 | _baz := 'three' 128 | ", 129 | ) 130 | .stdout("bar := \"two\"\n") 131 | .status(EXIT_SUCCESS) 132 | .run(); 133 | } 134 | 135 | #[test] 136 | fn evaluate_single_private() { 137 | Test::new() 138 | .arg("--evaluate") 139 | .arg("foo") 140 | .justfile( 141 | " 142 | [private] 143 | foo := 'one' 144 | bar := 'two' 145 | _baz := 'three' 146 | ", 147 | ) 148 | .stdout("one") 149 | .status(EXIT_SUCCESS) 150 | .run(); 151 | } 152 | -------------------------------------------------------------------------------- /tests/examples.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn examples() { 5 | for result in fs::read_dir("examples").unwrap() { 6 | let entry = result.unwrap(); 7 | let path = entry.path(); 8 | 9 | println!("Parsing `{}`…", path.display()); 10 | 11 | let output = Command::new(executable_path("just")) 12 | .arg("--justfile") 13 | .arg(&path) 14 | .arg("--dump") 15 | .output() 16 | .unwrap(); 17 | 18 | assert_success(&output); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/explain.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn explain_recipe() { 5 | Test::new() 6 | .justfile( 7 | " 8 | # List some fruits 9 | fruits: 10 | echo 'apple peach dragonfruit' 11 | ", 12 | ) 13 | .args(["--explain", "fruits"]) 14 | .stdout("apple peach dragonfruit\n") 15 | .stderr( 16 | " 17 | #### List some fruits 18 | echo 'apple peach dragonfruit' 19 | ", 20 | ) 21 | .run(); 22 | } 23 | -------------------------------------------------------------------------------- /tests/global.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | #[cfg(target_os = "macos")] 5 | fn macos() { 6 | let tempdir = tempdir(); 7 | 8 | let path = tempdir.path().to_owned(); 9 | 10 | Test::with_tempdir(tempdir) 11 | .no_justfile() 12 | .test_round_trip(false) 13 | .write( 14 | "Library/Application Support/just/justfile", 15 | "@default:\n echo foo", 16 | ) 17 | .env("HOME", path.to_str().unwrap()) 18 | .args(["--global-justfile"]) 19 | .stdout("foo\n") 20 | .run(); 21 | } 22 | 23 | #[test] 24 | #[cfg(all(unix, not(target_os = "macos")))] 25 | fn not_macos() { 26 | let tempdir = tempdir(); 27 | 28 | let path = tempdir.path().to_owned(); 29 | 30 | Test::with_tempdir(tempdir) 31 | .no_justfile() 32 | .test_round_trip(false) 33 | .write("just/justfile", "@default:\n echo foo") 34 | .env("XDG_CONFIG_HOME", path.to_str().unwrap()) 35 | .args(["--global-justfile"]) 36 | .stdout("foo\n") 37 | .run(); 38 | } 39 | 40 | #[test] 41 | #[cfg(unix)] 42 | fn unix() { 43 | let tempdir = tempdir(); 44 | 45 | let path = tempdir.path().to_owned(); 46 | 47 | let tempdir = Test::with_tempdir(tempdir) 48 | .no_justfile() 49 | .test_round_trip(false) 50 | .write("justfile", "@default:\n echo foo") 51 | .env("HOME", path.to_str().unwrap()) 52 | .args(["--global-justfile"]) 53 | .stdout("foo\n") 54 | .run() 55 | .tempdir; 56 | 57 | Test::with_tempdir(tempdir) 58 | .no_justfile() 59 | .test_round_trip(false) 60 | .write(".config/just/justfile", "@default:\n echo bar") 61 | .env("HOME", path.to_str().unwrap()) 62 | .args(["--global-justfile"]) 63 | .stdout("bar\n") 64 | .run(); 65 | } 66 | -------------------------------------------------------------------------------- /tests/ignore_comments.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn ignore_comments_in_recipe() { 5 | Test::new() 6 | .justfile( 7 | " 8 | set ignore-comments 9 | 10 | some_recipe: 11 | # A recipe-internal comment 12 | echo something-useful 13 | ", 14 | ) 15 | .stdout("something-useful\n") 16 | .stderr("echo something-useful\n") 17 | .run(); 18 | } 19 | 20 | #[test] 21 | fn dont_ignore_comments_in_recipe_by_default() { 22 | Test::new() 23 | .justfile( 24 | " 25 | some_recipe: 26 | # A recipe-internal comment 27 | echo something-useful 28 | ", 29 | ) 30 | .stdout("something-useful\n") 31 | .stderr("# A recipe-internal comment\necho something-useful\n") 32 | .run(); 33 | } 34 | 35 | #[test] 36 | fn ignore_recipe_comments_with_shell_setting() { 37 | Test::new() 38 | .justfile( 39 | " 40 | set shell := ['echo', '-n'] 41 | set ignore-comments 42 | 43 | some_recipe: 44 | # Alternate shells still ignore comments 45 | echo something-useful 46 | ", 47 | ) 48 | .stdout("something-useful\n") 49 | .stderr("echo something-useful\n") 50 | .run(); 51 | } 52 | 53 | #[test] 54 | fn continuations_with_echo_comments_false() { 55 | Test::new() 56 | .justfile( 57 | " 58 | set ignore-comments 59 | 60 | some_recipe: 61 | # Comment lines ignore line continuations \\ 62 | echo something-useful 63 | ", 64 | ) 65 | .stdout("something-useful\n") 66 | .stderr("echo something-useful\n") 67 | .run(); 68 | } 69 | 70 | #[test] 71 | fn continuations_with_echo_comments_true() { 72 | Test::new() 73 | .justfile( 74 | " 75 | set ignore-comments := false 76 | 77 | some_recipe: 78 | # comment lines can be continued \\ 79 | echo something-useful 80 | ", 81 | ) 82 | .stderr("# comment lines can be continued echo something-useful\n") 83 | .run(); 84 | } 85 | 86 | #[test] 87 | fn dont_evaluate_comments() { 88 | Test::new() 89 | .justfile( 90 | " 91 | set ignore-comments 92 | 93 | some_recipe: 94 | # {{ error('foo') }} 95 | ", 96 | ) 97 | .run(); 98 | } 99 | 100 | #[test] 101 | fn dont_analyze_comments() { 102 | Test::new() 103 | .justfile( 104 | " 105 | set ignore-comments 106 | 107 | some_recipe: 108 | # {{ bar }} 109 | ", 110 | ) 111 | .run(); 112 | } 113 | 114 | #[test] 115 | fn comments_still_must_be_parsable_when_ignored() { 116 | Test::new() 117 | .justfile( 118 | " 119 | set ignore-comments 120 | 121 | some_recipe: 122 | # {{ foo bar }} 123 | ", 124 | ) 125 | .stderr( 126 | " 127 | error: Expected '&&', '||', '}}', '(', '+', or '/', but found identifier 128 | ——▶ justfile:4:12 129 | │ 130 | 4 │ # {{ foo bar }} 131 | │ ^^^ 132 | ", 133 | ) 134 | .status(EXIT_FAILURE) 135 | .run(); 136 | } 137 | -------------------------------------------------------------------------------- /tests/invocation_directory.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[cfg(unix)] 4 | fn convert_native_path(path: &Path) -> String { 5 | fs::canonicalize(path) 6 | .expect("canonicalize failed") 7 | .to_str() 8 | .map(str::to_string) 9 | .expect("unicode decode failed") 10 | } 11 | 12 | #[cfg(windows)] 13 | fn convert_native_path(path: &Path) -> String { 14 | // Translate path from windows style to unix style 15 | let mut cygpath = Command::new("cygpath"); 16 | cygpath.arg("--unix"); 17 | cygpath.arg(path); 18 | 19 | let output = cygpath.output().expect("executing cygpath failed"); 20 | 21 | assert!(output.status.success()); 22 | 23 | let stdout = str::from_utf8(&output.stdout).expect("cygpath output was not utf8"); 24 | 25 | if stdout.ends_with('\n') { 26 | &stdout[0..stdout.len() - 1] 27 | } else if stdout.ends_with("\r\n") { 28 | &stdout[0..stdout.len() - 2] 29 | } else { 30 | stdout 31 | } 32 | .to_owned() 33 | } 34 | 35 | #[test] 36 | fn test_invocation_directory() { 37 | let tmp = tempdir(); 38 | 39 | let mut justfile_path = tmp.path().to_path_buf(); 40 | justfile_path.push("justfile"); 41 | fs::write( 42 | justfile_path, 43 | "default:\n @cd {{invocation_directory()}}\n @echo {{invocation_directory()}}", 44 | ) 45 | .unwrap(); 46 | 47 | let mut subdir = tmp.path().to_path_buf(); 48 | subdir.push("subdir"); 49 | fs::create_dir(&subdir).unwrap(); 50 | 51 | let output = Command::new(executable_path("just")) 52 | .current_dir(&subdir) 53 | .args(["--shell", "sh"]) 54 | .output() 55 | .expect("just invocation failed"); 56 | 57 | let mut failure = false; 58 | 59 | let expected_status = 0; 60 | let expected_stdout = convert_native_path(&subdir) + "\n"; 61 | let expected_stderr = ""; 62 | 63 | let status = output.status.code().unwrap(); 64 | if status != expected_status { 65 | println!("bad status: {status} != {expected_status}"); 66 | failure = true; 67 | } 68 | 69 | let stdout = str::from_utf8(&output.stdout).unwrap(); 70 | if stdout != expected_stdout { 71 | println!("bad stdout:\ngot:\n{stdout:?}\n\nexpected:\n{expected_stdout:?}"); 72 | failure = true; 73 | } 74 | 75 | let stderr = str::from_utf8(&output.stderr).unwrap(); 76 | if stderr != expected_stderr { 77 | println!("bad stderr:\ngot:\n{stderr:?}\n\nexpected:\n{expected_stderr:?}"); 78 | failure = true; 79 | } 80 | 81 | assert!(!failure, "test failed"); 82 | } 83 | 84 | #[test] 85 | fn invocation_directory_native() { 86 | let Output { 87 | stdout, tempdir, .. 88 | } = Test::new() 89 | .justfile("x := invocation_directory_native()") 90 | .args(["--evaluate", "x"]) 91 | .stdout_regex(".*") 92 | .run(); 93 | 94 | if cfg!(windows) { 95 | assert_eq!(Path::new(&stdout), tempdir.path()); 96 | } else { 97 | assert_eq!(Path::new(&stdout), tempdir.path().canonicalize().unwrap()); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /tests/lib.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::{ 3 | assert_stdout::assert_stdout, 4 | assert_success::assert_success, 5 | tempdir::tempdir, 6 | test::{assert_eval_eq, Output, Test}, 7 | }, 8 | executable_path::executable_path, 9 | just::{unindent, Response}, 10 | libc::{EXIT_FAILURE, EXIT_SUCCESS}, 11 | pretty_assertions::Comparison, 12 | regex::Regex, 13 | serde::{Deserialize, Serialize}, 14 | serde_json::{json, Value}, 15 | std::{ 16 | collections::BTreeMap, 17 | env::{self, consts::EXE_SUFFIX}, 18 | error::Error, 19 | fmt::Debug, 20 | fs, 21 | io::Write, 22 | iter, 23 | path::{Path, PathBuf, MAIN_SEPARATOR, MAIN_SEPARATOR_STR}, 24 | process::{Command, Stdio}, 25 | str, 26 | }, 27 | tempfile::TempDir, 28 | temptree::{temptree, tree, Tree}, 29 | which::which, 30 | }; 31 | 32 | #[cfg(not(windows))] 33 | use std::{ 34 | thread, 35 | time::{Duration, Instant}, 36 | }; 37 | 38 | fn default() -> T { 39 | Default::default() 40 | } 41 | 42 | #[macro_use] 43 | mod test; 44 | 45 | mod alias; 46 | mod alias_style; 47 | mod allow_duplicate_recipes; 48 | mod allow_duplicate_variables; 49 | mod allow_missing; 50 | mod assert_stdout; 51 | mod assert_success; 52 | mod assertions; 53 | mod assignment; 54 | mod attributes; 55 | mod backticks; 56 | mod byte_order_mark; 57 | mod changelog; 58 | mod choose; 59 | mod command; 60 | mod completions; 61 | mod conditional; 62 | mod confirm; 63 | mod constants; 64 | mod datetime; 65 | mod delimiters; 66 | mod directories; 67 | mod dotenv; 68 | mod edit; 69 | mod equals; 70 | mod error_messages; 71 | mod evaluate; 72 | mod examples; 73 | mod explain; 74 | mod export; 75 | mod fallback; 76 | mod format; 77 | mod functions; 78 | #[cfg(unix)] 79 | mod global; 80 | mod groups; 81 | mod ignore_comments; 82 | mod imports; 83 | mod init; 84 | mod invocation_directory; 85 | mod json; 86 | mod line_prefixes; 87 | mod list; 88 | mod logical_operators; 89 | mod man; 90 | mod misc; 91 | mod modules; 92 | mod multibyte_char; 93 | mod newline_escape; 94 | mod no_aliases; 95 | mod no_cd; 96 | mod no_dependencies; 97 | mod no_exit_message; 98 | mod os_attributes; 99 | mod parameters; 100 | mod parser; 101 | mod positional_arguments; 102 | mod private; 103 | mod quiet; 104 | mod quote; 105 | mod readme; 106 | mod recursion_limit; 107 | mod regexes; 108 | mod request; 109 | mod run; 110 | mod script; 111 | mod search; 112 | mod search_arguments; 113 | mod shadowing_parameters; 114 | mod shebang; 115 | mod shell; 116 | mod shell_expansion; 117 | mod show; 118 | #[cfg(unix)] 119 | mod signals; 120 | mod slash_operator; 121 | mod string; 122 | mod subsequents; 123 | mod summary; 124 | mod tempdir; 125 | mod timestamps; 126 | mod undefined_variables; 127 | mod unexport; 128 | mod unstable; 129 | mod which_function; 130 | #[cfg(windows)] 131 | mod windows; 132 | #[cfg(target_family = "windows")] 133 | mod windows_shell; 134 | mod working_directory; 135 | 136 | fn path(s: &str) -> String { 137 | if cfg!(windows) { 138 | s.replace('/', "\\") 139 | } else { 140 | s.into() 141 | } 142 | } 143 | 144 | fn path_for_regex(s: &str) -> String { 145 | if cfg!(windows) { 146 | s.replace('/', "\\\\") 147 | } else { 148 | s.into() 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /tests/line_prefixes.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn infallible_after_quiet() { 5 | Test::new() 6 | .justfile( 7 | " 8 | foo: 9 | @-exit 1 10 | ", 11 | ) 12 | .run(); 13 | } 14 | 15 | #[test] 16 | fn quiet_after_infallible() { 17 | Test::new() 18 | .justfile( 19 | " 20 | foo: 21 | -@exit 1 22 | ", 23 | ) 24 | .run(); 25 | } 26 | -------------------------------------------------------------------------------- /tests/logical_operators.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[track_caller] 4 | fn evaluate(expression: &str, expected: &str) { 5 | Test::new() 6 | .justfile(format!("x := {expression}")) 7 | .env("JUST_UNSTABLE", "1") 8 | .args(["--evaluate", "x"]) 9 | .stdout(expected) 10 | .run(); 11 | } 12 | 13 | #[test] 14 | fn logical_operators_are_unstable() { 15 | Test::new() 16 | .justfile("x := 'foo' && 'bar'") 17 | .args(["--evaluate", "x"]) 18 | .stderr_regex(r"error: The logical operators `&&` and `\|\|` are currently unstable. .*") 19 | .status(EXIT_FAILURE) 20 | .run(); 21 | 22 | Test::new() 23 | .justfile("x := 'foo' || 'bar'") 24 | .args(["--evaluate", "x"]) 25 | .stderr_regex(r"error: The logical operators `&&` and `\|\|` are currently unstable. .*") 26 | .status(EXIT_FAILURE) 27 | .run(); 28 | } 29 | 30 | #[test] 31 | fn and_returns_empty_string_if_lhs_is_empty() { 32 | evaluate("'' && 'hello'", ""); 33 | } 34 | 35 | #[test] 36 | fn and_returns_rhs_if_lhs_is_non_empty() { 37 | evaluate("'hello' && 'goodbye'", "goodbye"); 38 | } 39 | 40 | #[test] 41 | fn and_has_lower_precedence_than_plus() { 42 | evaluate("'' && 'goodbye' + 'foo'", ""); 43 | 44 | evaluate("'foo' + 'hello' && 'goodbye'", "goodbye"); 45 | 46 | evaluate("'foo' + '' && 'goodbye'", "goodbye"); 47 | 48 | evaluate("'foo' + 'hello' && 'goodbye' + 'bar'", "goodbyebar"); 49 | } 50 | 51 | #[test] 52 | fn or_returns_rhs_if_lhs_is_empty() { 53 | evaluate("'' || 'hello'", "hello"); 54 | } 55 | 56 | #[test] 57 | fn or_returns_lhs_if_lhs_is_non_empty() { 58 | evaluate("'hello' || 'goodbye'", "hello"); 59 | } 60 | 61 | #[test] 62 | fn or_has_lower_precedence_than_plus() { 63 | evaluate("'' || 'goodbye' + 'foo'", "goodbyefoo"); 64 | 65 | evaluate("'foo' + 'hello' || 'goodbye'", "foohello"); 66 | 67 | evaluate("'foo' + '' || 'goodbye'", "foo"); 68 | 69 | evaluate("'foo' + 'hello' || 'goodbye' + 'bar'", "foohello"); 70 | } 71 | 72 | #[test] 73 | fn and_has_higher_precedence_than_or() { 74 | evaluate("('' && 'foo') || 'bar'", "bar"); 75 | evaluate("'' && 'foo' || 'bar'", "bar"); 76 | evaluate("'a' && 'b' || 'c'", "b"); 77 | } 78 | 79 | #[test] 80 | fn nesting() { 81 | evaluate("'' || '' || '' || '' || 'foo'", "foo"); 82 | evaluate("'foo' && 'foo' && 'foo' && 'foo' && 'bar'", "bar"); 83 | } 84 | -------------------------------------------------------------------------------- /tests/man.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn output() { 5 | Test::new() 6 | .arg("--man") 7 | .stdout_regex("(?s).*.TH just 1.*") 8 | .run(); 9 | } 10 | -------------------------------------------------------------------------------- /tests/multibyte_char.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn bugfix() { 5 | Test::new().justfile("foo:\nx := '''ǩ'''").run(); 6 | } 7 | -------------------------------------------------------------------------------- /tests/newline_escape.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn newline_escape_deps() { 5 | Test::new() 6 | .justfile( 7 | " 8 | default: a \\ 9 | b \\ 10 | c 11 | a: 12 | echo a 13 | b: 14 | echo b 15 | c: 16 | echo c 17 | ", 18 | ) 19 | .stdout("a\nb\nc\n") 20 | .stderr("echo a\necho b\necho c\n") 21 | .run(); 22 | } 23 | 24 | #[test] 25 | fn newline_escape_deps_no_indent() { 26 | Test::new() 27 | .justfile( 28 | " 29 | default: a\\ 30 | b\\ 31 | c 32 | a: 33 | echo a 34 | b: 35 | echo b 36 | c: 37 | echo c 38 | ", 39 | ) 40 | .stdout("a\nb\nc\n") 41 | .stderr("echo a\necho b\necho c\n") 42 | .run(); 43 | } 44 | 45 | #[test] 46 | fn newline_escape_deps_linefeed() { 47 | Test::new() 48 | .justfile( 49 | " 50 | default: a\\\r 51 | b 52 | a: 53 | echo a 54 | b: 55 | echo b 56 | ", 57 | ) 58 | .stdout("a\nb\n") 59 | .stderr("echo a\necho b\n") 60 | .run(); 61 | } 62 | 63 | #[test] 64 | fn newline_escape_deps_invalid_esc() { 65 | Test::new() 66 | .justfile( 67 | " 68 | default: a\\ b 69 | ", 70 | ) 71 | .stderr( 72 | " 73 | error: `\\ ` is not a valid escape sequence 74 | ——▶ justfile:1:11 75 | │ 76 | 1 │ default: a\\ b 77 | │ ^ 78 | ", 79 | ) 80 | .status(EXIT_FAILURE) 81 | .run(); 82 | } 83 | 84 | #[test] 85 | fn newline_escape_unpaired_linefeed() { 86 | Test::new() 87 | .justfile( 88 | " 89 | default:\\\ra", 90 | ) 91 | .stderr( 92 | " 93 | error: Unpaired carriage return 94 | ——▶ justfile:1:9 95 | │ 96 | 1 │ default:\\\ra 97 | │ ^ 98 | ", 99 | ) 100 | .status(EXIT_FAILURE) 101 | .run(); 102 | } 103 | -------------------------------------------------------------------------------- /tests/no_aliases.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn skip_alias() { 5 | Test::new() 6 | .justfile( 7 | " 8 | alias t := test1 9 | 10 | test1: 11 | @echo 'test1' 12 | 13 | test2: 14 | @echo 'test2' 15 | ", 16 | ) 17 | .args(["--no-aliases", "--list"]) 18 | .stdout("Available recipes:\n test1\n test2\n") 19 | .run(); 20 | } 21 | -------------------------------------------------------------------------------- /tests/no_cd.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn linewise() { 5 | Test::new() 6 | .justfile( 7 | " 8 | [no-cd] 9 | foo: 10 | cat bar 11 | ", 12 | ) 13 | .current_dir("foo") 14 | .tree(tree! { 15 | foo: { 16 | bar: "hello", 17 | } 18 | }) 19 | .stderr("cat bar\n") 20 | .stdout("hello") 21 | .run(); 22 | } 23 | 24 | #[test] 25 | fn shebang() { 26 | Test::new() 27 | .justfile( 28 | " 29 | [no-cd] 30 | foo: 31 | #!/bin/sh 32 | cat bar 33 | ", 34 | ) 35 | .current_dir("foo") 36 | .tree(tree! { 37 | foo: { 38 | bar: "hello", 39 | } 40 | }) 41 | .stdout("hello") 42 | .run(); 43 | } 44 | -------------------------------------------------------------------------------- /tests/no_dependencies.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn skip_normal_dependency() { 5 | Test::new() 6 | .justfile( 7 | " 8 | a: 9 | @echo 'a' 10 | b: a 11 | @echo 'b' 12 | ", 13 | ) 14 | .args(["--no-deps", "b"]) 15 | .stdout("b\n") 16 | .run(); 17 | } 18 | 19 | #[test] 20 | fn skip_prior_dependency() { 21 | Test::new() 22 | .justfile( 23 | " 24 | a: 25 | @echo 'a' 26 | b: && a 27 | @echo 'b' 28 | ", 29 | ) 30 | .args(["--no-deps", "b"]) 31 | .stdout("b\n") 32 | .run(); 33 | } 34 | 35 | #[test] 36 | fn skip_dependency_multi() { 37 | Test::new() 38 | .justfile( 39 | " 40 | a: 41 | @echo 'a' 42 | b: && a 43 | @echo 'b' 44 | ", 45 | ) 46 | .args(["--no-deps", "b", "a"]) 47 | .stdout("b\na\n") 48 | .run(); 49 | } 50 | -------------------------------------------------------------------------------- /tests/os_attributes.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn os_family() { 5 | Test::new() 6 | .justfile( 7 | " 8 | [unix] 9 | foo: 10 | echo bar 11 | 12 | [windows] 13 | foo: 14 | echo baz 15 | ", 16 | ) 17 | .stdout(if cfg!(unix) { 18 | "bar\n" 19 | } else if cfg!(windows) { 20 | "baz\n" 21 | } else { 22 | panic!("unexpected os family") 23 | }) 24 | .stderr(if cfg!(unix) { 25 | "echo bar\n" 26 | } else if cfg!(windows) { 27 | "echo baz\n" 28 | } else { 29 | panic!("unexpected os family") 30 | }) 31 | .run(); 32 | } 33 | 34 | #[test] 35 | fn os() { 36 | Test::new() 37 | .justfile( 38 | " 39 | [macos] 40 | foo: 41 | echo bar 42 | 43 | [windows] 44 | foo: 45 | echo baz 46 | 47 | [linux] 48 | foo: 49 | echo quxx 50 | 51 | [openbsd] 52 | foo: 53 | echo bob 54 | ", 55 | ) 56 | .stdout(if cfg!(target_os = "macos") { 57 | "bar\n" 58 | } else if cfg!(windows) { 59 | "baz\n" 60 | } else if cfg!(target_os = "linux") { 61 | "quxx\n" 62 | } else if cfg!(target_os = "openbsd") { 63 | "bob\n" 64 | } else { 65 | panic!("unexpected os family") 66 | }) 67 | .stderr(if cfg!(target_os = "macos") { 68 | "echo bar\n" 69 | } else if cfg!(windows) { 70 | "echo baz\n" 71 | } else if cfg!(target_os = "linux") { 72 | "echo quxx\n" 73 | } else if cfg!(target_os = "openbsd") { 74 | "echo bob\n" 75 | } else { 76 | panic!("unexpected os family") 77 | }) 78 | .run(); 79 | } 80 | 81 | #[test] 82 | fn all() { 83 | Test::new() 84 | .justfile( 85 | " 86 | [linux] 87 | [macos] 88 | [openbsd] 89 | [unix] 90 | [windows] 91 | foo: 92 | echo bar 93 | ", 94 | ) 95 | .stdout("bar\n") 96 | .stderr("echo bar\n") 97 | .run(); 98 | } 99 | 100 | #[test] 101 | fn none() { 102 | Test::new() 103 | .justfile( 104 | " 105 | foo: 106 | echo bar 107 | ", 108 | ) 109 | .stdout("bar\n") 110 | .stderr("echo bar\n") 111 | .run(); 112 | } 113 | -------------------------------------------------------------------------------- /tests/parameters.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn parameter_default_values_may_use_earlier_parameters() { 5 | Test::new() 6 | .justfile( 7 | " 8 | @foo a b=a: 9 | echo {{ b }} 10 | ", 11 | ) 12 | .args(["foo", "bar"]) 13 | .stdout("bar\n") 14 | .run(); 15 | } 16 | 17 | #[test] 18 | fn parameter_default_values_may_not_use_later_parameters() { 19 | Test::new() 20 | .justfile( 21 | " 22 | @foo a b=c c='': 23 | echo {{ b }} 24 | ", 25 | ) 26 | .args(["foo", "bar"]) 27 | .stderr( 28 | " 29 | error: Variable `c` not defined 30 | ——▶ justfile:1:10 31 | │ 32 | 1 │ @foo a b=c c='': 33 | │ ^ 34 | ", 35 | ) 36 | .status(EXIT_FAILURE) 37 | .run(); 38 | } 39 | 40 | #[test] 41 | fn star_may_follow_default() { 42 | Test::new() 43 | .justfile( 44 | " 45 | foo bar='baz' *bob: 46 | @echo {{bar}} {{bob}} 47 | ", 48 | ) 49 | .args(["foo", "hello", "goodbye"]) 50 | .stdout("hello goodbye\n") 51 | .run(); 52 | } 53 | -------------------------------------------------------------------------------- /tests/parser.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn dont_run_duplicate_recipes() { 5 | Test::new() 6 | .justfile( 7 | " 8 | set dotenv-load # foo 9 | bar: 10 | ", 11 | ) 12 | .run(); 13 | } 14 | 15 | #[test] 16 | fn invalid_bang_operator() { 17 | Test::new() 18 | .justfile( 19 | " 20 | x := if '' !! '' { '' } else { '' } 21 | ", 22 | ) 23 | .status(1) 24 | .stderr( 25 | r" 26 | error: Expected character `=` or `~` 27 | ——▶ justfile:1:13 28 | │ 29 | 1 │ x := if '' !! '' { '' } else { '' } 30 | │ ^ 31 | ", 32 | ) 33 | .run(); 34 | } 35 | 36 | #[test] 37 | fn truncated_bang_operator() { 38 | Test::new() 39 | .justfile("x := if '' !") 40 | .status(1) 41 | .stderr( 42 | r" 43 | error: Expected character `=` or `~` but found end-of-file 44 | ——▶ justfile:1:13 45 | │ 46 | 1 │ x := if '' ! 47 | │ ^ 48 | ", 49 | ) 50 | .run(); 51 | } 52 | -------------------------------------------------------------------------------- /tests/positional_arguments.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn linewise() { 5 | Test::new() 6 | .arg("foo") 7 | .arg("hello") 8 | .arg("goodbye") 9 | .justfile( 10 | r#" 11 | set positional-arguments 12 | 13 | foo bar baz: 14 | echo $0 15 | echo $1 16 | echo $2 17 | echo "$@" 18 | "#, 19 | ) 20 | .stdout( 21 | " 22 | foo 23 | hello 24 | goodbye 25 | hello goodbye 26 | ", 27 | ) 28 | .stderr( 29 | r#" 30 | echo $0 31 | echo $1 32 | echo $2 33 | echo "$@" 34 | "#, 35 | ) 36 | .run(); 37 | } 38 | 39 | #[test] 40 | fn linewise_with_attribute() { 41 | Test::new() 42 | .arg("foo") 43 | .arg("hello") 44 | .arg("goodbye") 45 | .justfile( 46 | r#" 47 | [positional-arguments] 48 | foo bar baz: 49 | echo $0 50 | echo $1 51 | echo $2 52 | echo "$@" 53 | "#, 54 | ) 55 | .stdout( 56 | " 57 | foo 58 | hello 59 | goodbye 60 | hello goodbye 61 | ", 62 | ) 63 | .stderr( 64 | r#" 65 | echo $0 66 | echo $1 67 | echo $2 68 | echo "$@" 69 | "#, 70 | ) 71 | .run(); 72 | } 73 | 74 | #[test] 75 | fn variadic_linewise() { 76 | Test::new() 77 | .args(["foo", "a", "b", "c"]) 78 | .justfile( 79 | r#" 80 | set positional-arguments 81 | 82 | foo *bar: 83 | echo $1 84 | echo "$@" 85 | "#, 86 | ) 87 | .stdout("a\na b c\n") 88 | .stderr("echo $1\necho \"$@\"\n") 89 | .run(); 90 | } 91 | 92 | #[test] 93 | fn shebang() { 94 | Test::new() 95 | .arg("foo") 96 | .arg("hello") 97 | .justfile( 98 | " 99 | set positional-arguments 100 | 101 | foo bar: 102 | #!/bin/sh 103 | echo $1 104 | ", 105 | ) 106 | .stdout("hello\n") 107 | .run(); 108 | } 109 | 110 | #[test] 111 | fn shebang_with_attribute() { 112 | Test::new() 113 | .arg("foo") 114 | .arg("hello") 115 | .justfile( 116 | " 117 | [positional-arguments] 118 | foo bar: 119 | #!/bin/sh 120 | echo $1 121 | ", 122 | ) 123 | .stdout("hello\n") 124 | .run(); 125 | } 126 | 127 | #[test] 128 | fn variadic_shebang() { 129 | Test::new() 130 | .arg("foo") 131 | .arg("a") 132 | .arg("b") 133 | .arg("c") 134 | .justfile( 135 | r#" 136 | set positional-arguments 137 | 138 | foo *bar: 139 | #!/bin/sh 140 | echo $1 141 | echo "$@" 142 | "#, 143 | ) 144 | .stdout("a\na b c\n") 145 | .run(); 146 | } 147 | 148 | #[test] 149 | fn default_arguments() { 150 | Test::new() 151 | .justfile( 152 | r" 153 | set positional-arguments 154 | 155 | foo bar='baz': 156 | echo $1 157 | ", 158 | ) 159 | .stdout("baz\n") 160 | .stderr("echo $1\n") 161 | .run(); 162 | } 163 | 164 | #[test] 165 | fn empty_variadic_is_undefined() { 166 | Test::new() 167 | .justfile( 168 | r#" 169 | set positional-arguments 170 | 171 | foo *bar: 172 | if [ -n "${1+1}" ]; then echo defined; else echo undefined; fi 173 | "#, 174 | ) 175 | .stdout("undefined\n") 176 | .stderr("if [ -n \"${1+1}\" ]; then echo defined; else echo undefined; fi\n") 177 | .run(); 178 | } 179 | 180 | #[test] 181 | fn variadic_arguments_are_separate() { 182 | Test::new() 183 | .arg("foo") 184 | .arg("a") 185 | .arg("b") 186 | .justfile( 187 | r" 188 | set positional-arguments 189 | 190 | foo *bar: 191 | echo $1 192 | echo $2 193 | ", 194 | ) 195 | .stdout("a\nb\n") 196 | .stderr("echo $1\necho $2\n") 197 | .run(); 198 | } 199 | -------------------------------------------------------------------------------- /tests/private.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn private_attribute_for_recipe() { 5 | Test::new() 6 | .justfile( 7 | " 8 | [private] 9 | foo: 10 | ", 11 | ) 12 | .args(["--list"]) 13 | .stdout( 14 | " 15 | Available recipes: 16 | ", 17 | ) 18 | .run(); 19 | } 20 | 21 | #[test] 22 | fn private_attribute_for_alias() { 23 | Test::new() 24 | .justfile( 25 | " 26 | [private] 27 | alias f := foo 28 | 29 | foo: 30 | ", 31 | ) 32 | .args(["--list"]) 33 | .stdout( 34 | " 35 | Available recipes: 36 | foo 37 | ", 38 | ) 39 | .run(); 40 | } 41 | 42 | #[test] 43 | fn private_variables_are_not_listed() { 44 | Test::new() 45 | .justfile( 46 | " 47 | [private] 48 | foo := 'one' 49 | bar := 'two' 50 | _baz := 'three' 51 | ", 52 | ) 53 | .args(["--variables"]) 54 | .stdout("bar\n") 55 | .run(); 56 | } 57 | -------------------------------------------------------------------------------- /tests/quote.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn single_quotes_are_prepended_and_appended() { 5 | Test::new() 6 | .justfile( 7 | " 8 | x := quote('abc') 9 | ", 10 | ) 11 | .args(["--evaluate", "x"]) 12 | .stdout("'abc'") 13 | .run(); 14 | } 15 | 16 | #[test] 17 | fn quotes_are_escaped() { 18 | Test::new() 19 | .justfile( 20 | r#" 21 | x := quote("'") 22 | "#, 23 | ) 24 | .args(["--evaluate", "x"]) 25 | .stdout(r"''\'''") 26 | .run(); 27 | } 28 | 29 | #[test] 30 | fn quoted_strings_can_be_used_as_arguments() { 31 | Test::new() 32 | .justfile( 33 | r#" 34 | file := quote("foo ' bar") 35 | 36 | @foo: 37 | touch {{ file }} 38 | ls -1 39 | "#, 40 | ) 41 | .stdout("foo ' bar\njustfile\n") 42 | .run(); 43 | } 44 | -------------------------------------------------------------------------------- /tests/readme.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn readme() { 5 | let mut justfiles = Vec::new(); 6 | let mut current = None; 7 | 8 | for line in fs::read_to_string("README.md").unwrap().lines() { 9 | if let Some(mut justfile) = current { 10 | if line == "```" { 11 | justfiles.push(justfile); 12 | current = None; 13 | } else { 14 | justfile += line; 15 | justfile += "\n"; 16 | current = Some(justfile); 17 | } 18 | } else if line == "```just" { 19 | current = Some(String::new()); 20 | } 21 | } 22 | 23 | for justfile in justfiles { 24 | let tmp = tempdir(); 25 | 26 | let path = tmp.path().join("justfile"); 27 | 28 | fs::write(path, justfile).unwrap(); 29 | 30 | let output = Command::new(executable_path("just")) 31 | .current_dir(tmp.path()) 32 | .arg("--dump") 33 | .output() 34 | .unwrap(); 35 | 36 | assert_success(&output); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tests/recursion_limit.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn bugfix() { 5 | let mut justfile = String::from("foo: (x "); 6 | for _ in 0..500 { 7 | justfile.push('('); 8 | } 9 | Test::new() 10 | .justfile(&justfile) 11 | .stderr(RECURSION_LIMIT_REACHED) 12 | .status(EXIT_FAILURE) 13 | .run(); 14 | } 15 | 16 | #[cfg(not(windows))] 17 | const RECURSION_LIMIT_REACHED: &str = " 18 | error: Parsing recursion depth exceeded 19 | ——▶ justfile:1:265 20 | │ 21 | 1 │ foo: (x (((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((( 22 | │ ^ 23 | "; 24 | 25 | #[cfg(windows)] 26 | const RECURSION_LIMIT_REACHED: &str = " 27 | error: Parsing recursion depth exceeded 28 | ——▶ justfile:1:57 29 | │ 30 | 1 │ foo: (x (((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((( 31 | │ ^ 32 | "; 33 | -------------------------------------------------------------------------------- /tests/regexes.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn match_succeeds_evaluates_to_first_branch() { 5 | Test::new() 6 | .justfile( 7 | " 8 | foo := if 'abbbc' =~ 'ab+c' { 9 | 'yes' 10 | } else { 11 | 'no' 12 | } 13 | 14 | default: 15 | echo {{ foo }} 16 | ", 17 | ) 18 | .stderr("echo yes\n") 19 | .stdout("yes\n") 20 | .run(); 21 | } 22 | 23 | #[test] 24 | fn match_fails_evaluates_to_second_branch() { 25 | Test::new() 26 | .justfile( 27 | " 28 | foo := if 'abbbc' =~ 'ab{4}c' { 29 | 'yes' 30 | } else { 31 | 'no' 32 | } 33 | 34 | default: 35 | echo {{ foo }} 36 | ", 37 | ) 38 | .stderr("echo no\n") 39 | .stdout("no\n") 40 | .run(); 41 | } 42 | 43 | #[test] 44 | fn bad_regex_fails_at_runtime() { 45 | Test::new() 46 | .justfile( 47 | " 48 | default: 49 | echo before 50 | echo {{ if '' =~ '(' { 'a' } else { 'b' } }} 51 | echo after 52 | ", 53 | ) 54 | .stderr( 55 | " 56 | echo before 57 | error: regex parse error: 58 | ( 59 | ^ 60 | error: unclosed group 61 | ", 62 | ) 63 | .stdout("before\n") 64 | .status(EXIT_FAILURE) 65 | .run(); 66 | } 67 | 68 | #[test] 69 | fn mismatch() { 70 | Test::new() 71 | .justfile( 72 | " 73 | foo := if 'Foo' !~ '^ab+c' { 74 | 'mismatch' 75 | } else { 76 | 'match' 77 | } 78 | 79 | bar := if 'Foo' !~ 'Foo' { 80 | 'mismatch' 81 | } else { 82 | 'match' 83 | } 84 | 85 | @default: 86 | echo {{ foo }} {{ bar }} 87 | ", 88 | ) 89 | .stdout("mismatch match\n") 90 | .run(); 91 | } 92 | -------------------------------------------------------------------------------- /tests/request.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn environment_variable_set() { 5 | Test::new() 6 | .justfile( 7 | r#" 8 | export BAR := 'baz' 9 | 10 | @foo: 11 | '{{just_executable()}}' --request '{"environment-variable": "BAR"}' 12 | "#, 13 | ) 14 | .response(Response::EnvironmentVariable(Some("baz".into()))) 15 | .run(); 16 | } 17 | 18 | #[test] 19 | fn environment_variable_missing() { 20 | Test::new() 21 | .justfile( 22 | r#" 23 | @foo: 24 | '{{just_executable()}}' --request '{"environment-variable": "FOO_BAR_BAZ"}' 25 | "#, 26 | ) 27 | .response(Response::EnvironmentVariable(None)) 28 | .run(); 29 | } 30 | -------------------------------------------------------------------------------- /tests/run.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn dont_run_duplicate_recipes() { 5 | Test::new() 6 | .justfile( 7 | " 8 | @foo: 9 | echo foo 10 | ", 11 | ) 12 | .args(["foo", "foo"]) 13 | .stdout("foo\n") 14 | .run(); 15 | } 16 | 17 | #[test] 18 | fn one_flag_only_allows_one_invocation() { 19 | Test::new() 20 | .justfile( 21 | " 22 | @foo: 23 | echo foo 24 | ", 25 | ) 26 | .args(["--one", "foo"]) 27 | .stdout("foo\n") 28 | .run(); 29 | 30 | Test::new() 31 | .justfile( 32 | " 33 | @foo: 34 | echo foo 35 | 36 | @bar: 37 | echo bar 38 | ", 39 | ) 40 | .args(["--one", "foo", "bar"]) 41 | .stderr("error: Expected 1 command-line recipe invocation but found 2.\n") 42 | .status(1) 43 | .run(); 44 | } 45 | -------------------------------------------------------------------------------- /tests/search_arguments.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn argument_with_different_path_prefix_is_allowed() { 5 | Test::new() 6 | .justfile("foo bar:") 7 | .args(["./foo", "../bar"]) 8 | .run(); 9 | } 10 | 11 | #[test] 12 | fn passing_dot_as_argument_is_allowed() { 13 | Test::new() 14 | .justfile( 15 | " 16 | say ARG: 17 | echo {{ARG}} 18 | ", 19 | ) 20 | .write( 21 | "child/justfile", 22 | "say ARG:\n '{{just_executable()}}' ../say {{ARG}}", 23 | ) 24 | .current_dir("child") 25 | .args(["say", "."]) 26 | .stdout(".\n") 27 | .stderr_regex("'.*' ../say .\necho .\n") 28 | .run(); 29 | } 30 | -------------------------------------------------------------------------------- /tests/shadowing_parameters.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn parameter_may_shadow_variable() { 5 | Test::new() 6 | .arg("a") 7 | .arg("bar") 8 | .justfile("FOO := 'hello'\na FOO:\n echo {{FOO}}\n") 9 | .stdout("bar\n") 10 | .stderr("echo bar\n") 11 | .run(); 12 | } 13 | 14 | #[test] 15 | fn shadowing_parameters_do_not_change_environment() { 16 | Test::new() 17 | .arg("a") 18 | .arg("bar") 19 | .justfile("export FOO := 'hello'\na FOO:\n echo $FOO\n") 20 | .stdout("hello\n") 21 | .stderr("echo $FOO\n") 22 | .run(); 23 | } 24 | 25 | #[test] 26 | fn exporting_shadowing_parameters_does_change_environment() { 27 | Test::new() 28 | .arg("a") 29 | .arg("bar") 30 | .justfile("export FOO := 'hello'\na $FOO:\n echo $FOO\n") 31 | .stdout("bar\n") 32 | .stderr("echo $FOO\n") 33 | .run(); 34 | } 35 | -------------------------------------------------------------------------------- /tests/shebang.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[cfg(windows)] 4 | #[test] 5 | fn powershell() { 6 | Test::new() 7 | .justfile( 8 | r#" 9 | default: 10 | #!powershell 11 | Write-Host Hello-World 12 | "#, 13 | ) 14 | .stdout("Hello-World\n") 15 | .run(); 16 | } 17 | 18 | #[cfg(windows)] 19 | #[test] 20 | fn powershell_exe() { 21 | Test::new() 22 | .justfile( 23 | r#" 24 | default: 25 | #!powershell.exe 26 | Write-Host Hello-World 27 | "#, 28 | ) 29 | .stdout("Hello-World\n") 30 | .run(); 31 | } 32 | 33 | #[cfg(windows)] 34 | #[test] 35 | fn cmd() { 36 | Test::new() 37 | .justfile( 38 | r#" 39 | default: 40 | #!cmd /c 41 | @echo Hello-World 42 | "#, 43 | ) 44 | .stdout("Hello-World\r\n") 45 | .run(); 46 | } 47 | 48 | #[cfg(windows)] 49 | #[test] 50 | fn cmd_exe() { 51 | Test::new() 52 | .justfile( 53 | r#" 54 | default: 55 | #!cmd.exe /c 56 | @echo Hello-World 57 | "#, 58 | ) 59 | .stdout("Hello-World\r\n") 60 | .run(); 61 | } 62 | 63 | #[cfg(windows)] 64 | #[test] 65 | fn multi_line_cmd_shebangs_are_removed() { 66 | Test::new() 67 | .justfile( 68 | r#" 69 | default: 70 | #!cmd.exe /c 71 | #!foo 72 | @echo Hello-World 73 | "#, 74 | ) 75 | .stdout("Hello-World\r\n") 76 | .run(); 77 | } 78 | 79 | #[test] 80 | fn simple() { 81 | Test::new() 82 | .justfile( 83 | " 84 | foo: 85 | #!/bin/sh 86 | echo bar 87 | ", 88 | ) 89 | .stdout("bar\n") 90 | .run(); 91 | } 92 | 93 | #[test] 94 | fn echo() { 95 | Test::new() 96 | .justfile( 97 | " 98 | @baz: 99 | #!/bin/sh 100 | echo fizz 101 | ", 102 | ) 103 | .stdout("fizz\n") 104 | .stderr("#!/bin/sh\necho fizz\n") 105 | .run(); 106 | } 107 | 108 | #[test] 109 | fn echo_with_command_color() { 110 | Test::new() 111 | .justfile( 112 | " 113 | @baz: 114 | #!/bin/sh 115 | echo fizz 116 | ", 117 | ) 118 | .args(["--color", "always", "--command-color", "purple"]) 119 | .stdout("fizz\n") 120 | .stderr("\u{1b}[1;35m#!/bin/sh\u{1b}[0m\n\u{1b}[1;35mecho fizz\u{1b}[0m\n") 121 | .run(); 122 | } 123 | 124 | // This test exists to make sure that shebang recipes run correctly. Although 125 | // this script is still executed by a shell its behavior depends on the value of 126 | // a variable and continuing even though a command fails, whereas in plain 127 | // recipes variables are not available in subsequent lines and execution stops 128 | // when a line fails. 129 | #[test] 130 | fn run_shebang() { 131 | Test::new() 132 | .justfile( 133 | " 134 | a: 135 | #!/usr/bin/env sh 136 | code=200 137 | x() { return $code; } 138 | x 139 | x 140 | ", 141 | ) 142 | .status(200) 143 | .stderr("error: Recipe `a` failed with exit code 200\n") 144 | .run(); 145 | } 146 | -------------------------------------------------------------------------------- /tests/shell_expansion.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn strings_are_shell_expanded() { 5 | Test::new() 6 | .justfile( 7 | " 8 | x := x'$JUST_TEST_VARIABLE' 9 | ", 10 | ) 11 | .env("JUST_TEST_VARIABLE", "FOO") 12 | .args(["--evaluate", "x"]) 13 | .stdout("FOO") 14 | .run(); 15 | } 16 | 17 | #[test] 18 | fn shell_expanded_strings_must_not_have_whitespace() { 19 | Test::new() 20 | .justfile( 21 | " 22 | x := x '$JUST_TEST_VARIABLE' 23 | ", 24 | ) 25 | .status(1) 26 | .stderr( 27 | " 28 | error: Expected '&&', '||', comment, end of file, end of line, '(', '+', or '/', but found string 29 | ——▶ justfile:1:8 30 | │ 31 | 1 │ x := x '$JUST_TEST_VARIABLE' 32 | │ ^^^^^^^^^^^^^^^^^^^^^ 33 | ", 34 | ) 35 | .run(); 36 | } 37 | 38 | #[test] 39 | fn shell_expanded_error_messages_highlight_string_token() { 40 | Test::new() 41 | .justfile( 42 | " 43 | x := x'$FOOOOOOOOOOOOOOOOOOOOOOOOOOOOO' 44 | ", 45 | ) 46 | .env("JUST_TEST_VARIABLE", "FOO") 47 | .args(["--evaluate", "x"]) 48 | .status(1) 49 | .stderr( 50 | " 51 | error: Shell expansion failed: error looking key 'FOOOOOOOOOOOOOOOOOOOOOOOOOOOOO' up: environment variable not found 52 | ——▶ justfile:1:7 53 | │ 54 | 1 │ x := x'$FOOOOOOOOOOOOOOOOOOOOOOOOOOOOO' 55 | │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 56 | ") 57 | .run(); 58 | } 59 | 60 | #[test] 61 | fn shell_expanded_strings_are_dumped_correctly() { 62 | Test::new() 63 | .justfile( 64 | " 65 | x := x'$JUST_TEST_VARIABLE' 66 | ", 67 | ) 68 | .env("JUST_TEST_VARIABLE", "FOO") 69 | .args(["--dump"]) 70 | .stdout("x := x'$JUST_TEST_VARIABLE'\n") 71 | .run(); 72 | } 73 | 74 | #[test] 75 | fn shell_expanded_strings_can_be_used_in_settings() { 76 | Test::new() 77 | .justfile( 78 | " 79 | set dotenv-filename := x'$JUST_TEST_VARIABLE' 80 | 81 | @foo: 82 | echo $DOTENV_KEY 83 | ", 84 | ) 85 | .write(".env", "DOTENV_KEY=dotenv-value") 86 | .env("JUST_TEST_VARIABLE", ".env") 87 | .stdout("dotenv-value\n") 88 | .run(); 89 | } 90 | 91 | #[test] 92 | fn shell_expanded_strings_can_be_used_in_import_paths() { 93 | Test::new() 94 | .justfile( 95 | " 96 | import x'$JUST_TEST_VARIABLE' 97 | 98 | foo: bar 99 | ", 100 | ) 101 | .write("import.just", "@bar:\n echo BAR") 102 | .env("JUST_TEST_VARIABLE", "import.just") 103 | .stdout("BAR\n") 104 | .run(); 105 | } 106 | 107 | #[test] 108 | fn shell_expanded_strings_can_be_used_in_mod_paths() { 109 | Test::new() 110 | .justfile( 111 | " 112 | mod foo x'$JUST_TEST_VARIABLE' 113 | ", 114 | ) 115 | .write("mod.just", "@bar:\n echo BAR") 116 | .env("JUST_TEST_VARIABLE", "mod.just") 117 | .args(["foo", "bar"]) 118 | .stdout("BAR\n") 119 | .run(); 120 | } 121 | 122 | #[test] 123 | fn shell_expanded_strings_do_not_conflict_with_dependencies() { 124 | Test::new() 125 | .justfile( 126 | " 127 | foo a b: 128 | @echo {{a}}{{b}} 129 | bar a b: (foo a 'c') 130 | ", 131 | ) 132 | .args(["bar", "A", "B"]) 133 | .stdout("Ac\n") 134 | .run(); 135 | 136 | Test::new() 137 | .justfile( 138 | " 139 | foo a b: 140 | @echo {{a}}{{b}} 141 | bar a b: (foo a'c') 142 | ", 143 | ) 144 | .args(["bar", "A", "B"]) 145 | .stdout("Ac\n") 146 | .run(); 147 | 148 | Test::new() 149 | .justfile( 150 | " 151 | foo a b: 152 | @echo {{a}}{{b}} 153 | bar x b: (foo x 'c') 154 | ", 155 | ) 156 | .args(["bar", "A", "B"]) 157 | .stdout("Ac\n") 158 | .run(); 159 | } 160 | -------------------------------------------------------------------------------- /tests/show.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn show() { 5 | Test::new() 6 | .arg("--show") 7 | .arg("recipe") 8 | .justfile( 9 | r#"hello := "foo" 10 | bar := hello + hello 11 | recipe: 12 | echo {{hello + "bar" + bar}}"#, 13 | ) 14 | .stdout( 15 | r#" 16 | recipe: 17 | echo {{ hello + "bar" + bar }} 18 | "#, 19 | ) 20 | .run(); 21 | } 22 | 23 | #[test] 24 | fn alias_show() { 25 | Test::new() 26 | .arg("--show") 27 | .arg("f") 28 | .justfile("foo:\n bar\nalias f := foo") 29 | .stdout( 30 | " 31 | alias f := foo 32 | foo: 33 | bar 34 | ", 35 | ) 36 | .run(); 37 | } 38 | 39 | #[test] 40 | fn alias_show_missing_target() { 41 | Test::new() 42 | .arg("--show") 43 | .arg("f") 44 | .justfile("alias f := foo") 45 | .status(EXIT_FAILURE) 46 | .stderr( 47 | " 48 | error: Alias `f` has an unknown target `foo` 49 | ——▶ justfile:1:7 50 | │ 51 | 1 │ alias f := foo 52 | │ ^ 53 | ", 54 | ) 55 | .run(); 56 | } 57 | 58 | #[test] 59 | fn show_suggestion() { 60 | Test::new() 61 | .arg("--show") 62 | .arg("hell") 63 | .justfile( 64 | r#" 65 | hello a b='B ' c='C': 66 | echo {{a}} {{b}} {{c}} 67 | 68 | a Z="\t z": 69 | "#, 70 | ) 71 | .stderr("error: Justfile does not contain recipe `hell`\nDid you mean `hello`?\n") 72 | .status(EXIT_FAILURE) 73 | .run(); 74 | } 75 | 76 | #[test] 77 | fn show_alias_suggestion() { 78 | Test::new() 79 | .arg("--show") 80 | .arg("fo") 81 | .justfile( 82 | r#" 83 | hello a b='B ' c='C': 84 | echo {{a}} {{b}} {{c}} 85 | 86 | alias foo := hello 87 | 88 | a Z="\t z": 89 | "#, 90 | ) 91 | .stderr( 92 | " 93 | error: Justfile does not contain recipe `fo` 94 | Did you mean `foo`, an alias for `hello`? 95 | ", 96 | ) 97 | .status(EXIT_FAILURE) 98 | .run(); 99 | } 100 | 101 | #[test] 102 | fn show_no_suggestion() { 103 | Test::new() 104 | .arg("--show") 105 | .arg("hell") 106 | .justfile( 107 | r#" 108 | helloooooo a b='B ' c='C': 109 | echo {{a}} {{b}} {{c}} 110 | 111 | a Z="\t z": 112 | "#, 113 | ) 114 | .stderr("error: Justfile does not contain recipe `hell`\n") 115 | .status(EXIT_FAILURE) 116 | .run(); 117 | } 118 | 119 | #[test] 120 | fn show_no_alias_suggestion() { 121 | Test::new() 122 | .arg("--show") 123 | .arg("fooooooo") 124 | .justfile( 125 | r#" 126 | hello a b='B ' c='C': 127 | echo {{a}} {{b}} {{c}} 128 | 129 | alias foo := hello 130 | 131 | a Z="\t z": 132 | "#, 133 | ) 134 | .stderr("error: Justfile does not contain recipe `fooooooo`\n") 135 | .status(EXIT_FAILURE) 136 | .run(); 137 | } 138 | 139 | #[test] 140 | fn show_recipe_at_path() { 141 | Test::new() 142 | .write("foo.just", "bar:\n @echo MODULE") 143 | .justfile( 144 | " 145 | mod foo 146 | ", 147 | ) 148 | .args(["--show", "foo::bar"]) 149 | .stdout("bar:\n @echo MODULE\n") 150 | .run(); 151 | } 152 | 153 | #[test] 154 | fn show_invalid_path() { 155 | Test::new() 156 | .args(["--show", "$hello"]) 157 | .stderr("error: Invalid module path `$hello`\n") 158 | .status(1) 159 | .run(); 160 | } 161 | 162 | #[test] 163 | fn show_space_separated_path() { 164 | Test::new() 165 | .write("foo.just", "bar:\n @echo MODULE") 166 | .justfile( 167 | " 168 | mod foo 169 | ", 170 | ) 171 | .args(["--show", "foo bar"]) 172 | .stdout("bar:\n @echo MODULE\n") 173 | .run(); 174 | } 175 | -------------------------------------------------------------------------------- /tests/slash_operator.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn once() { 5 | Test::new() 6 | .justfile("x := 'a' / 'b'") 7 | .args(["--evaluate", "x"]) 8 | .stdout("a/b") 9 | .run(); 10 | } 11 | 12 | #[test] 13 | fn twice() { 14 | Test::new() 15 | .justfile("x := 'a' / 'b' / 'c'") 16 | .args(["--evaluate", "x"]) 17 | .stdout("a/b/c") 18 | .run(); 19 | } 20 | 21 | #[test] 22 | fn no_lhs_once() { 23 | Test::new() 24 | .justfile("x := / 'a'") 25 | .args(["--evaluate", "x"]) 26 | .stdout("/a") 27 | .run(); 28 | } 29 | 30 | #[test] 31 | fn no_lhs_twice() { 32 | Test::new() 33 | .justfile("x := / 'a' / 'b'") 34 | .args(["--evaluate", "x"]) 35 | .stdout("/a/b") 36 | .run(); 37 | Test::new() 38 | .justfile("x := // 'a'") 39 | .args(["--evaluate", "x"]) 40 | .stdout("//a") 41 | .run(); 42 | } 43 | 44 | #[test] 45 | fn no_rhs_once() { 46 | Test::new() 47 | .justfile("x := 'a' /") 48 | .stderr( 49 | " 50 | error: Expected backtick, identifier, '(', '/', or string, but found end of file 51 | ——▶ justfile:1:11 52 | │ 53 | 1 │ x := 'a' / 54 | │ ^ 55 | ", 56 | ) 57 | .status(EXIT_FAILURE) 58 | .run(); 59 | } 60 | 61 | #[test] 62 | fn default_un_parenthesized() { 63 | Test::new() 64 | .justfile( 65 | " 66 | foo x='a' / 'b': 67 | echo {{x}} 68 | ", 69 | ) 70 | .stderr( 71 | " 72 | error: Expected '*', ':', '$', identifier, or '+', but found '/' 73 | ——▶ justfile:1:11 74 | │ 75 | 1 │ foo x='a' / 'b': 76 | │ ^ 77 | ", 78 | ) 79 | .status(EXIT_FAILURE) 80 | .run(); 81 | } 82 | 83 | #[test] 84 | fn no_lhs_un_parenthesized() { 85 | Test::new() 86 | .justfile( 87 | " 88 | foo x=/ 'a' / 'b': 89 | echo {{x}} 90 | ", 91 | ) 92 | .stderr( 93 | " 94 | error: Expected backtick, identifier, '(', or string, but found '/' 95 | ——▶ justfile:1:7 96 | │ 97 | 1 │ foo x=/ 'a' / 'b': 98 | │ ^ 99 | ", 100 | ) 101 | .status(EXIT_FAILURE) 102 | .run(); 103 | } 104 | 105 | #[test] 106 | fn default_parenthesized() { 107 | Test::new() 108 | .justfile( 109 | " 110 | foo x=('a' / 'b'): 111 | echo {{x}} 112 | ", 113 | ) 114 | .stderr("echo a/b\n") 115 | .stdout("a/b\n") 116 | .run(); 117 | } 118 | 119 | #[test] 120 | fn no_lhs_parenthesized() { 121 | Test::new() 122 | .justfile( 123 | " 124 | foo x=(/ 'a' / 'b'): 125 | echo {{x}} 126 | ", 127 | ) 128 | .stderr("echo /a/b\n") 129 | .stdout("/a/b\n") 130 | .run(); 131 | } 132 | -------------------------------------------------------------------------------- /tests/subsequents.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn success() { 5 | Test::new() 6 | .justfile( 7 | " 8 | foo: && bar 9 | echo foo 10 | 11 | bar: 12 | echo bar 13 | ", 14 | ) 15 | .stdout( 16 | " 17 | foo 18 | bar 19 | ", 20 | ) 21 | .stderr( 22 | " 23 | echo foo 24 | echo bar 25 | ", 26 | ) 27 | .run(); 28 | } 29 | 30 | #[test] 31 | fn failure() { 32 | Test::new() 33 | .justfile( 34 | " 35 | foo: && bar 36 | echo foo 37 | false 38 | 39 | bar: 40 | echo bar 41 | ", 42 | ) 43 | .stdout( 44 | " 45 | foo 46 | ", 47 | ) 48 | .stderr( 49 | " 50 | echo foo 51 | false 52 | error: Recipe `foo` failed on line 3 with exit code 1 53 | ", 54 | ) 55 | .status(EXIT_FAILURE) 56 | .run(); 57 | } 58 | 59 | #[test] 60 | fn circular_dependency() { 61 | Test::new() 62 | .justfile( 63 | " 64 | foo: && foo 65 | ", 66 | ) 67 | .stderr( 68 | " 69 | error: Recipe `foo` depends on itself 70 | ——▶ justfile:1:9 71 | │ 72 | 1 │ foo: && foo 73 | │ ^^^ 74 | ", 75 | ) 76 | .status(EXIT_FAILURE) 77 | .run(); 78 | } 79 | 80 | #[test] 81 | fn unknown() { 82 | Test::new() 83 | .justfile( 84 | " 85 | foo: && bar 86 | ", 87 | ) 88 | .stderr( 89 | " 90 | error: Recipe `foo` has unknown dependency `bar` 91 | ——▶ justfile:1:9 92 | │ 93 | 1 │ foo: && bar 94 | │ ^^^ 95 | ", 96 | ) 97 | .status(EXIT_FAILURE) 98 | .run(); 99 | } 100 | 101 | #[test] 102 | fn unknown_argument() { 103 | Test::new() 104 | .justfile( 105 | " 106 | bar x: 107 | 108 | foo: && (bar y) 109 | ", 110 | ) 111 | .stderr( 112 | " 113 | error: Variable `y` not defined 114 | ——▶ justfile:3:14 115 | │ 116 | 3 │ foo: && (bar y) 117 | │ ^ 118 | ", 119 | ) 120 | .status(EXIT_FAILURE) 121 | .run(); 122 | } 123 | 124 | #[test] 125 | fn argument() { 126 | Test::new() 127 | .justfile( 128 | " 129 | foo: && (bar 'hello') 130 | 131 | bar x: 132 | echo {{ x }} 133 | ", 134 | ) 135 | .stdout( 136 | " 137 | hello 138 | ", 139 | ) 140 | .stderr( 141 | " 142 | echo hello 143 | ", 144 | ) 145 | .run(); 146 | } 147 | 148 | #[test] 149 | fn duplicate_subsequents_dont_run() { 150 | Test::new() 151 | .justfile( 152 | " 153 | a: && b c 154 | echo a 155 | 156 | b: d 157 | echo b 158 | 159 | c: d 160 | echo c 161 | 162 | d: 163 | echo d 164 | ", 165 | ) 166 | .stdout( 167 | " 168 | a 169 | d 170 | b 171 | c 172 | ", 173 | ) 174 | .stderr( 175 | " 176 | echo a 177 | echo d 178 | echo b 179 | echo c 180 | ", 181 | ) 182 | .run(); 183 | } 184 | 185 | #[test] 186 | fn subsequents_run_even_if_already_ran_as_prior() { 187 | Test::new() 188 | .justfile( 189 | " 190 | a: b && b 191 | echo a 192 | 193 | b: 194 | echo b 195 | ", 196 | ) 197 | .stdout( 198 | " 199 | b 200 | a 201 | b 202 | ", 203 | ) 204 | .stderr( 205 | " 206 | echo b 207 | echo a 208 | echo b 209 | ", 210 | ) 211 | .run(); 212 | } 213 | -------------------------------------------------------------------------------- /tests/summary.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn summary() { 5 | Test::new() 6 | .arg("--summary") 7 | .justfile( 8 | "b: a 9 | a: 10 | d: c 11 | c: b 12 | _z: _y 13 | _y: 14 | ", 15 | ) 16 | .stdout("a b c d\n") 17 | .run(); 18 | } 19 | 20 | #[test] 21 | fn summary_sorted() { 22 | Test::new() 23 | .arg("--summary") 24 | .justfile( 25 | " 26 | b: 27 | c: 28 | a: 29 | ", 30 | ) 31 | .stdout("a b c\n") 32 | .run(); 33 | } 34 | 35 | #[test] 36 | fn summary_unsorted() { 37 | Test::new() 38 | .arg("--summary") 39 | .arg("--unsorted") 40 | .justfile( 41 | " 42 | b: 43 | c: 44 | a: 45 | ", 46 | ) 47 | .stdout("b c a\n") 48 | .run(); 49 | } 50 | 51 | #[test] 52 | fn summary_none() { 53 | Test::new() 54 | .arg("--summary") 55 | .arg("--quiet") 56 | .justfile("") 57 | .stdout("\n\n\n") 58 | .run(); 59 | } 60 | 61 | #[test] 62 | fn no_recipes() { 63 | Test::new() 64 | .arg("--summary") 65 | .stderr("Justfile contains no recipes.\n") 66 | .stdout("\n\n\n") 67 | .run(); 68 | } 69 | 70 | #[test] 71 | fn submodule_recipes() { 72 | Test::new() 73 | .write("foo.just", "mod bar\nfoo:") 74 | .write("bar.just", "mod baz\nbar:") 75 | .write("baz.just", "mod biz\nbaz:") 76 | .write("biz.just", "biz:") 77 | .justfile( 78 | " 79 | mod foo 80 | 81 | bar: 82 | ", 83 | ) 84 | .arg("--summary") 85 | .stdout("bar foo::foo foo::bar::bar foo::bar::baz::baz foo::bar::baz::biz::biz\n") 86 | .run(); 87 | } 88 | 89 | #[test] 90 | fn summary_implies_unstable() { 91 | Test::new() 92 | .write("foo.just", "foo:") 93 | .justfile( 94 | " 95 | mod foo 96 | ", 97 | ) 98 | .arg("--summary") 99 | .stdout("foo::foo\n") 100 | .run(); 101 | } 102 | -------------------------------------------------------------------------------- /tests/tempdir.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | pub(crate) fn tempdir() -> TempDir { 4 | let mut builder = tempfile::Builder::new(); 5 | 6 | builder.prefix("just-test-tempdir"); 7 | 8 | if let Some(runtime_dir) = dirs::runtime_dir() { 9 | let path = runtime_dir.join("just"); 10 | fs::create_dir_all(&path).unwrap(); 11 | builder.tempdir_in(path) 12 | } else { 13 | builder.tempdir() 14 | } 15 | .expect("failed to create temporary directory") 16 | } 17 | 18 | #[test] 19 | fn test_tempdir_is_set() { 20 | Test::new() 21 | .justfile( 22 | " 23 | set tempdir := '.' 24 | foo: 25 | #!/usr/bin/env bash 26 | cat just*/foo 27 | ", 28 | ) 29 | .shell(false) 30 | .tree(tree! { 31 | foo: { 32 | } 33 | }) 34 | .current_dir("foo") 35 | .stdout(if cfg!(windows) { 36 | " 37 | 38 | 39 | 40 | cat just*/foo 41 | " 42 | } else { 43 | " 44 | #!/usr/bin/env bash 45 | 46 | 47 | cat just*/foo 48 | " 49 | }) 50 | .run(); 51 | } 52 | -------------------------------------------------------------------------------- /tests/timestamps.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn print_timestamps() { 5 | Test::new() 6 | .justfile( 7 | " 8 | recipe: 9 | echo 'one' 10 | ", 11 | ) 12 | .arg("--timestamp") 13 | .stderr_regex(concat!(r"\[\d\d:\d\d:\d\d\] echo 'one'", "\n")) 14 | .stdout("one\n") 15 | .run(); 16 | } 17 | 18 | #[test] 19 | fn print_timestamps_with_format_string() { 20 | Test::new() 21 | .justfile( 22 | " 23 | recipe: 24 | echo 'one' 25 | ", 26 | ) 27 | .args(["--timestamp", "--timestamp-format", "%H:%M:%S.%3f"]) 28 | .stderr_regex(concat!(r"\[\d\d:\d\d:\d\d\.\d\d\d] echo 'one'", "\n")) 29 | .stdout("one\n") 30 | .run(); 31 | } 32 | -------------------------------------------------------------------------------- /tests/undefined_variables.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn parameter_default_unknown_variable_in_expression() { 5 | Test::new() 6 | .justfile("foo a=(b+''):") 7 | .stderr( 8 | " 9 | error: Variable `b` not defined 10 | ——▶ justfile:1:8 11 | │ 12 | 1 │ foo a=(b+''): 13 | │ ^ 14 | ", 15 | ) 16 | .status(EXIT_FAILURE) 17 | .run(); 18 | } 19 | 20 | #[test] 21 | fn unknown_variable_in_unary_call() { 22 | Test::new() 23 | .justfile( 24 | " 25 | foo x=env_var(a): 26 | ", 27 | ) 28 | .stderr( 29 | " 30 | error: Variable `a` not defined 31 | ——▶ justfile:1:15 32 | │ 33 | 1 │ foo x=env_var(a): 34 | │ ^ 35 | ", 36 | ) 37 | .status(EXIT_FAILURE) 38 | .run(); 39 | } 40 | 41 | #[test] 42 | fn unknown_first_variable_in_binary_call() { 43 | Test::new() 44 | .justfile( 45 | " 46 | foo x=env_var_or_default(a, b): 47 | ", 48 | ) 49 | .stderr( 50 | " 51 | error: Variable `a` not defined 52 | ——▶ justfile:1:26 53 | │ 54 | 1 │ foo x=env_var_or_default(a, b): 55 | │ ^ 56 | ", 57 | ) 58 | .status(EXIT_FAILURE) 59 | .run(); 60 | } 61 | 62 | #[test] 63 | fn unknown_second_variable_in_binary_call() { 64 | Test::new() 65 | .justfile( 66 | " 67 | foo x=env_var_or_default('', b): 68 | ", 69 | ) 70 | .stderr( 71 | " 72 | error: Variable `b` not defined 73 | ——▶ justfile:1:30 74 | │ 75 | 1 │ foo x=env_var_or_default('', b): 76 | │ ^ 77 | ", 78 | ) 79 | .status(EXIT_FAILURE) 80 | .run(); 81 | } 82 | 83 | #[test] 84 | fn unknown_variable_in_ternary_call() { 85 | Test::new() 86 | .justfile( 87 | " 88 | foo x=replace(a, b, c): 89 | ", 90 | ) 91 | .stderr( 92 | " 93 | error: Variable `a` not defined 94 | ——▶ justfile:1:15 95 | │ 96 | 1 │ foo x=replace(a, b, c): 97 | │ ^ 98 | ", 99 | ) 100 | .status(EXIT_FAILURE) 101 | .run(); 102 | } 103 | -------------------------------------------------------------------------------- /tests/unexport.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn unexport_environment_variable_linewise() { 5 | Test::new() 6 | .justfile( 7 | " 8 | unexport JUST_TEST_VARIABLE 9 | 10 | @recipe: 11 | echo ${JUST_TEST_VARIABLE:-unset} 12 | ", 13 | ) 14 | .env("JUST_TEST_VARIABLE", "foo") 15 | .stdout("unset\n") 16 | .run(); 17 | } 18 | 19 | #[test] 20 | fn unexport_environment_variable_shebang() { 21 | Test::new() 22 | .justfile( 23 | " 24 | unexport JUST_TEST_VARIABLE 25 | 26 | recipe: 27 | #!/usr/bin/env bash 28 | echo ${JUST_TEST_VARIABLE:-unset} 29 | ", 30 | ) 31 | .env("JUST_TEST_VARIABLE", "foo") 32 | .stdout("unset\n") 33 | .run(); 34 | } 35 | 36 | #[test] 37 | fn duplicate_unexport_fails() { 38 | Test::new() 39 | .justfile( 40 | " 41 | unexport JUST_TEST_VARIABLE 42 | 43 | recipe: 44 | echo \"variable: $JUST_TEST_VARIABLE\" 45 | 46 | unexport JUST_TEST_VARIABLE 47 | ", 48 | ) 49 | .env("JUST_TEST_VARIABLE", "foo") 50 | .stderr( 51 | " 52 | error: Variable `JUST_TEST_VARIABLE` is unexported multiple times 53 | ——▶ justfile:6:10 54 | │ 55 | 6 │ unexport JUST_TEST_VARIABLE 56 | │ ^^^^^^^^^^^^^^^^^^ 57 | ", 58 | ) 59 | .status(1) 60 | .run(); 61 | } 62 | 63 | #[test] 64 | fn export_unexport_conflict() { 65 | Test::new() 66 | .justfile( 67 | " 68 | unexport JUST_TEST_VARIABLE 69 | 70 | recipe: 71 | echo variable: $JUST_TEST_VARIABLE 72 | 73 | export JUST_TEST_VARIABLE := 'foo' 74 | ", 75 | ) 76 | .stderr( 77 | " 78 | error: Variable JUST_TEST_VARIABLE is both exported and unexported 79 | ——▶ justfile:6:8 80 | │ 81 | 6 │ export JUST_TEST_VARIABLE := 'foo' 82 | │ ^^^^^^^^^^^^^^^^^^ 83 | ", 84 | ) 85 | .status(1) 86 | .run(); 87 | } 88 | 89 | #[test] 90 | fn unexport_doesnt_override_local_recipe_export() { 91 | Test::new() 92 | .justfile( 93 | " 94 | unexport JUST_TEST_VARIABLE 95 | 96 | recipe $JUST_TEST_VARIABLE: 97 | @echo \"variable: $JUST_TEST_VARIABLE\" 98 | ", 99 | ) 100 | .args(["recipe", "value"]) 101 | .stdout("variable: value\n") 102 | .run(); 103 | } 104 | 105 | #[test] 106 | fn unexport_does_not_conflict_with_recipe_syntax() { 107 | Test::new() 108 | .justfile( 109 | " 110 | unexport foo: 111 | @echo {{foo}} 112 | ", 113 | ) 114 | .args(["unexport", "bar"]) 115 | .stdout("bar\n") 116 | .run(); 117 | } 118 | 119 | #[test] 120 | fn unexport_does_not_conflict_with_assignment_syntax() { 121 | Test::new() 122 | .justfile("unexport := 'foo'") 123 | .args(["--evaluate", "unexport"]) 124 | .stdout("foo") 125 | .run(); 126 | } 127 | -------------------------------------------------------------------------------- /tests/unstable.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn set_unstable_true_with_env_var() { 5 | for val in ["true", "some-arbitrary-string"] { 6 | Test::new() 7 | .justfile("# hello") 8 | .args(["--fmt"]) 9 | .env("JUST_UNSTABLE", val) 10 | .status(EXIT_SUCCESS) 11 | .stderr_regex("Wrote justfile to `.*`\n") 12 | .run(); 13 | } 14 | } 15 | 16 | #[test] 17 | fn set_unstable_false_with_env_var() { 18 | for val in ["0", "", "false"] { 19 | Test::new() 20 | .justfile("") 21 | .args(["--fmt"]) 22 | .env("JUST_UNSTABLE", val) 23 | .status(EXIT_FAILURE) 24 | .stderr_regex("error: The `--fmt` command is currently unstable.*") 25 | .run(); 26 | } 27 | } 28 | 29 | #[test] 30 | fn set_unstable_false_with_env_var_unset() { 31 | Test::new() 32 | .justfile("") 33 | .args(["--fmt"]) 34 | .status(EXIT_FAILURE) 35 | .stderr_regex("error: The `--fmt` command is currently unstable.*") 36 | .run(); 37 | } 38 | 39 | #[test] 40 | fn set_unstable_with_setting() { 41 | Test::new() 42 | .justfile("set unstable") 43 | .arg("--fmt") 44 | .stderr_regex("Wrote justfile to .*") 45 | .run(); 46 | } 47 | 48 | // This test should be re-enabled if we get a new unstable feature which is 49 | // encountered in source files. (As opposed to, for example, the unstable 50 | // `--fmt` subcommand, which is encountered on the command line.) 51 | #[cfg(any())] 52 | #[test] 53 | fn unstable_setting_does_not_affect_submodules() { 54 | Test::new() 55 | .justfile( 56 | " 57 | set unstable 58 | 59 | mod foo 60 | ", 61 | ) 62 | .write("foo.just", "mod bar") 63 | .write("bar.just", "baz:\n echo hello") 64 | .args(["foo", "bar"]) 65 | .stderr_regex("error: Modules are currently unstable.*") 66 | .status(EXIT_FAILURE) 67 | .run(); 68 | } 69 | -------------------------------------------------------------------------------- /tests/windows.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn bare_bash_in_shebang() { 5 | Test::new() 6 | .justfile( 7 | " 8 | default: 9 | #!bash 10 | echo FOO 11 | ", 12 | ) 13 | .stdout("FOO\n") 14 | .run(); 15 | } 16 | -------------------------------------------------------------------------------- /tests/windows_shell.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn windows_shell_setting() { 5 | Test::new() 6 | .justfile( 7 | r#" 8 | set windows-shell := ["pwsh.exe", "-NoLogo", "-Command"] 9 | set shell := ["asdfasdfasdfasdf"] 10 | 11 | foo: 12 | Write-Output bar 13 | "#, 14 | ) 15 | .shell(false) 16 | .stdout("bar\r\n") 17 | .stderr("Write-Output bar\n") 18 | .run(); 19 | } 20 | 21 | #[test] 22 | fn windows_powershell_setting_uses_powershell_set_shell() { 23 | Test::new() 24 | .justfile( 25 | r#" 26 | set windows-powershell 27 | set shell := ["asdfasdfasdfasdf"] 28 | 29 | foo: 30 | Write-Output bar 31 | "#, 32 | ) 33 | .shell(false) 34 | .stdout("bar\r\n") 35 | .stderr("Write-Output bar\n") 36 | .run(); 37 | } 38 | 39 | #[test] 40 | fn windows_powershell_setting_uses_powershell() { 41 | Test::new() 42 | .justfile( 43 | r#" 44 | set windows-powershell 45 | 46 | foo: 47 | Write-Output bar 48 | "#, 49 | ) 50 | .shell(false) 51 | .stdout("bar\r\n") 52 | .stderr("Write-Output bar\n") 53 | .run(); 54 | } 55 | -------------------------------------------------------------------------------- /www/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/casey/just/515e806b5121a4696113ef15b5f0b12e69854570/www/.nojekyll -------------------------------------------------------------------------------- /www/CNAME: -------------------------------------------------------------------------------- 1 | just.systems 2 | -------------------------------------------------------------------------------- /www/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/casey/just/515e806b5121a4696113ef15b5f0b12e69854570/www/favicon.ico -------------------------------------------------------------------------------- /www/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --width-target: calc(100vw / 6); 3 | --height-target: calc(100vh / 3); 4 | --size: min(var(--width-target), var(--height-target)); 5 | --margin-vertical: calc((100vh - var(--size) * 2) / 2); 6 | --margin-horizontal: calc((100vw - var(--size) * 5) / 2); 7 | } 8 | 9 | * { 10 | margin: 0; 11 | padding: 0; 12 | } 13 | 14 | html { 15 | background-color: black; 16 | color: white; 17 | overflow: hidden; 18 | text-align: center; 19 | font-family: monospace; 20 | font-size: var(--size); 21 | line-height: var(--size); 22 | } 23 | 24 | a { 25 | color: white; 26 | text-decoration: none; 27 | } 28 | 29 | a:hover { 30 | text-shadow: 0 0 5px #fff; 31 | } 32 | 33 | body { 34 | display: grid; 35 | grid-template-columns: repeat(4, 1fr); 36 | margin: var(--margin-vertical) var(--margin-horizontal); 37 | } 38 | 39 | body > * { 40 | width: var(--size); 41 | } 42 | 43 | body > div { 44 | height: var(--size); 45 | text-shadow: 0 0 5px #fff; 46 | } 47 | 48 | body > a:nth-child(n+5) { 49 | align-items: center; 50 | display: flex; 51 | font-size: calc(var(--size) / 9); 52 | height: calc(var(--size) / 2); 53 | justify-content: center; 54 | line-height: calc(var(--size) / 9); 55 | } 56 | 57 | /* just is an isogram */ 58 | #j:after { content: 'j'; } 59 | #j:hover:after { content: ':'; } 60 | #u:after { content: 'u'; } 61 | #u:hover:after { content: '~'; } 62 | #s:after { content: 's'; } 63 | #s:hover:after { content: '$'; } 64 | #t:after { content: 't'; } 65 | #t:hover:after { content: '='; } 66 | -------------------------------------------------------------------------------- /www/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Just: A Command Runner 7 | 8 | 9 | 10 |
11 |
12 |
13 |
14 | 15 | github 16 | manual 17 | discord 18 | crates.io 19 | 20 |
21 | 用户指南 22 |
23 |
24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /www/man/en: -------------------------------------------------------------------------------- 1 | ../../book/en/build/html/ -------------------------------------------------------------------------------- /www/man/zh: -------------------------------------------------------------------------------- 1 | ../../book/zh/build/html/ --------------------------------------------------------------------------------