From aefdcea7d087095890b419e153bc66bf72f4a973 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Sat, 9 Nov 2019 21:43:20 -0800 Subject: [PATCH] Gargantuan refactor (#522) - Instead of changing the current directory with `env::set_current_dir` to be implicitly inherited by subprocesses, we now use `Command::current_dir` to set it explicitly. This feels much better, since we aren't dependent on the implicit state of the process's current directory. - Subcommand execution is much improved. - Added a ton of tests for config parsing, config execution, working dir, and search dir. - Error messages are improved. Many more will be colored. - The Config is now onwed, instead of borrowing from the arguments and the `clap::ArgMatches` object. This is a huge ergonomic improvement, especially in tests, and I don't think anyone will notice. - `--edit` now uses `$VISUAL`, `$EDITOR`, or `vim`, in that order, matching git, which I think is what most people will expect. - Added a cute `tmptree!{}` macro, for creating temporary directories populated with directories and files for tests. - Admitted that grammer is LL(k) and I don't know what `k` is. --- Cargo.lock | 76 ++++ Cargo.toml | 2 + GRAMMAR.md | 5 +- justfile | 2 +- src/assignment_evaluator.rs | 58 ++- src/assignment_resolver.rs | 2 +- src/color.rs | 4 +- src/common.rs | 37 +- src/compilation_error.rs | 7 +- src/compiler.rs | 4 +- src/config.rs | 770 +++++++++++++++++++++++++++++++++--- src/config_error.rs | 32 +- src/die.rs | 6 - src/error.rs | 7 + src/error_result_ext.rs | 22 ++ src/function.rs | 5 +- src/function_context.rs | 3 +- src/interrupt_handler.rs | 18 +- src/justfile.rs | 168 ++++---- src/lib.rs | 7 +- src/load_error.rs | 19 + src/parser.rs | 30 +- src/platform.rs | 17 +- src/platform_interface.rs | 3 +- src/recipe.rs | 18 +- src/recipe_context.rs | 3 +- src/run.rs | 118 +----- src/runtime_error.rs | 36 +- src/search.rs | 120 ++++-- src/search_config.rs | 21 + src/search_error.rs | 51 ++- src/subcommand.rs | 222 +---------- src/testing.rs | 11 + src/use_color.rs | 2 +- src/verbosity.rs | 2 +- test-utilities/src/lib.rs | 120 ++++++ tests/edit.rs | 116 ++++++ tests/integration.rs | 317 ++++++++------- tests/interrupts.rs | 2 +- tests/search.rs | 139 ++++--- tests/shell.rs | 40 ++ tests/working_directory.rs | 181 +++++++-- 42 files changed, 1926 insertions(+), 897 deletions(-) delete mode 100644 src/die.rs create mode 100644 src/error.rs create mode 100644 src/error_result_ext.rs create mode 100644 src/load_error.rs create mode 100644 src/search_config.rs create mode 100644 tests/edit.rs create mode 100644 tests/shell.rs diff --git a/Cargo.lock b/Cargo.lock index 2aaa486..c2cd0ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,6 +38,26 @@ dependencies = [ "winapi 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "backtrace" +version = "0.3.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "backtrace-sys 0.1.32 (registry+https://github.com/rust-lang/crates.io-index)", + "cfg-if 0.1.10 (registry+https://github.com/rust-lang/crates.io-index)", + "libc 0.2.65 (registry+https://github.com/rust-lang/crates.io-index)", + "rustc-demangle 0.1.16 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "backtrace-sys" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "cc 1.0.46 (registry+https://github.com/rust-lang/crates.io-index)", + "libc 0.2.65 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "bitflags" version = "1.2.1" @@ -98,6 +118,11 @@ name = "difference" version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" +[[package]] +name = "doc-comment" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" + [[package]] name = "dotenv" version = "0.15.0" @@ -130,6 +155,14 @@ name = "executable-path" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" +[[package]] +name = "failure" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "backtrace 0.3.40 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "getrandom" version = "0.1.12" @@ -174,10 +207,12 @@ dependencies = [ "libc 0.2.65 (registry+https://github.com/rust-lang/crates.io-index)", "log 0.4.8 (registry+https://github.com/rust-lang/crates.io-index)", "pretty_assertions 0.6.1 (registry+https://github.com/rust-lang/crates.io-index)", + "snafu 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)", "target 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)", "tempfile 3.1.0 (registry+https://github.com/rust-lang/crates.io-index)", "test-utilities 0.0.0", "unicode-width 0.1.6 (registry+https://github.com/rust-lang/crates.io-index)", + "which 3.1.0 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -326,6 +361,30 @@ dependencies = [ "winapi 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "rustc-demangle" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "snafu" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "doc-comment 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)", + "snafu-derive 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "snafu-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "proc-macro2 1.0.6 (registry+https://github.com/rust-lang/crates.io-index)", + "quote 1.0.2 (registry+https://github.com/rust-lang/crates.io-index)", + "syn 1.0.5 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "strsim" version = "0.8.0" @@ -415,6 +474,15 @@ name = "wasi" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" +[[package]] +name = "which" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "failure 0.1.6 (registry+https://github.com/rust-lang/crates.io-index)", + "libc 0.2.65 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "winapi" version = "0.3.8" @@ -457,6 +525,8 @@ dependencies = [ "checksum ansi_term 0.12.1 (registry+https://github.com/rust-lang/crates.io-index)" = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" "checksum assert_matches 1.3.0 (registry+https://github.com/rust-lang/crates.io-index)" = "7deb0a829ca7bcfaf5da70b073a8d128619259a7be8216a355e23f00763059e5" "checksum atty 0.2.13 (registry+https://github.com/rust-lang/crates.io-index)" = "1803c647a3ec87095e7ae7acfca019e98de5ec9a7d01343f611cf3152ed71a90" +"checksum backtrace 0.3.40 (registry+https://github.com/rust-lang/crates.io-index)" = "924c76597f0d9ca25d762c25a4d369d51267536465dc5064bdf0eb073ed477ea" +"checksum backtrace-sys 0.1.32 (registry+https://github.com/rust-lang/crates.io-index)" = "5d6575f128516de27e3ce99689419835fce9643a9b215a14d2b5b685be018491" "checksum bitflags 1.2.1 (registry+https://github.com/rust-lang/crates.io-index)" = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" "checksum c2-chacha 0.2.3 (registry+https://github.com/rust-lang/crates.io-index)" = "214238caa1bf3a496ec3392968969cab8549f96ff30652c9e56885329315f6bb" "checksum cc 1.0.46 (registry+https://github.com/rust-lang/crates.io-index)" = "0213d356d3c4ea2c18c40b037c3be23cd639825c18f25ee670ac7813beeef99c" @@ -465,11 +535,13 @@ dependencies = [ "checksum ctor 0.1.12 (registry+https://github.com/rust-lang/crates.io-index)" = "cd8ce37ad4184ab2ce004c33bf6379185d3b1c95801cab51026bd271bf68eedc" "checksum ctrlc 3.1.3 (registry+https://github.com/rust-lang/crates.io-index)" = "c7dfd2d8b4c82121dfdff120f818e09fc4380b0b7e17a742081a89b94853e87f" "checksum difference 2.0.0 (registry+https://github.com/rust-lang/crates.io-index)" = "524cbf6897b527295dff137cec09ecf3a05f4fddffd7dfcd1585403449e74198" +"checksum doc-comment 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)" = "923dea538cea0aa3025e8685b20d6ee21ef99c4f77e954a30febbaac5ec73a97" "checksum dotenv 0.15.0 (registry+https://github.com/rust-lang/crates.io-index)" = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" "checksum edit-distance 2.1.0 (registry+https://github.com/rust-lang/crates.io-index)" = "bbbaaaf38131deb9ca518a274a45bfdb8771f139517b073b16c2d3d32ae5037b" "checksum either 1.5.3 (registry+https://github.com/rust-lang/crates.io-index)" = "bb1f6b1ce1c140482ea30ddd3335fc0024ac7ee112895426e0a629a6c20adfe3" "checksum env_logger 0.7.1 (registry+https://github.com/rust-lang/crates.io-index)" = "44533bbbb3bb3c1fa17d9f2e4e38bbbaf8396ba82193c4cb1b6445d711445d36" "checksum executable-path 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)" = "3ebc5a6d89e3c90b84e8f33c8737933dda8f1c106b5415900b38b9d433841478" +"checksum failure 0.1.6 (registry+https://github.com/rust-lang/crates.io-index)" = "f8273f13c977665c5db7eb2b99ae520952fe5ac831ae4cd09d80c4c7042b5ed9" "checksum getrandom 0.1.12 (registry+https://github.com/rust-lang/crates.io-index)" = "473a1265acc8ff1e808cd0a1af8cee3c2ee5200916058a2ca113c29f2d903571" "checksum humantime 1.3.0 (registry+https://github.com/rust-lang/crates.io-index)" = "df004cfca50ef23c36850aaaa59ad52cc70d0e90243c3c7737a4dd32dc7a3c4f" "checksum itertools 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)" = "5b8467d9c1cebe26feb08c640139247fac215782d35371ade9a2136ed6085358" @@ -492,6 +564,9 @@ dependencies = [ "checksum regex 1.3.1 (registry+https://github.com/rust-lang/crates.io-index)" = "dc220bd33bdce8f093101afe22a037b8eb0e5af33592e6a9caafff0d4cb81cbd" "checksum regex-syntax 0.6.12 (registry+https://github.com/rust-lang/crates.io-index)" = "11a7e20d1cce64ef2fed88b66d347f88bd9babb82845b2b858f3edbf59a4f716" "checksum remove_dir_all 0.5.2 (registry+https://github.com/rust-lang/crates.io-index)" = "4a83fa3702a688b9359eccba92d153ac33fd2e8462f9e0e3fdf155239ea7792e" +"checksum rustc-demangle 0.1.16 (registry+https://github.com/rust-lang/crates.io-index)" = "4c691c0e608126e00913e33f0ccf3727d5fc84573623b8d65b2df340b5201783" +"checksum snafu 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)" = "41207ca11f96a62cd34e6b7fdf73d322b25ae3848eb9d38302169724bb32cf27" +"checksum snafu-derive 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)" = "4c5e338c8b0577457c9dda8e794b6ad7231c96e25b1b0dd5842d52249020c1c0" "checksum strsim 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)" = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" "checksum syn 1.0.5 (registry+https://github.com/rust-lang/crates.io-index)" = "66850e97125af79138385e9b88339cbcd037e3f28ceab8c5ad98e64f0f1f80bf" "checksum target 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)" = "10000465bb0cc031c87a44668991b284fd84c0e6bd945f62d4af04e9e52a222a" @@ -504,6 +579,7 @@ dependencies = [ "checksum vec_map 0.8.1 (registry+https://github.com/rust-lang/crates.io-index)" = "05c78687fb1a80548ae3250346c3db86a80a7cdd77bda190189f2d0a0987c81a" "checksum void 1.0.2 (registry+https://github.com/rust-lang/crates.io-index)" = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" "checksum wasi 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)" = "b89c3ce4ce14bdc6fb6beaf9ec7928ca331de5df7e5ea278375642a2f478570d" +"checksum which 3.1.0 (registry+https://github.com/rust-lang/crates.io-index)" = "5475d47078209a02e60614f7ba5e645ef3ed60f771920ac1906d7c1cc65024c8" "checksum winapi 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)" = "8093091eeb260906a183e6ae1abdba2ef5ef2257a21801128899c3fc699229c6" "checksum winapi-i686-pc-windows-gnu 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" "checksum winapi-util 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)" = "7168bab6e1daee33b4557efd0e95d5ca70a03706d39fa5f3fe7a236f584b03c9" diff --git a/Cargo.toml b/Cargo.toml index 11c253b..8c6b2cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ itertools = "0.8" lazy_static = "1" libc = "0.2" log = "0.4.4" +snafu = "0.6" target = "1" tempfile = "3" unicode-width = "0.1" @@ -37,6 +38,7 @@ features = ["termination"] [dev-dependencies] executable-path = "1" pretty_assertions = "0.6" +which = "3" # Until github.com/rust-lang/cargo/pull/7333 makes it into stable, # this version-less dev-dependency will interfere with publishing diff --git a/GRAMMAR.md b/GRAMMAR.md index 1e21295..d0d72cb 100644 --- a/GRAMMAR.md +++ b/GRAMMAR.md @@ -2,9 +2,8 @@ justfile grammar ================ Justfiles are processed by a mildly context-sensitive tokenizer -and a recursive descent parser. The grammar is mostly LL(1), -although an extra token of lookahead is used to distinguish between -export assignments and recipes with parameters. +and a recursive descent parser. The grammar is LL(k), for an +unknown but hopefully reasonable value of k. tokens ------ diff --git a/justfile b/justfile index adcf758..42ea3d7 100755 --- a/justfile +++ b/justfile @@ -34,7 +34,7 @@ check: cargo check watch +COMMAND='test': - cargo watch --clear --exec "{{COMMAND}}" + cargo watch --clear --exec build --exec "{{COMMAND}}" man: cargo build --features help4help2man diff --git a/src/assignment_evaluator.rs b/src/assignment_evaluator.rs index 674834d..5a84a90 100644 --- a/src/assignment_evaluator.rs +++ b/src/assignment_evaluator.rs @@ -2,36 +2,27 @@ use crate::common::*; pub(crate) struct AssignmentEvaluator<'a: 'b, 'b> { pub(crate) assignments: &'b BTreeMap<&'a str, Assignment<'a>>, - pub(crate) invocation_directory: &'b Result, + pub(crate) config: &'a Config, pub(crate) dotenv: &'b BTreeMap, - pub(crate) dry_run: bool, pub(crate) evaluated: BTreeMap<&'a str, (bool, String)>, - pub(crate) overrides: &'b BTreeMap<&'b str, &'b str>, - pub(crate) quiet: bool, pub(crate) scope: &'b BTreeMap<&'a str, (bool, String)>, - pub(crate) shell: &'b str, + pub(crate) working_directory: &'b Path, } impl<'a, 'b> AssignmentEvaluator<'a, 'b> { pub(crate) fn evaluate_assignments( - assignments: &BTreeMap<&'a str, Assignment<'a>>, - invocation_directory: &Result, + config: &'a Config, + working_directory: &'b Path, dotenv: &'b BTreeMap, - overrides: &BTreeMap<&str, &str>, - quiet: bool, - shell: &'a str, - dry_run: bool, + assignments: &BTreeMap<&'a str, Assignment<'a>>, ) -> RunResult<'a, BTreeMap<&'a str, (bool, String)>> { let mut evaluator = AssignmentEvaluator { evaluated: empty(), scope: &empty(), + config, assignments, - invocation_directory, + working_directory, dotenv, - dry_run, - overrides, - quiet, - shell, }; for name in assignments.keys() { @@ -64,7 +55,7 @@ impl<'a, 'b> AssignmentEvaluator<'a, 'b> { } if let Some(assignment) = self.assignments.get(name) { - if let Some(value) = self.overrides.get(name) { + if let Some(value) = self.config.overrides.get(name) { self .evaluated .insert(name, (assignment.export, value.to_string())); @@ -113,14 +104,15 @@ impl<'a, 'b> AssignmentEvaluator<'a, 'b> { .map(|argument| self.evaluate_expression(argument, arguments)) .collect::, RuntimeError>>()?; let context = FunctionContext { - invocation_directory: &self.invocation_directory, + invocation_directory: &self.config.invocation_directory, + working_directory: &self.working_directory, dotenv: self.dotenv, }; Function::evaluate(*function, &context, &call_arguments) } Expression::StringLiteral { string_literal } => Ok(string_literal.cooked.to_string()), Expression::Backtick { contents, token } => { - if self.dry_run { + if self.config.dry_run { Ok(format!("`{}`", contents)) } else { Ok(self.run_backtick(self.dotenv, contents, token)?) @@ -139,7 +131,9 @@ impl<'a, 'b> AssignmentEvaluator<'a, 'b> { raw: &str, token: &Token<'a>, ) -> RunResult<'a, String> { - let mut cmd = Command::new(self.shell); + let mut cmd = Command::new(&self.config.shell); + + cmd.current_dir(self.working_directory); cmd.arg("-cu").arg(raw); @@ -147,7 +141,7 @@ impl<'a, 'b> AssignmentEvaluator<'a, 'b> { cmd.stdin(process::Stdio::inherit()); - cmd.stderr(if self.quiet { + cmd.stderr(if self.config.quiet { process::Stdio::null() } else { process::Stdio::inherit() @@ -155,7 +149,7 @@ impl<'a, 'b> AssignmentEvaluator<'a, 'b> { InterruptHandler::guard(|| { output(cmd).map_err(|output_error| RuntimeError::Backtick { - token: token.clone(), + token: *token, output_error, }) }) @@ -165,14 +159,14 @@ impl<'a, 'b> AssignmentEvaluator<'a, 'b> { #[cfg(test)] mod tests { use super::*; - use crate::testing::compile; + use crate::testing::{compile, config}; #[test] fn backtick_code() { - match compile("a:\n echo {{`f() { return 100; }; f`}}") - .run(&["a"], &Default::default()) - .unwrap_err() - { + let justfile = compile("a:\n echo {{`f() { return 100; }; f`}}"); + let config = config(&["a"]); + let dir = env::current_dir().unwrap(); + match justfile.run(&config, &dir).unwrap_err() { RuntimeError::Backtick { token, output_error: OutputError::Code(code), @@ -193,12 +187,12 @@ b = `echo $exported_variable` recipe: echo {{b}} "#; - let config = Config { - quiet: true, - ..Default::default() - }; - match compile(text).run(&["recipe"], &config).unwrap_err() { + let justfile = compile(text); + let config = config(&["--quiet", "recipe"]); + let dir = env::current_dir().unwrap(); + + match justfile.run(&config, &dir).unwrap_err() { RuntimeError::Backtick { token, output_error: OutputError::Code(_), diff --git a/src/assignment_resolver.rs b/src/assignment_resolver.rs index 9dae818..46a7af9 100644 --- a/src/assignment_resolver.rs +++ b/src/assignment_resolver.rs @@ -62,7 +62,7 @@ impl<'a: 'b, 'b> AssignmentResolver<'a, 'b> { let token = self.assignments[variable].name.token(); self.stack.push(variable); return Err(token.error(CircularVariableDependency { - variable: variable, + variable, circle: self.stack.clone(), })); } else if self.assignments.contains_key(variable) { diff --git a/src/color.rs b/src/color.rs index 95b7e6e..a6a4b55 100644 --- a/src/color.rs +++ b/src/color.rs @@ -4,7 +4,7 @@ use ansi_term::Color::*; use ansi_term::{ANSIGenericString, Prefix, Style, Suffix}; use atty::Stream; -#[derive(Copy, Clone)] +#[derive(Copy, Clone, Debug, PartialEq)] pub(crate) struct Color { use_color: UseColor, atty: bool, @@ -128,7 +128,7 @@ impl Color { impl Default for Color { fn default() -> Color { Color { - use_color: UseColor::Never, + use_color: UseColor::Auto, atty: false, style: Style::new(), } diff --git a/src/common.rs b/src/common.rs index 003d4c6..21ce5fc 100644 --- a/src/common.rs +++ b/src/common.rs @@ -16,18 +16,23 @@ pub(crate) use std::{ usize, vec, }; +// modules used in tests +#[cfg(test)] +pub(crate) use crate::testing; + +// structs and enums used in tests +#[cfg(test)] +pub(crate) use crate::{node::Node, tree::Tree}; + // dependencies pub(crate) use edit_distance::edit_distance; pub(crate) use libc::EXIT_FAILURE; pub(crate) use log::warn; +pub(crate) use snafu::{ResultExt, Snafu}; pub(crate) use unicode_width::UnicodeWidthChar; // modules -pub(crate) use crate::{keyword, search}; - -// modules used in tests -#[cfg(test)] -pub(crate) use crate::testing; +pub(crate) use crate::{config_error, keyword, search_error}; // functions pub(crate) use crate::{ @@ -37,8 +42,9 @@ pub(crate) use crate::{ // traits pub(crate) use crate::{ - command_ext::CommandExt, compilation_result_ext::CompilationResultExt, keyed::Keyed, - ordinal::Ordinal, platform_interface::PlatformInterface, range_ext::RangeExt, + command_ext::CommandExt, compilation_result_ext::CompilationResultExt, error::Error, + error_result_ext::ErrorResultExt, keyed::Keyed, ordinal::Ordinal, + platform_interface::PlatformInterface, range_ext::RangeExt, }; // structs and enums @@ -50,20 +56,17 @@ pub(crate) use crate::{ enclosure::Enclosure, expression::Expression, fragment::Fragment, function::Function, function_context::FunctionContext, functions::Functions, interrupt_guard::InterruptGuard, interrupt_handler::InterruptHandler, item::Item, justfile::Justfile, lexer::Lexer, line::Line, - list::List, module::Module, name::Name, output_error::OutputError, parameter::Parameter, - parser::Parser, platform::Platform, position::Position, recipe::Recipe, + list::List, load_error::LoadError, module::Module, name::Name, output_error::OutputError, + parameter::Parameter, parser::Parser, platform::Platform, position::Position, recipe::Recipe, recipe_context::RecipeContext, recipe_resolver::RecipeResolver, runtime_error::RuntimeError, - search_error::SearchError, shebang::Shebang, show_whitespace::ShowWhitespace, state::State, - string_literal::StringLiteral, subcommand::Subcommand, table::Table, token::Token, - token_kind::TokenKind, use_color::UseColor, variables::Variables, verbosity::Verbosity, - warning::Warning, + search::Search, search_config::SearchConfig, search_error::SearchError, shebang::Shebang, + show_whitespace::ShowWhitespace, state::State, string_literal::StringLiteral, + subcommand::Subcommand, table::Table, token::Token, token_kind::TokenKind, use_color::UseColor, + variables::Variables, verbosity::Verbosity, warning::Warning, }; -// structs and enums used in tests -#[cfg(test)] -pub(crate) use crate::{node::Node, tree::Tree}; - // type aliases pub(crate) type CompilationResult<'a, T> = Result>; pub(crate) type ConfigResult = Result; pub(crate) type RunResult<'a, T> = Result>; +pub(crate) type SearchResult = Result; diff --git a/src/compilation_error.rs b/src/compilation_error.rs index fe55c95..80eafb4 100644 --- a/src/compilation_error.rs +++ b/src/compilation_error.rs @@ -10,13 +10,14 @@ pub(crate) struct CompilationError<'a> { pub(crate) kind: CompilationErrorKind<'a>, } -impl<'a> Display for CompilationError<'a> { +impl Error for CompilationError<'_> {} + +impl Display for CompilationError<'_> { fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> { use CompilationErrorKind::*; - let error = Color::fmt(f).error(); let message = Color::fmt(f).message(); - write!(f, "{} {}", error.paint("error:"), message.prefix())?; + write!(f, "{}", message.prefix())?; match self.kind { AliasShadowsRecipe { alias, recipe_line } => { diff --git a/src/compiler.rs b/src/compiler.rs index 5285a88..acba9ae 100644 --- a/src/compiler.rs +++ b/src/compiler.rs @@ -3,8 +3,8 @@ use crate::common::*; pub(crate) struct Compiler; impl Compiler { - pub(crate) fn compile(text: &str) -> CompilationResult { - let tokens = Lexer::lex(text)?; + pub(crate) fn compile(src: &str) -> CompilationResult { + let tokens = Lexer::lex(src)?; let ast = Parser::parse(&tokens)?; diff --git a/src/config.rs b/src/config.rs index 50ba564..a5bbe20 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,23 +1,23 @@ use crate::common::*; use clap::{App, AppSettings, Arg, ArgGroup, ArgMatches}; +use unicode_width::UnicodeWidthStr; pub(crate) const DEFAULT_SHELL: &str = "sh"; -pub(crate) struct Config<'a> { - pub(crate) subcommand: Subcommand<'a>, +#[derive(Debug, PartialEq)] +pub(crate) struct Config { + pub(crate) arguments: Vec, + pub(crate) color: Color, pub(crate) dry_run: bool, pub(crate) highlight: bool, - pub(crate) overrides: BTreeMap<&'a str, &'a str>, + pub(crate) invocation_directory: PathBuf, + pub(crate) overrides: BTreeMap, pub(crate) quiet: bool, - pub(crate) shell: &'a str, - pub(crate) color: Color, + pub(crate) search_config: SearchConfig, + pub(crate) shell: String, + pub(crate) subcommand: Subcommand, pub(crate) verbosity: Verbosity, - pub(crate) arguments: Vec<&'a str>, - pub(crate) justfile: Option<&'a Path>, - pub(crate) working_directory: Option<&'a Path>, - pub(crate) invocation_directory: Result, - pub(crate) search_directory: Option<&'a Path>, } mod cmd { @@ -48,7 +48,7 @@ mod arg { pub(crate) const COLOR_VALUES: &[&str] = &[COLOR_AUTO, COLOR_ALWAYS, COLOR_NEVER]; } -impl<'a> Config<'a> { +impl Config { pub(crate) fn app() -> App<'static, 'static> { let app = App::new(env!("CARGO_PKG_NAME")) .help_message("Print help information") @@ -83,7 +83,7 @@ impl<'a> Config<'a> { Arg::with_name(cmd::EDIT) .short("e") .long("edit") - .help("Open justfile with $EDITOR"), + .help("Edit justfile with editor given by $VISUAL or $EDITOR, falling back to `vim`"), ) .arg( Arg::with_name(cmd::EVALUATE) @@ -205,9 +205,8 @@ impl<'a> Config<'a> { } } - pub(crate) fn from_matches(matches: &'a ArgMatches<'a>) -> ConfigResult> { - let invocation_directory = - env::current_dir().map_err(|e| format!("Error getting current directory: {}", e)); + pub(crate) fn from_matches(matches: &ArgMatches) -> ConfigResult { + let invocation_directory = env::current_dir().context(config_error::CurrentDir)?; let verbosity = Verbosity::from_flag_occurrences(matches.occurrences_of(arg::VERBOSE)); @@ -217,12 +216,33 @@ impl<'a> Config<'a> { .expect("`--color` had no value"), )?; + let subcommand = if matches.is_present(cmd::EDIT) { + Subcommand::Edit + } else if matches.is_present(cmd::SUMMARY) { + Subcommand::Summary + } else if matches.is_present(cmd::DUMP) { + Subcommand::Dump + } else if matches.is_present(cmd::LIST) { + Subcommand::List + } else if matches.is_present(cmd::EVALUATE) { + Subcommand::Evaluate + } else if let Some(name) = matches.value_of(cmd::SHOW) { + Subcommand::Show { + name: name.to_owned(), + } + } else { + Subcommand::Run + }; + let set_count = matches.occurrences_of(arg::SET); let mut overrides = BTreeMap::new(); if set_count > 0 { let mut values = matches.values_of(arg::SET).unwrap(); for _ in 0..set_count { - overrides.insert(values.next().unwrap(), values.next().unwrap()); + overrides.insert( + values.next().unwrap().to_owned(), + values.next().unwrap().to_owned(), + ); } } @@ -243,8 +263,8 @@ impl<'a> Config<'a> { .unwrap() .0; - let name = &argument[..i]; - let value = &argument[i + 1..]; + let name = argument[..i].to_owned(); + let value = argument[i + 1..].to_owned(); overrides.insert(name, value); } @@ -258,13 +278,9 @@ impl<'a> Config<'a> { .flat_map(|(i, argument)| { if i == 0 { if let Some(i) = argument.rfind('/') { - if matches.is_present(arg::WORKING_DIRECTORY) { - die!("--working-directory and a path prefixed recipe may not be used together."); - } - let (dir, recipe) = argument.split_at(i + 1); - search_directory = Some(Path::new(dir)); + search_directory = Some(PathBuf::from(dir)); if recipe.is_empty() { return None; @@ -276,32 +292,43 @@ impl<'a> Config<'a> { Some(argument) }) - .collect::>(); + .map(|argument| argument.to_owned()) + .collect::>(); - let subcommand = if matches.is_present(cmd::EDIT) { - Subcommand::Edit - } else if matches.is_present(cmd::SUMMARY) { - Subcommand::Summary - } else if matches.is_present(cmd::DUMP) { - Subcommand::Dump - } else if matches.is_present(cmd::LIST) { - Subcommand::List - } else if matches.is_present(cmd::EVALUATE) { - Subcommand::Evaluate - } else if let Some(name) = matches.value_of(cmd::SHOW) { - Subcommand::Show { name } - } else { - Subcommand::Execute + let search_config = { + let justfile = matches.value_of(arg::JUSTFILE).map(PathBuf::from); + let working_directory = matches.value_of(arg::WORKING_DIRECTORY).map(PathBuf::from); + + if let Some(search_directory) = search_directory { + if justfile.is_some() || working_directory.is_some() { + return Err(ConfigError::SearchDirConflict); + } + SearchConfig::FromSearchDirectory { search_directory } + } else { + match (justfile, working_directory) { + (None, None) => SearchConfig::FromInvocationDirectory, + (Some(justfile), None) => SearchConfig::WithJustfile { justfile }, + (Some(justfile), Some(working_directory)) => { + SearchConfig::WithJustfileAndWorkingDirectory { + justfile, + working_directory, + } + } + (None, Some(_)) => { + return Err(ConfigError::internal( + "--working-directory set without --justfile", + )) + } + } + } }; Ok(Config { dry_run: matches.is_present(arg::DRY_RUN), highlight: !matches.is_present(arg::NO_HIGHLIGHT), quiet: matches.is_present(arg::QUIET), - shell: matches.value_of(arg::SHELL).unwrap(), - justfile: matches.value_of(arg::JUSTFILE).map(Path::new), - working_directory: matches.value_of(arg::WORKING_DIRECTORY).map(Path::new), - search_directory, + shell: matches.value_of(arg::SHELL).unwrap().to_owned(), + search_config, invocation_directory, subcommand, verbosity, @@ -310,26 +337,213 @@ impl<'a> Config<'a> { arguments, }) } -} -impl<'a> Default for Config<'a> { - fn default() -> Config<'static> { - Config { - subcommand: Subcommand::Execute, - dry_run: false, - highlight: false, - overrides: empty(), - arguments: empty(), - quiet: false, - shell: DEFAULT_SHELL, - color: default(), - verbosity: Verbosity::from_flag_occurrences(0), - justfile: None, - working_directory: None, - invocation_directory: env::current_dir() - .map_err(|e| format!("Error getting current directory: {}", e)), - search_directory: None, + pub(crate) fn run_subcommand(self) -> Result<(), i32> { + use Subcommand::*; + + let search = + Search::search(&self.search_config, &self.invocation_directory).eprint(self.color)?; + + if self.subcommand == Edit { + return self.edit(&search); } + + let src = fs::read_to_string(&search.justfile) + .map_err(|io_error| LoadError { + io_error, + path: &search.justfile, + }) + .eprint(self.color)?; + + let justfile = Compiler::compile(&src).eprint(self.color)?; + + for warning in &justfile.warnings { + if self.color.stderr().active() { + eprintln!("{:#}", warning); + } else { + eprintln!("{}", warning); + } + } + + match self.subcommand { + Dump => self.dump(justfile), + Run | Evaluate => self.run(justfile, &search.working_directory), + List => self.list(justfile), + Show { ref name } => self.show(&name, justfile), + Summary => self.summary(justfile), + Edit => unreachable!(), + } + } + + fn dump(&self, justfile: Justfile) -> Result<(), i32> { + println!("{}", justfile); + Ok(()) + } + + pub(crate) fn edit(&self, search: &Search) -> Result<(), i32> { + let editor = env::var_os("VISUAL") + .or_else(|| env::var_os("EDITOR")) + .unwrap_or_else(|| "vim".into()); + + let error = Command::new(&editor) + .current_dir(&search.working_directory) + .arg(&search.justfile) + .status(); + + match error { + Ok(status) => { + if status.success() { + Ok(()) + } else { + eprintln!("Editor `{}` failed: {}", editor.to_string_lossy(), status); + Err(status.code().unwrap_or(EXIT_FAILURE)) + } + } + Err(error) => { + eprintln!( + "Editor `{}` invocation failed: {}", + editor.to_string_lossy(), + error + ); + Err(EXIT_FAILURE) + } + } + } + + fn list(&self, justfile: Justfile) -> Result<(), i32> { + // Construct a target to alias map. + let mut recipe_aliases: BTreeMap<&str, Vec<&str>> = BTreeMap::new(); + for alias in justfile.aliases.values() { + if alias.is_private() { + continue; + } + + if !recipe_aliases.contains_key(alias.target.lexeme()) { + recipe_aliases.insert(alias.target.lexeme(), vec![alias.name.lexeme()]); + } else { + let aliases = recipe_aliases.get_mut(alias.target.lexeme()).unwrap(); + aliases.push(alias.name.lexeme()); + } + } + + let mut line_widths: BTreeMap<&str, usize> = BTreeMap::new(); + + for (name, recipe) in &justfile.recipes { + if recipe.private { + continue; + } + + for name in iter::once(name).chain(recipe_aliases.get(name).unwrap_or(&Vec::new())) { + let mut line_width = UnicodeWidthStr::width(*name); + + for parameter in &recipe.parameters { + line_width += UnicodeWidthStr::width(format!(" {}", parameter).as_str()); + } + + if line_width <= 30 { + line_widths.insert(name, line_width); + } + } + } + + let max_line_width = cmp::min(line_widths.values().cloned().max().unwrap_or(0), 30); + + let doc_color = self.color.stdout().doc(); + println!("Available recipes:"); + + for (name, recipe) in &justfile.recipes { + if recipe.private { + continue; + } + + let alias_doc = format!("alias for `{}`", recipe.name); + + for (i, name) in iter::once(name) + .chain(recipe_aliases.get(name).unwrap_or(&Vec::new())) + .enumerate() + { + print!(" {}", name); + for parameter in &recipe.parameters { + if self.color.stdout().active() { + print!(" {:#}", parameter); + } else { + print!(" {}", parameter); + } + } + + // Declaring this outside of the nested loops will probably be more efficient, but + // it creates all sorts of lifetime issues with variables inside the loops. + // If this is inlined like the docs say, it shouldn't make any difference. + let print_doc = |doc| { + print!( + " {:padding$}{} {}", + "", + doc_color.paint("#"), + doc_color.paint(doc), + padding = max_line_width + .saturating_sub(line_widths.get(name).cloned().unwrap_or(max_line_width)) + ); + }; + + match (i, recipe.doc) { + (0, Some(doc)) => print_doc(doc), + (0, None) => (), + _ => print_doc(&alias_doc), + } + println!(); + } + } + + Ok(()) + } + + fn run(&self, justfile: Justfile, working_directory: &Path) -> Result<(), i32> { + if let Err(error) = InterruptHandler::install() { + warn!("Failed to set CTRL-C handler: {}", error) + } + + let result = justfile.run(&self, working_directory); + + if !self.quiet { + result.eprint(self.color) + } else { + result.map_err(|err| err.code()) + } + } + + fn show(&self, name: &str, justfile: Justfile) -> Result<(), i32> { + if let Some(alias) = justfile.get_alias(name) { + let recipe = justfile.get_recipe(alias.target.lexeme()).unwrap(); + println!("{}", alias); + println!("{}", recipe); + Ok(()) + } else if let Some(recipe) = justfile.get_recipe(name) { + println!("{}", recipe); + Ok(()) + } else { + eprintln!("Justfile does not contain recipe `{}`.", name); + if let Some(suggestion) = justfile.suggest(name) { + eprintln!("Did you mean `{}`?", suggestion); + } + Err(EXIT_FAILURE) + } + } + + fn summary(&self, justfile: Justfile) -> Result<(), i32> { + if justfile.count() == 0 { + eprintln!("Justfile contains no recipes."); + } else { + let summary = justfile + .recipes + .iter() + .filter(|&(_, recipe)| !recipe.private) + .map(|(name, _)| name) + .cloned() + .collect::>() + .join(" "); + println!("{}", summary); + } + Ok(()) } } @@ -353,7 +567,8 @@ USAGE: FLAGS: --dry-run Print what just would do without doing it --dump Print entire justfile - -e, --edit Open justfile with $EDITOR + -e, --edit \ + Edit justfile with editor given by $VISUAL or $EDITOR, falling back to `vim` --evaluate Print evaluated variables --highlight Highlight echoed recipe lines in bold -l, --list List available recipes and their arguments @@ -384,4 +599,437 @@ ARGS: assert_eq!(help, EXPECTED_HELP); } + + macro_rules! test { + { + name: $name:ident, + args: [$($arg:expr),*], + $(arguments: $arguments:expr,)? + $(color: $color:expr,)? + $(dry_run: $dry_run:expr,)? + $(highlight: $highlight:expr,)? + $(overrides: $overrides:expr,)? + $(quiet: $quiet:expr,)? + $(search_config: $search_config:expr,)? + $(shell: $shell:expr,)? + $(subcommand: $subcommand:expr,)? + $(verbosity: $verbosity:expr,)? + } => { + #[test] + fn $name() { + let arguments = &[ + "just", + $($arg,)* + ]; + + let want = Config { + $(arguments: $arguments.iter().map(|argument| argument.to_string()).collect(),)? + $(color: $color,)? + $(dry_run: $dry_run,)? + $(highlight: $highlight,)? + $( + overrides: $overrides.iter().cloned() + .map(|(key, value): (&str, &str)| (key.to_owned(), value.to_owned())).collect(), + )? + $(quiet: $quiet,)? + $(search_config: $search_config,)? + $(shell: $shell.to_string(),)? + $(subcommand: $subcommand,)? + $(verbosity: $verbosity,)? + ..testing::config(&[]) + }; + + test(arguments, want); + } + } + } + + fn test(arguments: &[&str], want: Config) { + let app = Config::app(); + let matches = app + .get_matches_from_safe(arguments) + .expect("agument parsing failed"); + let have = Config::from_matches(&matches).expect("config parsing failed"); + assert_eq!(have, want); + } + + macro_rules! error { + { + name: $name:ident, + args: [$($arg:expr),*], + } => { + #[test] + fn $name() { + let arguments = &[ + "just", + $($arg,)* + ]; + + error(arguments); + } + } + } + + fn error(arguments: &[&str]) { + let app = Config::app(); + if let Ok(matches) = app.get_matches_from_safe(arguments) { + Config::from_matches(&matches).expect_err("config parsing unexpectedly succeeded"); + } else { + return; + } + } + + test! { + name: default_config, + args: [], + } + + test! { + name: color_default, + args: [], + color: Color::auto(), + } + + test! { + name: color_never, + args: ["--color", "never"], + color: Color::never(), + } + + test! { + name: color_always, + args: ["--color", "always"], + color: Color::always(), + } + + test! { + name: color_auto, + args: ["--color", "auto"], + color: Color::auto(), + } + + error! { + name: color_bad_value, + args: ["--color", "foo"], + } + + test! { + name: dry_run_default, + args: [], + dry_run: false, + } + + test! { + name: dry_run_true, + args: ["--dry-run"], + dry_run: true, + } + + error! { + name: dry_run_quiet, + args: ["--dry-run", "--quiet"], + } + + test! { + name: highlight_default, + args: [], + highlight: true, + } + + test! { + name: highlight_yes, + args: ["--highlight"], + highlight: true, + } + + test! { + name: highlight_no, + args: ["--no-highlight"], + highlight: false, + } + + test! { + name: highlight_no_yes, + args: ["--no-highlight", "--highlight"], + highlight: true, + } + + test! { + name: highlight_no_yes_no, + args: ["--no-highlight", "--highlight", "--no-highlight"], + highlight: false, + } + + test! { + name: highlight_yes_no, + args: ["--highlight", "--no-highlight"], + highlight: false, + } + + test! { + name: quiet_default, + args: [], + quiet: false, + } + + test! { + name: quiet_long, + args: ["--quiet"], + quiet: true, + } + + test! { + name: quiet_short, + args: ["-q"], + quiet: true, + } + + test! { + name: set_default, + args: [], + overrides: [], + } + + test! { + name: set_one, + args: ["--set", "foo", "bar"], + overrides: [("foo", "bar")], + } + + test! { + name: set_empty, + args: ["--set", "foo", ""], + overrides: [("foo", "")], + } + + test! { + name: set_two, + args: ["--set", "foo", "bar", "--set", "bar", "baz"], + overrides: [("foo", "bar"), ("bar", "baz")], + } + + test! { + name: set_override, + args: ["--set", "foo", "bar", "--set", "foo", "baz"], + overrides: [("foo", "baz")], + } + + error! { + name: set_bad, + args: ["--set", "foo"], + } + + test! { + name: shell_default, + args: [], + shell: "sh", + } + + test! { + name: shell_set, + args: ["--shell", "tclsh"], + shell: "tclsh", + } + + test! { + name: verbosity_default, + args: [], + verbosity: Verbosity::Taciturn, + } + + test! { + name: verbosity_long, + args: ["--verbose"], + verbosity: Verbosity::Loquacious, + } + + test! { + name: verbosity_loquacious, + args: ["-v"], + verbosity: Verbosity::Loquacious, + } + + test! { + name: verbosity_grandiloquent, + args: ["-v", "-v"], + verbosity: Verbosity::Grandiloquent, + } + + test! { + name: verbosity_great_grandiloquent, + args: ["-v", "-v", "-v"], + verbosity: Verbosity::Grandiloquent, + } + + test! { + name: subcommand_default, + args: [], + subcommand: Subcommand::Run, + } + + test! { + name: subcommand_dump, + args: ["--dump"], + subcommand: Subcommand::Dump, + } + + test! { + name: subcommand_edit, + args: ["--edit"], + subcommand: Subcommand::Edit, + } + + test! { + name: subcommand_evaluate, + args: ["--evaluate"], + subcommand: Subcommand::Evaluate, + } + + test! { + name: subcommand_list_long, + args: ["--list"], + subcommand: Subcommand::List, + } + + test! { + name: subcommand_list_short, + args: ["-l"], + subcommand: Subcommand::List, + } + + test! { + name: subcommand_show_long, + args: ["--show", "build"], + subcommand: Subcommand::Show { name: String::from("build") }, + } + + test! { + name: subcommand_show_short, + args: ["-s", "build"], + subcommand: Subcommand::Show { name: String::from("build") }, + } + + error! { + name: subcommand_show_no_arg, + args: ["--show"], + } + + test! { + name: subcommand_summary, + args: ["--summary"], + subcommand: Subcommand::Summary, + } + + test! { + name: arguments, + args: ["foo", "bar"], + arguments: ["foo", "bar"], + } + + test! { + name: arguments_leading_equals, + args: ["=foo"], + arguments: ["=foo"], + } + + test! { + name: overrides, + args: ["foo=bar", "bar=baz"], + overrides: [("foo", "bar"), ("bar", "baz")], + } + + test! { + name: overrides_empty, + args: ["foo=", "bar="], + overrides: [("foo", ""), ("bar", "")], + } + + test! { + name: overrides_override_sets, + args: ["--set", "foo", "0", "--set", "bar", "1", "foo=bar", "bar=baz"], + overrides: [("foo", "bar"), ("bar", "baz")], + } + + test! { + name: search_config_default, + args: [], + search_config: SearchConfig::FromInvocationDirectory, + } + + test! { + name: search_config_from_working_directory_and_justfile, + args: ["--working-directory", "foo", "--justfile", "bar"], + search_config: SearchConfig::WithJustfileAndWorkingDirectory { + justfile: PathBuf::from("bar"), + working_directory: PathBuf::from("foo"), + }, + } + + test! { + name: search_config_justfile_long, + args: ["--justfile", "foo"], + search_config: SearchConfig::WithJustfile { + justfile: PathBuf::from("foo"), + }, + } + + test! { + name: search_config_justfile_short, + args: ["-f", "foo"], + search_config: SearchConfig::WithJustfile { + justfile: PathBuf::from("foo"), + }, + } + + test! { + name: search_directory_parent, + args: ["../"], + search_config: SearchConfig::FromSearchDirectory { + search_directory: PathBuf::from(".."), + }, + } + + test! { + name: search_directory_parent_with_recipe, + args: ["../build"], + arguments: ["build"], + search_config: SearchConfig::FromSearchDirectory { + search_directory: PathBuf::from(".."), + }, + } + + test! { + name: search_directory_child, + args: ["foo/"], + search_config: SearchConfig::FromSearchDirectory { + search_directory: PathBuf::from("foo"), + }, + } + + test! { + name: search_directory_deep, + args: ["foo/bar/"], + search_config: SearchConfig::FromSearchDirectory { + search_directory: PathBuf::from("foo/bar"), + }, + } + + test! { + name: search_directory_child_with_recipe, + args: ["foo/build"], + arguments: ["build"], + search_config: SearchConfig::FromSearchDirectory { + search_directory: PathBuf::from("foo"), + }, + } + + error! { + name: search_directory_conflict_justfile, + args: ["--justfile", "bar", "foo/build"], + } + + error! { + name: search_directory_conflict_working_directory, + args: ["--justfile", "bar", "--working-directory", "baz", "foo/build"], + } } diff --git a/src/config_error.rs b/src/config_error.rs index a7ff0e4..e063ad5 100644 --- a/src/config_error.rs +++ b/src/config_error.rs @@ -1,20 +1,30 @@ use crate::common::*; +#[derive(Debug, Snafu)] +#[snafu(visibility(pub(crate)))] pub(crate) enum ConfigError { + #[snafu(display( + "Internal config error, this may indicate a bug in just: {} \ + consider filing an issue: https://github.com/casey/just/issues/new", + message + ))] Internal { message: String }, + #[snafu(display("Could not canonicalize justfile path `{}`: {}", path.display(), source))] + JustfilePathCanonicalize { path: PathBuf, source: io::Error }, + #[snafu(display("Failed to get current directory: {}", source))] + CurrentDir { source: io::Error }, + #[snafu(display( + "Path-prefixed recipes may not be used with `--working-directory` or `--justfile`." + ))] + SearchDirConflict, } -impl Display for ConfigError { - fn fmt(&self, f: &mut Formatter) -> fmt::Result { - use ConfigError::*; - - match self { - Internal { message } => write!( - f, - "Internal config error, this may indicate a bug in just: {} \ - consider filing an issue: https://github.com/casey/just/issues/new", - message - ), +impl ConfigError { + pub(crate) fn internal(message: impl Into) -> ConfigError { + ConfigError::Internal { + message: message.into(), } } } + +impl Error for ConfigError {} diff --git a/src/die.rs b/src/die.rs deleted file mode 100644 index 19dd0c3..0000000 --- a/src/die.rs +++ /dev/null @@ -1,6 +0,0 @@ -macro_rules! die { - ($($arg:tt)*) => {{ - eprintln!($($arg)*); - std::process::exit(EXIT_FAILURE) - }}; -} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..98b7d51 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,7 @@ +use crate::common::*; + +pub(crate) trait Error: Display { + fn code(&self) -> i32 { + EXIT_FAILURE + } +} diff --git a/src/error_result_ext.rs b/src/error_result_ext.rs new file mode 100644 index 0000000..d52880b --- /dev/null +++ b/src/error_result_ext.rs @@ -0,0 +1,22 @@ +use crate::common::*; + +pub(crate) trait ErrorResultExt { + fn eprint(self, color: Color) -> Result; +} + +impl ErrorResultExt for Result { + fn eprint(self, color: Color) -> Result { + match self { + Ok(ok) => Ok(ok), + Err(error) => { + if color.stderr().active() { + eprintln!("{} {:#}", color.error().paint("error:"), error); + } else { + eprintln!("error: {}", error); + } + + Err(error.code()) + } + } + } +} diff --git a/src/function.rs b/src/function.rs index edfe94e..564383f 100644 --- a/src/function.rs +++ b/src/function.rs @@ -107,9 +107,8 @@ pub(crate) fn os_family(_context: &FunctionContext) -> Result { } pub(crate) fn invocation_directory(context: &FunctionContext) -> Result { - context.invocation_directory.clone().and_then(|s| { - Platform::to_shell_path(&s).map_err(|e| format!("Error getting shell path: {}", e)) - }) + Platform::to_shell_path(context.working_directory, context.invocation_directory) + .map_err(|e| format!("Error getting shell path: {}", e)) } pub(crate) fn env_var(context: &FunctionContext, key: &str) -> Result { diff --git a/src/function_context.rs b/src/function_context.rs index b7cb5ff..cc48df7 100644 --- a/src/function_context.rs +++ b/src/function_context.rs @@ -1,6 +1,7 @@ use crate::common::*; pub(crate) struct FunctionContext<'a> { - pub(crate) invocation_directory: &'a Result, + pub(crate) invocation_directory: &'a Path, + pub(crate) working_directory: &'a Path, pub(crate) dotenv: &'a BTreeMap, } diff --git a/src/interrupt_handler.rs b/src/interrupt_handler.rs index c926724..575c1ef 100644 --- a/src/interrupt_handler.rs +++ b/src/interrupt_handler.rs @@ -17,12 +17,15 @@ impl InterruptHandler { match INSTANCE.lock() { Ok(guard) => guard, - Err(poison_error) => die!( - "{}", - RuntimeError::Internal { - message: format!("interrupt handler mutex poisoned: {}", poison_error), - } - ), + Err(poison_error) => { + eprintln!( + "{}", + RuntimeError::Internal { + message: format!("interrupt handler mutex poisoned: {}", poison_error), + } + ); + std::process::exit(EXIT_FAILURE); + } } } @@ -53,13 +56,14 @@ impl InterruptHandler { pub(crate) fn unblock(&mut self) { if self.blocks == 0 { - die!( + eprintln!( "{}", RuntimeError::Internal { message: "attempted to unblock interrupt handler, but handler was not blocked" .to_string(), } ); + std::process::exit(EXIT_FAILURE); } self.blocks -= 1; diff --git a/src/justfile.rs b/src/justfile.rs index f28bec4..be86860 100644 --- a/src/justfile.rs +++ b/src/justfile.rs @@ -42,13 +42,38 @@ impl<'a> Justfile<'a> { None } - pub(crate) fn run(&'a self, arguments: &[&'a str], config: &'a Config<'a>) -> RunResult<'a, ()> { + pub(crate) fn run( + &'a self, + config: &'a Config, + working_directory: &'a Path, + ) -> RunResult<'a, ()> { + let argvec: Vec<&str> = if !config.arguments.is_empty() { + config + .arguments + .iter() + .map(|argument| argument.as_str()) + .collect() + } else if let Some(recipe) = self.first() { + let min_arguments = recipe.min_arguments(); + if min_arguments > 0 { + return Err(RuntimeError::DefaultRecipeRequiresArguments { + recipe: recipe.name.lexeme(), + min_arguments, + }); + } + vec![recipe.name()] + } else { + return Err(RuntimeError::NoRecipes); + }; + + let arguments = argvec.as_slice(); + let unknown_overrides = config .overrides .keys() - .cloned() - .filter(|name| !self.assignments.contains_key(name)) - .collect::>(); + .filter(|name| !self.assignments.contains_key(name.as_str())) + .map(|name| name.as_str()) + .collect::>(); if !unknown_overrides.is_empty() { return Err(RuntimeError::UnknownOverrides { @@ -59,13 +84,10 @@ impl<'a> Justfile<'a> { let dotenv = load_dotenv()?; let scope = AssignmentEvaluator::evaluate_assignments( - &self.assignments, - &config.invocation_directory, + config, + working_directory, &dotenv, - &config.overrides, - config.quiet, - config.shell, - config.dry_run, + &self.assignments, )?; if config.subcommand == Subcommand::Evaluate { @@ -121,7 +143,11 @@ impl<'a> Justfile<'a> { }); } - let context = RecipeContext { config, scope }; + let context = RecipeContext { + config, + scope, + working_directory, + }; let mut ran = empty(); for (recipe, arguments) in grouped { @@ -201,14 +227,15 @@ mod tests { use super::*; use crate::runtime_error::RuntimeError::*; - use crate::testing::compile; + use crate::testing::{compile, config}; #[test] fn unknown_recipes() { - match compile("a:\nb:\nc:") - .run(&["a", "x", "y", "z"], &Default::default()) - .unwrap_err() - { + let justfile = compile("a:\nb:\nc:"); + let config = config(&["a", "x", "y", "z"]); + let dir = env::current_dir().unwrap(); + + match justfile.run(&config, &dir).unwrap_err() { UnknownRecipes { recipes, suggestion, @@ -216,7 +243,7 @@ mod tests { assert_eq!(recipes, &["x", "y", "z"]); assert_eq!(suggestion, None); } - other => panic!("expected an unknown recipe error, but got: {}", other), + other => panic!("unexpected error: {}", other), } } @@ -237,8 +264,10 @@ a: x x "; - - match compile(text).run(&["a"], &Default::default()).unwrap_err() { + let justfile = compile(text); + let config = config(&["a"]); + let dir = env::current_dir().unwrap(); + match justfile.run(&config, &dir).unwrap_err() { Code { recipe, line_number, @@ -248,16 +277,16 @@ a: assert_eq!(code, 200); assert_eq!(line_number, None); } - other => panic!("expected a code run error, but got: {}", other), + other => panic!("unexpected error: {}", other), } } #[test] fn code_error() { - match compile("fail:\n @exit 100") - .run(&["fail"], &Default::default()) - .unwrap_err() - { + let justfile = compile("fail:\n @exit 100"); + let config = config(&["fail"]); + let dir = env::current_dir().unwrap(); + match justfile.run(&config, &dir).unwrap_err() { Code { recipe, line_number, @@ -267,7 +296,7 @@ a: assert_eq!(code, 100); assert_eq!(line_number, Some(2)); } - other => panic!("expected a code run error, but got: {}", other), + other => panic!("unexpected error: {}", other), } } @@ -276,11 +305,11 @@ a: let text = r#" a return code: @x() { {{return}} {{code + "0"}}; }; x"#; + let justfile = compile(text); + let config = config(&["a", "return", "15"]); + let dir = env::current_dir().unwrap(); - match compile(text) - .run(&["a", "return", "15"], &Default::default()) - .unwrap_err() - { + match justfile.run(&config, &dir).unwrap_err() { Code { recipe, line_number, @@ -290,16 +319,16 @@ a return code: assert_eq!(code, 150); assert_eq!(line_number, Some(3)); } - other => panic!("expected a code run error, but got: {}", other), + other => panic!("unexpected error: {}", other), } } #[test] fn missing_some_arguments() { - match compile("a b c d:") - .run(&["a", "b", "c"], &Default::default()) - .unwrap_err() - { + let justfile = compile("a b c d:"); + let config = config(&["a", "b", "c"]); + let dir = env::current_dir().unwrap(); + match justfile.run(&config, &dir).unwrap_err() { ArgumentCountMismatch { recipe, parameters, @@ -317,16 +346,16 @@ a return code: assert_eq!(min, 3); assert_eq!(max, 3); } - other => panic!("expected a code run error, but got: {}", other), + other => panic!("unexpected error: {}", other), } } #[test] fn missing_some_arguments_variadic() { - match compile("a b c +d:") - .run(&["a", "B", "C"], &Default::default()) - .unwrap_err() - { + let justfile = compile("a b c +d:"); + let config = config(&["a", "B", "C"]); + let dir = env::current_dir().unwrap(); + match justfile.run(&config, &dir).unwrap_err() { ArgumentCountMismatch { recipe, parameters, @@ -344,16 +373,17 @@ a return code: assert_eq!(min, 3); assert_eq!(max, usize::MAX - 1); } - other => panic!("expected a code run error, but got: {}", other), + other => panic!("unexpected error: {}", other), } } #[test] fn missing_all_arguments() { - match compile("a b c d:\n echo {{b}}{{c}}{{d}}") - .run(&["a"], &Default::default()) - .unwrap_err() - { + let justfile = compile("a b c d:\n echo {{b}}{{c}}{{d}}"); + let config = config(&["a"]); + let dir = env::current_dir().unwrap(); + + match justfile.run(&config, &dir).unwrap_err() { ArgumentCountMismatch { recipe, parameters, @@ -371,16 +401,17 @@ a return code: assert_eq!(min, 3); assert_eq!(max, 3); } - other => panic!("expected a code run error, but got: {}", other), + other => panic!("unexpected error: {}", other), } } #[test] fn missing_some_defaults() { - match compile("a b c d='hello':") - .run(&["a", "b"], &Default::default()) - .unwrap_err() - { + let justfile = compile("a b c d='hello':"); + let config = config(&["a", "b"]); + let dir = env::current_dir().unwrap(); + + match justfile.run(&config, &dir).unwrap_err() { ArgumentCountMismatch { recipe, parameters, @@ -398,16 +429,17 @@ a return code: assert_eq!(min, 2); assert_eq!(max, 3); } - other => panic!("expected a code run error, but got: {}", other), + other => panic!("unexpected error: {}", other), } } #[test] fn missing_all_defaults() { - match compile("a b c='r' d='h':") - .run(&["a"], &Default::default()) - .unwrap_err() - { + let justfile = compile("a b c='r' d='h':"); + let config = &config(&["a"]); + let dir = env::current_dir().unwrap(); + + match justfile.run(&config, &dir).unwrap_err() { ArgumentCountMismatch { recipe, parameters, @@ -425,23 +457,21 @@ a return code: assert_eq!(min, 1); assert_eq!(max, 3); } - other => panic!("expected a code run error, but got: {}", other), + other => panic!("unexpected error: {}", other), } } #[test] fn unknown_overrides() { - let mut config: Config = Default::default(); - config.overrides.insert("foo", "bar"); - config.overrides.insert("baz", "bob"); - match compile("a:\n echo {{`f() { return 100; }; f`}}") - .run(&["a"], &config) - .unwrap_err() - { + let config = config(&["foo=bar", "baz=bob", "a"]); + let justfile = compile("a:\n echo {{`f() { return 100; }; f`}}"); + let dir = env::current_dir().unwrap(); + + match justfile.run(&config, &dir).unwrap_err() { UnknownOverrides { overrides } => { assert_eq!(overrides, &["baz", "foo"]); } - other => panic!("expected a code run error, but got: {}", other), + other => panic!("unexpected error: {}", other), } } @@ -457,12 +487,12 @@ wut: echo $foo $bar $baz "#; - let config = Config { - quiet: true, - ..Default::default() - }; + let config = config(&["--quiet", "wut"]); - match compile(text).run(&["wut"], &config).unwrap_err() { + let justfile = compile(text); + let dir = env::current_dir().unwrap(); + + match justfile.run(&config, &dir).unwrap_err() { Code { code: _, line_number, @@ -471,7 +501,7 @@ wut: assert_eq!(recipe, "wut"); assert_eq!(line_number, Some(8)); } - other => panic!("expected a recipe code errror, but got: {}", other), + other => panic!("unexpected error: {}", other), } } diff --git a/src/lib.rs b/src/lib.rs index 9853f79..0589936 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,9 +15,6 @@ pub mod node; #[cfg(fuzzing)] pub(crate) mod fuzzing; -#[macro_use] -mod die; - mod alias; mod alias_resolver; mod analyzer; @@ -37,6 +34,8 @@ mod count; mod default; mod empty; mod enclosure; +mod error; +mod error_result_ext; mod expression; mod fragment; mod function; @@ -52,6 +51,7 @@ mod lexer; mod line; mod list; mod load_dotenv; +mod load_error; mod module; mod name; mod ordinal; @@ -69,6 +69,7 @@ mod recipe_resolver; mod run; mod runtime_error; mod search; +mod search_config; mod search_error; mod shebang; mod show_whitespace; diff --git a/src/load_error.rs b/src/load_error.rs new file mode 100644 index 0000000..943b30f --- /dev/null +++ b/src/load_error.rs @@ -0,0 +1,19 @@ +use crate::common::*; + +pub(crate) struct LoadError<'path> { + pub(crate) path: &'path Path, + pub(crate) io_error: io::Error, +} + +impl Error for LoadError<'_> {} + +impl Display for LoadError<'_> { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + write!( + f, + "Failed to read justfile at `{}`: {}", + self.path.display(), + self.io_error + ) + } +} diff --git a/src/parser.rs b/src/parser.rs index b70bcfe..b45e6d9 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -48,7 +48,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> { &self, expected: &[TokenKind], ) -> CompilationResult<'src, CompilationError<'src>> { - let mut expected = expected.iter().cloned().collect::>(); + let mut expected = expected.to_vec(); expected.sort(); self.error(CompilationErrorKind::UnexpectedToken { @@ -69,7 +69,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> { /// An iterator over the remaining significant tokens fn rest(&self) -> impl Iterator> + 'tokens { self.tokens[self.next..] - .into_iter() + .iter() .cloned() .filter(|token| token.kind != Whitespace) } @@ -106,7 +106,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> { /// Get the `n`th next significant token fn get(&self, n: usize) -> CompilationResult<'src, Token<'src>> { - match self.rest().skip(n).next() { + match self.rest().nth(n) { Some(token) => Ok(token), None => Err(self.internal_error("`Parser::get()` advanced past end of token stream")?), } @@ -374,15 +374,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> { self.expect(ParenR)?; Ok(Expression::Group { contents }) } - _ => { - return Err(self.unexpected_token(&[ - StringCooked, - StringRaw, - Backtick, - Identifier, - ParenL, - ])?) - } + _ => Err(self.unexpected_token(&[StringCooked, StringRaw, Backtick, Identifier, ParenL])?), } } @@ -434,9 +426,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> { /// Parse a name from an identifier token fn parse_name(&mut self) -> CompilationResult<'src, Name<'src>> { - self - .expect(Identifier) - .map(|token| Name::from_identifier(token)) + self.expect(Identifier).map(Name::from_identifier) } /// Parse sequence of comma-separated expressions @@ -1415,7 +1405,10 @@ mod tests { line: 0, column: 10, width: 1, - kind: UnexpectedToken{expected: vec![Backtick, Identifier, ParenL, StringCooked, StringRaw], found: Eol}, + kind: UnexpectedToken { + expected: vec![Backtick, Identifier, ParenL, StringCooked, StringRaw], + found: Eol + }, } error! { @@ -1425,7 +1418,10 @@ mod tests { line: 0, column: 10, width: 0, - kind: UnexpectedToken{expected: vec![Backtick, Identifier, ParenL, StringCooked, StringRaw], found: Eof}, + kind: UnexpectedToken { + expected: vec![Backtick, Identifier, ParenL, StringCooked, StringRaw], + found: Eof, + }, } error! { diff --git a/src/platform.rs b/src/platform.rs index f07f0ee..222baf0 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -6,11 +6,16 @@ pub(crate) struct Platform; impl PlatformInterface for Platform { fn make_shebang_command( path: &Path, + working_directory: &Path, _command: &str, _argument: Option<&str>, ) -> Result { // shebang scripts can be executed directly on unix - Ok(Command::new(path)) + let mut cmd = Command::new(path); + + cmd.current_dir(working_directory); + + Ok(cmd) } fn set_execute_permission(path: &Path) -> Result<(), io::Error> { @@ -32,7 +37,7 @@ impl PlatformInterface for Platform { exit_status.signal() } - fn to_shell_path(path: &Path) -> Result { + fn to_shell_path(_working_directory: &Path, path: &Path) -> Result { path .to_str() .map(str::to_string) @@ -44,15 +49,20 @@ impl PlatformInterface for Platform { impl PlatformInterface for Platform { fn make_shebang_command( path: &Path, + working_directory: &Path, command: &str, argument: Option<&str>, ) -> Result { // Translate path to the interpreter from unix style to windows style let mut cygpath = Command::new("cygpath"); + cygpath.current_dir(working_directory); cygpath.arg("--windows"); cygpath.arg(command); let mut cmd = Command::new(output(cygpath)?); + + cmd.current_dir(working_directory); + if let Some(argument) = argument { cmd.arg(argument); } @@ -72,9 +82,10 @@ impl PlatformInterface for Platform { None } - fn to_shell_path(path: &Path) -> Result { + fn to_shell_path(working_directory: &Path, path: &Path) -> Result { // Translate path from windows style to unix style let mut cygpath = Command::new("cygpath"); + cygpath.current_dir(working_directory); cygpath.arg("--unix"); cygpath.arg(path); output(cygpath).map_err(|e| format!("Error converting shell path: {}", e)) diff --git a/src/platform_interface.rs b/src/platform_interface.rs index 2eb784c..50a22c9 100644 --- a/src/platform_interface.rs +++ b/src/platform_interface.rs @@ -5,6 +5,7 @@ pub(crate) trait PlatformInterface { /// shebang line `shebang` fn make_shebang_command( path: &Path, + working_directory: &Path, command: &str, argument: Option<&str>, ) -> Result; @@ -16,5 +17,5 @@ pub(crate) trait PlatformInterface { fn signal_from_exit_status(exit_status: process::ExitStatus) -> Option; /// Translate a path from a "native" path to a path the interpreter expects - fn to_shell_path(path: &Path) -> Result; + fn to_shell_path(working_directory: &Path, path: &Path) -> Result; } diff --git a/src/recipe.rs b/src/recipe.rs index 55291f0..445f54b 100644 --- a/src/recipe.rs +++ b/src/recipe.rs @@ -86,13 +86,10 @@ impl<'a> Recipe<'a> { let mut evaluator = AssignmentEvaluator { assignments: &empty(), - dry_run: config.dry_run, evaluated: empty(), - invocation_directory: &config.invocation_directory, - overrides: &empty(), - quiet: config.quiet, + working_directory: context.working_directory, scope: &context.scope, - shell: config.shell, + config, dotenv, }; @@ -196,12 +193,11 @@ impl<'a> Recipe<'a> { // create a command to run the script let mut command = - Platform::make_shebang_command(&path, interpreter, argument).map_err(|output_error| { - RuntimeError::Cygpath { + Platform::make_shebang_command(&path, context.working_directory, interpreter, argument) + .map_err(|output_error| RuntimeError::Cygpath { recipe: self.name(), output_error, - } - })?; + })?; command.export_environment_variables(&context.scope, dotenv)?; @@ -276,7 +272,9 @@ impl<'a> Recipe<'a> { continue; } - let mut cmd = Command::new(config.shell); + let mut cmd = Command::new(&config.shell); + + cmd.current_dir(context.working_directory); cmd.arg("-cu").arg(command); diff --git a/src/recipe_context.rs b/src/recipe_context.rs index 76a12eb..a05fa08 100644 --- a/src/recipe_context.rs +++ b/src/recipe_context.rs @@ -1,6 +1,7 @@ use crate::common::*; pub(crate) struct RecipeContext<'a> { - pub(crate) config: &'a Config<'a>, + pub(crate) config: &'a Config, pub(crate) scope: BTreeMap<&'a str, (bool, String)>, + pub(crate) working_directory: &'a Path, } diff --git a/src/run.rs b/src/run.rs index 21dbde5..ea36f1e 100644 --- a/src/run.rs +++ b/src/run.rs @@ -15,121 +15,7 @@ pub fn run() -> Result<(), i32> { let matches = app.get_matches(); - let config = match Config::from_matches(&matches) { - Ok(config) => config, - Err(error) => { - eprintln!("error: {}", error); - return Err(EXIT_FAILURE); - } - }; + let config = Config::from_matches(&matches).eprint(Color::auto())?; - let justfile = config.justfile; - - if let Some(directory) = config.search_directory { - if let Err(error) = env::set_current_dir(&directory) { - die!( - "Error changing directory to {}: {}", - directory.display(), - error - ); - } - } - - let mut working_directory = config.working_directory.map(PathBuf::from); - - if let (Some(justfile), None) = (justfile, working_directory.as_ref()) { - let mut justfile = justfile.to_path_buf(); - - if !justfile.is_absolute() { - match justfile.canonicalize() { - Ok(canonical) => justfile = canonical, - Err(err) => { - eprintln!( - "Could not canonicalize justfile path `{}`: {}", - justfile.display(), - err - ); - return Err(EXIT_FAILURE); - } - } - } - - justfile.pop(); - - working_directory = Some(justfile); - } - - let text; - if let (Some(justfile), Some(directory)) = (justfile, working_directory) { - if config.subcommand == Subcommand::Edit { - return Subcommand::edit(justfile); - } - - text = fs::read_to_string(justfile) - .unwrap_or_else(|error| die!("Error reading justfile: {}", error)); - - if let Err(error) = env::set_current_dir(&directory) { - die!( - "Error changing directory to {}: {}", - directory.display(), - error - ); - } - } else { - let current_dir = match env::current_dir() { - Ok(current_dir) => current_dir, - Err(io_error) => die!("Error getting current dir: {}", io_error), - }; - match search::justfile(¤t_dir) { - Ok(name) => { - if config.subcommand == Subcommand::Edit { - return Subcommand::edit(&name); - } - text = match fs::read_to_string(&name) { - Err(error) => { - eprintln!("Error reading justfile: {}", error); - return Err(EXIT_FAILURE); - } - Ok(text) => text, - }; - - let parent = name.parent().unwrap(); - - if let Err(error) = env::set_current_dir(&parent) { - eprintln!( - "Error changing directory to {}: {}", - parent.display(), - error - ); - return Err(EXIT_FAILURE); - } - } - Err(search_error) => { - eprintln!("{}", search_error); - return Err(EXIT_FAILURE); - } - } - } - - let justfile = match Compiler::compile(&text) { - Err(error) => { - if config.color.stderr().active() { - eprintln!("{:#}", error); - } else { - eprintln!("{}", error); - } - return Err(EXIT_FAILURE); - } - Ok(justfile) => justfile, - }; - - for warning in &justfile.warnings { - if config.color.stderr().active() { - eprintln!("{:#}", warning); - } else { - eprintln!("{}", warning); - } - } - - config.subcommand.run(&config, justfile) + config.run_subcommand() } diff --git a/src/runtime_error.rs b/src/runtime_error.rs index c389775..d608cc6 100644 --- a/src/runtime_error.rs +++ b/src/runtime_error.rs @@ -62,18 +62,22 @@ pub(crate) enum RuntimeError<'a> { recipe: &'a str, line_number: Option, }, + NoRecipes, + DefaultRecipeRequiresArguments { + recipe: &'a str, + min_arguments: usize, + }, } -impl<'a> RuntimeError<'a> { - pub(crate) fn code(&self) -> Option { - use RuntimeError::*; +impl Error for RuntimeError<'_> { + fn code(&self) -> i32 { match *self { - Code { code, .. } - | Backtick { + Self::Code { code, .. } => code, + Self::Backtick { output_error: OutputError::Code(code), .. - } => Some(code), - _ => None, + } => code, + _ => EXIT_FAILURE, } } } @@ -87,9 +91,8 @@ impl<'a> Display for RuntimeError<'a> { } else { Color::never() }; - let error = color.error(); let message = color.message(); - write!(f, "{} {}", error.paint("error:"), message.prefix())?; + write!(f, "{}", message.prefix())?; let mut error_token: Option = None; @@ -372,6 +375,21 @@ impl<'a> Display for RuntimeError<'a> { error_token = Some(*token); } }, + NoRecipes => { + writeln!(f, "Justfile contains no recipes.",)?; + } + DefaultRecipeRequiresArguments { + recipe, + min_arguments, + } => { + writeln!( + f, + "Recipe `{}` cannot be used as default recipe since it requires at least {} {}.", + recipe, + min_arguments, + Count("argument", min_arguments), + )?; + } Internal { ref message } => { write!( f, diff --git a/src/search.rs b/src/search.rs index 309940c..74e2123 100644 --- a/src/search.rs +++ b/src/search.rs @@ -2,31 +2,101 @@ use crate::common::*; const FILENAME: &str = "justfile"; -pub(crate) fn justfile(directory: &Path) -> Result { - let mut candidates = Vec::new(); - let dir = fs::read_dir(directory).map_err(|io_error| SearchError::Io { - io_error, - directory: directory.to_owned(), - })?; - for entry in dir { - let entry = entry.map_err(|io_error| SearchError::Io { +pub(crate) struct Search { + pub(crate) justfile: PathBuf, + pub(crate) working_directory: PathBuf, +} + +impl Search { + pub(crate) fn search( + search_config: &SearchConfig, + invocation_directory: &Path, + ) -> SearchResult { + match search_config { + SearchConfig::FromInvocationDirectory => { + let justfile = Self::justfile(&invocation_directory)?; + + let working_directory = Self::working_directory_from_justfile(&justfile)?; + + Ok(Search { + justfile, + working_directory, + }) + } + + SearchConfig::FromSearchDirectory { search_directory } => { + let justfile = Self::justfile(search_directory)?; + + let working_directory = Self::working_directory_from_justfile(&justfile)?; + + Ok(Search { + justfile, + working_directory, + }) + } + + SearchConfig::WithJustfile { justfile } => { + let justfile: PathBuf = justfile.to_path_buf(); + + let working_directory = Self::working_directory_from_justfile(&justfile)?; + + Ok(Search { + justfile, + working_directory, + }) + } + + SearchConfig::WithJustfileAndWorkingDirectory { + justfile, + working_directory, + } => Ok(Search { + justfile: justfile.to_path_buf(), + working_directory: working_directory.to_path_buf(), + }), + } + } + + fn justfile(directory: &Path) -> SearchResult { + let mut candidates = Vec::new(); + let entries = fs::read_dir(directory).map_err(|io_error| SearchError::Io { io_error, directory: directory.to_owned(), })?; - if let Some(name) = entry.file_name().to_str() { - if name.eq_ignore_ascii_case(FILENAME) { - candidates.push(entry.path()); + for entry in entries { + let entry = entry.map_err(|io_error| SearchError::Io { + io_error, + directory: directory.to_owned(), + })?; + if let Some(name) = entry.file_name().to_str() { + if name.eq_ignore_ascii_case(FILENAME) { + candidates.push(entry.path()); + } } } + if candidates.len() == 1 { + Ok(candidates.pop().unwrap()) + } else if candidates.len() > 1 { + Err(SearchError::MultipleCandidates { candidates }) + } else if let Some(parent) = directory.parent() { + Self::justfile(parent) + } else { + Err(SearchError::NotFound) + } } - if candidates.len() == 1 { - Ok(candidates.pop().unwrap()) - } else if candidates.len() > 1 { - Err(SearchError::MultipleCandidates { candidates }) - } else if let Some(parent_dir) = directory.parent() { - justfile(parent_dir) - } else { - Err(SearchError::NotFound) + + fn working_directory_from_justfile(justfile: &Path) -> SearchResult { + let justfile_canonical = justfile + .canonicalize() + .context(search_error::Canonicalize { path: justfile })?; + + Ok( + justfile_canonical + .parent() + .ok_or_else(|| SearchError::JustfileHadNoParent { + path: justfile_canonical.clone(), + })? + .to_owned(), + ) } } @@ -37,7 +107,7 @@ mod tests { #[test] fn not_found() { let tmp = testing::tempdir(); - match search::justfile(tmp.path()) { + match Search::justfile(tmp.path()) { Err(SearchError::NotFound) => { assert!(true); } @@ -59,7 +129,7 @@ mod tests { } fs::write(&path, "default:\n\techo ok").unwrap(); path.pop(); - match search::justfile(path.as_path()) { + match Search::justfile(path.as_path()) { Err(SearchError::MultipleCandidates { .. }) => { assert!(true); } @@ -74,7 +144,7 @@ mod tests { path.push(FILENAME); fs::write(&path, "default:\n\techo ok").unwrap(); path.pop(); - match search::justfile(path.as_path()) { + match Search::justfile(path.as_path()) { Ok(_path) => { assert!(true); } @@ -100,7 +170,7 @@ mod tests { path.push(spongebob_case); fs::write(&path, "default:\n\techo ok").unwrap(); path.pop(); - match search::justfile(path.as_path()) { + match Search::justfile(path.as_path()) { Ok(_path) => { assert!(true); } @@ -119,7 +189,7 @@ mod tests { fs::create_dir(&path).expect("test justfile search: failed to create intermediary directory"); path.push("b"); fs::create_dir(&path).expect("test justfile search: failed to create intermediary directory"); - match search::justfile(path.as_path()) { + match Search::justfile(path.as_path()) { Ok(_path) => { assert!(true); } @@ -141,7 +211,7 @@ mod tests { path.pop(); path.push("b"); fs::create_dir(&path).expect("test justfile search: failed to create intermediary directory"); - match search::justfile(path.as_path()) { + match Search::justfile(path.as_path()) { Ok(found_path) => { path.pop(); path.push(FILENAME); diff --git a/src/search_config.rs b/src/search_config.rs new file mode 100644 index 0000000..93c642a --- /dev/null +++ b/src/search_config.rs @@ -0,0 +1,21 @@ +use crate::common::*; + +/// Controls how `just` will search for the justfile. +#[derive(Debug, PartialEq)] +pub(crate) enum SearchConfig { + /// Recursively search for the justfile upwards from the + /// invocation directory to the root, setting the working + /// directory to the directory in which the justfile is + /// found. + FromInvocationDirectory, + /// As in `Invocation`, but start from `search_directory`. + FromSearchDirectory { search_directory: PathBuf }, + /// Use user-specified justfile, with the working directory + /// set to the directory that contains it. + WithJustfile { justfile: PathBuf }, + /// Use user-specified justfile and working directory. + WithJustfileAndWorkingDirectory { + justfile: PathBuf, + working_directory: PathBuf, + }, +} diff --git a/src/search_error.rs b/src/search_error.rs index 1373d16..d35f83d 100644 --- a/src/search_error.rs +++ b/src/search_error.rs @@ -1,42 +1,41 @@ use crate::common::*; +#[derive(Debug, Snafu)] +#[snafu(visibility(pub(crate)))] pub(crate) enum SearchError { + #[snafu(display( + "Multiple candidate justfiles found in `{}`: {}", + candidates[0].parent().unwrap().display(), + List::and_ticked( + candidates + .iter() + .map(|candidate| candidate.file_name().unwrap().to_string_lossy()) + ), + ))] MultipleCandidates { candidates: Vec, }, + #[snafu(display( + "I/O error reading directory `{}`: {}", + directory.display(), + io_error + ))] Io { directory: PathBuf, io_error: io::Error, }, + #[snafu(display("No justfile found"))] NotFound, + Canonicalize { + path: PathBuf, + source: io::Error, + }, + JustfileHadNoParent { + path: PathBuf, + }, } -impl fmt::Display for SearchError { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - match self { - SearchError::Io { - directory, - io_error, - } => write!( - f, - "I/O error reading directory `{}`: {}", - directory.display(), - io_error - ), - SearchError::MultipleCandidates { candidates } => write!( - f, - "Multiple candidate justfiles found in `{}`: {}", - candidates[0].parent().unwrap().display(), - List::and_ticked( - candidates - .iter() - .map(|candidate| candidate.file_name().unwrap().to_string_lossy()) - ), - ), - SearchError::NotFound => write!(f, "No justfile found"), - } - } -} +impl Error for SearchError {} #[cfg(test)] mod tests { diff --git a/src/subcommand.rs b/src/subcommand.rs index ae9ed35..8022e38 100644 --- a/src/subcommand.rs +++ b/src/subcommand.rs @@ -1,224 +1,10 @@ -use crate::common::*; - -use unicode_width::UnicodeWidthStr; - -#[derive(PartialEq, Clone, Copy)] -pub(crate) enum Subcommand<'a> { +#[derive(PartialEq, Clone, Debug)] +pub(crate) enum Subcommand { Dump, Edit, Evaluate, - Execute, + Run, List, - Show { name: &'a str }, + Show { name: String }, Summary, } - -impl<'a> Subcommand<'a> { - pub(crate) fn run(self, config: &Config, justfile: Justfile) -> Result<(), i32> { - use Subcommand::*; - - match self { - Dump => Self::dump(justfile), - Edit => { - eprintln!("Internal error: Subcommand::run unexpectadly invoked on Edit variant!"); - Err(EXIT_FAILURE) - } - Execute | Evaluate => Self::execute(config, justfile), - List => Self::list(config, justfile), - Show { name } => Self::show(justfile, name), - Summary => Self::summary(justfile), - } - } - - fn dump(justfile: Justfile) -> Result<(), i32> { - println!("{}", justfile); - Ok(()) - } - - pub(crate) fn edit(path: &Path) -> Result<(), i32> { - let editor = match env::var_os("EDITOR") { - None => { - eprintln!("Error getting EDITOR environment variable"); - return Err(EXIT_FAILURE); - } - Some(editor) => editor, - }; - - let error = Command::new(editor).arg(path).status(); - - match error { - Ok(status) => { - if status.success() { - Ok(()) - } else { - eprintln!("Editor failed: {}", status); - Err(status.code().unwrap_or(EXIT_FAILURE)) - } - } - Err(error) => { - eprintln!("Failed to invoke editor: {}", error); - Err(EXIT_FAILURE) - } - } - } - - fn list(config: &Config, justfile: Justfile) -> Result<(), i32> { - // Construct a target to alias map. - let mut recipe_aliases: BTreeMap<&str, Vec<&str>> = BTreeMap::new(); - for alias in justfile.aliases.values() { - if alias.is_private() { - continue; - } - - if !recipe_aliases.contains_key(alias.target.lexeme()) { - recipe_aliases.insert(alias.target.lexeme(), vec![alias.name.lexeme()]); - } else { - let aliases = recipe_aliases.get_mut(alias.target.lexeme()).unwrap(); - aliases.push(alias.name.lexeme()); - } - } - - let mut line_widths: BTreeMap<&str, usize> = BTreeMap::new(); - - for (name, recipe) in &justfile.recipes { - if recipe.private { - continue; - } - - for name in iter::once(name).chain(recipe_aliases.get(name).unwrap_or(&Vec::new())) { - let mut line_width = UnicodeWidthStr::width(*name); - - for parameter in &recipe.parameters { - line_width += UnicodeWidthStr::width(format!(" {}", parameter).as_str()); - } - - if line_width <= 30 { - line_widths.insert(name, line_width); - } - } - } - - let max_line_width = cmp::min(line_widths.values().cloned().max().unwrap_or(0), 30); - - let doc_color = config.color.stdout().doc(); - println!("Available recipes:"); - - for (name, recipe) in &justfile.recipes { - if recipe.private { - continue; - } - - let alias_doc = format!("alias for `{}`", recipe.name); - - for (i, name) in iter::once(name) - .chain(recipe_aliases.get(name).unwrap_or(&Vec::new())) - .enumerate() - { - print!(" {}", name); - for parameter in &recipe.parameters { - if config.color.stdout().active() { - print!(" {:#}", parameter); - } else { - print!(" {}", parameter); - } - } - - // Declaring this outside of the nested loops will probably be more efficient, but - // it creates all sorts of lifetime issues with variables inside the loops. - // If this is inlined like the docs say, it shouldn't make any difference. - let print_doc = |doc| { - print!( - " {:padding$}{} {}", - "", - doc_color.paint("#"), - doc_color.paint(doc), - padding = max_line_width - .saturating_sub(line_widths.get(name).cloned().unwrap_or(max_line_width)) - ); - }; - - match (i, recipe.doc) { - (0, Some(doc)) => print_doc(doc), - (0, None) => (), - _ => print_doc(&alias_doc), - } - println!(); - } - } - - Ok(()) - } - - fn execute(config: &Config, justfile: Justfile) -> Result<(), i32> { - let arguments = if !config.arguments.is_empty() { - config.arguments.clone() - } else if let Some(recipe) = justfile.first() { - let min_arguments = recipe.min_arguments(); - if min_arguments > 0 { - die!( - "Recipe `{}` cannot be used as default recipe since it requires at least {} {}.", - recipe.name, - min_arguments, - Count("argument", min_arguments), - ); - } - vec![recipe.name()] - } else { - die!("Justfile contains no recipes."); - }; - - if let Err(error) = InterruptHandler::install() { - warn!("Failed to set CTRL-C handler: {}", error) - } - - if let Err(run_error) = justfile.run(&arguments, &config) { - if !config.quiet { - if config.color.stderr().active() { - eprintln!("{:#}", run_error); - } else { - eprintln!("{}", run_error); - } - } - - return Err(run_error.code().unwrap_or(EXIT_FAILURE)); - } - - Ok(()) - } - - fn show(justfile: Justfile, name: &str) -> Result<(), i32> { - if let Some(alias) = justfile.get_alias(name) { - let recipe = justfile.get_recipe(alias.target.lexeme()).unwrap(); - println!("{}", alias); - println!("{}", recipe); - return Ok(()); - } - if let Some(recipe) = justfile.get_recipe(name) { - println!("{}", recipe); - return Ok(()); - } else { - eprintln!("Justfile does not contain recipe `{}`.", name); - if let Some(suggestion) = justfile.suggest(name) { - eprintln!("Did you mean `{}`?", suggestion); - } - return Err(EXIT_FAILURE); - } - } - - fn summary(justfile: Justfile) -> Result<(), i32> { - if justfile.count() == 0 { - eprintln!("Justfile contains no recipes."); - } else { - let summary = justfile - .recipes - .iter() - .filter(|&(_, recipe)| !recipe.private) - .map(|(name, _)| name) - .cloned() - .collect::>() - .join(" "); - println!("{}", summary); - } - Ok(()) - } -} diff --git a/src/testing.rs b/src/testing.rs index 5af01ea..90d9803 100644 --- a/src/testing.rs +++ b/src/testing.rs @@ -7,6 +7,17 @@ pub(crate) fn compile(text: &str) -> Justfile { } } +pub(crate) fn config(args: &[&str]) -> Config { + let mut args = Vec::from(args); + args.insert(0, "just"); + + let app = Config::app(); + + let matches = app.get_matches_from_safe(args).unwrap(); + + Config::from_matches(&matches).unwrap() +} + pub(crate) use test_utilities::{tempdir, unindent}; macro_rules! analysis_error { diff --git a/src/use_color.rs b/src/use_color.rs index 818e7fe..131ec6d 100644 --- a/src/use_color.rs +++ b/src/use_color.rs @@ -1,4 +1,4 @@ -#[derive(Copy, Clone)] +#[derive(Copy, Clone, Debug, PartialEq)] pub(crate) enum UseColor { Auto, Always, diff --git a/src/verbosity.rs b/src/verbosity.rs index 3025c04..843feb3 100644 --- a/src/verbosity.rs +++ b/src/verbosity.rs @@ -1,6 +1,6 @@ use Verbosity::*; -#[derive(Copy, Clone)] +#[derive(Copy, Clone, Debug, PartialEq)] pub(crate) enum Verbosity { Taciturn, Loquacious, diff --git a/test-utilities/src/lib.rs b/test-utilities/src/lib.rs index 3e906cc..93f0f9d 100644 --- a/test-utilities/src/lib.rs +++ b/test-utilities/src/lib.rs @@ -1,3 +1,5 @@ +use std::{collections::HashMap, fs, path::Path, process::Output}; + pub fn tempdir() -> tempfile::TempDir { tempfile::Builder::new() .prefix("just-test-tempdir") @@ -5,6 +7,16 @@ pub fn tempdir() -> tempfile::TempDir { .expect("failed to create temporary directory") } +pub fn assert_stdout(output: &Output, stdout: &str) { + if !output.status.success() { + eprintln!("stderr: {}", String::from_utf8_lossy(&output.stderr)); + eprintln!("stdout: {}", String::from_utf8_lossy(&output.stdout)); + panic!(output.status); + } + + assert_eq!(String::from_utf8_lossy(&output.stdout), stdout); +} + pub fn unindent(text: &str) -> String { // find line start and end indices let mut lines = Vec::new(); @@ -66,6 +78,90 @@ pub fn unindent(text: &str) -> String { text.to_owned() } +pub enum Entry { + File { + contents: &'static str, + }, + Dir { + entries: HashMap<&'static str, Entry>, + }, +} + +impl Entry { + fn instantiate(self, path: &Path) { + match self { + Entry::File { contents } => fs::write(path, contents).expect("Failed to write tempfile"), + Entry::Dir { entries } => { + fs::create_dir(path).expect("Failed to create tempdir"); + + for (name, entry) in entries { + entry.instantiate(&path.join(name)); + } + } + } + } + + pub fn instantiate_base(base: &Path, entries: HashMap<&'static str, Entry>) { + for (name, entry) in entries { + entry.instantiate(&base.join(name)); + } + } +} + +#[macro_export] +macro_rules! entry { + { + { + $($contents:tt)* + } + } => { + $crate::Entry::Dir{entries: $crate::entries!($($contents)*)} + }; + { + $contents:expr + } => { + $crate::Entry::File{contents: $contents} + }; +} + +#[macro_export] +macro_rules! entries { + { + } => { + std::collections::HashMap::new() + }; + { + $($name:ident : $contents:tt,)* + } => { + { + let mut entries: std::collections::HashMap<&'static str, $crate::Entry> = std::collections::HashMap::new(); + + $( + entries.insert(stringify!($name), $crate::entry!($contents)); + )* + + entries + } + } +} + +#[macro_export] +macro_rules! tmptree { + { + $($contents:tt)* + } => { + { + let tempdir = $crate::tempdir(); + + let entries = $crate::entries!($($contents)*); + + $crate::Entry::instantiate_base(&tempdir.path(), entries); + + tempdir + } + } +} + fn indentation(line: &str) -> &str { for (i, c) in line.char_indices() { if c != ' ' && c != '\t' { @@ -138,4 +234,28 @@ mod tests { assert_eq!(common("", ""), ""); assert_eq!(common("", "bar"), ""); } + + #[test] + fn tmptree_file() { + let tmpdir = tmptree! { + foo: "bar", + }; + + let contents = fs::read_to_string(tmpdir.path().join("foo")).unwrap(); + + assert_eq!(contents, "bar"); + } + + #[test] + fn tmptree_dir() { + let tmpdir = tmptree! { + foo: { + bar: "baz", + }, + }; + + let contents = fs::read_to_string(tmpdir.path().join("foo/bar")).unwrap(); + + assert_eq!(contents, "baz"); + } } diff --git a/tests/edit.rs b/tests/edit.rs new file mode 100644 index 0000000..ff25685 --- /dev/null +++ b/tests/edit.rs @@ -0,0 +1,116 @@ +use std::{env, iter, process::Command, str}; + +use executable_path::executable_path; +use which::which; + +use test_utilities::{assert_stdout, tmptree}; + +const JUSTFILE: &str = "Yooooooo, hopefully this never becomes valid syntax."; + +/// Test that --edit doesn't require a valid justfile +#[test] +fn invalid_justfile() { + let tmp = tmptree! { + justfile: JUSTFILE, + }; + + let output = Command::new(executable_path("just")) + .current_dir(tmp.path()) + .output() + .unwrap(); + + assert!(!output.status.success()); + + let output = Command::new(executable_path("just")) + .current_dir(tmp.path()) + .arg("--edit") + .env("VISUAL", "cat") + .output() + .unwrap(); + + assert_stdout(&output, JUSTFILE); +} + +/// Test that editor is $VISUAL, $EDITOR, or "vim" in that order +#[test] +fn editor_precedence() { + let tmp = tmptree! { + justfile: JUSTFILE, + }; + + let output = Command::new(executable_path("just")) + .current_dir(tmp.path()) + .arg("--edit") + .env("VISUAL", "cat") + .env("EDITOR", "this-command-doesnt-exist") + .output() + .unwrap(); + + assert_stdout(&output, JUSTFILE); + + let output = Command::new(executable_path("just")) + .current_dir(tmp.path()) + .arg("--edit") + .env_remove("VISUAL") + .env("EDITOR", "cat") + .output() + .unwrap(); + + assert_stdout(&output, JUSTFILE); + + let cat = which("cat").unwrap(); + let vim = tmp.path().join(format!("vim{}", env::consts::EXE_SUFFIX)); + + #[cfg(unix)] + std::os::unix::fs::symlink(cat, vim).unwrap(); + + #[cfg(windows)] + std::os::windows::fs::symlink_file(cat, vim).unwrap(); + + let path = env::join_paths( + iter::once(tmp.path().to_owned()).chain(env::split_paths(&env::var_os("PATH").unwrap())), + ) + .unwrap(); + + let output = Command::new(executable_path("just")) + .current_dir(tmp.path()) + .arg("--edit") + .env("PATH", path) + .env_remove("VISUAL") + .env_remove("EDITOR") + .output() + .unwrap(); + + assert_stdout(&output, JUSTFILE); +} + +/// Test that editor working directory is the same as edited justfile +#[cfg(unix)] +#[test] +fn editor_working_directory() { + let tmp = tmptree! { + justfile: JUSTFILE, + child: {}, + editor: "#!/usr/bin/env sh\ncat $1\npwd", + }; + + let editor = tmp.path().join("editor"); + + let permissions = std::os::unix::fs::PermissionsExt::from_mode(0o700); + std::fs::set_permissions(&editor, permissions).unwrap(); + + let output = Command::new(executable_path("just")) + .current_dir(tmp.path().join("child")) + .arg("--edit") + .env("VISUAL", &editor) + .output() + .unwrap(); + + let want = format!( + "{}{}\n", + JUSTFILE, + tmp.path().canonicalize().unwrap().display() + ); + + assert_stdout(&output, &want); +} diff --git a/tests/integration.rs b/tests/integration.rs index c280ad1..7df99f4 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -11,8 +11,7 @@ use libc::{EXIT_FAILURE, EXIT_SUCCESS}; use pretty_assertions::assert_eq; use test_utilities::{tempdir, unindent}; -/// Instantiate an integration test. -macro_rules! integration_test { +macro_rules! test { ( name: $name:ident, justfile: $justfile:expr, @@ -160,7 +159,7 @@ fn test_round_trip(tmpdir: &Path) { assert_eq!(reparsed, dumped, "reparse mismatch"); } -integration_test! { +test! { name: alias_listing, justfile: " foo: @@ -176,7 +175,7 @@ integration_test! { ", } -integration_test! { +test! { name: alias_listing_multiple_aliases, justfile: "foo:\n echo foo\nalias f := foo\nalias fo := foo", args: ("--list"), @@ -188,7 +187,7 @@ integration_test! { ", } -integration_test! { +test! { name: alias_listing_parameters, justfile: "foo PARAM='foo':\n echo {{PARAM}}\nalias f := foo", args: ("--list"), @@ -199,7 +198,7 @@ integration_test! { ", } -integration_test! { +test! { name: alias_listing_private, justfile: "foo PARAM='foo':\n echo {{PARAM}}\nalias _f := foo", args: ("--list"), @@ -209,7 +208,7 @@ integration_test! { ", } -integration_test! { +test! { name: alias, justfile: "foo:\n echo foo\nalias f := foo", args: ("f"), @@ -217,7 +216,7 @@ integration_test! { stderr: "echo foo\n", } -integration_test! { +test! { name: alias_with_parameters, justfile: "foo value='foo':\n echo {{value}}\nalias f := foo", args: ("f", "bar"), @@ -225,7 +224,7 @@ integration_test! { stderr: "echo bar\n", } -integration_test! { +test! { name: alias_with_dependencies, justfile: "foo:\n echo foo\nbar: foo\nalias b := bar", args: ("b"), @@ -233,7 +232,7 @@ integration_test! { stderr: "echo foo\n", } -integration_test! { +test! { name: duplicate_alias, justfile: "alias foo := bar\nalias foo := baz\n", stderr: " @@ -245,7 +244,7 @@ integration_test! { status: EXIT_FAILURE, } -integration_test! { +test! { name: unknown_alias_target, justfile: "alias foo := bar\n", stderr: " @@ -257,7 +256,7 @@ integration_test! { status: EXIT_FAILURE, } -integration_test! { +test! { name: alias_shadows_recipe, justfile: "bar:\n echo bar\nalias foo := bar\nfoo:\n echo foo", stderr: " @@ -269,7 +268,7 @@ integration_test! { status: EXIT_FAILURE, } -integration_test! { +test! { name: alias_show, justfile: "foo:\n bar\nalias f := foo", args: ("--show", "f"), @@ -280,7 +279,7 @@ integration_test! { ", } -integration_test! { +test! { name: alias_show_missing_target, justfile: "alias f := foo", args: ("--show", "f"), @@ -293,20 +292,20 @@ integration_test! { status: EXIT_FAILURE, } -integration_test! { +test! { name: default, justfile: "default:\n echo hello\nother: \n echo bar", stdout: "hello\n", stderr: "echo hello\n", } -integration_test! { +test! { name: quiet, justfile: "default:\n @echo hello", stdout: "hello\n", } -integration_test! { +test! { name: verbose, justfile: "default:\n @echo hello", args: ("--verbose"), @@ -314,7 +313,7 @@ integration_test! { stderr: "===> Running recipe `default`...\necho hello\n", } -integration_test! { +test! { name: order, justfile: " b: a @@ -338,7 +337,7 @@ c: b stderr: "echo a\necho b\necho c\necho d\n", } -integration_test! { +test! { name: summary, justfile: "b: a a: @@ -351,7 +350,7 @@ _y: stdout: "a b c d\n", } -integration_test! { +test! { name: select, justfile: "b: @echo b @@ -365,7 +364,7 @@ c: stdout: "d\nc\n", } -integration_test! { +test! { name: print, justfile: "b: echo b @@ -380,7 +379,7 @@ c: stderr: "echo d\necho c\n", } -integration_test! { +test! { name: show, justfile: r#"hello := "foo" bar := hello + hello @@ -393,7 +392,7 @@ recipe: "#, } -integration_test! { +test! { name: status_passthrough, justfile: " @@ -406,7 +405,7 @@ recipe: status: 100, } -integration_test! { +test! { name: unknown_dependency, justfile: "bar:\nhello:\nfoo: bar baaaaaaaz hello", stderr: " @@ -418,21 +417,21 @@ integration_test! { status: EXIT_FAILURE, } -integration_test! { +test! { name: backtick_success, justfile: "a := `printf Hello,`\nbar:\n printf '{{a + `printf ' world.'`}}'", stdout: "Hello, world.", stderr: "printf 'Hello, world.'\n", } -integration_test! { +test! { name: backtick_trimming, justfile: "a := `echo Hello,`\nbar:\n echo '{{a + `echo ' world.'`}}'", stdout: "Hello, world.\n", stderr: "echo 'Hello, world.'\n", } -integration_test! { +test! { name: backtick_code_assignment, justfile: "b := a\na := `exit 100`\nbar:\n echo '{{`exit 200`}}'", stderr: " @@ -444,7 +443,7 @@ integration_test! { status: 100, } -integration_test! { +test! { name: backtick_code_interpolation, justfile: "b := a\na := `echo hello`\nbar:\n echo '{{`exit 200`}}'", stderr: " @@ -456,7 +455,7 @@ integration_test! { status: 200, } -integration_test! { +test! { name: backtick_code_interpolation_mod, justfile: "f:\n 無{{`exit 200`}}", stderr: " @@ -468,7 +467,7 @@ integration_test! { status: 200, } -integration_test! { +test! { name: backtick_code_interpolation_tab, justfile: " backtick-fail: @@ -482,7 +481,7 @@ backtick-fail: status: 200, } -integration_test! { +test! { name: backtick_code_interpolation_tabs, justfile: " backtick-fail: @@ -496,7 +495,7 @@ backtick-fail: status: 200, } -integration_test! { +test! { name: backtick_code_interpolation_inner_tab, justfile: " backtick-fail: @@ -511,7 +510,7 @@ backtick-fail: status: 200, } -integration_test! { +test! { name: backtick_code_interpolation_leading_emoji, justfile: " backtick-fail: @@ -526,7 +525,7 @@ backtick-fail: status: 200, } -integration_test! { +test! { name: backtick_code_interpolation_unicode_hell, justfile: " backtick-fail: @@ -541,7 +540,7 @@ backtick-fail: status: 200, } -integration_test! { +test! { name: backtick_code_long, justfile: "\n\n\n\n\n\nb := a\na := `echo hello`\nbar:\n echo '{{`exit 200`}}'", stderr: " @@ -553,7 +552,7 @@ integration_test! { status: 200, } -integration_test! { +test! { name: shebang_backtick_failure, justfile: "foo: #!/bin/sh @@ -569,7 +568,7 @@ integration_test! { status: 123, } -integration_test! { +test! { name: command_backtick_failure, justfile: "foo: echo hello @@ -585,7 +584,7 @@ integration_test! { status: 123, } -integration_test! { +test! { name: assignment_backtick_failure, justfile: "foo: echo hello @@ -601,7 +600,7 @@ a := `exit 222`", status: 222, } -integration_test! { +test! { name: unknown_override_options, justfile: "foo: echo hello @@ -613,7 +612,7 @@ a := `exit 222`", status: EXIT_FAILURE, } -integration_test! { +test! { name: unknown_override_args, justfile: "foo: echo hello @@ -625,7 +624,7 @@ a := `exit 222`", status: EXIT_FAILURE, } -integration_test! { +test! { name: unknown_override_arg, justfile: "foo: echo hello @@ -636,7 +635,7 @@ a := `exit 222`", status: EXIT_FAILURE, } -integration_test! { +test! { name: overrides_first, justfile: r#" foo := "foo" @@ -651,7 +650,7 @@ recipe arg: stderr: "echo arg=baz=bar\necho barbbaz\n", } -integration_test! { +test! { name: overrides_not_evaluated, justfile: r#" foo := `exit 1` @@ -666,7 +665,7 @@ recipe arg: stderr: "echo arg=baz=bar\necho barbbaz\n", } -integration_test! { +test! { name: dry_run, justfile: r#" var := `echo stderr 1>&2; echo backtick` @@ -693,7 +692,7 @@ echo `echo command interpolation` ", } -integration_test! { +test! { name: evaluate, justfile: r#" foo := "a\t" @@ -712,7 +711,7 @@ hello := "c" "#, } -integration_test! { +test! { name: export_success, justfile: r#" export FOO := "a" @@ -727,7 +726,7 @@ wut: stderr: "echo $FOO $BAR $ABC\n", } -integration_test! { +test! { name: export_override, justfile: r#" export FOO := "a" @@ -743,7 +742,7 @@ wut: stderr: "echo $FOO $BAR $ABC\n", } -integration_test! { +test! { name: export_shebang, justfile: r#" export FOO := "a" @@ -758,7 +757,7 @@ wut: stdout: "a b abc\n", } -integration_test! { +test! { name: export_recipe_backtick, justfile: r#" export EXPORTED_VARIABLE := "A-IS-A" @@ -770,7 +769,7 @@ recipe: stderr: "echo recipe A-IS-A\n", } -integration_test! { +test! { name: raw_string, justfile: r#" export EXPORTED_VARIABLE := '\z' @@ -782,7 +781,7 @@ recipe: stderr: "printf \"$EXPORTED_VARIABLE\"\n", } -integration_test! { +test! { name: line_error_spacing, justfile: r#" @@ -804,7 +803,7 @@ integration_test! { status: EXIT_FAILURE, } -integration_test! { +test! { name: quiet_flag_no_stdout, justfile: r#" default: @@ -814,7 +813,7 @@ default: stdout: "", } -integration_test! { +test! { name: quiet_flag_no_stderr, justfile: r#" default: @@ -824,7 +823,7 @@ default: stdout: "", } -integration_test! { +test! { name: quiet_flag_no_command_echoing, justfile: r#" default: @@ -834,7 +833,7 @@ default: stdout: "", } -integration_test! { +test! { name: quiet_flag_no_error_messages, justfile: r#" default: @@ -845,7 +844,7 @@ default: status: 100, } -integration_test! { +test! { name: quiet_flag_no_assignment_backtick_stderr, justfile: r#" a := `echo hello 1>&2` @@ -857,7 +856,7 @@ default: status: 100, } -integration_test! { +test! { name: quiet_flag_no_interpolation_backtick_stderr, justfile: r#" default: @@ -869,7 +868,7 @@ default: status: 100, } -integration_test! { +test! { name: argument_single, justfile: " foo A: @@ -880,7 +879,7 @@ foo A: stderr: "echo ARGUMENT\n", } -integration_test! { +test! { name: argument_multiple, justfile: " foo A B: @@ -891,7 +890,7 @@ foo A B: stderr: "echo A:ONE B:TWO\n", } -integration_test! { +test! { name: argument_mismatch_more, justfile: " foo A B: @@ -903,7 +902,7 @@ foo A B: status: EXIT_FAILURE, } -integration_test! { +test! { name: argument_mismatch_fewer, justfile: " foo A B: @@ -915,7 +914,7 @@ foo A B: status: EXIT_FAILURE, } -integration_test! { +test! { name: argument_mismatch_more_with_default, justfile: " foo A B='B': @@ -927,7 +926,7 @@ foo A B='B': status: EXIT_FAILURE, } -integration_test! { +test! { name: argument_mismatch_fewer_with_default, justfile: " foo A B C='C': @@ -939,7 +938,7 @@ foo A B C='C': status: EXIT_FAILURE, } -integration_test! { +test! { name: unknown_recipe, justfile: "hello:", args: ("foo"), @@ -948,7 +947,7 @@ integration_test! { status: EXIT_FAILURE, } -integration_test! { +test! { name: unknown_recipes, justfile: "hello:", args: ("foo", "bar"), @@ -957,7 +956,7 @@ integration_test! { status: EXIT_FAILURE, } -integration_test! { +test! { name: color_always, justfile: "b := a\na := `exit 100`\nbar:\n echo '{{`exit 200`}}'", args: ("--color", "always"), @@ -967,7 +966,7 @@ integration_test! { status: 100, } -integration_test! { +test! { name: color_never, justfile: "b := a\na := `exit 100`\nbar:\n echo '{{`exit 200`}}'", args: ("--color", "never"), @@ -980,7 +979,7 @@ integration_test! { status: 100, } -integration_test! { +test! { name: color_auto, justfile: "b := a\na := `exit 100`\nbar:\n echo '{{`exit 200`}}'", args: ("--color", "auto"), @@ -993,7 +992,7 @@ integration_test! { status: 100, } -integration_test! { +test! { name: colors_no_context, justfile: " recipe: @@ -1005,7 +1004,7 @@ Recipe `recipe` failed on line 3 with exit code 100\u{1b}[0m\n", status: 100, } -integration_test! { +test! { name: dump, justfile: r#" # this recipe does something @@ -1018,7 +1017,7 @@ recipe a b +d: ", } -integration_test! { +test! { name: mixed_whitespace, justfile: "bar:\n\t echo hello", stdout: "", @@ -1031,7 +1030,7 @@ Leading whitespace may consist of tabs or spaces, but not both status: EXIT_FAILURE, } -integration_test! { +test! { name: extra_leading_whitespace, justfile: "bar:\n\t\techo hello\n\t\t\techo goodbye", stdout: "", @@ -1043,7 +1042,7 @@ integration_test! { status: EXIT_FAILURE, } -integration_test! { +test! { name: inconsistent_leading_whitespace, justfile: "bar:\n\t\techo hello\n\t echo goodbye", stdout: "", @@ -1056,7 +1055,7 @@ integration_test! { status: EXIT_FAILURE, } -integration_test! { +test! { name: required_after_default, justfile: "bar:\nhello baz arg='foo' bar:", stdout: "", @@ -1068,7 +1067,7 @@ integration_test! { status: EXIT_FAILURE, } -integration_test! { +test! { name: required_after_variadic, justfile: "bar:\nhello baz +arg bar:", stdout: "", @@ -1080,7 +1079,7 @@ integration_test! { status: EXIT_FAILURE, } -integration_test! { +test! { name: use_string_default, justfile: r#" bar: @@ -1092,7 +1091,7 @@ hello baz arg="XYZ\t\" ": stderr: "echo 'ABC...XYZ\t\"\t'\n", } -integration_test! { +test! { name: use_raw_string_default, justfile: r#" bar: @@ -1104,7 +1103,7 @@ hello baz arg='XYZ" ': stderr: "printf 'ABC...XYZ\"\t'\n", } -integration_test! { +test! { name: supply_use_default, justfile: r#" hello a b='B' c='C': @@ -1115,7 +1114,7 @@ hello a b='B' c='C': stderr: "echo 0 1 C\n", } -integration_test! { +test! { name: supply_defaults, justfile: r#" hello a b='B' c='C': @@ -1126,7 +1125,7 @@ hello a b='B' c='C': stderr: "echo 0 1 2\n", } -integration_test! { +test! { name: list, justfile: r#" @@ -1149,7 +1148,7 @@ _private-recipe: "#, } -integration_test! { +test! { name: list_alignment, justfile: r#" @@ -1171,7 +1170,7 @@ _private-recipe: "#, } -integration_test! { +test! { name: list_alignment_long, justfile: r#" @@ -1198,7 +1197,7 @@ _private-recipe: "#, } -integration_test! { +test! { name: show_suggestion, justfile: r#" hello a b='B ' c='C': @@ -1212,7 +1211,7 @@ a Z="\t z": status: EXIT_FAILURE, } -integration_test! { +test! { name: show_no_suggestion, justfile: r#" helloooooo a b='B ' c='C': @@ -1226,7 +1225,7 @@ a Z="\t z": status: EXIT_FAILURE, } -integration_test! { +test! { name: run_suggestion, justfile: r#" hello a b='B ' c='C': @@ -1240,7 +1239,7 @@ a Z="\t z": status: EXIT_FAILURE, } -integration_test! { +test! { name: line_continuation_with_space, justfile: r#" foo: @@ -1252,7 +1251,7 @@ foo: stderr: "echo a b c\n", } -integration_test! { +test! { name: line_continuation_with_quoted_space, justfile: r#" foo: @@ -1264,7 +1263,7 @@ foo: stderr: "echo 'a b c'\n", } -integration_test! { +test! { name: line_continuation_no_space, justfile: r#" foo: @@ -1276,7 +1275,7 @@ foo: stderr: "echo abc\n", } -integration_test! { +test! { name: test_os_arch_functions_in_interpolation, justfile: r#" foo: @@ -1286,7 +1285,7 @@ foo: stderr: format!("echo {} {} {}\n", target::arch(), target::os(), target::os_family()).as_str(), } -integration_test! { +test! { name: test_os_arch_functions_in_expression, justfile: r#" a := arch() @@ -1301,7 +1300,7 @@ foo: } #[cfg(not(windows))] -integration_test! { +test! { name: env_var_functions, justfile: r#" p := env_var('USER') @@ -1316,7 +1315,7 @@ foo: } #[cfg(windows)] -integration_test! { +test! { name: env_var_functions, justfile: r#" p := env_var('USERNAME') @@ -1330,7 +1329,7 @@ foo: stderr: format!("/bin/echo '{}' 'HTAP' 'ABC'\n", env::var("USERNAME").unwrap()).as_str(), } -integration_test! { +test! { name: env_var_failure, justfile: "a:\n echo {{env_var('ZADDY')}}", args: ("a"), @@ -1343,7 +1342,7 @@ integration_test! { status: EXIT_FAILURE, } -integration_test! { +test! { name: quiet_recipe, justfile: r#" @quiet: @@ -1355,7 +1354,7 @@ integration_test! { stderr: "echo c\n", } -integration_test! { +test! { name: quiet_shebang_recipe, justfile: r#" @quiet: @@ -1366,7 +1365,7 @@ integration_test! { stderr: "#!/bin/sh\necho hello\n", } -integration_test! { +test! { name: shebang_line_numbers, justfile: r#" quiet: @@ -1394,7 +1393,7 @@ c ", } -integration_test! { +test! { name: complex_dependencies, justfile: r#" a: b @@ -1405,7 +1404,7 @@ c: b a stdout: "", } -integration_test! { +test! { name: parameter_shadows_variable, justfile: "FOO := 'hello'\na FOO:", args: ("a"), @@ -1418,7 +1417,7 @@ integration_test! { status: EXIT_FAILURE, } -integration_test! { +test! { name: unknown_function_in_assignment, justfile: r#"foo := foo() + "hello" bar:"#, @@ -1432,7 +1431,7 @@ bar:"#, status: EXIT_FAILURE, } -integration_test! { +test! { name: dependency_takes_arguments, justfile: "b: a\na FOO:", args: ("b"), @@ -1446,7 +1445,7 @@ integration_test! { status: EXIT_FAILURE, } -integration_test! { +test! { name: duplicate_parameter, justfile: "a foo foo:", args: ("a"), @@ -1459,7 +1458,7 @@ integration_test! { status: EXIT_FAILURE, } -integration_test! { +test! { name: duplicate_dependency, justfile: "b:\na: b b", args: ("a"), @@ -1472,7 +1471,7 @@ integration_test! { status: EXIT_FAILURE, } -integration_test! { +test! { name: duplicate_recipe, justfile: "b:\nb:", args: ("b"), @@ -1485,7 +1484,7 @@ integration_test! { status: EXIT_FAILURE, } -integration_test! { +test! { name: duplicate_variable, justfile: "a := 'hello'\na := 'hello'\nfoo:", args: ("foo"), @@ -1498,7 +1497,7 @@ integration_test! { status: EXIT_FAILURE, } -integration_test! { +test! { name: unexpected_token_in_dependency_position, justfile: "foo: 'bar'", args: ("foo"), @@ -1511,7 +1510,7 @@ integration_test! { status: EXIT_FAILURE, } -integration_test! { +test! { name: unexpected_token_after_name, justfile: "foo 'bar'", args: ("foo"), @@ -1524,7 +1523,7 @@ integration_test! { status: EXIT_FAILURE, } -integration_test! { +test! { name: self_dependency, justfile: "a: a", args: ("a"), @@ -1537,7 +1536,7 @@ integration_test! { status: EXIT_FAILURE, } -integration_test! { +test! { name: long_circular_recipe_dependency, justfile: "a: b\nb: c\nc: d\nd: a", args: ("a"), @@ -1550,7 +1549,7 @@ integration_test! { status: EXIT_FAILURE, } -integration_test! { +test! { name: variable_self_dependency, justfile: "z := z\na:", args: ("a"), @@ -1563,7 +1562,7 @@ integration_test! { status: EXIT_FAILURE, } -integration_test! { +test! { name: variable_circular_dependency, justfile: "x := y\ny := z\nz := x\na:", args: ("a"), @@ -1576,7 +1575,7 @@ integration_test! { status: EXIT_FAILURE, } -integration_test! { +test! { name: invalid_escape_sequence, justfile: r#"x := "\q" a:"#, @@ -1590,7 +1589,7 @@ a:"#, status: EXIT_FAILURE, } -integration_test! { +test! { name: multiline_raw_string, justfile: " string := 'hello @@ -1608,7 +1607,7 @@ whatever' ", } -integration_test! { +test! { name: error_line_after_multiline_raw_string, justfile: " string := 'hello @@ -1628,7 +1627,7 @@ a: status: EXIT_FAILURE, } -integration_test! { +test! { name: error_column_after_multiline_raw_string, justfile: " string := 'hello @@ -1648,7 +1647,7 @@ a: status: EXIT_FAILURE, } -integration_test! { +test! { name: multiline_raw_string_in_interpolation, justfile: r#" a: @@ -1666,7 +1665,7 @@ a: ", } -integration_test! { +test! { name: error_line_after_multiline_raw_string_in_interpolation, justfile: r#" a: @@ -1685,7 +1684,7 @@ a: status: EXIT_FAILURE, } -integration_test! { +test! { name: unterminated_raw_string, justfile: " a b= ': @@ -1700,7 +1699,7 @@ a b= ': status: EXIT_FAILURE, } -integration_test! { +test! { name: unterminated_string, justfile: r#" a b= ": @@ -1715,7 +1714,7 @@ a b= ": status: EXIT_FAILURE, } -integration_test! { +test! { name: variadic_recipe, justfile: " a x y +z: @@ -1726,7 +1725,7 @@ a x y +z: stderr: "echo 0 1 2 3 4 \n", } -integration_test! { +test! { name: variadic_ignore_default, justfile: " a x y +z='HELLO': @@ -1737,7 +1736,7 @@ a x y +z='HELLO': stderr: "echo 0 1 2 3 4 \n", } -integration_test! { +test! { name: variadic_use_default, justfile: " a x y +z='HELLO': @@ -1748,7 +1747,7 @@ a x y +z='HELLO': stderr: "echo 0 1 HELLO\n", } -integration_test! { +test! { name: variadic_too_few, justfile: " a x y +z: @@ -1760,7 +1759,7 @@ a x y +z: status: EXIT_FAILURE, } -integration_test! { +test! { name: argument_grouping, justfile: " FOO A B='blarg': @@ -1777,7 +1776,7 @@ BAZ +Z: stderr: "echo bar: 0\necho foo: 1 2\necho baz: 3 4 5\n", } -integration_test! { +test! { name: missing_second_dependency, justfile: " x: @@ -1793,7 +1792,7 @@ a: x y status: EXIT_FAILURE, } -integration_test! { +test! { name: list_colors, justfile: " # comment @@ -1810,7 +1809,7 @@ a B C +D='hello': ", } -integration_test! { +test! { name: run_colors, justfile: " # comment @@ -1822,7 +1821,19 @@ a: stderr: "\u{1b}[1;36m===> Running recipe `a`...\u{1b}[0m\n\u{1b}[1mecho hi\u{1b}[0m\n", } -integration_test! { +test! { + name: no_highlight, + justfile: " +# comment +a: + echo hi +", + args: ("--color", "always", "--highlight", "--no-highlight", "--verbose"), + stdout: "hi\n", + stderr: "\u{1b}[1;36m===> Running recipe `a`...\u{1b}[0m\necho hi\n", +} + +test! { name: trailing_flags, justfile: " echo A B C: @@ -1833,7 +1844,7 @@ echo A B C: stderr: "echo --some --awesome --flags\n", } -integration_test! { +test! { name: comment_before_variable, justfile: " # @@ -1846,7 +1857,7 @@ echo: stderr: "echo 1\n", } -integration_test! { +test! { name: dotenv_variable_in_recipe, justfile: " # @@ -1857,7 +1868,7 @@ echo: stderr: "echo $DOTENV_KEY\n", } -integration_test! { +test! { name: dotenv_variable_in_backtick, justfile: " # @@ -1868,7 +1879,7 @@ echo: stdout: "dotenv-value\n", stderr: "echo dotenv-value\n", } -integration_test! { +test! { name: dotenv_variable_in_function_in_recipe, justfile: " # @@ -1880,7 +1891,7 @@ echo: stderr: "echo dotenv-value\necho dotenv-value\n", } -integration_test! { +test! { name: dotenv_variable_in_function_in_backtick, justfile: " # @@ -1894,7 +1905,7 @@ echo: stderr: "echo dotenv-value\necho dotenv-value\n", } -integration_test! { +test! { name: invalid_escape_sequence_message, justfile: r#" X := "\'" @@ -1908,7 +1919,7 @@ X := "\'" status: EXIT_FAILURE, } -integration_test! { +test! { name: unknown_variable_in_default, justfile: " foo x=bar: @@ -1922,7 +1933,7 @@ foo x=bar: status: EXIT_FAILURE, } -integration_test! { +test! { name: unknown_function_in_default, justfile: " foo x=bar(): @@ -1936,7 +1947,7 @@ foo x=bar(): status: EXIT_FAILURE, } -integration_test! { +test! { name: default_string, justfile: " foo x='bar': @@ -1946,7 +1957,7 @@ foo x='bar': stderr: "echo bar\n", } -integration_test! { +test! { name: default_concatination, justfile: " foo x=(`echo foo` + 'bar'): @@ -1956,7 +1967,7 @@ foo x=(`echo foo` + 'bar'): stderr: "echo foobar\n", } -integration_test! { +test! { name: default_backtick, justfile: " foo x=`echo foo`: @@ -1966,7 +1977,7 @@ foo x=`echo foo`: stderr: "echo foo\n", } -integration_test! { +test! { name: default_variable, justfile: " y := 'foo' @@ -1977,7 +1988,7 @@ foo x=y: stderr: "echo foo\n", } -integration_test! { +test! { name: test_os_arch_functions_in_default, justfile: r#" foo a=arch() o=os() f=os_family(): @@ -1987,7 +1998,7 @@ foo a=arch() o=os() f=os_family(): stderr: format!("echo {} {} {}\n", target::arch(), target::os(), target::os_family()).as_str(), } -integration_test! { +test! { name: unterminated_interpolation_eol, justfile: " foo: @@ -2002,7 +2013,7 @@ foo: status: EXIT_FAILURE, } -integration_test! { +test! { name: unterminated_interpolation_eof, justfile: " foo: @@ -2016,7 +2027,7 @@ foo: status: EXIT_FAILURE, } -integration_test! { +test! { name: unterminated_backtick, justfile: " foo a=\t`echo blaaaaaah: @@ -2030,7 +2041,7 @@ foo a=\t`echo blaaaaaah: status: EXIT_FAILURE, } -integration_test! { +test! { name: unknown_start_of_token, justfile: " assembly_source_files = $(wildcard src/arch/$(arch)/*.s) @@ -2044,7 +2055,7 @@ assembly_source_files = $(wildcard src/arch/$(arch)/*.s) status: EXIT_FAILURE, } -integration_test! { +test! { name: backtick_variable_cat, justfile: " stdin := `cat` @@ -2057,7 +2068,7 @@ default: stderr: "echo STDIN\n", } -integration_test! { +test! { name: backtick_default_cat_stdin, justfile: " default stdin = `cat`: @@ -2068,7 +2079,7 @@ default stdin = `cat`: stderr: "echo STDIN\n", } -integration_test! { +test! { name: backtick_default_cat_justfile, justfile: " default stdin = `cat justfile`: @@ -2086,7 +2097,7 @@ default stdin = `cat justfile`: ", } -integration_test! { +test! { name: backtick_variable_read_single, justfile: " password := `read PW && echo $PW` @@ -2099,7 +2110,7 @@ default: stderr: "echo foobar\n", } -integration_test! { +test! { name: backtick_variable_read_multiple, justfile: " a := `read A && echo $A` @@ -2114,7 +2125,7 @@ default: stderr: "echo foo\necho bar\n", } -integration_test! { +test! { name: backtick_default_read_multiple, justfile: " @@ -2127,7 +2138,7 @@ default a=`read A && echo $A` b=`read B && echo $B`: stderr: "echo foo\necho bar\n", } -integration_test! { +test! { name: equals_deprecated_assignment, justfile: " foo = 'bar' @@ -2146,7 +2157,7 @@ integration_test! { ", } -integration_test! { +test! { name: equals_deprecated_export, justfile: " export FOO = 'bar' @@ -2165,7 +2176,7 @@ integration_test! { ", } -integration_test! { +test! { name: equals_deprecated_alias, justfile: " alias foo = default diff --git a/tests/interrupts.rs b/tests/interrupts.rs index 0184cf8..d230532 100644 --- a/tests/interrupts.rs +++ b/tests/interrupts.rs @@ -74,7 +74,7 @@ default: fn interrupt_backtick() { interrupt_test( " -foo = `sleep 1` +foo := `sleep 1` default: @echo {{foo}} diff --git a/tests/search.rs b/tests/search.rs index 96919ad..0bd1d6e 100644 --- a/tests/search.rs +++ b/tests/search.rs @@ -1,12 +1,7 @@ use executable_path::executable_path; -use std::{fs, path, process, str}; +use std::{path, process, str}; -fn tempdir() -> tempfile::TempDir { - tempfile::Builder::new() - .prefix("just-test-tempdir") - .tempdir() - .expect("failed to create temporary directory") -} +use test_utilities::tmptree; fn search_test>(path: P, args: &[&str]) { let binary = executable_path("just"); @@ -28,78 +23,59 @@ fn search_test>(path: P, args: &[&str]) { #[test] fn test_justfile_search() { - let tmp = tempdir(); - let mut path = tmp.path().to_path_buf(); - path.push("justfile"); - fs::write(&path, "default:\n\techo ok").unwrap(); - path.pop(); + let tmp = tmptree! { + justfile: "default:\n\techo ok", + a: { + b: { + c: { + d: {}, + }, + }, + }, + }; - path.push("a"); - fs::create_dir(&path).expect("test justfile search: failed to create intermediary directory"); - path.push("b"); - fs::create_dir(&path).expect("test justfile search: failed to create intermediary directory"); - path.push("c"); - fs::create_dir(&path).expect("test justfile search: failed to create intermediary directory"); - path.push("d"); - fs::create_dir(&path).expect("test justfile search: failed to create intermediary directory"); - - search_test(path, &[]); + search_test(tmp.path().join("a/b/c/d"), &[]); } #[test] fn test_capitalized_justfile_search() { - let tmp = tempdir(); - let mut path = tmp.path().to_path_buf(); - path.push("Justfile"); - fs::write(&path, "default:\n\techo ok").unwrap(); - path.pop(); + let tmp = tmptree! { + Justfile: "default:\n\techo ok", + a: { + b: { + c: { + d: {}, + }, + }, + }, + }; - path.push("a"); - fs::create_dir(&path).expect("test justfile search: failed to create intermediary directory"); - path.push("b"); - fs::create_dir(&path).expect("test justfile search: failed to create intermediary directory"); - path.push("c"); - fs::create_dir(&path).expect("test justfile search: failed to create intermediary directory"); - path.push("d"); - fs::create_dir(&path).expect("test justfile search: failed to create intermediary directory"); - - search_test(path, &[]); + search_test(tmp.path().join("a/b/c/d"), &[]); } #[test] fn test_upwards_path_argument() { - let tmp = tempdir(); - let mut path = tmp.path().to_path_buf(); - path.push("justfile"); - fs::write(&path, "default:\n\techo ok").unwrap(); - path.pop(); + let tmp = tmptree! { + justfile: "default:\n\techo ok", + a: { + justfile: "default:\n\techo bad", + }, + }; - path.push("a"); - fs::create_dir(&path).expect("test justfile search: failed to create intermediary directory"); - - path.push("justfile"); - fs::write(&path, "default:\n\techo bad").unwrap(); - path.pop(); - - search_test(&path, &["../"]); - search_test(&path, &["../default"]); + search_test(&tmp.path().join("a"), &["../"]); + search_test(&tmp.path().join("a"), &["../default"]); } #[test] fn test_downwards_path_argument() { - let tmp = tempdir(); - let mut path = tmp.path().to_path_buf(); - path.push("justfile"); - fs::write(&path, "default:\n\techo bad").unwrap(); - path.pop(); + let tmp = tmptree! { + justfile: "default:\n\techo bad", + a: { + justfile: "default:\n\techo ok", + }, + }; - path.push("a"); - fs::create_dir(&path).expect("test justfile search: failed to create intermediary directory"); - - path.push("justfile"); - fs::write(&path, "default:\n\techo ok").unwrap(); - path.pop(); - path.pop(); + let path = tmp.path(); search_test(&path, &["a/"]); search_test(&path, &["a/default"]); @@ -108,3 +84,40 @@ fn test_downwards_path_argument() { search_test(&path, &["./a/"]); search_test(&path, &["./a/default"]); } + +#[test] +fn test_upwards_multiple_path_argument() { + let tmp = tmptree! { + justfile: "default:\n\techo ok", + a: { + b: { + justfile: "default:\n\techo bad", + }, + }, + }; + + let path = tmp.path().join("a").join("b"); + search_test(&path, &["../../"]); + search_test(&path, &["../../default"]); +} + +#[test] +fn test_downwards_multiple_path_argument() { + let tmp = tmptree! { + justfile: "default:\n\techo bad", + a: { + b: { + justfile: "default:\n\techo ok", + }, + }, + }; + + let path = tmp.path(); + + search_test(&path, &["a/b/"]); + search_test(&path, &["a/b/default"]); + search_test(&path, &["./a/b/"]); + search_test(&path, &["./a/b/default"]); + search_test(&path, &["./a/b/"]); + search_test(&path, &["./a/b/default"]); +} diff --git a/tests/shell.rs b/tests/shell.rs new file mode 100644 index 0000000..f3cf8f5 --- /dev/null +++ b/tests/shell.rs @@ -0,0 +1,40 @@ +use std::{process::Command, str}; + +use executable_path::executable_path; + +use test_utilities::{assert_stdout, tmptree}; + +const JUSTFILE: &str = " +expression := `EXPRESSION` + +recipe default=`DEFAULT`: + {{expression}} + {{default}} + RECIPE +"; + +/// Test that --shell correctly sets the shell +#[cfg(unix)] +#[test] +fn shell() { + let tmp = tmptree! { + justfile: JUSTFILE, + shell: "#!/usr/bin/env bash\necho \"$@\"", + }; + + let shell = tmp.path().join("shell"); + + let permissions = std::os::unix::fs::PermissionsExt::from_mode(0o700); + std::fs::set_permissions(&shell, permissions).unwrap(); + + let output = Command::new(executable_path("just")) + .current_dir(tmp.path()) + .arg("--shell") + .arg(shell) + .output() + .unwrap(); + + let stdout = "-cu -cu EXPRESSION\n-cu -cu DEFAULT\n-cu RECIPE\n"; + + assert_stdout(&output, stdout); +} diff --git a/tests/working_directory.rs b/tests/working_directory.rs index ecab99d..d044e99 100644 --- a/tests/working_directory.rs +++ b/tests/working_directory.rs @@ -1,65 +1,186 @@ -use std::{error::Error, fs, process::Command}; +use std::{error::Error, process::Command}; use executable_path::executable_path; -use test_utilities::tempdir; +use test_utilities::tmptree; + +const JUSTFILE: &str = r#" +foo := `cat data` + +linewise bar=`cat data`: shebang + echo expression: {{foo}} + echo default: {{bar}} + echo linewise: `cat data` + +shebang: + #!/usr/bin/env sh + echo "shebang:" `cat data` +"#; + +const DATA: &str = "OK"; + +const WANT: &str = "shebang: OK\nexpression: OK\ndefault: OK\nlinewise: OK\n"; /// Test that just runs with the correct working directory when invoked with /// `--justfile` but not `--working-directory` #[test] fn justfile_without_working_directory() -> Result<(), Box> { - let tmp = tempdir(); - let justfile = tmp.path().join("justfile"); - let data = tmp.path().join("data"); - fs::write( - &justfile, - "foo = `cat data`\ndefault:\n echo {{foo}}\n cat data", - )?; - fs::write(&data, "found it")?; + let tmp = tmptree! { + justfile: JUSTFILE, + data: DATA, + }; let output = Command::new(executable_path("just")) .arg("--justfile") - .arg(&justfile) + .arg(&tmp.path().join("justfile")) .output()?; if !output.status.success() { - panic!() + eprintln!("{:?}", String::from_utf8_lossy(&output.stderr)); + panic!(); } let stdout = String::from_utf8(output.stdout).unwrap(); - assert_eq!(stdout, "found it\nfound it"); + assert_eq!(stdout, WANT); + + Ok(()) +} + +/// Test that just runs with the correct working directory when invoked with +/// `--justfile` but not `--working-directory`, and justfile path has no +/// parent +#[test] +fn justfile_without_working_directory_relative() -> Result<(), Box> { + let tmp = tmptree! { + justfile: JUSTFILE, + data: DATA, + }; + + let output = Command::new(executable_path("just")) + .current_dir(&tmp.path()) + .arg("--justfile") + .arg("justfile") + .output()?; + + if !output.status.success() { + eprintln!("{:?}", String::from_utf8_lossy(&output.stderr)); + panic!(); + } + + let stdout = String::from_utf8(output.stdout).unwrap(); + + assert_eq!(stdout, WANT); Ok(()) } /// Test that just invokes commands from the directory in which the justfile is found #[test] -fn change_working_directory_to_justfile_parent() -> Result<(), Box> { - let tmp = tempdir(); - - let justfile = tmp.path().join("justfile"); - fs::write( - &justfile, - "foo = `cat data`\ndefault:\n echo {{foo}}\n cat data", - )?; - - let data = tmp.path().join("data"); - fs::write(&data, "found it")?; - - let subdir = tmp.path().join("subdir"); - fs::create_dir(&subdir)?; +fn change_working_directory_to_search_justfile_parent() -> Result<(), Box> { + let tmp = tmptree! { + justfile: JUSTFILE, + data: DATA, + subdir: {}, + }; let output = Command::new(executable_path("just")) - .current_dir(subdir) + .current_dir(tmp.path().join("subdir")) .output()?; if !output.status.success() { - panic!("just invocation failed: {}", output.status) + eprintln!("{:?}", String::from_utf8_lossy(&output.stderr)); + panic!(); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + + assert_eq!(stdout, WANT); + + Ok(()) +} + +/// Test that just runs with the correct working directory when invoked with +/// `--justfile` but not `--working-directory` +#[test] +fn justfile_and_working_directory() -> Result<(), Box> { + let tmp = tmptree! { + justfile: JUSTFILE, + sub: { + data: DATA, + }, + }; + + let output = Command::new(executable_path("just")) + .arg("--justfile") + .arg(&tmp.path().join("justfile")) + .arg("--working-directory") + .arg(&tmp.path().join("sub")) + .output()?; + + if !output.status.success() { + eprintln!("{:?}", String::from_utf8_lossy(&output.stderr)); + panic!(); } let stdout = String::from_utf8(output.stdout).unwrap(); - assert_eq!(stdout, "found it\nfound it"); + assert_eq!(stdout, WANT); + + Ok(()) +} + +/// Test that just runs with the correct working directory when invoked with +/// `--justfile` but not `--working-directory` +#[test] +fn search_dir_child() -> Result<(), Box> { + let tmp = tmptree! { + child: { + justfile: JUSTFILE, + data: DATA, + }, + }; + + let output = Command::new(executable_path("just")) + .current_dir(&tmp.path()) + .arg("child/") + .output()?; + + if !output.status.success() { + eprintln!("{:?}", String::from_utf8_lossy(&output.stderr)); + panic!(); + } + + let stdout = String::from_utf8(output.stdout).unwrap(); + + assert_eq!(stdout, WANT); + + Ok(()) +} + +/// Test that just runs with the correct working directory when invoked with +/// `--justfile` but not `--working-directory` +#[test] +fn search_dir_parent() -> Result<(), Box> { + let tmp = tmptree! { + child: { + }, + justfile: JUSTFILE, + data: DATA, + }; + + let output = Command::new(executable_path("just")) + .current_dir(&tmp.path().join("child")) + .arg("../") + .output()?; + + if !output.status.success() { + eprintln!("{:?}", String::from_utf8_lossy(&output.stderr)); + panic!(); + } + + let stdout = String::from_utf8(output.stdout).unwrap(); + + assert_eq!(stdout, WANT); Ok(()) }