Split into lib.rs and main.rs, wrote a bunch of tests
This commit is contained in:
parent
2d783d5bfb
commit
0c29b55df6
64
Cargo.lock
generated
64
Cargo.lock
generated
@ -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"
|
||||
|
@ -8,4 +8,5 @@ homepage = "https://github.com/casey/j"
|
||||
|
||||
[dependencies]
|
||||
|
||||
regex = "*"
|
||||
regex = "^0.1.77"
|
||||
clap = "^2.0.0"
|
||||
|
339
src/lib.rs
Normal file
339
src/lib.rs
Normal file
@ -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<String, std::io::Error>;
|
||||
}
|
||||
|
||||
impl Slurp for fs::File {
|
||||
fn slurp(&mut self) -> Result<String, std::io::Error> {
|
||||
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<Justfile, Error> {
|
||||
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<Recipe> = 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})
|
||||
}
|
325
src/main.rs
325
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<String, std::io::Error>;
|
||||
}
|
||||
|
||||
impl Slurp for fs::File {
|
||||
fn slurp(&mut self) -> Result<String, std::io::Error> {
|
||||
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<Justfile, Error> {
|
||||
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<Recipe> = 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. <casey@rodarmor.com>")
|
||||
.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<String> = std::env::args().skip(1).collect();
|
||||
// for request in requests {
|
||||
// println!("{}", request);
|
||||
// }
|
||||
|
||||
// let arguments: Vec<String> = 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::<Vec<_>>().join(" "));
|
||||
}
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
if let Some(values) = matches.values_of("recipe") {
|
||||
let names = values.collect::<Vec<_>>();
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
132
src/tests.rs
Normal file
132
src/tests.rs
Normal file
@ -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::<Vec<_>>(), 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<_>>() == 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");
|
||||
}
|
Loading…
Reference in New Issue
Block a user