diff --git a/Cargo.lock b/Cargo.lock index edf8f6b..97fe81a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,7 @@ name = "j" 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)", ] @@ -13,6 +14,31 @@ dependencies = [ "memchr 0.1.11 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "ansi_term" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "bitflags" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "clap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "ansi_term 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)", + "bitflags 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)", + "libc 0.2.16 (registry+https://github.com/rust-lang/crates.io-index)", + "strsim 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)", + "term_size 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", + "unicode-segmentation 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)", + "unicode-width 0.1.3 (registry+https://github.com/rust-lang/crates.io-index)", + "vec_map 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "kernel32-sys" version = "0.2.2" @@ -52,6 +78,21 @@ name = "regex-syntax" version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" +[[package]] +name = "strsim" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "term_size" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "kernel32-sys 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)", + "libc 0.2.16 (registry+https://github.com/rust-lang/crates.io-index)", + "winapi 0.2.8 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "thread-id" version = "2.0.0" @@ -69,11 +110,26 @@ dependencies = [ "thread-id 2.0.0 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "unicode-segmentation" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "unicode-width" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" + [[package]] name = "utf8-ranges" version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" +[[package]] +name = "vec_map" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" + [[package]] name = "winapi" version = "0.2.8" @@ -86,13 +142,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" [metadata] "checksum aho-corasick 0.5.3 (registry+https://github.com/rust-lang/crates.io-index)" = "ca972c2ea5f742bfce5687b9aef75506a764f61d37f8f649047846a9686ddb66" +"checksum ansi_term 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)" = "23ac7c30002a5accbf7e8987d0632fa6de155b7c3d39d0067317a391e00a2ef6" +"checksum bitflags 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)" = "aad18937a628ec6abcd26d1489012cc0e18c21798210f491af69ded9b881106d" +"checksum clap 2.13.0 (registry+https://github.com/rust-lang/crates.io-index)" = "2887ae5b606c1fa314b9238e25a8be3fa673378415c32efc5749464f3365ee9d" "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 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 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" +"checksum unicode-segmentation 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)" = "b905d0fc2a1f0befd86b0e72e31d1787944efef9d38b9358a9e92a69757f7e3b" +"checksum unicode-width 0.1.3 (registry+https://github.com/rust-lang/crates.io-index)" = "2d6722facc10989f63ee0e20a83cd4e1714a9ae11529403ac7e0afd069abc39e" "checksum utf8-ranges 0.1.3 (registry+https://github.com/rust-lang/crates.io-index)" = "a1ca13c08c41c9c3e04224ed9ff80461d97e121589ff27c753a16cb10830ae0f" +"checksum vec_map 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)" = "cac5efe5cb0fa14ec2f84f83c701c562ee63f6dcc680861b21d65c682adfb05f" "checksum winapi 0.2.8 (registry+https://github.com/rust-lang/crates.io-index)" = "167dc9d6949a9b857f3451275e911c3f44255842c1f7a76f33c55103a909087a" "checksum winapi-build 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)" = "2d315eee3b34aca4797b2da6b13ed88266e6d612562a0c46390af8299fc699bc" diff --git a/Cargo.toml b/Cargo.toml index 182d5cf..a6abcd5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,4 +8,5 @@ homepage = "https://github.com/casey/j" [dependencies] -regex = "*" +regex = "^0.1.77" +clap = "^2.0.0" diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..5615529 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,339 @@ +#[cfg(test)] +mod tests; + +extern crate regex; + +use std::io::prelude::*; + +use std::{fs, fmt}; +use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::fmt::Display; +use regex::Regex; + +macro_rules! warn { + ($($arg:tt)*) => {{ + extern crate std; + use std::io::prelude::*; + let _ = writeln!(&mut std::io::stderr(), $($arg)*); + }}; +} +macro_rules! die { + ($($arg:tt)*) => {{ + extern crate std; + warn!($($arg)*); + std::process::exit(-1) + }}; +} + +pub trait Slurp { + fn slurp(&mut self) -> Result; +} + +impl Slurp for fs::File { + fn slurp(&mut self) -> Result { + let mut destination = String::new(); + try!(self.read_to_string(&mut destination)); + Ok(destination) + } +} + +fn re(pattern: &str) -> Regex { + Regex::new(pattern).unwrap() +} + +pub struct Recipe<'a> { + line: usize, + name: &'a str, + leading_whitespace: &'a str, + commands: Vec<&'a str>, + dependencies: BTreeSet<&'a str>, +} + +#[derive(Debug)] +pub struct Error<'a> { + text: &'a str, + line: usize, + kind: ErrorKind<'a> +} + +#[derive(Debug, PartialEq)] +enum ErrorKind<'a> { + BadRecipeName{name: &'a str}, + CircularDependency{circle: Vec<&'a str>}, + DuplicateDependency{name: &'a str}, + DuplicateRecipe{first: usize, name: &'a str}, + TabAfterSpace{whitespace: &'a str}, + MixedLeadingWhitespace{whitespace: &'a str}, + InconsistentLeadingWhitespace{expected: &'a str, found: &'a str}, + Shebang, + UnknownDependency{name: &'a str, unknown: &'a str}, + Unparsable, + UnparsableDependencies, +} + +fn error<'a>(text: &'a str, line: usize, kind: ErrorKind<'a>) + -> Error<'a> +{ + Error { + text: text, + line: line, + kind: kind, + } +} + +fn show_whitespace(text: &str) -> String { + text.chars().map(|c| match c { '\t' => 't', ' ' => 's', _ => c }).collect() +} + +fn mixed(text: &str) -> bool { + !(text.chars().all(|c| c == ' ') || text.chars().all(|c| c == '\t')) +} + +fn tab_after_space(text: &str) -> bool { + let mut space = false; + for c in text.chars() { + match c { + ' ' => space = true, + '\t' => if space { + return true; + }, + _ => {}, + } + } + return false; +} + +impl<'a> Display for Error<'a> { + fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> { + try!(write!(f, "justfile:{}: ", self.line)); + + match self.kind { + ErrorKind::BadRecipeName{name} => { + try!(writeln!(f, "recipe name does not match /[a-z](-[a-z]|[a-z])*/: {}", name)); + } + ErrorKind::CircularDependency{ref circle} => { + try!(write!(f, "circular dependency: {}", circle.join(" -> "))); + return Ok(()); + } + ErrorKind::DuplicateDependency{name} => { + try!(writeln!(f, "duplicate dependency: {}", name)); + } + ErrorKind::DuplicateRecipe{first, name} => { + try!(write!(f, "duplicate recipe: {} appears on lines {} and {}", + name, first, self.line)); + return Ok(()); + } + ErrorKind::TabAfterSpace{whitespace} => { + try!(writeln!(f, "found tab after space: {}", show_whitespace(whitespace))); + } + ErrorKind::MixedLeadingWhitespace{whitespace} => { + try!(writeln!(f, + "inconsistant leading whitespace: recipe started with {}:", + show_whitespace(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::UnknownDependency{name, unknown} => { + try!(writeln!(f, "recipe {} has unknown dependency {}", name, unknown)); + } + ErrorKind::Unparsable => { + try!(writeln!(f, "could not parse line:")); + } + ErrorKind::UnparsableDependencies => { + try!(writeln!(f, "could not parse dependencies:")); + } + } + + match self.text.lines().nth(self.line) { + Some(line) => try!(write!(f, "{}", line)), + None => die!("internal error: Error has invalid line number: {}", self.line), + } + + Ok(()) + } +} + +pub struct Justfile<'a> { + pub recipes: BTreeMap<&'a str, Recipe<'a>>, +} + +impl<'a> Justfile<'a> { + pub fn first(&self) -> Option<&'a str> { + 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 { + first = Some(recipe) + } + } else { + first = Some(recipe); + } + } + first.map(|recipe| recipe.name) + } + + pub fn run(&self, recipes: &[&str]) { + if recipes.len() == 0 { + println!("running first recipe"); + } else { + for recipe in recipes { + println!("running {}...", recipe); + } + } + } + + pub fn contains(&self, name: &str) -> bool { + self.recipes.contains_key(name) + } +} + +pub fn parse<'a>(text: &'a str) -> Result { + let shebang_re = re(r"^\s*#!(.*)$" ); + let comment_re = re(r"^\s*#([^!].*)?$" ); + let command_re = re(r"^(\s+).*$" ); + let blank_re = re(r"^\s*$" ); + let label_re = re(r"^([^#]*):(.*)$" ); + let name_re = re(r"^[a-z](-[a-z]|[a-z])*$"); + let whitespace_re = re(r"\s+" ); + + let mut recipes: BTreeMap<&'a str, Recipe<'a>> = BTreeMap::new(); + let mut current_recipe: Option = None; + 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 { + match command_re.captures(line) { + Some(captures) => { + let leading_whitespace = captures.at(1).unwrap(); + if tab_after_space(leading_whitespace) { + return Err(error(text, i, ErrorKind::TabAfterSpace{ + whitespace: leading_whitespace, + })); + } else if recipe.leading_whitespace == "" { + if mixed(leading_whitespace) { + return Err(error(text, i, ErrorKind::MixedLeadingWhitespace{ + whitespace: leading_whitespace + })); + } + recipe.leading_whitespace = leading_whitespace; + } else if !line.starts_with(recipe.leading_whitespace) { + return Err(error(text, i, ErrorKind::InconsistentLeadingWhitespace{ + expected: recipe.leading_whitespace, + found: leading_whitespace, + })); + } + recipe.commands.push(line.split_at(recipe.leading_whitespace.len()).1); + current_recipe = Some(recipe); + continue; + }, + None => { + recipes.insert(recipe.name, recipe); + current_recipe = None; + }, + } + } + + if comment_re.is_match(line) { + // ignore + } else if let Some(captures) = label_re.captures(line) { + let name = captures.at(1).unwrap(); + if !name_re.is_match(name) { + return Err(error(text, i, ErrorKind::BadRecipeName { + name: name, + })); + } + if let Some(recipe) = recipes.get(name) { + return Err(error(text, i, ErrorKind::DuplicateRecipe { + first: recipe.line, + name: name, + })); + } + + let rest = captures.at(2).unwrap().trim(); + let mut dependencies = BTreeSet::new(); + for part in whitespace_re.split(rest) { + if name_re.is_match(part) { + if dependencies.contains(part) { + return Err(error(text, i, ErrorKind::DuplicateDependency{ + name: part, + })); + } + dependencies.insert(part); + } else { + return Err(error(text, i, ErrorKind::UnparsableDependencies)); + } + } + + current_recipe = Some(Recipe{ + line: i, + name: name, + leading_whitespace: "", + commands: vec![], + dependencies: dependencies, + }); + } else { + return Err(error(text, i, ErrorKind::Unparsable)); + } + } + + if let Some(recipe) = current_recipe { + recipes.insert(recipe.name, recipe); + } + + let mut resolved = HashSet::new(); + let mut seen = HashSet::new(); + let mut stack = vec![]; + + fn resolve<'a>( + text: &'a str, + recipes: &BTreeMap<&str, Recipe<'a>>, + resolved: &mut HashSet<&'a str>, + seen: &mut HashSet<&'a str>, + stack: &mut Vec<&'a str>, + recipe: &Recipe<'a>, + ) -> Result<(), Error<'a>> { + stack.push(recipe.name); + seen.insert(recipe.name); + for dependency_name in &recipe.dependencies { + match recipes.get(dependency_name) { + Some(dependency) => if !resolved.contains(dependency.name) { + if seen.contains(dependency.name) { + let first = stack[0]; + stack.push(first); + return Err(error(text, recipe.line, ErrorKind::CircularDependency { + circle: stack.iter() + .skip_while(|name| **name != dependency.name) + .cloned().collect() + })); + } + return resolve(text, recipes, resolved, seen, stack, dependency); + }, + None => return Err(error(text, recipe.line, ErrorKind::UnknownDependency { + name: recipe.name, + unknown: dependency_name + })), + } + } + resolved.insert(recipe.name); + stack.pop(); + Ok(()) + } + + for (_, ref recipe) in &recipes { + try!(resolve(text, &recipes, &mut resolved, &mut seen, &mut stack, &recipe)); + } + + Ok(Justfile{recipes: recipes}) +} diff --git a/src/main.rs b/src/main.rs index f813f79..662fa8a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,9 @@ -extern crate regex; +extern crate j; +extern crate clap; -use std::io::prelude::*; - -use std::{io, fs, env, fmt}; -use std::collections::{HashSet, BTreeMap}; -use std::fmt::Display; -use regex::Regex; +use std::{io, fs, env}; +use clap::{App, Arg}; +use j::Slurp; macro_rules! warn { ($($arg:tt)*) => {{ @@ -22,241 +20,20 @@ macro_rules! die { }}; } -trait Slurp { - fn slurp(&mut self) -> Result; -} - -impl Slurp for fs::File { - fn slurp(&mut self) -> Result { - let mut destination = String::new(); - try!(self.read_to_string(&mut destination)); - Ok(destination) - } -} - -fn re(pattern: &str) -> Regex { - Regex::new(pattern).unwrap() -} - -struct Recipe<'a> { - line: usize, - name: &'a str, - leading_whitespace: &'a str, - commands: Vec<&'a str>, - dependencies: HashSet<&'a str>, -} - -struct Error<'a> { - text: &'a str, - line: usize, - kind: ErrorKind<'a> -} - -enum ErrorKind<'a> { - CircularDependency{circle: Vec<&'a str>}, - DuplicateDependency{name: &'a str}, - DuplicateRecipe{first: usize, name: &'a str}, - InconsistentLeadingWhitespace{expected: &'a str, found: &'a str}, - Shebang, - UnknownDependency{name: &'a str, unknown: &'a str}, - Unparsable, - UnparsableDependencies, -} - -fn error<'a>(text: &'a str, line: usize, kind: ErrorKind<'a>) - -> Error<'a> -{ - Error { - text: text, - line: line, - kind: kind, - } -} - -fn show_whitespace(text: &str) -> String { - text.chars().map(|c| match c { '\t' => 't', ' ' => 's', _ => c }).collect() -} - -impl<'a> Display for Error<'a> { - fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> { - try!(write!(f, "justfile:{}: ", self.line)); - - match self.kind { - ErrorKind::CircularDependency{ref circle} => { - try!(write!(f, "circular dependency: {}", circle.join(" -> "))); - return Ok(()); - } - ErrorKind::DuplicateDependency{name} => { - try!(writeln!(f, "duplicate dependency: {}", name)); - } - ErrorKind::DuplicateRecipe{first, name} => { - try!(write!(f, "duplicate recipe: {} appears on lines {} and {}", - name, first, self.line)); - return Ok(()); - } - 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::UnknownDependency{name, unknown} => { - try!(writeln!(f, "recipe {} has unknown dependency {}", name, unknown)); - } - ErrorKind::Unparsable => { - try!(writeln!(f, "could not parse line:")); - } - ErrorKind::UnparsableDependencies => { - try!(writeln!(f, "could not parse dependencies:")); - } - } - - match self.text.lines().nth(self.line) { - Some(line) => try!(write!(f, "{}", line)), - None => die!("internal error: Error has invalid line number: {}", self.line), - } - - Ok(()) - } -} - -struct Justfile<'a> { - _recipes: BTreeMap<&'a str, Recipe<'a>> -} - -fn parse<'a>(text: &'a str) -> Result { - let shebang_re = re(r"^\s*#!(.*)$"); - let comment_re = re(r"^\s*#[^!].*$"); - let command_re = re(r"^(\s+).*$"); - let blank_re = re(r"^\s*$"); - let label_re = re(r"^([a-z](-[a-z]|[a-z])*):(.*)$"); - let name_re = re(r"^[a-z](-[a-z]|[a-z])*$"); - let whitespace_re = re(r"\s+"); - - let mut recipes: BTreeMap<&'a str, Recipe<'a>> = BTreeMap::new(); - let mut current_recipe: Option = None; - 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 { - match command_re.captures(line) { - Some(captures) => { - let leading_whitespace = captures.at(1).unwrap(); - if recipe.leading_whitespace == "" { - recipe.leading_whitespace = leading_whitespace; - } else if !line.starts_with(recipe.leading_whitespace) { - return Err(error(text, i, ErrorKind::InconsistentLeadingWhitespace{ - expected: recipe.leading_whitespace, - found: leading_whitespace, - })); - } - recipe.commands.push(line.split_at(recipe.leading_whitespace.len()).1); - current_recipe = Some(recipe); - continue; - }, - None => { - recipes.insert(recipe.name, recipe); - current_recipe = None; - }, - } - } - - if comment_re.is_match(line) { - // ignore - } else if let Some(captures) = label_re.captures(line) { - let name = captures.at(1).unwrap(); - if let Some(recipe) = recipes.get(name) { - return Err(error(text, i, ErrorKind::DuplicateRecipe { - first: recipe.line, - name: name, - })); - } - - let rest = captures.at(3).unwrap().trim(); - let mut dependencies = HashSet::new(); - for part in whitespace_re.split(rest) { - if name_re.is_match(part) { - if dependencies.contains(part) { - return Err(error(text, i, ErrorKind::DuplicateDependency{ - name: part, - })); - } - dependencies.insert(part); - } else { - return Err(error(text, i, ErrorKind::UnparsableDependencies)); - } - } - - current_recipe = Some(Recipe{ - line: i, - name: name, - leading_whitespace: "", - commands: vec![], - dependencies: dependencies, - }); - } else { - return Err(error(text, i, ErrorKind::Unparsable)); - } - } - - if let Some(recipe) = current_recipe { - recipes.insert(recipe.name, recipe); - } - - let mut resolved = HashSet::new(); - let mut seen = HashSet::new(); - let mut stack = vec![]; - - fn resolve<'a>( - text: &'a str, - recipes: &BTreeMap<&str, Recipe<'a>>, - resolved: &mut HashSet<&'a str>, - seen: &mut HashSet<&'a str>, - stack: &mut Vec<&'a str>, - recipe: &Recipe<'a>, - ) -> Result<(), Error<'a>> { - stack.push(recipe.name); - seen.insert(recipe.name); - for dependency_name in &recipe.dependencies { - match recipes.get(dependency_name) { - Some(dependency) => if !resolved.contains(dependency.name) { - if seen.contains(dependency.name) { - let first = stack[0]; - stack.push(first); - return Err(error(text, recipe.line, ErrorKind::CircularDependency { - circle: stack.iter() - .skip_while(|name| **name != dependency.name) - .cloned().collect() - })); - } - return resolve(text, recipes, resolved, seen, stack, dependency); - }, - None => return Err(error(text, recipe.line, ErrorKind::UnknownDependency { - name: recipe.name, - unknown: dependency_name - })), - } - } - resolved.insert(recipe.name); - stack.pop(); - Ok(()) - } - - for (_, ref recipe) in &recipes { - try!(resolve(text, &recipes, &mut resolved, &mut seen, &mut stack, &recipe)); - } - - Ok(Justfile{_recipes: recipes}) -} - fn main() { + let matches = App::new("j") + .version("0.1.5") + .author("Casey R. ") + .about("Just a command runner") + .arg(Arg::with_name("list") + .short("l") + .long("list") + .help("Lists available recipes")) + .arg(Arg::with_name("recipe") + .multiple(true) + .help("recipe(s) to run, defaults to the first recipe in the justfile")) + .get_matches(); + loop { match fs::metadata("justfile") { Ok(metadata) => if metadata.is_file() { break; }, @@ -282,40 +59,40 @@ fn main() { .slurp() .unwrap_or_else(|error| die!("Error reading justfile: {}", error)); - let _justfile = parse(&text).unwrap_or_else(|error| die!("{}", error)); + let justfile = j::parse(&text).unwrap_or_else(|error| die!("{}", error)); - /* - // let requests: Vec = std::env::args().skip(1).collect(); - // for request in requests { - // println!("{}", request); - // } - - // let arguments: Vec = std::env::args().skip(1 + recipes.len() + 1).collect(); - - // for (i, argument) in arguments.into_iter().enumerate() { - // std::env::set_var(format!("ARG{}", i), argument); - // } - - let mut command = std::process::Command::new(make.command()); - - command.arg("MAKEFLAGS="); - - if make.gnu() { - command.arg("--always-make").arg("--no-print-directory"); - } - - command.arg("-f").arg("justfile"); - - for recipe in recipes { - command.arg(recipe); - } - - match command.status() { - Err(error) => die!("Failed to execute `{:?}`: {}", command, error), - Ok(exit_status) => match exit_status.code() { - Some(code) => std::process::exit(code), - None => std::process::exit(-1), + if let Some(recipes) = matches.values_of("recipe") { + let mut missing = vec![]; + for recipe in recipes { + if !justfile.recipes.contains_key(recipe) { + missing.push(recipe); + } + } + if missing.len() > 0 { + die!("unknown recipe{}: {}", if missing.len() == 1 { "" } else { "s" }, missing.join(" ")); } } - */ + + if matches.is_present("list") { + if justfile.recipes.len() == 0 { + warn!("Justfile contains no recipes"); + } else { + warn!("{}", justfile.recipes.keys().cloned().collect::>().join(" ")); + } + std::process::exit(0); + } + + if let Some(values) = matches.values_of("recipe") { + let names = values.collect::>(); + for name in names.iter() { + if !justfile.contains(name) { + die!("Justfile does not contain recipe \"{}\"", name); + } + } + justfile.run(&names) + } else if let Some(name) = justfile.first() { + justfile.run(&[name]) + } else { + die!("Justfile contains no recipes"); + } } diff --git a/src/tests.rs b/src/tests.rs new file mode 100644 index 0000000..1ef2d85 --- /dev/null +++ b/src/tests.rs @@ -0,0 +1,132 @@ +use super::{ErrorKind, Justfile}; + +fn expect_error(text: &str, line: usize, expected_error_kind: ErrorKind) { + match super::parse(text) { + Ok(_) => panic!("Expected {:?} but parse succeeded", expected_error_kind), + Err(error) => { + if error.line != line { + panic!("Expected {:?} error on line {} but error was on line {}", + expected_error_kind, line, error.line); + } + if error.kind != expected_error_kind { + panic!("Expected {:?} error but got {:?}", error.kind, expected_error_kind); + } + } + } +} + +fn check_recipe( + justfile: &Justfile, + name: &str, + line: usize, + leading_whitespace: &str, + commands: &[&str], + dependencies: &[&str] +) { + let recipe = match justfile.recipes.get(name) { + Some(recipe) => recipe, + None => panic!("Justfile had no recipe \"{}\"", name), + }; + assert_eq!(recipe.name, name); + assert_eq!(recipe.line, line); + assert_eq!(recipe.leading_whitespace, leading_whitespace); + assert_eq!(recipe.commands, commands); + assert_eq!(recipe.dependencies.iter().cloned().collect::>(), dependencies); +} + +fn expect_success(text: &str) -> Justfile { + match super::parse(text) { + Ok(justfile) => justfile, + Err(error) => panic!("Expected successful parse but got error {}", error), + } +} + +#[test] +fn circular_dependency() { + expect_error("a: b\nb: a", 1, ErrorKind::CircularDependency{circle: vec!["a", "b", "a"]}); +} + +#[test] +fn duplicate_dependency() { + expect_error("a: b b", 0, ErrorKind::DuplicateDependency{name: "b"}); +} + +#[test] +fn duplicate_recipe() { + expect_error( + "a:\na:", + 1, ErrorKind::DuplicateRecipe{first: 0, name: "a"} + ); +} + +#[test] +fn tab_after_paces() { + expect_error( + "a:\n \tspaces", + 1, ErrorKind::TabAfterSpace{whitespace: " \t"} + ); +} + +#[test] +fn mixed_leading_whitespace() { + expect_error( + "a:\n\t spaces", + 1, ErrorKind::MixedLeadingWhitespace{whitespace: "\t "} + ); +} + +#[test] +fn inconsistent_leading_whitespace() { + expect_error( + "a:\n\t\ttabs\n\t\ttabs\n spaces", + 3, ErrorKind::InconsistentLeadingWhitespace{expected: "\t\t", found: " "} + ); +} + +#[test] +fn shebang() { + expect_error("#!/bin/sh", 0, ErrorKind::Shebang); + expect_error("a:\n #!/bin/sh", 1, ErrorKind::Shebang); +} + +#[test] +fn unknown_dependency() { + expect_error("a: b", 0, ErrorKind::UnknownDependency{name: "a", unknown: "b"}); +} + +#[test] +fn unparsable() { + expect_error("hello", 0, ErrorKind::Unparsable); +} + +#[test] +fn unparsable_dependencies() { + expect_error("a: -f", 0, ErrorKind::UnparsableDependencies); +} + +#[test] +fn bad_recipe_names() { + fn expect_bad_name(text: &str, name: &str) { + expect_error(text, 0, ErrorKind::BadRecipeName{name: name}); + } + expect_bad_name("Z:", "Z"); + expect_bad_name("a-:", "a-"); + expect_bad_name("-a:", "-a"); + expect_bad_name("a--a:", "a--a"); + expect_bad_name("@:", "@"); +} + +#[test] +fn parse() { + let justfile = expect_success("a: b c\nb: c\n echo hello\n\nc:\n\techo goodbye\n#\n#hello"); + assert!(justfile.recipes.keys().cloned().collect::>() == vec!["a", "b", "c"]); + check_recipe(&justfile, "a", 0, "", &[ ], &["b", "c"]); + check_recipe(&justfile, "b", 1, " ", &["echo hello" ], &["c" ]); + check_recipe(&justfile, "c", 4, "\t", &["echo goodbye"], &[ ]); +} + +#[test] +fn first() { + let justfile = expect_success("#hello\n#goodbye\na:\nb:\nc:\n"); + assert!(justfile.first().unwrap() == "a"); +}