]>
Commit | Line | Data |
---|---|---|
5869c6ff | 1 | use jsonpath_lib::select; |
17df50a5 | 2 | use once_cell::sync::Lazy; |
5869c6ff XL |
3 | use regex::{Regex, RegexBuilder}; |
4 | use serde_json::Value; | |
6a06907d | 5 | use std::borrow::Cow; |
5869c6ff XL |
6 | use std::{env, fmt, fs}; |
7 | ||
8 | mod cache; | |
9 | mod config; | |
10 | mod error; | |
11 | ||
12 | use cache::Cache; | |
13 | use config::parse_config; | |
14 | use error::CkError; | |
15 | ||
16 | fn main() -> Result<(), String> { | |
17 | let config = parse_config(env::args().collect()); | |
18 | ||
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))?; | |
23 | ||
24 | for command in commands { | |
25 | if let Err(e) = check_command(command, &mut cache) { | |
26 | failed.push(e); | |
27 | } | |
28 | } | |
29 | ||
30 | if failed.is_empty() { | |
31 | Ok(()) | |
32 | } else { | |
33 | for i in failed { | |
34 | eprintln!("{}", i); | |
35 | } | |
36 | Err(format!("Jsondocck failed for {}", &config.template)) | |
37 | } | |
38 | } | |
39 | ||
40 | #[derive(Debug)] | |
41 | pub struct Command { | |
42 | negated: bool, | |
43 | kind: CommandKind, | |
44 | args: Vec<String>, | |
45 | lineno: usize, | |
46 | } | |
47 | ||
48 | #[derive(Debug)] | |
49 | pub enum CommandKind { | |
50 | Has, | |
51 | Count, | |
6a06907d XL |
52 | Is, |
53 | Set, | |
5869c6ff XL |
54 | } |
55 | ||
56 | impl 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()), | |
6a06907d XL |
60 | CommandKind::Count | CommandKind::Is => 3 == args.len(), |
61 | CommandKind::Set => 4 == args.len(), | |
5869c6ff XL |
62 | }; |
63 | ||
64 | if !count { | |
65 | print_err(&format!("Incorrect number of arguments to `@{}`", self), lineno); | |
66 | return false; | |
67 | } | |
68 | ||
69 | if args[0] == "-" && command_num == 0 { | |
70 | print_err(&format!("Tried to use the previous path in the first command"), lineno); | |
71 | return false; | |
72 | } | |
73 | ||
74 | if let CommandKind::Count = self { | |
75 | if args[2].parse::<usize>().is_err() { | |
5e7ed085 FG |
76 | print_err( |
77 | &format!("Third argument to @count must be a valid usize (got `{}`)", args[2]), | |
78 | lineno, | |
79 | ); | |
5869c6ff XL |
80 | return false; |
81 | } | |
82 | } | |
83 | ||
84 | true | |
85 | } | |
86 | } | |
87 | ||
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", | |
6a06907d XL |
93 | CommandKind::Is => "is", |
94 | CommandKind::Set => "set", | |
5869c6ff XL |
95 | }; |
96 | write!(f, "{}", text) | |
97 | } | |
98 | } | |
99 | ||
17df50a5 XL |
100 | static LINE_PATTERN: Lazy<Regex> = Lazy::new(|| { |
101 | RegexBuilder::new( | |
5869c6ff XL |
102 | r#" |
103 | \s(?P<invalid>!?)@(?P<negated>!?) | |
104 | (?P<cmd>[A-Za-z]+(?:-[A-Za-z]+)*) | |
105 | (?P<args>.*)$ | |
17df50a5 | 106 | "#, |
5869c6ff XL |
107 | ) |
108 | .ignore_whitespace(true) | |
109 | .unicode(true) | |
110 | .build() | |
17df50a5 XL |
111 | .unwrap() |
112 | }); | |
5869c6ff XL |
113 | |
114 | fn print_err(msg: &str, lineno: usize) { | |
115 | eprintln!("Invalid command: {} on line {}", msg, lineno) | |
116 | } | |
117 | ||
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(); | |
124 | ||
125 | for (lineno, line) in file.split('\n').enumerate() { | |
126 | let lineno = lineno + 1; | |
127 | ||
128 | let cap = match LINE_PATTERN.captures(line) { | |
129 | Some(c) => c, | |
130 | None => continue, | |
131 | }; | |
132 | ||
133 | let negated = cap.name("negated").unwrap().as_str() == "!"; | |
134 | let cmd = cap.name("cmd").unwrap().as_str(); | |
135 | ||
136 | let cmd = match cmd { | |
137 | "has" => CommandKind::Has, | |
138 | "count" => CommandKind::Count, | |
6a06907d XL |
139 | "is" => CommandKind::Is, |
140 | "set" => CommandKind::Set, | |
5869c6ff XL |
141 | _ => { |
142 | print_err(&format!("Unrecognized command name `@{}`", cmd), lineno); | |
143 | errors = true; | |
144 | continue; | |
145 | } | |
146 | }; | |
147 | ||
148 | if let Some(m) = cap.name("invalid") { | |
149 | if m.as_str() == "!" { | |
150 | print_err( | |
151 | &format!( | |
152 | "`!@{0}{1}`, (help: try with `@!{1}`)", | |
153 | if negated { "!" } else { "" }, | |
154 | cmd, | |
155 | ), | |
156 | lineno, | |
157 | ); | |
158 | errors = true; | |
159 | continue; | |
160 | } | |
161 | } | |
162 | ||
163 | let args = cap.name("args").map_or(Some(vec![]), |m| shlex::split(m.as_str())); | |
164 | ||
165 | let args = match args { | |
166 | Some(args) => args, | |
167 | None => { | |
168 | print_err( | |
169 | &format!( | |
170 | "Invalid arguments to shlex::split: `{}`", | |
171 | cap.name("args").unwrap().as_str() | |
172 | ), | |
173 | lineno, | |
174 | ); | |
175 | errors = true; | |
176 | continue; | |
177 | } | |
178 | }; | |
179 | ||
180 | if !cmd.validate(&args, commands.len(), lineno) { | |
181 | errors = true; | |
182 | continue; | |
183 | } | |
184 | ||
185 | commands.push(Command { negated, kind: cmd, args, lineno }) | |
186 | } | |
187 | ||
188 | if !errors { Ok(commands) } else { Err(()) } | |
189 | } | |
190 | ||
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> { | |
6a06907d | 194 | // FIXME: Be more granular about why, (e.g. syntax error, count not equal) |
5869c6ff XL |
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 | |
201 | 2 => { | |
202 | let val = cache.get_value(&command.args[0])?; | |
6a06907d XL |
203 | let results = select(&val, &command.args[1]).unwrap(); |
204 | !results.is_empty() | |
5869c6ff XL |
205 | } |
206 | // @has <path> <jsonpath> <value> = check *any* item matched by path equals value | |
207 | 3 => { | |
208 | let val = cache.get_value(&command.args[0])?; | |
6a06907d XL |
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( | |
215 | format!( | |
216 | "{} matched to {:?} but didn't have {:?}", | |
217 | &command.args[1], | |
218 | results, | |
219 | pat.as_ref() | |
220 | ), | |
221 | command, | |
222 | )); | |
223 | } else { | |
224 | has | |
5869c6ff XL |
225 | } |
226 | } | |
227 | _ => unreachable!(), | |
228 | } | |
229 | } | |
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(); | |
234 | ||
235 | let val = cache.get_value(&command.args[0])?; | |
6a06907d | 236 | let results = select(&val, &command.args[1]).unwrap(); |
5099ac24 FG |
237 | let eq = results.len() == expected; |
238 | if !command.negated && !eq { | |
239 | return Err(CkError::FailedCheck( | |
240 | format!( | |
241 | "`{}` matched to `{:?}` with length {}, but expected length {}", | |
242 | &command.args[1], | |
243 | results, | |
244 | results.len(), | |
245 | expected | |
246 | ), | |
247 | command, | |
248 | )); | |
249 | } else { | |
250 | eq | |
251 | } | |
6a06907d XL |
252 | } |
253 | CommandKind::Is => { | |
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); | |
cdc7bbd5 XL |
259 | let is = results.len() == 1 && results[0] == pat.as_ref(); |
260 | if !command.negated && !is { | |
261 | return Err(CkError::FailedCheck( | |
262 | format!( | |
263 | "{} matched to {:?}, but expected {:?}", | |
264 | &command.args[1], | |
265 | results, | |
266 | pat.as_ref() | |
267 | ), | |
268 | command, | |
269 | )); | |
270 | } else { | |
271 | is | |
272 | } | |
6a06907d XL |
273 | } |
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(); | |
280 | assert_eq!( | |
281 | results.len(), | |
282 | 1, | |
5e7ed085 | 283 | "Expected 1 match for `{}` (because of @set): matched to {:?}", |
6a06907d XL |
284 | command.args[3], |
285 | results | |
286 | ); | |
287 | match results.len() { | |
288 | 0 => false, | |
289 | 1 => { | |
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]); | |
292 | true | |
293 | } | |
294 | _ => { | |
295 | panic!( | |
296 | "Got multiple results in `@set` for `{}`: {:?}", | |
297 | &command.args[3], results | |
298 | ); | |
299 | } | |
5869c6ff XL |
300 | } |
301 | } | |
302 | }; | |
303 | ||
304 | if result == command.negated { | |
305 | if command.negated { | |
306 | Err(CkError::FailedCheck( | |
307 | format!( | |
308 | "`@!{} {}` matched when it shouldn't", | |
309 | command.kind, | |
310 | command.args.join(" ") | |
311 | ), | |
312 | command, | |
313 | )) | |
314 | } else { | |
315 | // FIXME: In the future, try 'peeling back' each step, and see at what level the match failed | |
316 | Err(CkError::FailedCheck( | |
317 | format!( | |
318 | "`@{} {}` didn't match when it should", | |
319 | command.kind, | |
320 | command.args.join(" ") | |
321 | ), | |
322 | command, | |
323 | )) | |
324 | } | |
325 | } else { | |
326 | Ok(()) | |
327 | } | |
328 | } | |
6a06907d XL |
329 | |
330 | fn string_to_value<'a>(s: &str, cache: &'a Cache) -> Cow<'a, Value> { | |
331 | if s.starts_with("$") { | |
cdc7bbd5 XL |
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) | |
335 | })) | |
6a06907d | 336 | } else { |
5099ac24 | 337 | Cow::Owned(serde_json::from_str(s).expect(&format!("Cannot convert `{}` to json", s))) |
6a06907d XL |
338 | } |
339 | } |