1use std::collections::HashMap;
36use std::path::Path;
37
38use regex::Regex;
39use thiserror::Error;
40
41use crate::facts::{FactValue, FactValues};
42use crate::scope::Scope;
43use crate::walker::FileIndex;
44
45#[derive(Debug, Error)]
48pub enum WhenError {
49 #[error("when parse error at column {pos}: {message}")]
50 Parse { pos: usize, message: String },
51 #[error("when evaluation error: {0}")]
52 Eval(String),
53 #[error("invalid regex in `matches`: {0}")]
54 Regex(String),
55}
56
57#[derive(Debug, Clone)]
60pub enum Value {
61 Bool(bool),
62 Int(i64),
63 String(String),
64 List(Vec<Value>),
65 Null,
66}
67
68impl Value {
69 pub fn truthy(&self) -> bool {
70 match self {
71 Self::Bool(b) => *b,
72 Self::Int(n) => *n != 0,
73 Self::String(s) => !s.is_empty(),
74 Self::List(v) => !v.is_empty(),
75 Self::Null => false,
76 }
77 }
78
79 fn type_name(&self) -> &'static str {
80 match self {
81 Self::Bool(_) => "bool",
82 Self::Int(_) => "int",
83 Self::String(_) => "string",
84 Self::List(_) => "list",
85 Self::Null => "null",
86 }
87 }
88}
89
90impl From<&FactValue> for Value {
91 fn from(f: &FactValue) -> Self {
92 match f {
93 FactValue::Bool(b) => Self::Bool(*b),
94 FactValue::Int(n) => Self::Int(*n),
95 FactValue::String(s) => Self::String(s.clone()),
96 }
97 }
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum Namespace {
104 Facts,
105 Vars,
106 Iter,
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub enum CmpOp {
116 Eq,
117 Ne,
118 Lt,
119 Le,
120 Gt,
121 Ge,
122 In,
123}
124
125#[derive(Debug, Clone)]
126pub enum WhenExpr {
127 Literal(Value),
128 Ident {
129 ns: Namespace,
130 name: String,
131 },
132 Call {
138 ns: Namespace,
139 method: String,
140 args: Vec<WhenExpr>,
141 },
142 Not(Box<WhenExpr>),
143 And(Box<WhenExpr>, Box<WhenExpr>),
144 Or(Box<WhenExpr>, Box<WhenExpr>),
145 Cmp {
146 left: Box<WhenExpr>,
147 op: CmpOp,
148 right: Box<WhenExpr>,
149 },
150 Matches {
152 left: Box<WhenExpr>,
153 pattern: Regex,
154 },
155 List(Vec<WhenExpr>),
156}
157
158#[derive(Debug)]
161pub struct WhenEnv<'a> {
162 pub facts: &'a FactValues,
163 pub vars: &'a HashMap<String, String>,
164 pub iter: Option<IterEnv<'a>>,
171}
172
173impl<'a> WhenEnv<'a> {
174 #[must_use]
178 pub fn new(facts: &'a FactValues, vars: &'a HashMap<String, String>) -> Self {
179 Self {
180 facts,
181 vars,
182 iter: None,
183 }
184 }
185
186 #[must_use]
190 pub fn with_iter(mut self, iter: IterEnv<'a>) -> Self {
191 self.iter = Some(iter);
192 self
193 }
194}
195
196#[derive(Debug, Clone, Copy)]
201pub struct IterEnv<'a> {
202 pub path: &'a Path,
204 pub is_dir: bool,
208 pub index: &'a FileIndex,
211}
212
213pub fn parse(src: &str) -> Result<WhenExpr, WhenError> {
216 let tokens = lex(src)?;
217 let mut p = Parser { tokens, pos: 0 };
218 let expr = p.parse_expr()?;
219 p.expect_eof()?;
220 Ok(expr)
221}
222
223impl WhenExpr {
224 pub fn evaluate(&self, env: &WhenEnv<'_>) -> Result<bool, WhenError> {
225 let v = eval(self, env)?;
226 Ok(v.truthy())
227 }
228}
229
230#[derive(Debug, Clone)]
233enum Tok {
234 Bool(bool),
235 Null,
236 Int(i64),
237 Str(String),
238 Ident(String),
239 Dot,
240 LParen,
241 RParen,
242 LBracket,
243 RBracket,
244 Comma,
245 Eq2,
246 Ne,
247 Lt,
248 Le,
249 Gt,
250 Ge,
251 KwAnd,
252 KwOr,
253 KwNot,
254 KwIn,
255 KwMatches,
256}
257
258#[allow(clippy::too_many_lines)]
259fn lex(src: &str) -> Result<Vec<(Tok, usize)>, WhenError> {
260 let bytes = src.as_bytes();
261 let mut out = Vec::new();
262 let mut i = 0;
263 while i < bytes.len() {
264 let c = bytes[i];
265 if c == b' ' || c == b'\t' || c == b'\n' || c == b'\r' {
267 i += 1;
268 continue;
269 }
270 let start = i;
271 match c {
272 b'.' => {
273 out.push((Tok::Dot, start));
274 i += 1;
275 }
276 b'(' => {
277 out.push((Tok::LParen, start));
278 i += 1;
279 }
280 b')' => {
281 out.push((Tok::RParen, start));
282 i += 1;
283 }
284 b'[' => {
285 out.push((Tok::LBracket, start));
286 i += 1;
287 }
288 b']' => {
289 out.push((Tok::RBracket, start));
290 i += 1;
291 }
292 b',' => {
293 out.push((Tok::Comma, start));
294 i += 1;
295 }
296 b'=' => {
297 if bytes.get(i + 1) == Some(&b'=') {
298 out.push((Tok::Eq2, start));
299 i += 2;
300 } else {
301 return Err(WhenError::Parse {
302 pos: start,
303 message: "expected '==' (bare '=' is not an operator)".into(),
304 });
305 }
306 }
307 b'!' => {
308 if bytes.get(i + 1) == Some(&b'=') {
309 out.push((Tok::Ne, start));
310 i += 2;
311 } else {
312 return Err(WhenError::Parse {
313 pos: start,
314 message: "expected '!=' (use 'not' for logical negation)".into(),
315 });
316 }
317 }
318 b'<' => {
319 if bytes.get(i + 1) == Some(&b'=') {
320 out.push((Tok::Le, start));
321 i += 2;
322 } else {
323 out.push((Tok::Lt, start));
324 i += 1;
325 }
326 }
327 b'>' => {
328 if bytes.get(i + 1) == Some(&b'=') {
329 out.push((Tok::Ge, start));
330 i += 2;
331 } else {
332 out.push((Tok::Gt, start));
333 i += 1;
334 }
335 }
336 b'"' | b'\'' => {
337 let quote = c;
338 i += 1;
339 let mut s = String::new();
340 while i < bytes.len() && bytes[i] != quote {
341 if bytes[i] == b'\\' && i + 1 < bytes.len() {
342 let esc = bytes[i + 1];
343 let ch = match esc {
344 b'n' => '\n',
345 b't' => '\t',
346 b'r' => '\r',
347 b'\\' => '\\',
348 b'"' => '"',
349 b'\'' => '\'',
350 _ => {
351 return Err(WhenError::Parse {
352 pos: i,
353 message: format!(
354 "unknown escape \\{} in string literal",
355 esc as char,
356 ),
357 });
358 }
359 };
360 s.push(ch);
361 i += 2;
362 } else {
363 s.push(bytes[i] as char);
364 i += 1;
365 }
366 }
367 if i >= bytes.len() {
368 return Err(WhenError::Parse {
369 pos: start,
370 message: "unterminated string literal".into(),
371 });
372 }
373 i += 1;
374 out.push((Tok::Str(s), start));
375 }
376 c if c.is_ascii_digit() => {
377 let mut j = i;
378 while j < bytes.len() && bytes[j].is_ascii_digit() {
379 j += 1;
380 }
381 let num = std::str::from_utf8(&bytes[i..j])
382 .unwrap()
383 .parse::<i64>()
384 .map_err(|e| WhenError::Parse {
385 pos: start,
386 message: format!("invalid integer: {e}"),
387 })?;
388 out.push((Tok::Int(num), start));
389 i = j;
390 }
391 c if is_ident_start(c) => {
392 let mut j = i;
393 while j < bytes.len() && is_ident_cont(bytes[j]) {
394 j += 1;
395 }
396 let word = &src[i..j];
397 let tok = match word {
398 "true" => Tok::Bool(true),
399 "false" => Tok::Bool(false),
400 "null" => Tok::Null,
401 "and" => Tok::KwAnd,
402 "or" => Tok::KwOr,
403 "not" => Tok::KwNot,
404 "in" => Tok::KwIn,
405 "matches" => Tok::KwMatches,
406 _ => Tok::Ident(word.to_string()),
407 };
408 out.push((tok, start));
409 i = j;
410 }
411 _ => {
412 return Err(WhenError::Parse {
413 pos: start,
414 message: format!("unexpected character {:?}", c as char),
415 });
416 }
417 }
418 }
419 Ok(out)
420}
421
422fn is_ident_start(c: u8) -> bool {
423 c.is_ascii_alphabetic() || c == b'_'
424}
425
426fn is_ident_cont(c: u8) -> bool {
427 c.is_ascii_alphanumeric() || c == b'_'
428}
429
430fn is_known_iter_method(name: &str) -> bool {
434 matches!(name, "has_file")
435}
436
437struct Parser {
440 tokens: Vec<(Tok, usize)>,
441 pos: usize,
442}
443
444impl Parser {
445 fn peek(&self) -> Option<&Tok> {
446 self.tokens.get(self.pos).map(|(t, _)| t)
447 }
448
449 fn advance(&mut self) -> Option<&(Tok, usize)> {
450 let p = self.pos;
451 self.pos += 1;
452 self.tokens.get(p)
453 }
454
455 fn pos_here(&self) -> usize {
456 self.tokens.get(self.pos).map_or_else(
457 || self.tokens.last().map_or(0, |(_, p)| *p + 1),
458 |(_, p)| *p,
459 )
460 }
461
462 fn err(&self, message: impl Into<String>) -> WhenError {
463 WhenError::Parse {
464 pos: self.pos_here(),
465 message: message.into(),
466 }
467 }
468
469 fn expect_eof(&mut self) -> Result<(), WhenError> {
470 if self.peek().is_some() {
471 Err(self.err("unexpected trailing token"))
472 } else {
473 Ok(())
474 }
475 }
476
477 fn parse_expr(&mut self) -> Result<WhenExpr, WhenError> {
478 self.parse_or()
479 }
480
481 fn parse_or(&mut self) -> Result<WhenExpr, WhenError> {
482 let mut left = self.parse_and()?;
483 while matches!(self.peek(), Some(Tok::KwOr)) {
484 self.advance();
485 let right = self.parse_and()?;
486 left = WhenExpr::Or(Box::new(left), Box::new(right));
487 }
488 Ok(left)
489 }
490
491 fn parse_and(&mut self) -> Result<WhenExpr, WhenError> {
492 let mut left = self.parse_not()?;
493 while matches!(self.peek(), Some(Tok::KwAnd)) {
494 self.advance();
495 let right = self.parse_not()?;
496 left = WhenExpr::And(Box::new(left), Box::new(right));
497 }
498 Ok(left)
499 }
500
501 fn parse_not(&mut self) -> Result<WhenExpr, WhenError> {
502 if matches!(self.peek(), Some(Tok::KwNot)) {
503 self.advance();
504 let inner = self.parse_cmp()?;
505 return Ok(WhenExpr::Not(Box::new(inner)));
506 }
507 self.parse_cmp()
508 }
509
510 fn parse_cmp(&mut self) -> Result<WhenExpr, WhenError> {
511 let left = self.parse_primary()?;
512 let op = match self.peek() {
513 Some(Tok::Eq2) => Some(CmpOp::Eq),
514 Some(Tok::Ne) => Some(CmpOp::Ne),
515 Some(Tok::Lt) => Some(CmpOp::Lt),
516 Some(Tok::Le) => Some(CmpOp::Le),
517 Some(Tok::Gt) => Some(CmpOp::Gt),
518 Some(Tok::Ge) => Some(CmpOp::Ge),
519 Some(Tok::KwIn) => Some(CmpOp::In),
520 _ => None,
521 };
522 if let Some(op) = op {
523 self.advance();
524 let right = self.parse_primary()?;
525 return Ok(WhenExpr::Cmp {
526 left: Box::new(left),
527 op,
528 right: Box::new(right),
529 });
530 }
531 if matches!(self.peek(), Some(Tok::KwMatches)) {
532 self.advance();
533 let pos = self.pos_here();
534 match self.advance() {
535 Some((Tok::Str(s), _)) => {
536 let pattern = Regex::new(s)
537 .map_err(|e| WhenError::Regex(format!("{e} (at column {pos})")))?;
538 return Ok(WhenExpr::Matches {
539 left: Box::new(left),
540 pattern,
541 });
542 }
543 _ => {
544 return Err(WhenError::Parse {
545 pos,
546 message: "`matches` right-hand side must be a string literal".into(),
547 });
548 }
549 }
550 }
551 Ok(left)
552 }
553
554 #[allow(clippy::too_many_lines)] fn parse_primary(&mut self) -> Result<WhenExpr, WhenError> {
556 let pos = self.pos_here();
557 match self.advance() {
558 Some((Tok::Bool(b), _)) => Ok(WhenExpr::Literal(Value::Bool(*b))),
559 Some((Tok::Null, _)) => Ok(WhenExpr::Literal(Value::Null)),
560 Some((Tok::Int(n), _)) => Ok(WhenExpr::Literal(Value::Int(*n))),
561 Some((Tok::Str(s), _)) => Ok(WhenExpr::Literal(Value::String(s.clone()))),
562 Some((Tok::LParen, _)) => {
563 let inner = self.parse_expr()?;
564 match self.advance() {
565 Some((Tok::RParen, _)) => Ok(inner),
566 _ => Err(WhenError::Parse {
567 pos,
568 message: "expected ')'".into(),
569 }),
570 }
571 }
572 Some((Tok::LBracket, _)) => {
573 let mut items = Vec::new();
574 if !matches!(self.peek(), Some(Tok::RBracket)) {
575 items.push(self.parse_expr()?);
576 while matches!(self.peek(), Some(Tok::Comma)) {
577 self.advance();
578 items.push(self.parse_expr()?);
579 }
580 }
581 match self.advance() {
582 Some((Tok::RBracket, _)) => Ok(WhenExpr::List(items)),
583 _ => Err(WhenError::Parse {
584 pos,
585 message: "expected ']'".into(),
586 }),
587 }
588 }
589 Some((Tok::Ident(name), _)) => {
590 let name_owned = name.clone();
591 let ns = match name_owned.as_str() {
592 "facts" => Namespace::Facts,
593 "vars" => Namespace::Vars,
594 "iter" => Namespace::Iter,
595 other => {
596 return Err(WhenError::Parse {
597 pos,
598 message: format!(
599 "unknown identifier {other:?}; only `facts.NAME`, \
600 `vars.NAME`, and `iter.NAME` are allowed"
601 ),
602 });
603 }
604 };
605 if !matches!(self.advance(), Some((Tok::Dot, _))) {
606 return Err(WhenError::Parse {
607 pos,
608 message: format!("expected '.' after {name_owned:?}"),
609 });
610 }
611 let field_pos = self.pos_here();
612 let field = match self.advance() {
613 Some((Tok::Ident(f), _)) => f.clone(),
614 _ => {
615 return Err(WhenError::Parse {
616 pos: field_pos,
617 message: "expected identifier after '.'".into(),
618 });
619 }
620 };
621 if matches!(self.peek(), Some(Tok::LParen)) {
623 self.advance(); if ns != Namespace::Iter {
625 return Err(WhenError::Parse {
626 pos: field_pos,
627 message: format!(
628 "function-call syntax is only available on `iter` \
629 (got `{name_owned}.{field}(...)`)"
630 ),
631 });
632 }
633 if !is_known_iter_method(&field) {
634 return Err(WhenError::Parse {
635 pos: field_pos,
636 message: format!(
637 "unknown iter method {field:?}; the only callable \
638 method on `iter` is `has_file`"
639 ),
640 });
641 }
642 let mut args = Vec::new();
643 if !matches!(self.peek(), Some(Tok::RParen)) {
644 args.push(self.parse_expr()?);
645 while matches!(self.peek(), Some(Tok::Comma)) {
646 self.advance();
647 args.push(self.parse_expr()?);
648 }
649 }
650 match self.advance() {
651 Some((Tok::RParen, _)) => {}
652 _ => {
653 return Err(WhenError::Parse {
654 pos: field_pos,
655 message: "expected ')'".into(),
656 });
657 }
658 }
659 return Ok(WhenExpr::Call {
660 ns,
661 method: field,
662 args,
663 });
664 }
665 Ok(WhenExpr::Ident { ns, name: field })
666 }
667 _ => Err(WhenError::Parse {
668 pos,
669 message: "expected literal, identifier, '(' or '['".into(),
670 }),
671 }
672 }
673}
674
675fn eval(e: &WhenExpr, env: &WhenEnv<'_>) -> Result<Value, WhenError> {
678 match e {
679 WhenExpr::Literal(v) => Ok(v.clone()),
680 WhenExpr::Ident { ns, name } => match ns {
681 Namespace::Facts => match env.facts.get(name) {
682 Some(f) => Ok(Value::from(f)),
683 None => Ok(Value::Null),
684 },
685 Namespace::Vars => match env.vars.get(name) {
686 Some(v) => Ok(Value::String(v.clone())),
687 None => Ok(Value::Null),
688 },
689 Namespace::Iter => Ok(eval_iter_value(name, env.iter.as_ref())),
690 },
691 WhenExpr::Call { ns, method, args } => match ns {
692 Namespace::Iter => eval_iter_call(method, args, env),
693 _ => Err(WhenError::Eval(format!(
696 "function-call evaluation not supported on namespace {ns:?}"
697 ))),
698 },
699 WhenExpr::Not(inner) => Ok(Value::Bool(!eval(inner, env)?.truthy())),
700 WhenExpr::And(l, r) => {
701 let lv = eval(l, env)?;
702 if !lv.truthy() {
703 return Ok(Value::Bool(false));
704 }
705 Ok(Value::Bool(eval(r, env)?.truthy()))
706 }
707 WhenExpr::Or(l, r) => {
708 let lv = eval(l, env)?;
709 if lv.truthy() {
710 return Ok(Value::Bool(true));
711 }
712 Ok(Value::Bool(eval(r, env)?.truthy()))
713 }
714 WhenExpr::Cmp { left, op, right } => {
715 let lv = eval(left, env)?;
716 let rv = eval(right, env)?;
717 Ok(Value::Bool(apply_cmp(&lv, *op, &rv)?))
718 }
719 WhenExpr::Matches { left, pattern } => {
720 let lv = eval(left, env)?;
721 match lv {
722 Value::String(s) => Ok(Value::Bool(pattern.is_match(&s))),
723 other => Err(WhenError::Eval(format!(
724 "`matches` left-hand side must be a string; got {}",
725 other.type_name()
726 ))),
727 }
728 }
729 WhenExpr::List(items) => {
730 let mut out = Vec::with_capacity(items.len());
731 for item in items {
732 out.push(eval(item, env)?);
733 }
734 Ok(Value::List(out))
735 }
736 }
737}
738
739fn eval_iter_value(name: &str, iter: Option<&IterEnv<'_>>) -> Value {
744 let Some(iter) = iter else {
745 return Value::Null;
746 };
747 match name {
748 "path" => Value::String(iter.path.to_string_lossy().into_owned()),
749 "basename" => match iter.path.file_name().and_then(|s| s.to_str()) {
750 Some(s) => Value::String(s.to_string()),
751 None => Value::Null,
752 },
753 "parent_name" => iter
754 .path
755 .parent()
756 .and_then(|p| p.file_name())
757 .and_then(|s| s.to_str())
758 .map_or(Value::Null, |s| Value::String(s.to_string())),
759 "stem" => iter
760 .path
761 .file_stem()
762 .and_then(|s| s.to_str())
763 .map_or(Value::Null, |s| Value::String(s.to_string())),
764 "ext" => iter
765 .path
766 .extension()
767 .and_then(|s| s.to_str())
768 .map_or(Value::Null, |s| Value::String(s.to_string())),
769 "is_dir" => Value::Bool(iter.is_dir),
770 _ => Value::Null,
771 }
772}
773
774fn eval_iter_call(method: &str, args: &[WhenExpr], env: &WhenEnv<'_>) -> Result<Value, WhenError> {
780 match method {
781 "has_file" => {
782 if args.len() != 1 {
783 return Err(WhenError::Eval(format!(
784 "iter.has_file expects exactly 1 argument; got {}",
785 args.len()
786 )));
787 }
788 let pattern = match eval(&args[0], env)? {
789 Value::String(s) => s,
790 other => {
791 return Err(WhenError::Eval(format!(
792 "iter.has_file argument must be a string; got {}",
793 other.type_name()
794 )));
795 }
796 };
797 Ok(Value::Bool(iter_has_file(env.iter.as_ref(), &pattern)?))
798 }
799 _ => Err(WhenError::Eval(format!(
800 "unknown iter method {method:?} (parser should have caught this)"
801 ))),
802 }
803}
804
805fn iter_has_file(iter: Option<&IterEnv<'_>>, pattern: &str) -> Result<bool, WhenError> {
821 let Some(iter) = iter else {
822 return Ok(false);
823 };
824 if !iter.is_dir {
825 return Ok(false);
826 }
827 if !pattern
828 .chars()
829 .any(|c| matches!(c, '*' | '?' | '[' | ']' | '{' | '}'))
830 && !pattern.starts_with('!')
831 {
832 let candidate = iter.path.join(pattern);
833 return Ok(iter.index.contains_file(&candidate));
834 }
835 let combined = format!("{}/{}", iter.path.to_string_lossy(), pattern);
836 let scope = Scope::from_patterns(std::slice::from_ref(&combined))
837 .map_err(|e| WhenError::Eval(format!("iter.has_file: invalid glob: {e}")))?;
838 Ok(iter
839 .index
840 .files()
841 .any(|e| scope.matches(&e.path, iter.index)))
842}
843
844fn apply_cmp(l: &Value, op: CmpOp, r: &Value) -> Result<bool, WhenError> {
845 use Value::{Bool, Int, List, Null, String as S};
846 match op {
847 CmpOp::Eq => Ok(values_equal(l, r)),
848 CmpOp::Ne => Ok(!values_equal(l, r)),
849 CmpOp::Lt | CmpOp::Le | CmpOp::Gt | CmpOp::Ge => match (l, r) {
850 (Int(a), Int(b)) => Ok(cmp_ord(a, b, op)),
851 (S(a), S(b)) => Ok(cmp_ord(&a.as_str(), &b.as_str(), op)),
852 _ => Err(WhenError::Eval(format!(
853 "cannot compare {} with {}",
854 l.type_name(),
855 r.type_name(),
856 ))),
857 },
858 CmpOp::In => match r {
859 List(items) => Ok(items.iter().any(|x| values_equal(l, x))),
860 S(haystack) => match l {
861 S(needle) => Ok(haystack.contains(needle.as_str())),
862 _ => Err(WhenError::Eval(format!(
863 "`in` with a string right-hand side requires a string left; got {}",
864 l.type_name()
865 ))),
866 },
867 _ => {
868 let _ = (Bool(false), Null);
869 Err(WhenError::Eval(format!(
870 "`in` right-hand side must be a list or string; got {}",
871 r.type_name()
872 )))
873 }
874 },
875 }
876}
877
878fn values_equal(a: &Value, b: &Value) -> bool {
879 match (a, b) {
880 (Value::Bool(x), Value::Bool(y)) => x == y,
881 (Value::Int(x), Value::Int(y)) => x == y,
882 (Value::String(x), Value::String(y)) => x == y,
883 (Value::Null, Value::Null) => true,
884 (Value::List(x), Value::List(y)) => {
885 x.len() == y.len() && x.iter().zip(y.iter()).all(|(a, b)| values_equal(a, b))
886 }
887 _ => false,
888 }
889}
890
891fn cmp_ord<T: PartialOrd>(a: &T, b: &T, op: CmpOp) -> bool {
892 match op {
893 CmpOp::Lt => a < b,
894 CmpOp::Le => a <= b,
895 CmpOp::Gt => a > b,
896 CmpOp::Ge => a >= b,
897 _ => unreachable!(),
898 }
899}
900
901#[cfg(test)]
904mod tests {
905 use super::*;
906
907 fn env() -> (FactValues, HashMap<String, String>) {
908 let mut f = FactValues::new();
909 f.insert("is_rust".into(), FactValue::Bool(true));
910 f.insert("is_node".into(), FactValue::Bool(false));
911 f.insert("n_files".into(), FactValue::Int(42));
912 f.insert("primary".into(), FactValue::String("Rust".into()));
913 let mut v = HashMap::new();
914 v.insert("org".into(), "Acme Corp".into());
915 v.insert("year".into(), "2026".into());
916 (f, v)
917 }
918
919 fn check(src: &str) -> bool {
920 let (facts, vars) = env();
921 let expr = parse(src).unwrap();
922 expr.evaluate(&WhenEnv {
923 facts: &facts,
924 vars: &vars,
925 iter: None,
926 })
927 .unwrap()
928 }
929
930 #[test]
931 fn simple_facts() {
932 assert!(check("facts.is_rust"));
933 assert!(!check("facts.is_node"));
934 assert!(check("not facts.is_node"));
935 }
936
937 #[test]
938 fn integer_comparison() {
939 assert!(check("facts.n_files > 0"));
940 assert!(check("facts.n_files == 42"));
941 assert!(!check("facts.n_files < 10"));
942 assert!(check("facts.n_files >= 42"));
943 }
944
945 #[test]
946 fn string_equality() {
947 assert!(check("facts.primary == \"Rust\""));
948 assert!(!check("facts.primary == \"Go\""));
949 }
950
951 #[test]
952 fn logical_ops_short_circuit() {
953 assert!(check("facts.is_rust and facts.n_files > 0"));
954 assert!(check("facts.is_node or facts.is_rust"));
955 assert!(!check("facts.is_node and facts.nonexistent == 5"));
956 }
957
958 #[test]
959 fn in_list() {
960 assert!(check("facts.primary in [\"Rust\", \"Go\"]"));
961 assert!(!check("facts.primary in [\"Python\", \"Java\"]"));
962 }
963
964 #[test]
965 fn in_string_is_substring() {
966 assert!(check("\"cme\" in vars.org"));
967 assert!(!check("\"Xyz\" in vars.org"));
968 }
969
970 #[test]
971 fn matches_regex() {
972 assert!(check("vars.org matches \"^Acme\""));
973 assert!(check("vars.year matches \"^\\\\d{4}$\""));
974 assert!(!check("vars.org matches \"^Xyz\""));
975 }
976
977 #[test]
978 fn parentheses_override_precedence() {
979 assert!(check(
980 "(facts.is_node or facts.is_rust) and facts.n_files > 0"
981 ));
982 assert!(!check("facts.is_node or facts.is_rust and facts.is_node"));
983 }
986
987 #[test]
988 fn unknown_facts_are_null_and_falsy() {
989 assert!(!check("facts.nonexistent"));
990 assert!(check("not facts.nonexistent"));
991 }
992
993 #[test]
994 fn unknown_vars_are_null() {
995 assert!(!check("vars.not_set"));
996 }
997
998 #[test]
999 fn null_equals_null() {
1000 assert!(check("facts.nonexistent == null"));
1001 }
1002
1003 #[test]
1004 fn parse_rejects_bare_equals() {
1005 let e = parse("facts.x = 1").unwrap_err();
1006 matches!(e, WhenError::Parse { .. });
1007 }
1008
1009 #[test]
1010 fn parse_rejects_bang_alone() {
1011 let e = parse("!facts.x").unwrap_err();
1012 matches!(e, WhenError::Parse { .. });
1013 }
1014
1015 #[test]
1016 fn parse_rejects_invalid_identifier_namespace() {
1017 let e = parse("ctx.x").unwrap_err();
1018 let WhenError::Parse { message, .. } = e else {
1019 panic!();
1020 };
1021 assert!(message.contains("facts.NAME"));
1022 }
1023
1024 #[test]
1025 fn parse_rejects_matches_with_non_literal_rhs() {
1026 let e = parse("vars.org matches vars.pattern").unwrap_err();
1027 let WhenError::Parse { message, .. } = e else {
1028 panic!();
1029 };
1030 assert!(message.contains("string literal"));
1031 }
1032
1033 #[test]
1034 fn parse_rejects_invalid_regex() {
1035 let e = parse("vars.org matches \"[unclosed\"").unwrap_err();
1036 matches!(e, WhenError::Regex(_));
1037 }
1038
1039 #[test]
1040 fn evaluate_rejects_ordering_mixed_types() {
1041 let (facts, vars) = env();
1042 let expr = parse("facts.primary > facts.n_files").unwrap();
1043 let result = expr.evaluate(&WhenEnv {
1044 facts: &facts,
1045 vars: &vars,
1046 iter: None,
1047 });
1048 assert!(result.is_err());
1049 }
1050
1051 #[test]
1052 fn string_escapes() {
1053 let (facts, vars) = env();
1054 let expr = parse("vars.org == \"Acme Corp\"").unwrap();
1055 assert!(
1056 expr.evaluate(&WhenEnv {
1057 facts: &facts,
1058 vars: &vars,
1059 iter: None,
1060 })
1061 .unwrap()
1062 );
1063 }
1064
1065 #[test]
1066 fn nested_not_and_or() {
1067 assert!(check(
1068 "not (facts.is_node or (facts.n_files == 0 and facts.is_rust))"
1069 ));
1070 }
1071
1072 use crate::walker::{FileEntry, FileIndex};
1075 use std::path::Path;
1076
1077 fn idx(paths: &[(&str, bool)]) -> FileIndex {
1078 FileIndex::from_entries(
1079 paths
1080 .iter()
1081 .map(|(p, is_dir)| FileEntry {
1082 path: Path::new(p).into(),
1083 is_dir: *is_dir,
1084 size: 1,
1085 })
1086 .collect(),
1087 )
1088 }
1089
1090 fn check_iter(src: &str, iter_path: &Path, is_dir: bool, index: &FileIndex) -> bool {
1091 let (facts, vars) = env();
1092 let expr = parse(src).unwrap();
1093 expr.evaluate(&WhenEnv {
1094 facts: &facts,
1095 vars: &vars,
1096 iter: Some(IterEnv {
1097 path: iter_path,
1098 is_dir,
1099 index,
1100 }),
1101 })
1102 .unwrap()
1103 }
1104
1105 #[test]
1106 fn iter_namespace_parses_and_resolves_value_fields() {
1107 let index = idx(&[("crates/alint-core", true)]);
1108 assert!(check_iter(
1109 "iter.path == \"crates/alint-core\"",
1110 Path::new("crates/alint-core"),
1111 true,
1112 &index,
1113 ));
1114 assert!(check_iter(
1115 "iter.basename == \"alint-core\"",
1116 Path::new("crates/alint-core"),
1117 true,
1118 &index,
1119 ));
1120 assert!(check_iter(
1121 "iter.parent_name == \"crates\"",
1122 Path::new("crates/alint-core"),
1123 true,
1124 &index,
1125 ));
1126 assert!(check_iter(
1127 "iter.is_dir",
1128 Path::new("crates/alint-core"),
1129 true,
1130 &index,
1131 ));
1132 }
1133
1134 #[test]
1135 fn iter_has_file_matches_literal_child() {
1136 let index = idx(&[
1137 ("crates/alint-core", true),
1138 ("crates/alint-core/Cargo.toml", false),
1139 ("crates/alint-core/src", true),
1140 ("crates/alint-core/src/lib.rs", false),
1141 ("crates/other", true),
1142 ("crates/other/Cargo.toml", false),
1143 ]);
1144 assert!(check_iter(
1145 "iter.has_file(\"Cargo.toml\")",
1146 Path::new("crates/alint-core"),
1147 true,
1148 &index,
1149 ));
1150 assert!(!check_iter(
1151 "iter.has_file(\"package.json\")",
1152 Path::new("crates/alint-core"),
1153 true,
1154 &index,
1155 ));
1156 }
1157
1158 #[test]
1159 fn iter_has_file_supports_recursive_glob() {
1160 let index = idx(&[
1161 ("pkg", true),
1162 ("pkg/src", true),
1163 ("pkg/src/main.rs", false),
1164 ("pkg/src/inner", true),
1165 ("pkg/src/inner/lib.rs", false),
1166 ]);
1167 assert!(check_iter(
1168 "iter.has_file(\"**/*.rs\")",
1169 Path::new("pkg"),
1170 true,
1171 &index,
1172 ));
1173 assert!(!check_iter(
1174 "iter.has_file(\"**/*.py\")",
1175 Path::new("pkg"),
1176 true,
1177 &index,
1178 ));
1179 }
1180
1181 #[test]
1182 fn iter_has_file_returns_false_for_file_iteration() {
1183 let index = idx(&[("a.rs", false)]);
1184 assert!(!check_iter(
1185 "iter.has_file(\"x\")",
1186 Path::new("a.rs"),
1187 false,
1188 &index,
1189 ));
1190 }
1191
1192 #[test]
1193 fn iter_references_outside_iter_context_are_falsy() {
1194 assert!(!check("iter.path"));
1198 assert!(check("iter.path == null"));
1199 assert!(!check("iter.has_file(\"X\")"));
1200 }
1201
1202 #[test]
1203 fn iter_has_file_can_compose_with_boolean_logic() {
1204 let index = idx(&[("pkg", true), ("pkg/Cargo.toml", false), ("other", true)]);
1205 assert!(check_iter(
1206 "iter.has_file(\"Cargo.toml\") and iter.is_dir",
1207 Path::new("pkg"),
1208 true,
1209 &index,
1210 ));
1211 assert!(!check_iter(
1212 "iter.has_file(\"BUILD\") or iter.has_file(\"BUILD.bazel\")",
1213 Path::new("pkg"),
1214 true,
1215 &index,
1216 ));
1217 }
1218
1219 #[test]
1220 fn parse_rejects_call_on_non_iter_namespace() {
1221 let e = parse("facts.something(\"x\")").unwrap_err();
1222 let WhenError::Parse { message, .. } = e else {
1223 panic!("expected parse error, got {e:?}");
1224 };
1225 assert!(
1226 message.contains("only available on `iter`"),
1227 "msg: {message}"
1228 );
1229 }
1230
1231 #[test]
1232 fn parse_rejects_unknown_iter_method() {
1233 let e = parse("iter.bogus(\"x\")").unwrap_err();
1234 let WhenError::Parse { message, .. } = e else {
1235 panic!("expected parse error, got {e:?}");
1236 };
1237 assert!(message.contains("unknown iter method"), "msg: {message}");
1238 }
1239
1240 #[test]
1241 fn evaluate_rejects_has_file_with_non_string_arg() {
1242 let (facts, vars) = env();
1243 let index = FileIndex::default();
1244 let expr = parse("iter.has_file(42)").unwrap();
1245 let err = expr
1246 .evaluate(&WhenEnv {
1247 facts: &facts,
1248 vars: &vars,
1249 iter: Some(IterEnv {
1250 path: Path::new("p"),
1251 is_dir: true,
1252 index: &index,
1253 }),
1254 })
1255 .unwrap_err();
1256 let WhenError::Eval(msg) = err else {
1257 panic!("expected eval error");
1258 };
1259 assert!(msg.contains("must be a string"), "msg: {msg}");
1260 }
1261}