diff options
Diffstat (limited to '')
-rw-r--r-- | src/tools/jsondocck/Cargo.toml | 13 | ||||
-rw-r--r-- | src/tools/jsondocck/src/cache.rs | 77 | ||||
-rw-r--r-- | src/tools/jsondocck/src/config.rs | 37 | ||||
-rw-r--r-- | src/tools/jsondocck/src/error.rs | 28 | ||||
-rw-r--r-- | src/tools/jsondocck/src/main.rs | 339 |
5 files changed, 494 insertions, 0 deletions
diff --git a/src/tools/jsondocck/Cargo.toml b/src/tools/jsondocck/Cargo.toml new file mode 100644 index 000000000..ccabe6483 --- /dev/null +++ b/src/tools/jsondocck/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "jsondocck" +version = "0.1.0" +edition = "2021" + +[dependencies] +jsonpath_lib = "0.2" +getopts = "0.2" +regex = "1.4" +shlex = "1.0" +serde_json = "1.0" +fs-err = "2.5.0" +once_cell = "1.0" diff --git a/src/tools/jsondocck/src/cache.rs b/src/tools/jsondocck/src/cache.rs new file mode 100644 index 000000000..a188750c5 --- /dev/null +++ b/src/tools/jsondocck/src/cache.rs @@ -0,0 +1,77 @@ +use crate::error::CkError; +use serde_json::Value; +use std::collections::HashMap; +use std::io; +use std::path::{Path, PathBuf}; + +use fs_err as fs; + +#[derive(Debug)] +pub struct Cache { + root: PathBuf, + files: HashMap<PathBuf, String>, + values: HashMap<PathBuf, Value>, + pub variables: HashMap<String, Value>, + last_path: Option<PathBuf>, +} + +impl Cache { + /// Create a new cache, used to read files only once and otherwise store their contents. + pub fn new(doc_dir: &str) -> Cache { + Cache { + root: Path::new(doc_dir).to_owned(), + files: HashMap::new(), + values: HashMap::new(), + variables: HashMap::new(), + last_path: None, + } + } + + fn resolve_path(&mut self, path: &String) -> PathBuf { + if path != "-" { + let resolve = self.root.join(path); + self.last_path = Some(resolve.clone()); + resolve + } else { + self.last_path + .as_ref() + // FIXME: Point to a line number + .expect("No last path set. Make sure to specify a full path before using `-`") + .clone() + } + } + + fn read_file(&mut self, path: PathBuf) -> Result<String, io::Error> { + if let Some(f) = self.files.get(&path) { + return Ok(f.clone()); + } + + let file = fs::read_to_string(&path)?; + + self.files.insert(path, file.clone()); + + Ok(file) + } + + /// Get the text from a file. If called multiple times, the file will only be read once + pub fn get_file(&mut self, path: &String) -> Result<String, io::Error> { + let path = self.resolve_path(path); + self.read_file(path) + } + + /// Parse the JSON from a file. If called multiple times, the file will only be read once. + pub fn get_value(&mut self, path: &String) -> Result<Value, CkError> { + let path = self.resolve_path(path); + + if let Some(v) = self.values.get(&path) { + return Ok(v.clone()); + } + + let content = self.read_file(path.clone())?; + let val = serde_json::from_str::<Value>(&content)?; + + self.values.insert(path, val.clone()); + + Ok(val) + } +} diff --git a/src/tools/jsondocck/src/config.rs b/src/tools/jsondocck/src/config.rs new file mode 100644 index 000000000..9b3ba3f3f --- /dev/null +++ b/src/tools/jsondocck/src/config.rs @@ -0,0 +1,37 @@ +use getopts::Options; + +#[derive(Debug)] +pub struct Config { + /// The directory documentation output was generated in + pub doc_dir: String, + /// The file documentation was generated for, with docck commands to check + pub template: String, +} + +/// Create a Config from a vector of command-line arguments +pub fn parse_config(args: Vec<String>) -> Config { + let mut opts = Options::new(); + opts.reqopt("", "doc-dir", "Path to the documentation directory", "PATH") + .reqopt("", "template", "Path to the template file", "PATH") + .optflag("h", "help", "show this message"); + + let (argv0, args_) = args.split_first().unwrap(); + if args.len() == 1 { + let message = format!("Usage: {} <doc-dir> <template>", argv0); + println!("{}", opts.usage(&message)); + std::process::exit(1); + } + + let matches = opts.parse(args_).unwrap(); + + if matches.opt_present("h") || matches.opt_present("help") { + let message = format!("Usage: {} <doc-dir> <template>", argv0); + println!("{}", opts.usage(&message)); + std::process::exit(1); + } + + Config { + doc_dir: matches.opt_str("doc-dir").unwrap(), + template: matches.opt_str("template").unwrap(), + } +} diff --git a/src/tools/jsondocck/src/error.rs b/src/tools/jsondocck/src/error.rs new file mode 100644 index 000000000..53b9af287 --- /dev/null +++ b/src/tools/jsondocck/src/error.rs @@ -0,0 +1,28 @@ +use crate::Command; +use std::error::Error; +use std::fmt; + +#[derive(Debug)] +pub enum CkError { + /// A check failed. File didn't exist or failed to match the command + FailedCheck(String, Command), + /// An error triggered by some other error + Induced(Box<dyn Error>), +} + +impl fmt::Display for CkError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + CkError::FailedCheck(msg, cmd) => { + write!(f, "Failed check: {} on line {}", msg, cmd.lineno) + } + CkError::Induced(err) => write!(f, "Check failed: {}", err), + } + } +} + +impl<T: Error + 'static> From<T> for CkError { + fn from(err: T) -> CkError { + CkError::Induced(Box::new(err)) + } +} diff --git a/src/tools/jsondocck/src/main.rs b/src/tools/jsondocck/src/main.rs new file mode 100644 index 000000000..c44624666 --- /dev/null +++ b/src/tools/jsondocck/src/main.rs @@ -0,0 +1,339 @@ +use jsonpath_lib::select; +use once_cell::sync::Lazy; +use regex::{Regex, RegexBuilder}; +use serde_json::Value; +use std::borrow::Cow; +use std::{env, fmt, fs}; + +mod cache; +mod config; +mod error; + +use cache::Cache; +use config::parse_config; +use error::CkError; + +fn main() -> Result<(), String> { + let config = parse_config(env::args().collect()); + + let mut failed = Vec::new(); + let mut cache = Cache::new(&config.doc_dir); + let commands = get_commands(&config.template) + .map_err(|_| format!("Jsondocck failed for {}", &config.template))?; + + for command in commands { + if let Err(e) = check_command(command, &mut cache) { + failed.push(e); + } + } + + if failed.is_empty() { + Ok(()) + } else { + for i in failed { + eprintln!("{}", i); + } + Err(format!("Jsondocck failed for {}", &config.template)) + } +} + +#[derive(Debug)] +pub struct Command { + negated: bool, + kind: CommandKind, + args: Vec<String>, + lineno: usize, +} + +#[derive(Debug)] +pub enum CommandKind { + Has, + Count, + Is, + Set, +} + +impl CommandKind { + fn validate(&self, args: &[String], command_num: usize, lineno: usize) -> bool { + let count = match self { + CommandKind::Has => (1..=3).contains(&args.len()), + CommandKind::Count | CommandKind::Is => 3 == args.len(), + CommandKind::Set => 4 == args.len(), + }; + + if !count { + print_err(&format!("Incorrect number of arguments to `@{}`", self), lineno); + return false; + } + + if args[0] == "-" && command_num == 0 { + print_err(&format!("Tried to use the previous path in the first command"), lineno); + return false; + } + + if let CommandKind::Count = self { + if args[2].parse::<usize>().is_err() { + print_err( + &format!("Third argument to @count must be a valid usize (got `{}`)", args[2]), + lineno, + ); + return false; + } + } + + true + } +} + +impl fmt::Display for CommandKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let text = match self { + CommandKind::Has => "has", + CommandKind::Count => "count", + CommandKind::Is => "is", + CommandKind::Set => "set", + }; + write!(f, "{}", text) + } +} + +static LINE_PATTERN: Lazy<Regex> = Lazy::new(|| { + RegexBuilder::new( + r#" + \s(?P<invalid>!?)@(?P<negated>!?) + (?P<cmd>[A-Za-z]+(?:-[A-Za-z]+)*) + (?P<args>.*)$ + "#, + ) + .ignore_whitespace(true) + .unicode(true) + .build() + .unwrap() +}); + +fn print_err(msg: &str, lineno: usize) { + eprintln!("Invalid command: {} on line {}", msg, lineno) +} + +/// Get a list of commands from a file. Does the work of ensuring the commands +/// are syntactically valid. +fn get_commands(template: &str) -> Result<Vec<Command>, ()> { + let mut commands = Vec::new(); + let mut errors = false; + let file = fs::read_to_string(template).unwrap(); + + for (lineno, line) in file.split('\n').enumerate() { + let lineno = lineno + 1; + + let cap = match LINE_PATTERN.captures(line) { + Some(c) => c, + None => continue, + }; + + let negated = cap.name("negated").unwrap().as_str() == "!"; + let cmd = cap.name("cmd").unwrap().as_str(); + + let cmd = match cmd { + "has" => CommandKind::Has, + "count" => CommandKind::Count, + "is" => CommandKind::Is, + "set" => CommandKind::Set, + _ => { + print_err(&format!("Unrecognized command name `@{}`", cmd), lineno); + errors = true; + continue; + } + }; + + if let Some(m) = cap.name("invalid") { + if m.as_str() == "!" { + print_err( + &format!( + "`!@{0}{1}`, (help: try with `@!{1}`)", + if negated { "!" } else { "" }, + cmd, + ), + lineno, + ); + errors = true; + continue; + } + } + + let args = cap.name("args").map_or(Some(vec![]), |m| shlex::split(m.as_str())); + + let args = match args { + Some(args) => args, + None => { + print_err( + &format!( + "Invalid arguments to shlex::split: `{}`", + cap.name("args").unwrap().as_str() + ), + lineno, + ); + errors = true; + continue; + } + }; + + if !cmd.validate(&args, commands.len(), lineno) { + errors = true; + continue; + } + + commands.push(Command { negated, kind: cmd, args, lineno }) + } + + if !errors { Ok(commands) } else { Err(()) } +} + +/// Performs the actual work of ensuring a command passes. Generally assumes the command +/// is syntactically valid. +fn check_command(command: Command, cache: &mut Cache) -> Result<(), CkError> { + // FIXME: Be more granular about why, (e.g. syntax error, count not equal) + let result = match command.kind { + CommandKind::Has => { + match command.args.len() { + // @has <path> = file existence + 1 => cache.get_file(&command.args[0]).is_ok(), + // @has <path> <jsonpath> = check path exists + 2 => { + let val = cache.get_value(&command.args[0])?; + let results = select(&val, &command.args[1]).unwrap(); + !results.is_empty() + } + // @has <path> <jsonpath> <value> = check *any* item matched by path equals value + 3 => { + let val = cache.get_value(&command.args[0])?; + let results = select(&val, &command.args[1]).unwrap(); + let pat = string_to_value(&command.args[2], cache); + let has = results.contains(&pat.as_ref()); + // Give better error for when @has check fails + if !command.negated && !has { + return Err(CkError::FailedCheck( + format!( + "{} matched to {:?} but didn't have {:?}", + &command.args[1], + results, + pat.as_ref() + ), + command, + )); + } else { + has + } + } + _ => unreachable!(), + } + } + CommandKind::Count => { + // @count <path> <jsonpath> <count> = Check that the jsonpath matches exactly [count] times + assert_eq!(command.args.len(), 3); + let expected: usize = command.args[2].parse().unwrap(); + + let val = cache.get_value(&command.args[0])?; + let results = select(&val, &command.args[1]).unwrap(); + let eq = results.len() == expected; + if !command.negated && !eq { + return Err(CkError::FailedCheck( + format!( + "`{}` matched to `{:?}` with length {}, but expected length {}", + &command.args[1], + results, + results.len(), + expected + ), + command, + )); + } else { + eq + } + } + CommandKind::Is => { + // @has <path> <jsonpath> <value> = check *exactly one* item matched by path, and it equals value + assert_eq!(command.args.len(), 3); + let val = cache.get_value(&command.args[0])?; + let results = select(&val, &command.args[1]).unwrap(); + let pat = string_to_value(&command.args[2], cache); + let is = results.len() == 1 && results[0] == pat.as_ref(); + if !command.negated && !is { + return Err(CkError::FailedCheck( + format!( + "{} matched to {:?}, but expected {:?}", + &command.args[1], + results, + pat.as_ref() + ), + command, + )); + } else { + is + } + } + CommandKind::Set => { + // @set <name> = <path> <jsonpath> + assert_eq!(command.args.len(), 4); + assert_eq!(command.args[1], "=", "Expected an `=`"); + let val = cache.get_value(&command.args[2])?; + let results = select(&val, &command.args[3]).unwrap(); + assert_eq!( + results.len(), + 1, + "Expected 1 match for `{}` (because of @set): matched to {:?}", + command.args[3], + results + ); + match results.len() { + 0 => false, + 1 => { + let r = cache.variables.insert(command.args[0].clone(), results[0].clone()); + assert!(r.is_none(), "Name collision: {} is duplicated", command.args[0]); + true + } + _ => { + panic!( + "Got multiple results in `@set` for `{}`: {:?}", + &command.args[3], results + ); + } + } + } + }; + + if result == command.negated { + if command.negated { + Err(CkError::FailedCheck( + format!( + "`@!{} {}` matched when it shouldn't", + command.kind, + command.args.join(" ") + ), + command, + )) + } else { + // FIXME: In the future, try 'peeling back' each step, and see at what level the match failed + Err(CkError::FailedCheck( + format!( + "`@{} {}` didn't match when it should", + command.kind, + command.args.join(" ") + ), + command, + )) + } + } else { + Ok(()) + } +} + +fn string_to_value<'a>(s: &str, cache: &'a Cache) -> Cow<'a, Value> { + if s.starts_with("$") { + Cow::Borrowed(&cache.variables.get(&s[1..]).unwrap_or_else(|| { + // FIXME(adotinthevoid): Show line number + panic!("No variable: `{}`. Current state: `{:?}`", &s[1..], cache.variables) + })) + } else { + Cow::Owned(serde_json::from_str(s).expect(&format!("Cannot convert `{}` to json", s))) + } +} |