1use std::fmt;
40
41#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum Condition {
47 Check(String),
49 Not(Box<Condition>),
50 And(Box<Condition>, Box<Condition>),
51 Or(Box<Condition>, Box<Condition>),
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
56#[error("{message} at column {column}")]
57pub struct ExprError {
58 pub message: String,
60 pub column: usize,
62}
63
64impl ExprError {
65 fn new(message: impl Into<String>, column: usize) -> Self {
66 Self {
67 message: message.into(),
68 column,
69 }
70 }
71}
72
73#[must_use]
79pub fn is_reserved_word(name: &str) -> bool {
80 matches!(
81 name.to_ascii_lowercase().as_str(),
82 KEYWORD_AND | KEYWORD_OR | KEYWORD_NOT
83 )
84}
85
86const KEYWORD_AND: &str = "and";
87const KEYWORD_OR: &str = "or";
88const KEYWORD_NOT: &str = "not";
89
90#[derive(Debug, Clone, PartialEq, Eq)]
91enum TokenKind {
92 Name(String),
93 And,
94 Or,
95 Not,
96 Open,
97 Close,
98}
99
100impl TokenKind {
101 fn describe(&self) -> String {
103 match self {
104 Self::Name(name) => format!("the check name `{name}`"),
105 Self::And => "the keyword `and`".to_string(),
106 Self::Or => "the keyword `or`".to_string(),
107 Self::Not => "the keyword `not`".to_string(),
108 Self::Open => "`(`".to_string(),
109 Self::Close => "`)`".to_string(),
110 }
111 }
112}
113
114#[derive(Debug, Clone)]
115struct Token {
116 kind: TokenKind,
117 column: usize,
118}
119
120fn tokenize(source: &str) -> Result<Vec<Token>, ExprError> {
128 let characters: Vec<char> = source.chars().collect();
129 let mut tokens = Vec::new();
130 let mut index = 0;
131
132 while index < characters.len() {
133 let character = characters[index];
134 let column = index + 1;
135
136 if character.is_whitespace() {
137 index += 1;
138 continue;
139 }
140
141 match character {
142 '(' => {
143 tokens.push(Token {
144 kind: TokenKind::Open,
145 column,
146 });
147 index += 1;
148 }
149 ')' => {
150 tokens.push(Token {
151 kind: TokenKind::Close,
152 column,
153 });
154 index += 1;
155 }
156 c if is_word_character(c) => {
157 let start = index;
158 while index < characters.len() && is_word_character(characters[index]) {
159 index += 1;
160 }
161 let word: String = characters[start..index].iter().collect();
162 let kind = match word.to_ascii_lowercase().as_str() {
163 KEYWORD_AND => TokenKind::And,
164 KEYWORD_OR => TokenKind::Or,
165 KEYWORD_NOT => TokenKind::Not,
166 _ => TokenKind::Name(word),
167 };
168 tokens.push(Token { kind, column });
169 }
170 other => {
171 return Err(ExprError::new(
172 format!("unexpected character {other:?}"),
173 column,
174 ));
175 }
176 }
177 }
178
179 Ok(tokens)
180}
181
182fn is_word_character(character: char) -> bool {
183 character.is_ascii_alphanumeric() || character == '-' || character == '_'
184}
185
186struct Parser {
187 tokens: Vec<Token>,
188 position: usize,
189 end_column: usize,
191}
192
193impl Parser {
194 fn peek(&self) -> Option<&Token> {
195 self.tokens.get(self.position)
196 }
197
198 fn next_column(&self) -> usize {
199 self.peek().map_or(self.end_column, |token| token.column)
200 }
201
202 fn parse_expr(&mut self) -> Result<Condition, ExprError> {
204 let mut left = self.parse_term()?;
205 while matches!(self.peek().map(|token| &token.kind), Some(TokenKind::Or)) {
206 self.position += 1;
207 let right = self.parse_term()?;
208 left = Condition::Or(Box::new(left), Box::new(right));
209 }
210 Ok(left)
211 }
212
213 fn parse_term(&mut self) -> Result<Condition, ExprError> {
215 let mut left = self.parse_factor()?;
216 while matches!(self.peek().map(|token| &token.kind), Some(TokenKind::And)) {
217 self.position += 1;
218 let right = self.parse_factor()?;
219 left = Condition::And(Box::new(left), Box::new(right));
220 }
221 Ok(left)
222 }
223
224 fn parse_factor(&mut self) -> Result<Condition, ExprError> {
226 let Some(token) = self.peek().cloned() else {
227 return Err(ExprError::new(
228 "expected a check name, `not` or `(`",
229 self.end_column,
230 ));
231 };
232
233 match token.kind {
234 TokenKind::Not => {
235 self.position += 1;
236 Ok(Condition::Not(Box::new(self.parse_factor()?)))
237 }
238 TokenKind::Open => {
239 let opened_at = token.column;
240 self.position += 1;
241 let inner = self.parse_expr()?;
242 match self.peek().map(|token| &token.kind) {
243 Some(TokenKind::Close) => {
244 self.position += 1;
245 Ok(inner)
246 }
247 _ => Err(ExprError::new("unbalanced `(` opened", opened_at)),
248 }
249 }
250 TokenKind::Name(name) => {
251 self.position += 1;
252 Ok(Condition::Check(name))
253 }
254 other => Err(ExprError::new(
255 format!(
256 "expected a check name, `not` or `(`, found {}",
257 other.describe()
258 ),
259 token.column,
260 )),
261 }
262 }
263}
264
265impl Condition {
266 pub fn parse(source: &str) -> Result<Self, ExprError> {
273 let tokens = tokenize(source)?;
274 let mut parser = Parser {
275 tokens,
276 position: 0,
277 end_column: source.chars().count() + 1,
278 };
279
280 let condition = parser.parse_expr()?;
281
282 if parser.position < parser.tokens.len() {
283 return Err(ExprError::new(
284 format!(
285 "unexpected trailing input starting at {}",
286 parser.tokens[parser.position].kind.describe()
287 ),
288 parser.next_column(),
289 ));
290 }
291
292 Ok(condition)
293 }
294
295 pub fn check_names(&self) -> Vec<&str> {
298 let mut names = Vec::new();
299 self.collect_names(&mut names);
300 names
301 }
302
303 fn collect_names<'a>(&'a self, into: &mut Vec<&'a str>) {
304 match self {
305 Self::Check(name) => into.push(name),
306 Self::Not(inner) => inner.collect_names(into),
307 Self::And(left, right) | Self::Or(left, right) => {
308 left.collect_names(into);
309 right.collect_names(into);
310 }
311 }
312 }
313
314 fn render_operand(&self) -> String {
316 match self {
317 Self::Check(name) => name.clone(),
318 other => format!("({other})"),
319 }
320 }
321}
322
323impl fmt::Display for Condition {
324 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
331 match self {
332 Self::Check(name) => formatter.write_str(name),
333 Self::Not(inner) => write!(formatter, "not {}", inner.render_operand()),
334 Self::And(left, right) => write!(
335 formatter,
336 "{} and {}",
337 left.render_operand(),
338 right.render_operand()
339 ),
340 Self::Or(left, right) => write!(
341 formatter,
342 "{} or {}",
343 left.render_operand(),
344 right.render_operand()
345 ),
346 }
347 }
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353
354 fn printed(source: &str) -> String {
358 Condition::parse(source)
359 .unwrap_or_else(|error| panic!("{source:?} should parse: {error}"))
360 .to_string()
361 }
362
363 fn error(source: &str) -> ExprError {
364 Condition::parse(source).expect_err(&format!("{source:?} should not parse"))
365 }
366
367 #[test]
368 fn a_bare_name_is_a_condition() {
369 assert_eq!(printed("mgmt-net"), "mgmt-net");
370 assert_eq!(
371 Condition::parse("mgmt-net").unwrap(),
372 Condition::Check("mgmt-net".to_string())
373 );
374 }
375
376 #[test]
377 fn and_binds_tighter_than_or() {
378 assert_eq!(printed("a or b and c"), "a or (b and c)");
379 assert_eq!(printed("a and b or c"), "(a and b) or c");
380 }
381
382 #[test]
383 fn not_binds_tightest() {
384 assert_eq!(printed("not a and b"), "(not a) and b");
385 assert_eq!(printed("not a or b"), "(not a) or b");
386 assert_eq!(printed("not not a"), "not (not a)");
387 }
388
389 #[test]
390 fn both_operators_are_left_associative() {
391 assert_eq!(printed("a and b and c"), "(a and b) and c");
392 assert_eq!(printed("a or b or c"), "(a or b) or c");
393 }
394
395 #[test]
396 fn parentheses_override_precedence() {
397 assert_eq!(printed("(a or b) and c"), "(a or b) and c");
398 assert_eq!(printed("not (a and b)"), "not (a and b)");
399 assert_eq!(printed("((a))"), "a");
400 }
401
402 #[test]
403 fn keywords_are_case_insensitive() {
404 assert_eq!(printed("a AND b"), "a and b");
405 assert_eq!(printed("a Or NOT b"), "a or (not b)");
406 }
407
408 #[test]
409 fn whitespace_is_irrelevant() {
410 assert_eq!(printed(" a and\tb "), "a and b");
411 assert_eq!(printed("not(a)or(b)"), "(not a) or b");
412 }
413
414 #[test]
415 fn names_may_carry_digits_and_hyphens() {
416 assert_eq!(printed("tenant-a1 and net-10"), "tenant-a1 and net-10");
417 }
418
419 #[test]
420 fn check_names_are_collected_in_evaluation_order_with_duplicates() {
421 let condition = Condition::parse("a or (b and not a)").unwrap();
422 assert_eq!(condition.check_names(), vec!["a", "b", "a"]);
423 }
424
425 #[test]
426 fn the_three_keywords_are_reserved_in_any_case() {
427 for word in ["and", "or", "not", "AND", "Or", "NOT"] {
428 assert!(is_reserved_word(word), "{word} should be reserved");
429 }
430 assert!(!is_reserved_word("android"));
431 assert!(!is_reserved_word("nothing"));
432 assert!(!is_reserved_word("mgmt-net"));
433 }
434
435 #[test]
439 fn parse_failures_report_a_position() {
440 let cases: &[(&str, &str, usize)] = &[
441 ("", "expected a check name", 1),
442 (" ", "expected a check name", 4),
443 ("a and", "expected a check name", 6),
444 ("a and )", "expected a check name", 7),
445 ("and b", "expected a check name", 1),
446 ("or b", "expected a check name", 1),
447 ("(a and b", "unbalanced `(` opened", 1),
448 ("a and (b or c", "unbalanced `(` opened", 7),
449 ("a b", "unexpected trailing input", 3),
450 ("a) or b", "unexpected trailing input", 2),
451 ("a and b#c", "unexpected character '#'", 8),
452 ];
453
454 for (source, expected, column) in cases {
455 let error = error(source);
456 assert!(
457 error.message.starts_with(expected),
458 "{source:?}: expected a message starting {expected:?}, got {:?}",
459 error.message
460 );
461 assert_eq!(error.column, *column, "{source:?}: wrong column");
462 }
463 }
464
465 #[test]
466 fn an_error_renders_its_column() {
467 assert_eq!(
468 error("a and").to_string(),
469 "expected a check name, `not` or `(` at column 6"
470 );
471 }
472
473 #[test]
474 fn a_keyword_in_a_name_position_is_named_in_the_error() {
475 assert!(error("a and or b").message.contains("the keyword `or`"));
476 }
477
478 #[test]
479 fn a_trailing_name_is_named_in_the_error() {
480 assert!(error("a b").message.contains("the check name `b`"));
481 }
482
483 #[test]
487 fn an_unconventional_name_parses_and_is_left_for_the_builder() {
488 assert_eq!(printed("Mgmt_Net"), "Mgmt_Net");
489 }
490
491 #[test]
492 fn re_parsing_a_rendered_condition_is_a_fixed_point() {
493 for source in [
494 "a",
495 "a or b and c",
496 "not a and (b or not c)",
497 "((a or b) and c) or not d",
498 ] {
499 let once = printed(source);
500 assert_eq!(printed(&once), once, "{source:?} did not round-trip");
501 }
502 }
503}