From cf3fde442f5c3a215ffdef387f5f56f1759d17cd Mon Sep 17 00:00:00 2001 From: Joshua Warner Date: Tue, 19 Jun 2018 10:04:03 -0700 Subject: [PATCH] Implement invocation_directory function (#312) --- README.adoc | 18 ++++++++ justfile | 4 ++ src/assignment_evaluator.rs | 14 ++++++- src/function.rs | 12 ++++++ src/justfile.rs | 37 ++++++++++------- src/platform.rs | 18 ++++++++ src/recipe.rs | 3 ++ src/run.rs | 9 +++- tests/invocation_directory.rs | 77 +++++++++++++++++++++++++++++++++++ 9 files changed, 175 insertions(+), 17 deletions(-) create mode 100644 tests/invocation_directory.rs diff --git a/README.adoc b/README.adoc index 4d02b6d..54fa0aa 100644 --- a/README.adoc +++ b/README.adoc @@ -289,6 +289,24 @@ This is an x86_64 machine - `env_var_or_default(key, default)` – Retrieves the environment variable with name `key`, returning `default` if it is not present. +==== Invocation Directory + +- `invocation_directory()` - Retrieves the path of the current working directory, before `just` changed it (chdir'd) prior to executing commands. + +For example, to call `rustfmt` on files just under the "current directory" (from the user/invoker's perspective), use the following rule: + +``` +rustfmt: + find {{invocation_directory()}} -name \*.rs -exec rustfmt {} \; +``` + +Alternatively, if your command needs to be run from the current directory, you could use (e.g.): + +``` +build: + cd {{invocation_directory()}}; ./some_script_that_needs_to_be_run_from_here +``` + ==== Dotenv Integration `just` will load environment variables from a file named `.env`. This file can be located in the same directory as your justfile or in a parent directory. These variables are environment variables, not `just` variables, and so must be accessed using `$VARIABLE_NAME` in recipes and backticks. diff --git a/justfile b/justfile index 3337435..ad26366 100644 --- a/justfile +++ b/justfile @@ -141,6 +141,10 @@ ruby: #!/usr/bin/env ruby puts "Hello from ruby!" +# Print working directory, for demonstration purposes! +pwd: + echo {{invocation_directory()}} + # Local Variables: # mode: makefile # End: diff --git a/src/assignment_evaluator.rs b/src/assignment_evaluator.rs index a439d2b..9faab3f 100644 --- a/src/assignment_evaluator.rs +++ b/src/assignment_evaluator.rs @@ -1,9 +1,12 @@ +use std::path::PathBuf; + use common::*; use brev; pub struct AssignmentEvaluator<'a: 'b, 'b> { pub assignments: &'b Map<&'a str, Expression<'a>>, + pub invocation_directory: &'b Result, pub dotenv: &'b Map, pub dry_run: bool, pub evaluated: Map<&'a str, String>, @@ -17,6 +20,7 @@ pub struct AssignmentEvaluator<'a: 'b, 'b> { impl<'a, 'b> AssignmentEvaluator<'a, 'b> { pub fn evaluate_assignments( assignments: &Map<&'a str, Expression<'a>>, + invocation_directory: &Result, dotenv: &'b Map, overrides: &Map<&str, &str>, quiet: bool, @@ -28,6 +32,7 @@ impl<'a, 'b> AssignmentEvaluator<'a, 'b> { exports: &empty(), scope: &empty(), assignments, + invocation_directory, dotenv, dry_run, overrides, @@ -107,6 +112,7 @@ impl<'a, 'b> AssignmentEvaluator<'a, 'b> { self.evaluate_expression(argument, arguments) }).collect::, RuntimeError>>()?; let context = FunctionContext { + invocation_directory: &self.invocation_directory, dotenv: self.dotenv, }; evaluate_function(token, name, &context, &call_arguments) @@ -161,10 +167,14 @@ mod test { use testing::parse_success; use Configuration; + fn no_cwd_err() -> Result { + Err(String::from("no cwd in tests")) + } + #[test] fn backtick_code() { match parse_success("a:\n echo {{`f() { return 100; }; f`}}") - .run(&["a"], &Default::default()).unwrap_err() { + .run(no_cwd_err(), &["a"], &Default::default()).unwrap_err() { RuntimeError::Backtick{token, output_error: OutputError::Code(code)} => { assert_eq!(code, 100); assert_eq!(token.lexeme, "`f() { return 100; }; f`"); @@ -187,7 +197,7 @@ recipe: ..Default::default() }; - match parse_success(text).run(&["recipe"], &configuration).unwrap_err() { + match parse_success(text).run(no_cwd_err(), &["recipe"], &configuration).unwrap_err() { RuntimeError::Backtick{token, output_error: OutputError::Code(_)} => { assert_eq!(token.lexeme, "`echo $exported_variable`"); }, diff --git a/src/function.rs b/src/function.rs index 4e4ebc4..9a05988 100644 --- a/src/function.rs +++ b/src/function.rs @@ -1,6 +1,10 @@ +use std::path::PathBuf; + use common::*; use target; +use platform::{Platform, PlatformInterface}; + lazy_static! { static ref FUNCTIONS: Map<&'static str, Function> = vec![ ("arch", Function::Nullary(arch )), @@ -8,6 +12,7 @@ lazy_static! { ("os_family", Function::Nullary(os_family )), ("env_var", Function::Unary (env_var )), ("env_var_or_default", Function::Binary (env_var_or_default)), + ("invocation_directory", Function::Nullary(invocation_directory)), ].into_iter().collect(); } @@ -29,6 +34,7 @@ impl Function { } pub struct FunctionContext<'a> { + pub invocation_directory: &'a Result, pub dotenv: &'a Map, } @@ -92,6 +98,12 @@ pub fn os_family(_context: &FunctionContext) -> Result { Ok(target::os_family().to_string()) } +pub 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))) +} + pub fn env_var(context: &FunctionContext, key: &str) -> Result { use std::env::VarError::*; diff --git a/src/justfile.rs b/src/justfile.rs index 5d81556..5c47d68 100644 --- a/src/justfile.rs +++ b/src/justfile.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + use common::*; use edit_distance::edit_distance; @@ -42,6 +44,7 @@ impl<'a, 'b> Justfile<'a> where 'a: 'b { pub fn run( &'a self, + invocation_directory: Result, arguments: &[&'a str], configuration: &Configuration<'a>, ) -> RunResult<'a, ()> { @@ -57,6 +60,7 @@ impl<'a, 'b> Justfile<'a> where 'a: 'b { let scope = AssignmentEvaluator::evaluate_assignments( &self.assignments, + &invocation_directory, &dotenv, &configuration.overrides, configuration.quiet, @@ -115,7 +119,7 @@ impl<'a, 'b> Justfile<'a> where 'a: 'b { let mut ran = empty(); for (recipe, arguments) in grouped { - self.run_recipe(recipe, arguments, &scope, &dotenv, configuration, &mut ran)? + self.run_recipe(&invocation_directory, recipe, arguments, &scope, &dotenv, configuration, &mut ran)? } Ok(()) @@ -123,6 +127,7 @@ impl<'a, 'b> Justfile<'a> where 'a: 'b { fn run_recipe<'c>( &'c self, + invocation_directory: &Result, recipe: &Recipe<'a>, arguments: &[&'a str], scope: &Map<&'c str, String>, @@ -132,10 +137,10 @@ impl<'a, 'b> Justfile<'a> where 'a: 'b { ) -> RunResult<()> { for dependency_name in &recipe.dependencies { if !ran.contains(dependency_name) { - self.run_recipe(&self.recipes[dependency_name], &[], scope, dotenv, configuration, ran)?; + self.run_recipe(invocation_directory, &self.recipes[dependency_name], &[], scope, dotenv, configuration, ran)?; } } - recipe.run(arguments, scope, dotenv, &self.exports, configuration)?; + recipe.run(invocation_directory, arguments, scope, dotenv, &self.exports, configuration)?; ran.insert(recipe.name); Ok(()) } @@ -171,9 +176,13 @@ mod test { use testing::parse_success; use RuntimeError::*; + fn no_cwd_err() -> Result { + Err(String::from("no cwd in tests")) + } + #[test] fn unknown_recipes() { - match parse_success("a:\nb:\nc:").run(&["a", "x", "y", "z"], &Default::default()).unwrap_err() { + match parse_success("a:\nb:\nc:").run(no_cwd_err(), &["a", "x", "y", "z"], &Default::default()).unwrap_err() { UnknownRecipes{recipes, suggestion} => { assert_eq!(recipes, &["x", "y", "z"]); assert_eq!(suggestion, None); @@ -201,7 +210,7 @@ a: x "; - match parse_success(text).run(&["a"], &Default::default()).unwrap_err() { + match parse_success(text).run(no_cwd_err(), &["a"], &Default::default()).unwrap_err() { Code{recipe, line_number, code} => { assert_eq!(recipe, "a"); assert_eq!(code, 200); @@ -214,7 +223,7 @@ a: #[test] fn code_error() { match parse_success("fail:\n @exit 100") - .run(&["fail"], &Default::default()).unwrap_err() { + .run(no_cwd_err(), &["fail"], &Default::default()).unwrap_err() { Code{recipe, line_number, code} => { assert_eq!(recipe, "fail"); assert_eq!(code, 100); @@ -230,7 +239,7 @@ a: a return code: @x() { {{return}} {{code + "0"}}; }; x"#; - match parse_success(text).run(&["a", "return", "15"], &Default::default()).unwrap_err() { + match parse_success(text).run(no_cwd_err(), &["a", "return", "15"], &Default::default()).unwrap_err() { Code{recipe, line_number, code} => { assert_eq!(recipe, "a"); assert_eq!(code, 150); @@ -242,7 +251,7 @@ a return code: #[test] fn missing_some_arguments() { - match parse_success("a b c d:").run(&["a", "b", "c"], &Default::default()).unwrap_err() { + match parse_success("a b c d:").run(no_cwd_err(), &["a", "b", "c"], &Default::default()).unwrap_err() { ArgumentCountMismatch{recipe, found, min, max} => { assert_eq!(recipe, "a"); assert_eq!(found, 2); @@ -255,7 +264,7 @@ a return code: #[test] fn missing_some_arguments_variadic() { - match parse_success("a b c +d:").run(&["a", "B", "C"], &Default::default()).unwrap_err() { + match parse_success("a b c +d:").run(no_cwd_err(), &["a", "B", "C"], &Default::default()).unwrap_err() { ArgumentCountMismatch{recipe, found, min, max} => { assert_eq!(recipe, "a"); assert_eq!(found, 2); @@ -269,7 +278,7 @@ a return code: #[test] fn missing_all_arguments() { match parse_success("a b c d:\n echo {{b}}{{c}}{{d}}") - .run(&["a"], &Default::default()).unwrap_err() { + .run(no_cwd_err(), &["a"], &Default::default()).unwrap_err() { ArgumentCountMismatch{recipe, found, min, max} => { assert_eq!(recipe, "a"); assert_eq!(found, 0); @@ -282,7 +291,7 @@ a return code: #[test] fn missing_some_defaults() { - match parse_success("a b c d='hello':").run(&["a", "b"], &Default::default()).unwrap_err() { + match parse_success("a b c d='hello':").run(no_cwd_err(), &["a", "b"], &Default::default()).unwrap_err() { ArgumentCountMismatch{recipe, found, min, max} => { assert_eq!(recipe, "a"); assert_eq!(found, 1); @@ -295,7 +304,7 @@ a return code: #[test] fn missing_all_defaults() { - match parse_success("a b c='r' d='h':").run(&["a"], &Default::default()).unwrap_err() { + match parse_success("a b c='r' d='h':").run(no_cwd_err(), &["a"], &Default::default()).unwrap_err() { ArgumentCountMismatch{recipe, found, min, max} => { assert_eq!(recipe, "a"); assert_eq!(found, 0); @@ -312,7 +321,7 @@ a return code: configuration.overrides.insert("foo", "bar"); configuration.overrides.insert("baz", "bob"); match parse_success("a:\n echo {{`f() { return 100; }; f`}}") - .run(&["a"], &configuration).unwrap_err() { + .run(no_cwd_err(), &["a"], &configuration).unwrap_err() { UnknownOverrides{overrides} => { assert_eq!(overrides, &["baz", "foo"]); }, @@ -337,7 +346,7 @@ wut: ..Default::default() }; - match parse_success(text).run(&["wut"], &configuration).unwrap_err() { + match parse_success(text).run(no_cwd_err(), &["wut"], &configuration).unwrap_err() { Code{code: _, line_number, recipe} => { assert_eq!(recipe, "wut"); assert_eq!(line_number, Some(8)); diff --git a/src/platform.rs b/src/platform.rs index 2c6b116..5e6de39 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -15,8 +15,12 @@ pub trait PlatformInterface { /// Extract the signal from a process exit status, if it was terminated by a signal 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; } + #[cfg(unix)] impl PlatformInterface for Platform { fn make_shebang_command(path: &Path, _command: &str, _argument: Option<&str>) @@ -44,6 +48,12 @@ impl PlatformInterface for Platform { use std::os::unix::process::ExitStatusExt; exit_status.signal() } + + fn to_shell_path(path: &Path) -> Result { + path.to_str().map(str::to_string) + .ok_or_else(|| String::from( + "Error getting current directory: unicode decode error")) + } } #[cfg(windows)] @@ -75,4 +85,12 @@ impl PlatformInterface for Platform { // from a windows process exit status, so just return None None } + + fn to_shell_path(path: &Path) -> Result { + // Translate path from windows style to unix style + let mut cygpath = Command::new("cygpath"); + cygpath.arg("--unix"); + cygpath.arg(path); + brev::output(cygpath).map_err(|e| format!("Error converting shell path: {}", e)) + } } diff --git a/src/recipe.rs b/src/recipe.rs index 9b04f35..66e6440 100644 --- a/src/recipe.rs +++ b/src/recipe.rs @@ -1,5 +1,6 @@ use common::*; +use std::path::PathBuf; use std::process::{ExitStatus, Command, Stdio}; use platform::{Platform, PlatformInterface}; @@ -50,6 +51,7 @@ impl<'a> Recipe<'a> { pub fn run( &self, + invocation_directory: &Result, arguments: &[&'a str], scope: &Map<&'a str, String>, dotenv: &Map, @@ -86,6 +88,7 @@ impl<'a> Recipe<'a> { let mut evaluator = AssignmentEvaluator { assignments: &empty(), + invocation_directory, dry_run: configuration.dry_run, evaluated: empty(), overrides: &empty(), diff --git a/src/run.rs b/src/run.rs index cfdac6f..be37f74 100644 --- a/src/run.rs +++ b/src/run.rs @@ -47,6 +47,9 @@ pub fn run() { #[cfg(windows)] enable_ansi_support().ok(); + let invocation_directory = env::current_dir() + .map_err(|e| format!("Error getting current directory: {}", e)); + let matches = App::new(env!("CARGO_PKG_NAME")) .version(concat!("v", env!("CARGO_PKG_VERSION"))) .author(env!("CARGO_PKG_AUTHORS")) @@ -354,7 +357,11 @@ pub fn run() { overrides, }; - if let Err(run_error) = justfile.run(&arguments, &configuration) { + if let Err(run_error) = justfile.run( + invocation_directory, + &arguments, + &configuration) + { if !configuration.quiet { if color.stderr().active() { eprintln!("{:#}", run_error); diff --git a/tests/invocation_directory.rs b/tests/invocation_directory.rs new file mode 100644 index 0000000..2cf72a9 --- /dev/null +++ b/tests/invocation_directory.rs @@ -0,0 +1,77 @@ +extern crate brev; +extern crate executable_path; +extern crate libc; +extern crate target; +extern crate tempdir; + +use executable_path::executable_path; +use std::process; +use std::str; +use std::path::Path; +use tempdir::TempDir; + +#[cfg(unix)] +fn to_shell_path(path: &Path) -> String { + use std::fs; + fs::canonicalize(path).expect("canonicalize failed") + .to_str().map(str::to_string).expect("unicode decode failed") +} + +#[cfg(windows)] +fn to_shell_path(path: &Path) -> String { + // Translate path from windows style to unix style + let mut cygpath = process::Command::new("cygpath"); + cygpath.arg("--unix"); + cygpath.arg(path); + brev::output(cygpath).expect("converting cygwin path failed") +} + +#[test] +fn test_invocation_directory() { + let tmp = TempDir::new("just-integration") + .unwrap_or_else( + |err| panic!("integration test: failed to create temporary directory: {}", err)); + + let mut justfile_path = tmp.path().to_path_buf(); + justfile_path.push("justfile"); + brev::dump(justfile_path, "default:\n @cd {{invocation_directory()}}\n @echo {{invocation_directory()}}"); + + let mut subdir = tmp.path().to_path_buf(); + subdir.push("subdir"); + brev::mkdir(&subdir); + + let output = process::Command::new(&executable_path("just")) + .current_dir(&subdir) + .args(&["--shell", "sh"]) + .output() + .expect("just invocation failed"); + + let mut failure = false; + + let expected_status = 0; + let expected_stdout = + to_shell_path(&subdir) + "\n"; + let expected_stderr = ""; + + let status = output.status.code().unwrap(); + if status != expected_status { + println!("bad status: {} != {}", status, expected_status); + failure = true; + } + + let stdout = str::from_utf8(&output.stdout).unwrap(); + if stdout != expected_stdout { + println!("bad stdout:\ngot:\n{:?}\n\nexpected:\n{:?}", stdout, expected_stdout); + failure = true; + } + + let stderr = str::from_utf8(&output.stderr).unwrap(); + if stderr != expected_stderr { + println!("bad stderr:\ngot:\n{:?}\n\nexpected:\n{:?}", stderr, expected_stderr); + failure = true; + } + + if failure { + panic!("test failed"); + } +}