1 use jsonpath_lib::select;
2 use once_cell::sync::Lazy;
3 use regex::{Regex, RegexBuilder};
6 use std::{env, fmt, fs};
13 use config::parse_config;
16 fn main() -> Result<(), String> {
17 let config = parse_config(env::args().collect());
19 let mut failed = Vec::new();
20 let mut cache = Cache::new(&config.doc_dir);
21 let commands = get_commands(&config.template)
22 .map_err(|_| format!("Jsondocck failed for {}", &config.template))?;
24 for command in commands {
25 if let Err(e) = check_command(command, &mut cache) {
30 if failed.is_empty() {
36 Err(format!("Jsondocck failed for {}", &config.template))
49 pub enum CommandKind {
57 fn validate(&self, args: &[String], command_num: usize, lineno: usize) -> bool {
58 let count = match self {
59 CommandKind::Has => (1..=3).contains(&args.len()),
60 CommandKind::Count | CommandKind::Is => 3 == args.len(),
61 CommandKind::Set => 4 == args.len(),
65 print_err(&format!("Incorrect number of arguments to `@{}`", self), lineno);
69 if args[0] == "-" && command_num == 0 {
70 print_err(&format!("Tried to use the previous path in the first command"), lineno);
74 if let CommandKind::Count = self {
75 if args[2].parse::<usize>().is_err() {
77 &format!("Third argument to @count must be a valid usize (got `{}`)", args[2]),
88 impl fmt::Display for CommandKind {
89 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90 let text = match self {
91 CommandKind::Has => "has",
92 CommandKind::Count => "count",
93 CommandKind::Is => "is",
94 CommandKind::Set => "set",
100 static LINE_PATTERN: Lazy<Regex> = Lazy::new(|| {
103 \s(?P<invalid>!?)@(?P<negated>!?)
104 (?P<cmd>[A-Za-z]+(?:-[A-Za-z]+)*)
108 .ignore_whitespace(true)
114 fn print_err(msg: &str, lineno: usize) {
115 eprintln!("Invalid command: {} on line {}", msg, lineno)
118 /// Get a list of commands from a file. Does the work of ensuring the commands
119 /// are syntactically valid.
120 fn get_commands(template: &str) -> Result<Vec<Command>, ()> {
121 let mut commands = Vec::new();
122 let mut errors = false;
123 let file = fs::read_to_string(template).unwrap();
125 for (lineno, line) in file.split('\n').enumerate() {
126 let lineno = lineno + 1;
128 let cap = match LINE_PATTERN.captures(line) {
133 let negated = cap.name("negated").unwrap().as_str() == "!";
134 let cmd = cap.name("cmd").unwrap().as_str();
136 let cmd = match cmd {
137 "has" => CommandKind::Has,
138 "count" => CommandKind::Count,
139 "is" => CommandKind::Is,
140 "set" => CommandKind::Set,
142 print_err(&format!("Unrecognized command name `@{}`", cmd), lineno);
148 if let Some(m) = cap.name("invalid") {
149 if m.as_str() == "!" {
152 "`!@{0}{1}`, (help: try with `@!{1}`)",
153 if negated { "!" } else { "" },
163 let args = cap.name("args").map_or(Some(vec![]), |m| shlex::split(m.as_str()));
165 let args = match args {
170 "Invalid arguments to shlex::split: `{}`",
171 cap.name("args").unwrap().as_str()
180 if !cmd.validate(&args, commands.len(), lineno) {
185 commands.push(Command { negated, kind: cmd, args, lineno })
188 if !errors { Ok(commands) } else { Err(()) }
191 /// Performs the actual work of ensuring a command passes. Generally assumes the command
192 /// is syntactically valid.
193 fn check_command(command: Command, cache: &mut Cache) -> Result<(), CkError> {
194 // FIXME: Be more granular about why, (e.g. syntax error, count not equal)
195 let result = match command.kind {
196 CommandKind::Has => {
197 match command.args.len() {
198 // @has <path> = file existence
199 1 => cache.get_file(&command.args[0]).is_ok(),
200 // @has <path> <jsonpath> = check path exists
202 let val = cache.get_value(&command.args[0])?;
203 let results = select(&val, &command.args[1]).unwrap();
206 // @has <path> <jsonpath> <value> = check *any* item matched by path equals value
208 let val = cache.get_value(&command.args[0])?;
209 let results = select(&val, &command.args[1]).unwrap();
210 let pat = string_to_value(&command.args[2], cache);
211 let has = results.contains(&pat.as_ref());
212 // Give better error for when @has check fails
213 if !command.negated && !has {
214 return Err(CkError::FailedCheck(
216 "{} matched to {:?} but didn't have {:?}",
230 CommandKind::Count => {
231 // @count <path> <jsonpath> <count> = Check that the jsonpath matches exactly [count] times
232 assert_eq!(command.args.len(), 3);
233 let expected: usize = command.args[2].parse().unwrap();
235 let val = cache.get_value(&command.args[0])?;
236 let results = select(&val, &command.args[1]).unwrap();
237 let eq = results.len() == expected;
238 if !command.negated && !eq {
239 return Err(CkError::FailedCheck(
241 "`{}` matched to `{:?}` with length {}, but expected length {}",
254 // @has <path> <jsonpath> <value> = check *exactly one* item matched by path, and it equals value
255 assert_eq!(command.args.len(), 3);
256 let val = cache.get_value(&command.args[0])?;
257 let results = select(&val, &command.args[1]).unwrap();
258 let pat = string_to_value(&command.args[2], cache);
259 let is = results.len() == 1 && results[0] == pat.as_ref();
260 if !command.negated && !is {
261 return Err(CkError::FailedCheck(
263 "{} matched to {:?}, but expected {:?}",
274 CommandKind::Set => {
275 // @set <name> = <path> <jsonpath>
276 assert_eq!(command.args.len(), 4);
277 assert_eq!(command.args[1], "=", "Expected an `=`");
278 let val = cache.get_value(&command.args[2])?;
279 let results = select(&val, &command.args[3]).unwrap();
283 "Expected 1 match for `{}` (because of @set): matched to {:?}",
287 match results.len() {
290 let r = cache.variables.insert(command.args[0].clone(), results[0].clone());
291 assert!(r.is_none(), "Name collision: {} is duplicated", command.args[0]);
296 "Got multiple results in `@set` for `{}`: {:?}",
297 &command.args[3], results
304 if result == command.negated {
306 Err(CkError::FailedCheck(
308 "`@!{} {}` matched when it shouldn't",
310 command.args.join(" ")
315 // FIXME: In the future, try 'peeling back' each step, and see at what level the match failed
316 Err(CkError::FailedCheck(
318 "`@{} {}` didn't match when it should",
320 command.args.join(" ")
330 fn string_to_value<'a>(s: &str, cache: &'a Cache) -> Cow<'a, Value> {
331 if s.starts_with("$") {
332 Cow::Borrowed(&cache.variables.get(&s[1..]).unwrap_or_else(|| {
333 // FIXME(adotinthevoid): Show line number
334 panic!("No variable: `{}`. Current state: `{:?}`", &s[1..], cache.variables)
337 Cow::Owned(serde_json::from_str(s).expect(&format!("Cannot convert `{}` to json", s)))