From 355fd685bab6e742b7c70179f245780124203316 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Wed, 5 Oct 2016 13:58:18 -0700 Subject: [PATCH 01/14] Actually run recipes, add a bunch of tests --- Cargo.lock | 19 +++++++++++++++++ Cargo.toml | 1 + justfile | 2 +- notes | 5 ++--- src/lib.rs | 60 ++++++++++++++++++++++++++++++++++++++++++++++------ src/tests.rs | 42 ++++++++++++++++++++++++++++++++++++ 6 files changed, 119 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 97fe81a..4727635 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,6 +4,7 @@ version = "0.1.5" dependencies = [ "clap 2.13.0 (registry+https://github.com/rust-lang/crates.io-index)", "regex 0.1.77 (registry+https://github.com/rust-lang/crates.io-index)", + "tempdir 0.3.5 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -61,6 +62,14 @@ dependencies = [ "libc 0.2.16 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "rand" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "libc 0.2.16 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "regex" version = "0.1.77" @@ -83,6 +92,14 @@ name = "strsim" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" +[[package]] +name = "tempdir" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "rand 0.3.14 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "term_size" version = "0.2.1" @@ -148,9 +165,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" "checksum kernel32-sys 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)" = "7507624b29483431c0ba2d82aece8ca6cdba9382bff4ddd0f7490560c056098d" "checksum libc 0.2.16 (registry+https://github.com/rust-lang/crates.io-index)" = "408014cace30ee0f767b1c4517980646a573ec61a57957aeeabcac8ac0a02e8d" "checksum memchr 0.1.11 (registry+https://github.com/rust-lang/crates.io-index)" = "d8b629fb514376c675b98c1421e80b151d3817ac42d7c667717d282761418d20" +"checksum rand 0.3.14 (registry+https://github.com/rust-lang/crates.io-index)" = "2791d88c6defac799c3f20d74f094ca33b9332612d9aef9078519c82e4fe04a5" "checksum regex 0.1.77 (registry+https://github.com/rust-lang/crates.io-index)" = "64b03446c466d35b42f2a8b203c8e03ed8b91c0f17b56e1f84f7210a257aa665" "checksum regex-syntax 0.3.5 (registry+https://github.com/rust-lang/crates.io-index)" = "279401017ae31cf4e15344aa3f085d0e2e5c1e70067289ef906906fdbe92c8fd" "checksum strsim 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)" = "50c069df92e4b01425a8bf3576d5d417943a6a7272fbabaf5bd80b1aaa76442e" +"checksum tempdir 0.3.5 (registry+https://github.com/rust-lang/crates.io-index)" = "87974a6f5c1dfb344d733055601650059a3363de2a6104819293baff662132d6" "checksum term_size 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)" = "3f7f5f3f71b0040cecc71af239414c23fd3c73570f5ff54cf50e03cef637f2a0" "checksum thread-id 2.0.0 (registry+https://github.com/rust-lang/crates.io-index)" = "a9539db560102d1cef46b8b78ce737ff0bb64e7e18d35b2a5688f7d097d0ff03" "checksum thread_local 0.2.7 (registry+https://github.com/rust-lang/crates.io-index)" = "8576dbbfcaef9641452d5cf0df9b0e7eeab7694956dd33bb61515fb8f18cfdd5" diff --git a/Cargo.toml b/Cargo.toml index a6abcd5..1d08ba8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,3 +10,4 @@ homepage = "https://github.com/casey/j" regex = "^0.1.77" clap = "^2.0.0" +tempdir = "^0.3.5" diff --git a/justfile b/justfile index 2f82138..25c65c0 100644 --- a/justfile +++ b/justfile @@ -1,6 +1,6 @@ test: cargo test - cargo run -- quine + cargo run -- quine clean > /dev/null 2> /dev/null # list all recipies list: diff --git a/notes b/notes index 3479937..5bfc695 100644 --- a/notes +++ b/notes @@ -2,12 +2,11 @@ notes ----- -- report double compile error --- actually run recipes --- actually parse recipe and validate contents --- think about maybe using multiple cores -- should pre-requisite order really be arbitrary? -- test plan order +-- use multiple cores + - look through all justfiles for features of make that I use. so far: . phony . SHELL := zsh diff --git a/src/lib.rs b/src/lib.rs index adf2050..8f17aae 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,7 +5,7 @@ extern crate regex; use std::io::prelude::*; -use std::{fs, fmt}; +use std::{fs, fmt, process, io}; use std::collections::{BTreeMap, BTreeSet, HashSet}; use std::fmt::Display; use regex::Regex; @@ -49,14 +49,46 @@ struct Recipe<'a> { dependencies: BTreeSet<&'a str>, } +#[cfg(unix)] +fn error_from_signal<'a>(recipe: &'a str, exit_status: process::ExitStatus) -> RunError<'a> { + use std::os::unix::process::ExitStatusExt; + match exit_status.signal() { + Some(signal) => RunError::Signal{recipe: recipe, signal: signal}, + None => RunError::UnknownFailure{recipe: recipe}, + } +} + +#[cfg(windows)] +fn error_from_signal<'a>(recipe: &'a str, exit_status: process::ExitStatus) -> RunError<'a> { + RunError::UnknownFailure{recipe: recipe} +} + impl<'a> Recipe<'a> { fn run(&self) -> Result<(), RunError<'a>> { - // TODO: actually run recipes - warn!("running {}", self.name); for command in &self.commands { - warn!("{}", command); + let mut command = *command; + if !command.starts_with("@") { + warn!("{}", command); + } else { + command = &command[1..]; + } + let status = process::Command::new("sh") + .arg("-c") + .arg(command) + .status(); + try!(match status { + Ok(exit_status) => if let Some(code) = exit_status.code() { + if code == 0 { + Ok(()) + } else { + Err(RunError::Code{recipe: self.name, code: code}) + } + } else { + Err(error_from_signal(self.name, exit_status)) + }, + Err(io_error) => Err(RunError::IoError{recipe: self.name, io_error: io_error}) + }); } - // Err(RunError::Code{recipe: self.name, code: -1}) Ok(()) } } @@ -270,10 +302,13 @@ impl<'a> Justfile<'a> { } } +#[derive(Debug)] pub enum RunError<'a> { UnknownRecipes{recipes: Vec<&'a str>}, - // Signal{recipe: &'a str, signal: i32}, + Signal{recipe: &'a str, signal: i32}, Code{recipe: &'a str, code: i32}, + UnknownFailure{recipe: &'a str}, + IoError{recipe: &'a str, io_error: io::Error}, } impl<'a> Display for RunError<'a> { @@ -289,6 +324,19 @@ impl<'a> Display for RunError<'a> { &RunError::Code{recipe, code} => { try!(write!(f, "Recipe \"{}\" failed with code {}", recipe, code)); }, + &RunError::Signal{recipe, signal} => { + try!(write!(f, "Recipe \"{}\" wast terminated by signal {}", recipe, signal)); + } + &RunError::UnknownFailure{recipe} => { + try!(write!(f, "Recipe \"{}\" failed for an unknown reason", recipe)); + }, + &RunError::IoError{recipe, ref io_error} => { + try!(match io_error.kind() { + io::ErrorKind::NotFound => write!(f, "Recipe \"{}\" could not be run because j could not find `sh` the command:\n{}", recipe, io_error), + io::ErrorKind::PermissionDenied => write!(f, "Recipe \"{}\" could not be run because j could not run `sh`:\n{}", recipe, io_error), + _ => write!(f, "Recipe \"{}\" could not be run because of an IO error while launching the `sh`:\n{}", recipe, io_error), + }); + }, } Ok(()) } diff --git a/src/tests.rs b/src/tests.rs index 1ef2d85..fd3602f 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -1,3 +1,5 @@ +extern crate tempdir; + use super::{ErrorKind, Justfile}; fn expect_error(text: &str, line: usize, expected_error_kind: ErrorKind) { @@ -130,3 +132,43 @@ fn first() { let justfile = expect_success("#hello\n#goodbye\na:\nb:\nc:\n"); assert!(justfile.first().unwrap() == "a"); } + +#[test] +fn unknown_recipes() { + match expect_success("a:\nb:\nc:").run(&["a", "x", "y", "z"]).unwrap_err() { + super::RunError::UnknownRecipes{recipes} => assert_eq!(recipes, &["x", "y", "z"]), + other @ _ => panic!("expected an unknown recipe error, but got: {}", other), + } +} + +#[test] +fn code_error() { + match expect_success("fail:\n @function x { return 100; }; x").run(&["fail"]).unwrap_err() { + super::RunError::Code{recipe, code} => { + assert_eq!(recipe, "fail"); + assert_eq!(code, 100); + }, + other @ _ => panic!("expected an code run error, but got: {}", other), + } +} + +#[test] +fn run_order() { + let tmp = tempdir::TempDir::new("run_order").unwrap_or_else(|err| panic!("tmpdir: failed to create temporary directory: {}", err)); + let path = tmp.path().to_str().unwrap_or_else(|| panic!("tmpdir: path was not valid UTF-8")).to_owned(); + let text = r" +a: + @touch a + +b: a + @mv a b + +c: b + @mv b c + +d: c + @rm c +"; + super::std::env::set_current_dir(path).expect("failed to set current directory"); + expect_success(text).run(&["a", "d"]).unwrap(); +} From fa3856669fa1666ef26daf361c5d642b54562874 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Wed, 5 Oct 2016 14:00:51 -0700 Subject: [PATCH 02/14] Update notes --- notes | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/notes b/notes index 5bfc695..3914fb9 100644 --- a/notes +++ b/notes @@ -1,19 +1,12 @@ notes ----- --- report double compile error --- should pre-requisite order really be arbitrary? --- test plan order - --- use multiple cores - - look through all justfiles for features of make that I use. so far: . phony . SHELL := zsh . quiet . make variables - ask travis for his justfiles - - comment code command line arguments: @@ -35,6 +28,7 @@ polyglot: extras: - args +- use launch recipes asyncronously - ~/.justfile: . is this for non-project specific commands, so that when you type .j in any directory, it uses it as a justfile? From 2ab75ef03aa6c4501d321e45ef78249ed990ba1d Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Wed, 5 Oct 2016 16:03:11 -0700 Subject: [PATCH 03/14] Add --show command line flag --- notes | 3 ++- src/lib.rs | 16 +++++++++++++++- src/main.rs | 15 +++++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/notes b/notes index 3914fb9..430b5da 100644 --- a/notes +++ b/notes @@ -1,6 +1,8 @@ notes ----- +- fix --show to not print final newline, add a helper that iterates over + (item, first, last) - look through all justfiles for features of make that I use. so far: . phony . SHELL := zsh @@ -11,7 +13,6 @@ notes command line arguments: - --show recipe: print recipe information -- --list recipes if a bad recipe given execution: - indent for line continuation diff --git a/src/lib.rs b/src/lib.rs index 8f17aae..f907654 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -41,7 +41,7 @@ fn re(pattern: &str) -> Regex { Regex::new(pattern).unwrap() } -struct Recipe<'a> { +pub struct Recipe<'a> { line: usize, name: &'a str, leading_whitespace: &'a str, @@ -49,6 +49,16 @@ struct Recipe<'a> { dependencies: BTreeSet<&'a str>, } +impl<'a> Display for Recipe<'a> { + fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> { + try!(writeln!(f, "{}:", self.name)); + for command in self.commands.iter() { + try!(writeln!(f, " {}", command)); + } + Ok(()) + } +} + #[cfg(unix)] fn error_from_signal<'a>(recipe: &'a str, exit_status: process::ExitStatus) -> RunError<'a> { use std::os::unix::process::ExitStatusExt; @@ -300,6 +310,10 @@ impl<'a> Justfile<'a> { } Ok(()) } + + pub fn get(&self, name: &str) -> Option<&Recipe<'a>> { + self.recipes.get(name) + } } #[derive(Debug)] diff --git a/src/main.rs b/src/main.rs index 590fd16..443bd5f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,6 +29,11 @@ fn main() { .short("l") .long("list") .help("Lists available recipes")) + .arg(Arg::with_name("show") + .short("s") + .long("show") + .takes_value(true) + .help("Show information about a recipe")) .arg(Arg::with_name("recipe") .multiple(true) .help("recipe(s) to run, defaults to the first recipe in the justfile")) @@ -70,6 +75,16 @@ fn main() { std::process::exit(0); } + if let Some(name) = matches.value_of("show") { + match justfile.get(name) { + Some(recipe) => { + warn!("{}", recipe); + std::process::exit(0); + } + None => die!("justfile contains no recipe \"{}\"", name) + } + } + let names = if let Some(names) = matches.values_of("recipe") { names.collect::>() } else if let Some(name) = justfile.first() { From 18b43786be328605bbfe28c477cda46558f2b4c6 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Wed, 5 Oct 2016 16:08:13 -0700 Subject: [PATCH 04/14] Update notes --- notes | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/notes b/notes index 430b5da..9f26cfe 100644 --- a/notes +++ b/notes @@ -1,34 +1,32 @@ notes ----- +todo: - fix --show to not print final newline, add a helper that iterates over (item, first, last) +- comment code - look through all justfiles for features of make that I use. so far: . phony . SHELL := zsh . quiet . make variables - ask travis for his justfiles -- comment code - -command line arguments: -- --show recipe: print recipe information - -execution: -- indent for line continuation -- use sh -c to execute lines polyglot: - recipes can have shebangs - extract and run script - preserve line numbers -- whole file can have shebang +- special 'prelude recipe" . allow launching binaries from cargo . script until -- . all recipes are then in that language? extras: -- args +- args can be passed after --, or with some special syntax: + a: 1 2 3 : +- should also add an annotation for recipes + a FOO BAR, export variables FOO and BAR with args +- indent for line continuation - use launch recipes asyncronously - ~/.justfile: . is this for non-project specific commands, so that when you From f87ecd61543b639f1eb1d5fd4cc73909d3de29ac Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Thu, 6 Oct 2016 16:48:42 -0700 Subject: [PATCH 05/14] Remove list and args from justfile --- justfile | 7 ------- 1 file changed, 7 deletions(-) diff --git a/justfile b/justfile index 25c65c0..e9d9cdc 100644 --- a/justfile +++ b/justfile @@ -2,13 +2,6 @@ test: cargo test cargo run -- quine clean > /dev/null 2> /dev/null -# list all recipies -list: - @$(MAKE) -pRrq -f $(lastword $(MAKEFILE_LIST)) : 2>/dev/null | awk -v RS= -F: '/^# File/,/^# Finished Make data base/ {if ($$1 !~ "^[#.]") {print $$1}}' | sort | egrep -v -e '^[^[:alnum:]]' -e '^$@$$' | xargs - -args: - @echo "I got some arguments: ARG0=${ARG0} ARG1=${ARG1} ARG2=${ARG2}" - # make a quine, compile it, and verify it quine: create cc tmp/gen0.c -o tmp/gen0 From b1d8fdb398ae5e7e1ae5defd0ddd048f56655a49 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Thu, 6 Oct 2016 16:50:31 -0700 Subject: [PATCH 06/14] Don't print double newline for --show --- src/lib.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index f907654..9f11cb0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -52,8 +52,12 @@ pub struct Recipe<'a> { impl<'a> Display for Recipe<'a> { fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> { try!(writeln!(f, "{}:", self.name)); - for command in self.commands.iter() { - try!(writeln!(f, " {}", command)); + for (i, command) in self.commands.iter().enumerate() { + if i + 1 < self.commands.len() { + try!(writeln!(f, " {}", command)); + } { + try!(write!(f, " {}", command)); + } } Ok(()) } From 2a721db2807e72c27f4bae3cc2ebeb7a04c4976a Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Thu, 6 Oct 2016 16:56:21 -0700 Subject: [PATCH 07/14] Invoke sh with `-u` (error on unbound variable) --- src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index 9f11cb0..3afe90c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -87,7 +87,7 @@ impl<'a> Recipe<'a> { command = &command[1..]; } let status = process::Command::new("sh") - .arg("-c") + .arg("-cu") .arg(command) .status(); try!(match status { From d503b37fb3393c9a1384ad2fcafe502bbc68ba9f Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Thu, 6 Oct 2016 16:56:36 -0700 Subject: [PATCH 08/14] Notes --- notes | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/notes b/notes index 9f26cfe..5c4724d 100644 --- a/notes +++ b/notes @@ -1,16 +1,9 @@ notes ----- -todo: -- fix --show to not print final newline, add a helper that iterates over - (item, first, last) - comment code -- look through all justfiles for features of make that I use. so far: - . phony - . SHELL := zsh - . quiet - . make variables -- ask travis for his justfiles +- fix docs (note that shell is invoked with -cu) +- publish to github and cargo polyglot: - recipes can have shebangs From fa25c846c7fcf75bb582352cb95ad9c64289c9be Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Thu, 6 Oct 2016 17:43:30 -0700 Subject: [PATCH 09/14] More validation of recipes, allow leading shebang --- notes | 25 +++++++++++------- src/lib.rs | 73 +++++++++++++++++++++++++++++++++++++--------------- src/tests.rs | 16 ++++++++---- 3 files changed, 78 insertions(+), 36 deletions(-) diff --git a/notes b/notes index 5c4724d..2475a3c 100644 --- a/notes +++ b/notes @@ -1,24 +1,29 @@ notes ----- +polyglot: +- recipes can have shebangs on first line +- complain if there is extra leading whitespace in a non-shebang recipe +- extract and run script +- preserve line numbers + +- change name to "a polyglot command runner" - comment code - fix docs (note that shell is invoked with -cu) - publish to github and cargo +- spam facebook, reddit -polyglot: -- recipes can have shebangs -- extract and run script -- preserve line numbers -- special 'prelude recipe" - . allow launching binaries from cargo - . script until -- - . all recipes are then in that language? - -extras: +wishlist: +- preludes: + may be nice to allow all recipes in a given langauge to share + functions, variables, etc. could have a "prelude" recipe + which was included as a prefix to other recipes +- windows support: currently calling 'sh', which won't work on windows - args can be passed after --, or with some special syntax: a: 1 2 3 : - should also add an annotation for recipes a FOO BAR, export variables FOO and BAR with args + fail if doesn't get two arguments - indent for line continuation - use launch recipes asyncronously - ~/.justfile: diff --git a/src/lib.rs b/src/lib.rs index 3afe90c..a316854 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -42,21 +42,23 @@ fn re(pattern: &str) -> Regex { } pub struct Recipe<'a> { - line: usize, + line_number: usize, + label: &'a str, name: &'a str, leading_whitespace: &'a str, - commands: Vec<&'a str>, + lines: Vec<&'a str>, dependencies: BTreeSet<&'a str>, + shebang: bool, } impl<'a> Display for Recipe<'a> { fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> { - try!(writeln!(f, "{}:", self.name)); - for (i, command) in self.commands.iter().enumerate() { - if i + 1 < self.commands.len() { - try!(writeln!(f, " {}", command)); + try!(writeln!(f, "{}", self.label)); + for (i, line) in self.lines.iter().enumerate() { + if i + 1 < self.lines.len() { + try!(writeln!(f, " {}", line)); } { - try!(write!(f, " {}", command)); + try!(write!(f, " {}", line)); } } Ok(()) @@ -79,7 +81,8 @@ fn error_from_signal<'a>(recipe: &'a str, exit_status: process::ExitStatus) -> R impl<'a> Recipe<'a> { fn run(&self) -> Result<(), RunError<'a>> { - for command in &self.commands { + // TODO: if shebang, run as script + for command in &self.lines { let mut command = *command; if !command.starts_with("@") { warn!("{}", command); @@ -126,7 +129,7 @@ fn resolve<'a>( if seen.contains(dependency.name) { let first = stack[0]; stack.push(first); - return Err(error(text, recipe.line, ErrorKind::CircularDependency { + return Err(error(text, recipe.line_number, ErrorKind::CircularDependency { circle: stack.iter() .skip_while(|name| **name != dependency.name) .cloned().collect() @@ -134,7 +137,7 @@ fn resolve<'a>( } return resolve(text, recipes, resolved, seen, stack, dependency); }, - None => return Err(error(text, recipe.line, ErrorKind::UnknownDependency { + None => return Err(error(text, recipe.line_number, ErrorKind::UnknownDependency { name: recipe.name, unknown: dependency_name })), @@ -160,8 +163,10 @@ enum ErrorKind<'a> { DuplicateRecipe{first: usize, name: &'a str}, TabAfterSpace{whitespace: &'a str}, MixedLeadingWhitespace{whitespace: &'a str}, + ExtraLeadingWhitespace, InconsistentLeadingWhitespace{expected: &'a str, found: &'a str}, - Shebang, + OuterShebang, + NonLeadingShebang{recipe: &'a str}, UnknownDependency{name: &'a str, unknown: &'a str}, Unparsable, UnparsableDependencies, @@ -228,14 +233,20 @@ impl<'a> Display for Error<'a> { show_whitespace(whitespace) )); } + ErrorKind::ExtraLeadingWhitespace => { + try!(writeln!(f, "line has extra leading whitespace")); + } ErrorKind::InconsistentLeadingWhitespace{expected, found} => { try!(writeln!(f, "inconsistant leading whitespace: recipe started with {} but found line with {}:", show_whitespace(expected), show_whitespace(found) )); } - ErrorKind::Shebang => { - try!(writeln!(f, "shebang \"#!\" is reserved syntax")) + ErrorKind::OuterShebang => { + try!(writeln!(f, "a shebang \"#!\" is reserved syntax outside of recipes")) + } + ErrorKind::NonLeadingShebang{..} => { + try!(writeln!(f, "a shebang \"#!\" may only appear on the first line of a recipe")) } ErrorKind::UnknownDependency{name, unknown} => { try!(writeln!(f, "recipe {} has unknown dependency {}", name, unknown)); @@ -266,7 +277,7 @@ impl<'a> Justfile<'a> { let mut first: Option<&Recipe<'a>> = None; for (_, recipe) in self.recipes.iter() { if let Some(first_recipe) = first { - if recipe.line < first_recipe.line { + if recipe.line_number < first_recipe.line_number { first = Some(recipe) } } else { @@ -374,8 +385,6 @@ pub fn parse<'a>(text: &'a str) -> Result { for (i, line) in text.lines().enumerate() { if blank_re.is_match(line) { continue; - } else if shebang_re.is_match(line) { - return Err(error(text, i, ErrorKind::Shebang)); } if let Some(mut recipe) = current_recipe { @@ -399,7 +408,7 @@ pub fn parse<'a>(text: &'a str) -> Result { found: leading_whitespace, })); } - recipe.commands.push(line.split_at(recipe.leading_whitespace.len()).1); + recipe.lines.push(line.split_at(recipe.leading_whitespace.len()).1); current_recipe = Some(recipe); continue; }, @@ -412,6 +421,8 @@ pub fn parse<'a>(text: &'a str) -> Result { if comment_re.is_match(line) { // ignore + } else if shebang_re.is_match(line) { + return Err(error(text, i, ErrorKind::OuterShebang)); } else if let Some(captures) = label_re.captures(line) { let name = captures.at(1).unwrap(); if !name_re.is_match(name) { @@ -421,7 +432,7 @@ pub fn parse<'a>(text: &'a str) -> Result { } if let Some(recipe) = recipes.get(name) { return Err(error(text, i, ErrorKind::DuplicateRecipe { - first: recipe.line, + first: recipe.line_number, name: name, })); } @@ -442,11 +453,13 @@ pub fn parse<'a>(text: &'a str) -> Result { } current_recipe = Some(Recipe{ - line: i, + line_number: i, + label: line, name: name, leading_whitespace: "", - commands: vec![], - dependencies: dependencies, + lines: vec![], + dependencies: dependencies, + shebang: false, }); } else { return Err(error(text, i, ErrorKind::Unparsable)); @@ -457,6 +470,24 @@ pub fn parse<'a>(text: &'a str) -> Result { recipes.insert(recipe.name, recipe); } + let leading_whitespace_re = re(r"^\s+"); + + for recipe in recipes.values_mut() { + for (i, line) in recipe.lines.iter().enumerate() { + let line_number = recipe.line_number + 1 + i; + if shebang_re.is_match(line) { + if i == 0 { + recipe.shebang = true; + } else { + return Err(error(text, line_number, ErrorKind::NonLeadingShebang{recipe: recipe.name})); + } + } + if !recipe.shebang && leading_whitespace_re.is_match(line) { + return Err(error(text, line_number, ErrorKind::ExtraLeadingWhitespace)); + } + } + } + let mut resolved = HashSet::new(); let mut seen = HashSet::new(); let mut stack = vec![]; diff --git a/src/tests.rs b/src/tests.rs index fd3602f..6b5c542 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -22,7 +22,7 @@ fn check_recipe( name: &str, line: usize, leading_whitespace: &str, - commands: &[&str], + lines: &[&str], dependencies: &[&str] ) { let recipe = match justfile.recipes.get(name) { @@ -30,9 +30,9 @@ fn check_recipe( None => panic!("Justfile had no recipe \"{}\"", name), }; assert_eq!(recipe.name, name); - assert_eq!(recipe.line, line); + assert_eq!(recipe.line_number, line); assert_eq!(recipe.leading_whitespace, leading_whitespace); - assert_eq!(recipe.commands, commands); + assert_eq!(recipe.lines, lines); assert_eq!(recipe.dependencies.iter().cloned().collect::>(), dependencies); } @@ -87,8 +87,8 @@ fn inconsistent_leading_whitespace() { #[test] fn shebang() { - expect_error("#!/bin/sh", 0, ErrorKind::Shebang); - expect_error("a:\n #!/bin/sh", 1, ErrorKind::Shebang); + expect_error("#!/bin/sh", 0, ErrorKind::OuterShebang); + expect_error("a:\n echo hello\n #!/bin/sh", 2, ErrorKind::NonLeadingShebang{recipe:"a"}); } #[test] @@ -96,6 +96,12 @@ fn unknown_dependency() { expect_error("a: b", 0, ErrorKind::UnknownDependency{name: "a", unknown: "b"}); } +#[test] +fn extra_whitespace() { + expect_error("a:\n blah\n blarg", 2, ErrorKind::ExtraLeadingWhitespace); + expect_success("a:\n #!\n print(1)"); +} + #[test] fn unparsable() { expect_error("hello", 0, ErrorKind::Unparsable); From 2e97f3f0264c9581184f507fde81a76225a265c5 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Fri, 7 Oct 2016 17:56:39 -0700 Subject: [PATCH 10/14] Add polyglot recipes to justfile --- justfile | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/justfile b/justfile index e9d9cdc..a0f1810 100644 --- a/justfile +++ b/justfile @@ -16,7 +16,27 @@ create: mkdir -p tmp echo 'int printf(const char*, ...); int main() { char *s = "int printf(const char*, ...); int main() { char *s = %c%s%c; printf(s, 34, s, 34); return 0; }"; printf(s, 34, s, 34); return 0; }' > tmp/gen0.c - # clean up clean: rm -r tmp + +python: + #!/usr/bin/env python3 + print('Hello from python!') + +js: + #!/usr/bin/env node + console.log('Greetings from JavaScript!') + +perl: + #!/usr/bin/env perl + print "Larry Wall says Hi!\n"; + +sh: + #!/usr/bin/env sh + hello='Yo' + echo "$hello from a shell script!" + +ruby: + #!/usr/bin/env ruby + puts "Hello from ruby!" From 25785eea77540d5519ce890d80f2f0a42926030b Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Fri, 7 Oct 2016 17:56:52 -0700 Subject: [PATCH 11/14] Support for polyglot recipes! --- src/lib.rs | 92 +++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 78 insertions(+), 14 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index a316854..356fe74 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ mod tests; extern crate regex; +extern crate tempdir; use std::io::prelude::*; @@ -10,6 +11,8 @@ use std::collections::{BTreeMap, BTreeSet, HashSet}; use std::fmt::Display; use regex::Regex; +use std::os::unix::fs::PermissionsExt; + macro_rules! warn { ($($arg:tt)*) => {{ extern crate std; @@ -81,18 +84,51 @@ fn error_from_signal<'a>(recipe: &'a str, exit_status: process::ExitStatus) -> R impl<'a> Recipe<'a> { fn run(&self) -> Result<(), RunError<'a>> { - // TODO: if shebang, run as script - for command in &self.lines { - let mut command = *command; - if !command.starts_with("@") { - warn!("{}", command); - } else { - command = &command[1..]; + if self.shebang { + let tmp = try!( + tempdir::TempDir::new("j") + .map_err(|error| RunError::TmpdirIoError{recipe: self.name, io_error: error}) + ); + let mut path = tmp.path().to_path_buf(); + path.push(self.name); + { + let mut f = try!( + fs::File::create(&path) + .map_err(|error| RunError::TmpdirIoError{recipe: self.name, io_error: error}) + ); + let mut text = String::new(); + // add the shebang + text += self.lines[0]; + text += "\n"; + // add blank lines so that lines in the generated script + // have the same line number as the corresponding lines + // in the justfile + for _ in 1..(self.line_number + 2) { + text += "\n" + } + for line in &self.lines[1..] { + text += line; + text += "\n"; + } + try!( + f.write_all(text.as_bytes()) + .map_err(|error| RunError::TmpdirIoError{recipe: self.name, io_error: error}) + ); } - let status = process::Command::new("sh") - .arg("-cu") - .arg(command) - .status(); + + // get current permissions + let mut perms = try!( + fs::metadata(&path) + .map_err(|error| RunError::TmpdirIoError{recipe: self.name, io_error: error}) + ).permissions(); + + // make the script executable + let current_mode = perms.mode(); + perms.set_mode(current_mode | 0o100); + try!(fs::set_permissions(&path, perms).map_err(|error| RunError::TmpdirIoError{recipe: self.name, io_error: error})); + + // run it! + let status = process::Command::new(path).status(); try!(match status { Ok(exit_status) => if let Some(code) = exit_status.code() { if code == 0 { @@ -103,8 +139,33 @@ impl<'a> Recipe<'a> { } else { Err(error_from_signal(self.name, exit_status)) }, - Err(io_error) => Err(RunError::IoError{recipe: self.name, io_error: io_error}) + Err(io_error) => Err(RunError::TmpdirIoError{recipe: self.name, io_error: io_error}) }); + } else { + for command in &self.lines { + let mut command = *command; + if !command.starts_with("@") { + warn!("{}", command); + } else { + command = &command[1..]; + } + let status = process::Command::new("sh") + .arg("-cu") + .arg(command) + .status(); + try!(match status { + Ok(exit_status) => if let Some(code) = exit_status.code() { + if code == 0 { + Ok(()) + } else { + Err(RunError::Code{recipe: self.name, code: code}) + } + } else { + Err(error_from_signal(self.name, exit_status)) + }, + Err(io_error) => Err(RunError::IoError{recipe: self.name, io_error: io_error}) + }); + } } Ok(()) } @@ -261,8 +322,8 @@ impl<'a> Display for Error<'a> { match self.text.lines().nth(self.line) { Some(line) => try!(write!(f, "{}", line)), - None => die!("internal error: Error has invalid line number: {}", self.line), - } + None => try!(write!(f, "internal error: Error has invalid line number: {}", self.line)), + }; Ok(()) } @@ -338,6 +399,7 @@ pub enum RunError<'a> { Code{recipe: &'a str, code: i32}, UnknownFailure{recipe: &'a str}, IoError{recipe: &'a str, io_error: io::Error}, + TmpdirIoError{recipe: &'a str, io_error: io::Error}, } impl<'a> Display for RunError<'a> { @@ -366,6 +428,8 @@ impl<'a> Display for RunError<'a> { _ => write!(f, "Recipe \"{}\" could not be run because of an IO error while launching the `sh`:\n{}", recipe, io_error), }); }, + &RunError::TmpdirIoError{recipe, ref io_error} => + try!(write!(f, "Recipe \"{}\" could not be run because of an IO error while trying to create a temporary directory or write a file to that directory`:\n{}", recipe, io_error)), } Ok(()) } From a421858d76e0ec6f2d6d86442a0064cebd0a643a Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Fri, 7 Oct 2016 17:57:35 -0700 Subject: [PATCH 12/14] Update notes --- notes | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/notes b/notes index 2475a3c..1645161 100644 --- a/notes +++ b/notes @@ -2,11 +2,7 @@ notes ----- polyglot: -- recipes can have shebangs on first line -- complain if there is extra leading whitespace in a non-shebang recipe -- extract and run script -- preserve line numbers - +- get the extracted script and test its structure - change name to "a polyglot command runner" - comment code - fix docs (note that shell is invoked with -cu) From 09826a17c3dbd965690e8dc1bf3fd5cd23237dfa Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Fri, 7 Oct 2016 21:07:02 -0700 Subject: [PATCH 13/14] Add test for shebang recipes --- src/tests.rs | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/tests.rs b/src/tests.rs index 6b5c542..b38ea63 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -86,7 +86,7 @@ fn inconsistent_leading_whitespace() { } #[test] -fn shebang() { +fn shebang_errors() { expect_error("#!/bin/sh", 0, ErrorKind::OuterShebang); expect_error("a:\n echo hello\n #!/bin/sh", 2, ErrorKind::NonLeadingShebang{recipe:"a"}); } @@ -154,7 +154,7 @@ fn code_error() { assert_eq!(recipe, "fail"); assert_eq!(code, 100); }, - other @ _ => panic!("expected an code run error, but got: {}", other), + other @ _ => panic!("expected a code run error, but got: {}", other), } } @@ -178,3 +178,27 @@ d: c super::std::env::set_current_dir(path).expect("failed to set current directory"); expect_success(text).run(&["a", "d"]).unwrap(); } + +#[test] +fn shebang() { + // this test exists to make sure that shebang recipes + // are run correctly. although it is still executed + // by sh its behavior depends on the value of a + // variable, which would not be available if it were + // a plain recipe + let text = " +a: + #!/usr/bin/env sh + code=200 + function x { return $code; } + x +"; + + match expect_success(text).run(&["a"]).unwrap_err() { + super::RunError::Code{recipe, code} => { + assert_eq!(recipe, "a"); + assert_eq!(code, 200); + }, + other @ _ => panic!("expected an code run error, but got: {}", other), + } +} From 853bb7d28aa3204f35135949f09eb224563e5d9e Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Sat, 8 Oct 2016 12:53:53 -0700 Subject: [PATCH 14/14] Update shebang test notes --- src/tests.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/tests.rs b/src/tests.rs index b38ea63..07a732f 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -182,16 +182,16 @@ d: c #[test] fn shebang() { // this test exists to make sure that shebang recipes - // are run correctly. although it is still executed - // by sh its behavior depends on the value of a - // variable, which would not be available if it were - // a plain recipe + // run correctly. although this script is still + // executed by sh its behavior depends on the value of a + // variable and continuing even though a command fails let text = " a: #!/usr/bin/env sh code=200 function x { return $code; } x + x "; match expect_success(text).run(&["a"]).unwrap_err() {