diff --git a/README.adoc b/README.adoc index ec003d9..1cde04f 100644 --- a/README.adoc +++ b/README.adoc @@ -1111,7 +1111,7 @@ echo: The interpreter path `/bin/sh` will be translated to a Windows-style path using `cygpath` before being executed. -If the interpreter path does not contain a `/` it will be executed without being translated. This is useful if `cygpath` is not available, or you wish to use a Windows style path to the interpreter. +If the interpreter path does not contain a `/` it will be executed without being translated. This is useful if `cygpath` is not available, or you wish to pass a Windows style path to the interpreter. === Setting Variables in a Recipe diff --git a/completions/just.bash b/completions/just.bash index fa5716e..4de4bcf 100644 --- a/completions/just.bash +++ b/completions/just.bash @@ -20,7 +20,7 @@ _just() { case "${cmd}" in just) - opts=" -q -u -v -e -l -h -V -f -d -s --dry-run --highlight --no-dotenv --no-highlight --quiet --clear-shell-args --unsorted --verbose --choose --dump --edit --evaluate --init --list --summary --variables --help --version --chooser --color --list-heading --list-prefix --justfile --set --shell --shell-arg --working-directory --completions --show ... " + opts=" -q -u -v -e -l -h -V -f -d -c -s --dry-run --highlight --no-dotenv --no-highlight --quiet --shell-command --clear-shell-args --unsorted --verbose --choose --dump --edit --evaluate --init --list --summary --variables --help --version --chooser --color --list-heading --list-prefix --justfile --set --shell --shell-arg --working-directory --command --completions --show ... " if [[ ${cur} == -* ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -77,6 +77,14 @@ _just() { COMPREPLY=($(compgen -f "${cur}")) return 0 ;; + --command) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + -c) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; --completions) COMPREPLY=($(compgen -W "zsh bash fish powershell elvish" -- "${cur}")) return 0 diff --git a/completions/just.elvish b/completions/just.elvish index e49cf35..a2c3fe1 100644 --- a/completions/just.elvish +++ b/completions/just.elvish @@ -25,6 +25,8 @@ edit:completion:arg-completer[just] = [@words]{ cand --shell-arg 'Invoke shell with as an argument' cand -d 'Use as working directory. --justfile must also be set' cand --working-directory 'Use as working directory. --justfile must also be set' + cand -c 'Run an arbitrary command with the working directory, `.env`, overrides, and exports set' + cand --command 'Run an arbitrary command with the working directory, `.env`, overrides, and exports set' cand --completions 'Print shell completion script for ' cand -s 'Show information about ' cand --show 'Show information about ' @@ -34,6 +36,7 @@ edit:completion:arg-completer[just] = [@words]{ cand --no-highlight 'Don''t highlight echoed recipe lines in bold' cand -q 'Suppress all output' cand --quiet 'Suppress all output' + cand --shell-command 'Invoke with the shell used to run recipe lines and backticks' cand --clear-shell-args 'Clear shell arguments' cand -u 'Return list and summary entries in source order' cand --unsorted 'Return list and summary entries in source order' diff --git a/completions/just.fish b/completions/just.fish index 87f27ca..83f2267 100644 --- a/completions/just.fish +++ b/completions/just.fish @@ -18,6 +18,7 @@ complete -c just -n "__fish_use_subcommand" -l set -d 'Override with complete -c just -n "__fish_use_subcommand" -l shell -d 'Invoke to run recipes' complete -c just -n "__fish_use_subcommand" -l shell-arg -d 'Invoke shell with as an argument' complete -c just -n "__fish_use_subcommand" -s d -l working-directory -d 'Use as working directory. --justfile must also be set' +complete -c just -n "__fish_use_subcommand" -s c -l command -d 'Run an arbitrary command with the working directory, `.env`, overrides, and exports set' complete -c just -n "__fish_use_subcommand" -l completions -d 'Print shell completion script for ' -r -f -a "zsh bash fish powershell elvish" complete -c just -n "__fish_use_subcommand" -s s -l show -d 'Show information about ' complete -c just -n "__fish_use_subcommand" -l dry-run -d 'Print what just would do without doing it' @@ -25,6 +26,7 @@ complete -c just -n "__fish_use_subcommand" -l highlight -d 'Highlight echoed re complete -c just -n "__fish_use_subcommand" -l no-dotenv -d 'Don\'t load `.env` file' complete -c just -n "__fish_use_subcommand" -l no-highlight -d 'Don\'t highlight echoed recipe lines in bold' complete -c just -n "__fish_use_subcommand" -s q -l quiet -d 'Suppress all output' +complete -c just -n "__fish_use_subcommand" -l shell-command -d 'Invoke with the shell used to run recipe lines and backticks' complete -c just -n "__fish_use_subcommand" -l clear-shell-args -d 'Clear shell arguments' complete -c just -n "__fish_use_subcommand" -s u -l unsorted -d 'Return list and summary entries in source order' complete -c just -n "__fish_use_subcommand" -s v -l verbose -d 'Use verbose output' diff --git a/completions/just.powershell b/completions/just.powershell index b33fdb0..64d8990 100644 --- a/completions/just.powershell +++ b/completions/just.powershell @@ -30,6 +30,8 @@ Register-ArgumentCompleter -Native -CommandName 'just' -ScriptBlock { [CompletionResult]::new('--shell-arg', 'shell-arg', [CompletionResultType]::ParameterName, 'Invoke shell with as an argument') [CompletionResult]::new('-d', 'd', [CompletionResultType]::ParameterName, 'Use as working directory. --justfile must also be set') [CompletionResult]::new('--working-directory', 'working-directory', [CompletionResultType]::ParameterName, 'Use as working directory. --justfile must also be set') + [CompletionResult]::new('-c', 'c', [CompletionResultType]::ParameterName, 'Run an arbitrary command with the working directory, `.env`, overrides, and exports set') + [CompletionResult]::new('--command', 'command', [CompletionResultType]::ParameterName, 'Run an arbitrary command with the working directory, `.env`, overrides, and exports set') [CompletionResult]::new('--completions', 'completions', [CompletionResultType]::ParameterName, 'Print shell completion script for ') [CompletionResult]::new('-s', 's', [CompletionResultType]::ParameterName, 'Show information about ') [CompletionResult]::new('--show', 'show', [CompletionResultType]::ParameterName, 'Show information about ') @@ -39,6 +41,7 @@ Register-ArgumentCompleter -Native -CommandName 'just' -ScriptBlock { [CompletionResult]::new('--no-highlight', 'no-highlight', [CompletionResultType]::ParameterName, 'Don''t highlight echoed recipe lines in bold') [CompletionResult]::new('-q', 'q', [CompletionResultType]::ParameterName, 'Suppress all output') [CompletionResult]::new('--quiet', 'quiet', [CompletionResultType]::ParameterName, 'Suppress all output') + [CompletionResult]::new('--shell-command', 'shell-command', [CompletionResultType]::ParameterName, 'Invoke with the shell used to run recipe lines and backticks') [CompletionResult]::new('--clear-shell-args', 'clear-shell-args', [CompletionResultType]::ParameterName, 'Clear shell arguments') [CompletionResult]::new('-u', 'u', [CompletionResultType]::ParameterName, 'Return list and summary entries in source order') [CompletionResult]::new('--unsorted', 'unsorted', [CompletionResultType]::ParameterName, 'Return list and summary entries in source order') diff --git a/completions/just.zsh b/completions/just.zsh index b08318d..d31d1a5 100644 --- a/completions/just.zsh +++ b/completions/just.zsh @@ -26,6 +26,8 @@ _just() { '*--shell-arg=[Invoke shell with as an argument]' \ '-d+[Use as working directory. --justfile must also be set]' \ '--working-directory=[Use as working directory. --justfile must also be set]' \ +'-c+[Run an arbitrary command with the working directory, `.env`, overrides, and exports set]' \ +'--command=[Run an arbitrary command with the working directory, `.env`, overrides, and exports set]' \ '--completions=[Print shell completion script for ]: :(zsh bash fish powershell elvish)' \ '-s+[Show information about ]: :_just_commands' \ '--show=[Show information about ]: :_just_commands' \ @@ -35,6 +37,7 @@ _just() { '--no-highlight[Don'\''t highlight echoed recipe lines in bold]' \ '(--dry-run)-q[Suppress all output]' \ '(--dry-run)--quiet[Suppress all output]' \ +'--shell-command[Invoke with the shell used to run recipe lines and backticks]' \ '--clear-shell-args[Clear shell arguments]' \ '-u[Return list and summary entries in source order]' \ '--unsorted[Return list and summary entries in source order]' \ diff --git a/rustfmt.toml b/rustfmt.toml index f8ac04f..4e7d74f 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -5,10 +5,10 @@ error_on_unformatted = true format_code_in_doc_comments = true format_macro_bodies = true format_strings = true +imports_granularity = "Crate" match_arm_blocks = false match_block_trailing_comma = true max_width = 100 -merge_imports = true newline_style = "Unix" normalize_comments = true overflow_delimited_expr = true diff --git a/src/common.rs b/src/common.rs index 5dd5ec0..d7d0db1 100644 --- a/src/common.rs +++ b/src/common.rs @@ -3,7 +3,7 @@ pub(crate) use std::{ cmp, collections::{BTreeMap, BTreeSet}, env, - ffi::OsString, + ffi::{OsStr, OsString}, fmt::{self, Debug, Display, Formatter}, fs, io::{self, Cursor, Write}, diff --git a/src/config.rs b/src/config.rs index 10ed7e9..5da8b6d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -26,6 +26,7 @@ pub(crate) struct Config { pub(crate) shell: String, pub(crate) shell_args: Vec, pub(crate) shell_present: bool, + pub(crate) shell_command: bool, pub(crate) subcommand: Subcommand, pub(crate) unsorted: bool, pub(crate) verbosity: Verbosity, @@ -42,14 +43,16 @@ mod cmd { pub(crate) const SHOW: &str = "SHOW"; pub(crate) const SUMMARY: &str = "SUMMARY"; pub(crate) const VARIABLES: &str = "VARIABLES"; + pub(crate) const COMMAND: &str = "COMMAND"; pub(crate) const ALL: &[&str] = &[ CHOOSE, + COMMAND, COMPLETIONS, DUMP, EDIT, - INIT, EVALUATE, + INIT, LIST, SHOW, SUMMARY, @@ -75,15 +78,16 @@ mod arg { pub(crate) const COLOR: &str = "COLOR"; pub(crate) const DRY_RUN: &str = "DRY-RUN"; pub(crate) const HIGHLIGHT: &str = "HIGHLIGHT"; + pub(crate) const JUSTFILE: &str = "JUSTFILE"; pub(crate) const LIST_HEADING: &str = "LIST-HEADING"; pub(crate) const LIST_PREFIX: &str = "LIST-PREFIX"; - pub(crate) const JUSTFILE: &str = "JUSTFILE"; pub(crate) const NO_DOTENV: &str = "NO-DOTENV"; pub(crate) const NO_HIGHLIGHT: &str = "NO-HIGHLIGHT"; pub(crate) const QUIET: &str = "QUIET"; pub(crate) const SET: &str = "SET"; pub(crate) const SHELL: &str = "SHELL"; pub(crate) const SHELL_ARG: &str = "SHELL-ARG"; + pub(crate) const SHELL_COMMAND: &str = "SHELL-COMMAND"; pub(crate) const UNSORTED: &str = "UNSORTED"; pub(crate) const VERBOSE: &str = "VERBOSE"; pub(crate) const WORKING_DIRECTORY: &str = "WORKING-DIRECTORY"; @@ -193,6 +197,12 @@ impl Config { .overrides_with(arg::CLEAR_SHELL_ARGS) .help("Invoke shell with as an argument"), ) + .arg( + Arg::with_name(arg::SHELL_COMMAND) + .long("shell-command") + .requires(cmd::COMMAND) + .help("Invoke with the shell used to run recipe lines and backticks"), + ) .arg( Arg::with_name(arg::CLEAR_SHELL_ARGS) .long("clear-shell-args") @@ -220,12 +230,18 @@ impl Config { .help("Use as working directory. --justfile must also be set") .requires(arg::JUSTFILE), ) - .arg( - Arg::with_name(arg::ARGUMENTS) - .multiple(true) - .help("Overrides and recipe(s) to run, defaulting to the first recipe in the justfile"), - ) .arg(Arg::with_name(cmd::CHOOSE).long("choose").help(CHOOSE_HELP)) + .arg( + Arg::with_name(cmd::COMMAND) + .long("command") + .short("c") + .min_values(1) + .allow_hyphen_values(true) + .help( + "Run an arbitrary command with the working directory, `.env`, overrides, and exports \ + set", + ), + ) .arg( Arg::with_name(cmd::COMPLETIONS) .long("completions") @@ -279,7 +295,12 @@ impl Config { .long("variables") .help("List names of variables"), ) - .group(ArgGroup::with_name("SUBCOMMAND").args(cmd::ALL)); + .group(ArgGroup::with_name("SUBCOMMAND").args(cmd::ALL)) + .arg( + Arg::with_name(arg::ARGUMENTS) + .multiple(true) + .help("Overrides and recipe(s) to run, defaulting to the first recipe in the justfile"), + ); if cfg!(feature = "help4help2man") { app.version(env!("CARGO_PKG_VERSION")).about(concat!( @@ -401,6 +422,16 @@ impl Config { chooser: matches.value_of(arg::CHOOSER).map(str::to_owned), overrides, } + } else if let Some(values) = matches.values_of_os(cmd::COMMAND) { + let mut arguments = values + .into_iter() + .map(OsStr::to_owned) + .collect::>(); + Subcommand::Command { + binary: arguments.remove(0), + arguments, + overrides, + } } else if let Some(shell) = matches.value_of(cmd::COMPLETIONS) { Subcommand::Completions { shell: shell.to_owned(), @@ -463,6 +494,7 @@ impl Config { highlight: !matches.is_present(arg::NO_HIGHLIGHT), shell: matches.value_of(arg::SHELL).unwrap().to_owned(), load_dotenv: !matches.is_present(arg::NO_DOTENV), + shell_command: matches.is_present(arg::SHELL_COMMAND), unsorted: matches.is_present(arg::UNSORTED), list_heading: matches .value_of(arg::LIST_HEADING) @@ -522,6 +554,7 @@ impl Config { match &self.subcommand { Choose { overrides, chooser } => self.choose(justfile, &search, overrides, chooser.as_deref())?, + Command { overrides, .. } => self.run(justfile, &search, overrides, &[])?, Dump => Self::dump(justfile), Evaluate { overrides, .. } => self.run(justfile, &search, overrides, &[])?, List => self.list(justfile), @@ -893,6 +926,8 @@ FLAGS: --no-dotenv Don't load `.env` file --no-highlight Don't highlight echoed recipe lines in bold -q, --quiet Suppress all output + --shell-command Invoke with the shell used to run recipe lines and \ + backticks --summary List names of available recipes -u, --unsorted Return list and summary entries in source order --variables List names of variables @@ -903,6 +938,9 @@ OPTIONS: --color Print colorful output [default: auto] [possible values: auto, always, never] + -c, --command + Run an arbitrary command with the working directory, `.env`, overrides, and exports set + --completions Print shell completion script for [possible values: zsh, bash, fish, \ powershell, elvish] diff --git a/src/justfile.rs b/src/justfile.rs index ed20f3f..cfd3cee 100644 --- a/src/justfile.rs +++ b/src/justfile.rs @@ -127,34 +127,70 @@ impl<'src> Justfile<'src> { )? }; - if let Subcommand::Evaluate { variable, .. } = &config.subcommand { - if let Some(variable) = variable { - if let Some(value) = scope.value(variable) { - print!("{}", value); + match &config.subcommand { + Subcommand::Command { + binary, arguments, .. + } => { + let mut command = if config.shell_command { + let mut command = self.settings.shell_command(&config); + command.arg(binary); + command } else { - return Err(RuntimeError::EvalUnknownVariable { - suggestion: self.suggest_variable(&variable), - variable: variable.clone(), - }); - } - } else { - let mut width = 0; + Command::new(binary) + }; - for name in scope.names() { - width = cmp::max(name.len(), width); + command.args(arguments); + + command.current_dir(&search.working_directory); + + let scope = scope.child(); + + command.export(&self.settings, &dotenv, &scope); + + let status = InterruptHandler::guard(|| command.status()).map_err(|io_error| { + RuntimeError::CommandInvocation { + binary: binary.clone(), + arguments: arguments.clone(), + io_error, + } + })?; + + if !status.success() { + process::exit(status.code().unwrap_or(EXIT_FAILURE)); + }; + + return Ok(()); + }, + Subcommand::Evaluate { variable, .. } => { + if let Some(variable) = variable { + if let Some(value) = scope.value(variable) { + print!("{}", value); + } else { + return Err(RuntimeError::EvalUnknownVariable { + suggestion: self.suggest_variable(&variable), + variable: variable.clone(), + }); + } + } else { + let mut width = 0; + + for name in scope.names() { + width = cmp::max(name.len(), width); + } + + for binding in scope.bindings() { + println!( + "{0:1$} := \"{2}\"", + binding.name.lexeme(), + width, + binding.value + ); + } } - for binding in scope.bindings() { - println!( - "{0:1$} := \"{2}\"", - binding.name.lexeme(), - width, - binding.value - ); - } - } - - return Ok(()); + return Ok(()); + }, + _ => {}, } let argvec: Vec<&str> = if !arguments.is_empty() { diff --git a/src/runtime_error.rs b/src/runtime_error.rs index df2ad95..7aab4c8 100644 --- a/src/runtime_error.rs +++ b/src/runtime_error.rs @@ -18,6 +18,11 @@ pub(crate) enum RuntimeError<'src> { line_number: Option, code: i32, }, + CommandInvocation { + binary: OsString, + arguments: Vec, + io_error: io::Error, + }, Cygpath { recipe: &'src str, output_error: OutputError, @@ -201,6 +206,22 @@ impl<'src> Display for RuntimeError<'src> { } else { write!(f, "Recipe `{}` failed with exit code {}", recipe, code)?; }, + CommandInvocation { + binary, + arguments, + io_error, + } => { + write!( + f, + "Failed to invoke {}: {}", + iter::once(binary) + .chain(arguments) + .map(|value| Enclosure::tick(value.to_string_lossy()).to_string()) + .collect::>() + .join(" "), + io_error, + )?; + }, Cygpath { recipe, output_error, diff --git a/src/subcommand.rs b/src/subcommand.rs index 36b3273..a3084c6 100644 --- a/src/subcommand.rs +++ b/src/subcommand.rs @@ -6,6 +6,11 @@ pub(crate) enum Subcommand { overrides: BTreeMap, chooser: Option, }, + Command { + arguments: Vec, + binary: OsString, + overrides: BTreeMap, + }, Completions { shell: String, }, diff --git a/tests/command.rs b/tests/command.rs new file mode 100644 index 0000000..f740135 --- /dev/null +++ b/tests/command.rs @@ -0,0 +1,134 @@ +use crate::common::*; + +test! { + name: long, + justfile: " + x: + echo XYZ + ", + args: ("--command", "printf", "foo"), + stdout: "foo", +} + +test! { + name: short, + justfile: " + x: + echo XYZ + ", + args: ("-c", "printf", "foo"), + stdout: "foo", +} + +test! { + name: no_binary, + justfile: " + x: + echo XYZ + ", + args: ("--command"), + stderr: &format!(" + error: The argument '--command ' requires a value but none was supplied + + USAGE: + just{} --color --shell --shell-arg ... \ + <--choose|--command |--completions |--dump|--edit|\ + --evaluate|--init|--list|--show |--summary|--variables> + + For more information try --help + ", EXE_SUFFIX), + status: EXIT_FAILURE, +} + +test! { + name: env_is_loaded, + justfile: " + x: + echo XYZ + ", + args: ("--command", "sh", "-c", "printf $DOTENV_KEY"), + stdout: "dotenv-value", +} + +test! { + name: exports_are_available, + justfile: " + export FOO := 'bar' + + x: + echo XYZ + ", + args: ("--command", "sh", "-c", "printf $FOO"), + stdout: "bar", +} + +test! { + name: set_overrides_work, + justfile: " + export FOO := 'bar' + + x: + echo XYZ + ", + args: ("--set", "FOO", "baz", "--command", "sh", "-c", "printf $FOO"), + stdout: "baz", +} + +test! { + name: run_in_shell, + justfile: " + set shell := ['printf'] + ", + args: ("--shell-command", "--command", "bar baz"), + stdout: "bar baz", + shell: false, +} + +test! { + name: exit_status, + justfile: " + x: + echo XYZ + ", + args: ("--command", "false"), + status: EXIT_FAILURE, +} + +#[test] +fn working_directory_is_correct() { + let tmp = tempdir(); + + fs::write(tmp.path().join("justfile"), "").unwrap(); + fs::write(tmp.path().join("bar"), "baz").unwrap(); + fs::create_dir(tmp.path().join("foo")).unwrap(); + + let output = Command::new(&executable_path("just")) + .args(&["--command", "cat", "bar"]) + .current_dir(tmp.path().join("foo")) + .output() + .unwrap(); + + assert_eq!(str::from_utf8(&output.stderr).unwrap(), ""); + + assert!(output.status.success()); + + assert_eq!(str::from_utf8(&output.stdout).unwrap(), "baz"); +} + +#[test] +fn command_not_found() { + let tmp = tempdir(); + + fs::write(tmp.path().join("justfile"), "").unwrap(); + + let output = Command::new(&executable_path("just")) + .args(&["--command", "asdfasdfasdfasdfadfsadsfadsf", "bar"]) + .output() + .unwrap(); + + assert!(str::from_utf8(&output.stderr) + .unwrap() + .starts_with("error: Failed to invoke `asdfasdfasdfasdfadfsadsfadsf` `bar`:")); + + assert!(!output.status.success()); +} diff --git a/tests/common.rs b/tests/common.rs index 4602111..43ce227 100644 --- a/tests/common.rs +++ b/tests/common.rs @@ -1,6 +1,7 @@ pub(crate) use std::{ collections::BTreeMap, - env, fs, + env::{self, consts::EXE_SUFFIX}, + fs, io::Write, iter, path::Path, diff --git a/tests/interrupts.rs b/tests/interrupts.rs index d230532..7b68452 100644 --- a/tests/interrupts.rs +++ b/tests/interrupts.rs @@ -1,6 +1,7 @@ #[cfg(unix)] mod unix { use executable_path::executable_path; + use just::unindent; use std::{ fs, process::Command, @@ -14,16 +15,17 @@ mod unix { } } - fn interrupt_test(justfile: &str) { + fn interrupt_test(arguments: &[&str], justfile: &str) { let tmp = tempdir(); let mut justfile_path = tmp.path().to_path_buf(); justfile_path.push("justfile"); - fs::write(justfile_path, justfile).unwrap(); + fs::write(justfile_path, unindent(justfile)).unwrap(); let start = Instant::now(); let mut child = Command::new(&executable_path("just")) .current_dir(&tmp) + .args(arguments) .spawn() .expect("just invocation failed"); @@ -50,11 +52,12 @@ mod unix { #[ignore] fn interrupt_shebang() { interrupt_test( + &[], " -default: - #!/usr/bin/env sh - sleep 1 -", + default: + #!/usr/bin/env sh + sleep 1 + ", ); } @@ -62,10 +65,11 @@ default: #[ignore] fn interrupt_line() { interrupt_test( + &[], " -default: - @sleep 1 -", + default: + @sleep 1 + ", ); } @@ -73,12 +77,19 @@ default: #[ignore] fn interrupt_backtick() { interrupt_test( + &[], " -foo := `sleep 1` + foo := `sleep 1` -default: - @echo {{foo}} -", + default: + @echo {{foo}} + ", ); } + + #[test] + #[ignore] + fn interrupt_command() { + interrupt_test(&["--command", "sleep", "1"], ""); + } } diff --git a/tests/lib.rs b/tests/lib.rs index 78cc53f..de8644a 100644 --- a/tests/lib.rs +++ b/tests/lib.rs @@ -4,6 +4,7 @@ mod test; mod common; mod choose; +mod command; mod completions; mod conditional; mod delimiters;