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> {
813 let Some(iter) = iter else {
814 return Ok(false);
815 };
816 if !iter.is_dir {
817 return Ok(false);
818 }
819 let combined = format!("{}/{}", iter.path.to_string_lossy(), pattern);
820 let scope = Scope::from_patterns(std::slice::from_ref(&combined))
821 .map_err(|e| WhenError::Eval(format!("iter.has_file: invalid glob: {e}")))?;
822 Ok(iter.index.files().any(|e| scope.matches(&e.path)))
823}
824
825fn apply_cmp(l: &Value, op: CmpOp, r: &Value) -> Result<bool, WhenError> {
826 use Value::{Bool, Int, List, Null, String as S};
827 match op {
828 CmpOp::Eq => Ok(values_equal(l, r)),
829 CmpOp::Ne => Ok(!values_equal(l, r)),
830 CmpOp::Lt | CmpOp::Le | CmpOp::Gt | CmpOp::Ge => match (l, r) {
831 (Int(a), Int(b)) => Ok(cmp_ord(a, b, op)),
832 (S(a), S(b)) => Ok(cmp_ord(&a.as_str(), &b.as_str(), op)),
833 _ => Err(WhenError::Eval(format!(
834 "cannot compare {} with {}",
835 l.type_name(),
836 r.type_name(),
837 ))),
838 },
839 CmpOp::In => match r {
840 List(items) => Ok(items.iter().any(|x| values_equal(l, x))),
841 S(haystack) => match l {
842 S(needle) => Ok(haystack.contains(needle.as_str())),
843 _ => Err(WhenError::Eval(format!(
844 "`in` with a string right-hand side requires a string left; got {}",
845 l.type_name()
846 ))),
847 },
848 _ => {
849 let _ = (Bool(false), Null);
850 Err(WhenError::Eval(format!(
851 "`in` right-hand side must be a list or string; got {}",
852 r.type_name()
853 )))
854 }
855 },
856 }
857}
858
859fn values_equal(a: &Value, b: &Value) -> bool {
860 match (a, b) {
861 (Value::Bool(x), Value::Bool(y)) => x == y,
862 (Value::Int(x), Value::Int(y)) => x == y,
863 (Value::String(x), Value::String(y)) => x == y,
864 (Value::Null, Value::Null) => true,
865 (Value::List(x), Value::List(y)) => {
866 x.len() == y.len() && x.iter().zip(y.iter()).all(|(a, b)| values_equal(a, b))
867 }
868 _ => false,
869 }
870}
871
872fn cmp_ord<T: PartialOrd>(a: &T, b: &T, op: CmpOp) -> bool {
873 match op {
874 CmpOp::Lt => a < b,
875 CmpOp::Le => a <= b,
876 CmpOp::Gt => a > b,
877 CmpOp::Ge => a >= b,
878 _ => unreachable!(),
879 }
880}
881
882#[cfg(test)]
885mod tests {
886 use super::*;
887
888 fn env() -> (FactValues, HashMap<String, String>) {
889 let mut f = FactValues::new();
890 f.insert("is_rust".into(), FactValue::Bool(true));
891 f.insert("is_node".into(), FactValue::Bool(false));
892 f.insert("n_files".into(), FactValue::Int(42));
893 f.insert("primary".into(), FactValue::String("Rust".into()));
894 let mut v = HashMap::new();
895 v.insert("org".into(), "Acme Corp".into());
896 v.insert("year".into(), "2026".into());
897 (f, v)
898 }
899
900 fn check(src: &str) -> bool {
901 let (facts, vars) = env();
902 let expr = parse(src).unwrap();
903 expr.evaluate(&WhenEnv {
904 facts: &facts,
905 vars: &vars,
906 iter: None,
907 })
908 .unwrap()
909 }
910
911 #[test]
912 fn simple_facts() {
913 assert!(check("facts.is_rust"));
914 assert!(!check("facts.is_node"));
915 assert!(check("not facts.is_node"));
916 }
917
918 #[test]
919 fn integer_comparison() {
920 assert!(check("facts.n_files > 0"));
921 assert!(check("facts.n_files == 42"));
922 assert!(!check("facts.n_files < 10"));
923 assert!(check("facts.n_files >= 42"));
924 }
925
926 #[test]
927 fn string_equality() {
928 assert!(check("facts.primary == \"Rust\""));
929 assert!(!check("facts.primary == \"Go\""));
930 }
931
932 #[test]
933 fn logical_ops_short_circuit() {
934 assert!(check("facts.is_rust and facts.n_files > 0"));
935 assert!(check("facts.is_node or facts.is_rust"));
936 assert!(!check("facts.is_node and facts.nonexistent == 5"));
937 }
938
939 #[test]
940 fn in_list() {
941 assert!(check("facts.primary in [\"Rust\", \"Go\"]"));
942 assert!(!check("facts.primary in [\"Python\", \"Java\"]"));
943 }
944
945 #[test]
946 fn in_string_is_substring() {
947 assert!(check("\"cme\" in vars.org"));
948 assert!(!check("\"Xyz\" in vars.org"));
949 }
950
951 #[test]
952 fn matches_regex() {
953 assert!(check("vars.org matches \"^Acme\""));
954 assert!(check("vars.year matches \"^\\\\d{4}$\""));
955 assert!(!check("vars.org matches \"^Xyz\""));
956 }
957
958 #[test]
959 fn parentheses_override_precedence() {
960 assert!(check(
961 "(facts.is_node or facts.is_rust) and facts.n_files > 0"
962 ));
963 assert!(!check("facts.is_node or facts.is_rust and facts.is_node"));
964 }
967
968 #[test]
969 fn unknown_facts_are_null_and_falsy() {
970 assert!(!check("facts.nonexistent"));
971 assert!(check("not facts.nonexistent"));
972 }
973
974 #[test]
975 fn unknown_vars_are_null() {
976 assert!(!check("vars.not_set"));
977 }
978
979 #[test]
980 fn null_equals_null() {
981 assert!(check("facts.nonexistent == null"));
982 }
983
984 #[test]
985 fn parse_rejects_bare_equals() {
986 let e = parse("facts.x = 1").unwrap_err();
987 matches!(e, WhenError::Parse { .. });
988 }
989
990 #[test]
991 fn parse_rejects_bang_alone() {
992 let e = parse("!facts.x").unwrap_err();
993 matches!(e, WhenError::Parse { .. });
994 }
995
996 #[test]
997 fn parse_rejects_invalid_identifier_namespace() {
998 let e = parse("ctx.x").unwrap_err();
999 let WhenError::Parse { message, .. } = e else {
1000 panic!();
1001 };
1002 assert!(message.contains("facts.NAME"));
1003 }
1004
1005 #[test]
1006 fn parse_rejects_matches_with_non_literal_rhs() {
1007 let e = parse("vars.org matches vars.pattern").unwrap_err();
1008 let WhenError::Parse { message, .. } = e else {
1009 panic!();
1010 };
1011 assert!(message.contains("string literal"));
1012 }
1013
1014 #[test]
1015 fn parse_rejects_invalid_regex() {
1016 let e = parse("vars.org matches \"[unclosed\"").unwrap_err();
1017 matches!(e, WhenError::Regex(_));
1018 }
1019
1020 #[test]
1021 fn evaluate_rejects_ordering_mixed_types() {
1022 let (facts, vars) = env();
1023 let expr = parse("facts.primary > facts.n_files").unwrap();
1024 let result = expr.evaluate(&WhenEnv {
1025 facts: &facts,
1026 vars: &vars,
1027 iter: None,
1028 });
1029 assert!(result.is_err());
1030 }
1031
1032 #[test]
1033 fn string_escapes() {
1034 let (facts, vars) = env();
1035 let expr = parse("vars.org == \"Acme Corp\"").unwrap();
1036 assert!(
1037 expr.evaluate(&WhenEnv {
1038 facts: &facts,
1039 vars: &vars,
1040 iter: None,
1041 })
1042 .unwrap()
1043 );
1044 }
1045
1046 #[test]
1047 fn nested_not_and_or() {
1048 assert!(check(
1049 "not (facts.is_node or (facts.n_files == 0 and facts.is_rust))"
1050 ));
1051 }
1052
1053 use crate::walker::{FileEntry, FileIndex};
1056 use std::path::{Path, PathBuf};
1057
1058 fn idx(paths: &[(&str, bool)]) -> FileIndex {
1059 FileIndex {
1060 entries: paths
1061 .iter()
1062 .map(|(p, is_dir)| FileEntry {
1063 path: PathBuf::from(p),
1064 is_dir: *is_dir,
1065 size: 1,
1066 })
1067 .collect(),
1068 }
1069 }
1070
1071 fn check_iter(src: &str, iter_path: &Path, is_dir: bool, index: &FileIndex) -> bool {
1072 let (facts, vars) = env();
1073 let expr = parse(src).unwrap();
1074 expr.evaluate(&WhenEnv {
1075 facts: &facts,
1076 vars: &vars,
1077 iter: Some(IterEnv {
1078 path: iter_path,
1079 is_dir,
1080 index,
1081 }),
1082 })
1083 .unwrap()
1084 }
1085
1086 #[test]
1087 fn iter_namespace_parses_and_resolves_value_fields() {
1088 let index = idx(&[("crates/alint-core", true)]);
1089 assert!(check_iter(
1090 "iter.path == \"crates/alint-core\"",
1091 Path::new("crates/alint-core"),
1092 true,
1093 &index,
1094 ));
1095 assert!(check_iter(
1096 "iter.basename == \"alint-core\"",
1097 Path::new("crates/alint-core"),
1098 true,
1099 &index,
1100 ));
1101 assert!(check_iter(
1102 "iter.parent_name == \"crates\"",
1103 Path::new("crates/alint-core"),
1104 true,
1105 &index,
1106 ));
1107 assert!(check_iter(
1108 "iter.is_dir",
1109 Path::new("crates/alint-core"),
1110 true,
1111 &index,
1112 ));
1113 }
1114
1115 #[test]
1116 fn iter_has_file_matches_literal_child() {
1117 let index = idx(&[
1118 ("crates/alint-core", true),
1119 ("crates/alint-core/Cargo.toml", false),
1120 ("crates/alint-core/src", true),
1121 ("crates/alint-core/src/lib.rs", false),
1122 ("crates/other", true),
1123 ("crates/other/Cargo.toml", false),
1124 ]);
1125 assert!(check_iter(
1126 "iter.has_file(\"Cargo.toml\")",
1127 Path::new("crates/alint-core"),
1128 true,
1129 &index,
1130 ));
1131 assert!(!check_iter(
1132 "iter.has_file(\"package.json\")",
1133 Path::new("crates/alint-core"),
1134 true,
1135 &index,
1136 ));
1137 }
1138
1139 #[test]
1140 fn iter_has_file_supports_recursive_glob() {
1141 let index = idx(&[
1142 ("pkg", true),
1143 ("pkg/src", true),
1144 ("pkg/src/main.rs", false),
1145 ("pkg/src/inner", true),
1146 ("pkg/src/inner/lib.rs", false),
1147 ]);
1148 assert!(check_iter(
1149 "iter.has_file(\"**/*.rs\")",
1150 Path::new("pkg"),
1151 true,
1152 &index,
1153 ));
1154 assert!(!check_iter(
1155 "iter.has_file(\"**/*.py\")",
1156 Path::new("pkg"),
1157 true,
1158 &index,
1159 ));
1160 }
1161
1162 #[test]
1163 fn iter_has_file_returns_false_for_file_iteration() {
1164 let index = idx(&[("a.rs", false)]);
1165 assert!(!check_iter(
1166 "iter.has_file(\"x\")",
1167 Path::new("a.rs"),
1168 false,
1169 &index,
1170 ));
1171 }
1172
1173 #[test]
1174 fn iter_references_outside_iter_context_are_falsy() {
1175 assert!(!check("iter.path"));
1179 assert!(check("iter.path == null"));
1180 assert!(!check("iter.has_file(\"X\")"));
1181 }
1182
1183 #[test]
1184 fn iter_has_file_can_compose_with_boolean_logic() {
1185 let index = idx(&[("pkg", true), ("pkg/Cargo.toml", false), ("other", true)]);
1186 assert!(check_iter(
1187 "iter.has_file(\"Cargo.toml\") and iter.is_dir",
1188 Path::new("pkg"),
1189 true,
1190 &index,
1191 ));
1192 assert!(!check_iter(
1193 "iter.has_file(\"BUILD\") or iter.has_file(\"BUILD.bazel\")",
1194 Path::new("pkg"),
1195 true,
1196 &index,
1197 ));
1198 }
1199
1200 #[test]
1201 fn parse_rejects_call_on_non_iter_namespace() {
1202 let e = parse("facts.something(\"x\")").unwrap_err();
1203 let WhenError::Parse { message, .. } = e else {
1204 panic!("expected parse error, got {e:?}");
1205 };
1206 assert!(
1207 message.contains("only available on `iter`"),
1208 "msg: {message}"
1209 );
1210 }
1211
1212 #[test]
1213 fn parse_rejects_unknown_iter_method() {
1214 let e = parse("iter.bogus(\"x\")").unwrap_err();
1215 let WhenError::Parse { message, .. } = e else {
1216 panic!("expected parse error, got {e:?}");
1217 };
1218 assert!(message.contains("unknown iter method"), "msg: {message}");
1219 }
1220
1221 #[test]
1222 fn evaluate_rejects_has_file_with_non_string_arg() {
1223 let (facts, vars) = env();
1224 let index = FileIndex { entries: vec![] };
1225 let expr = parse("iter.has_file(42)").unwrap();
1226 let err = expr
1227 .evaluate(&WhenEnv {
1228 facts: &facts,
1229 vars: &vars,
1230 iter: Some(IterEnv {
1231 path: Path::new("p"),
1232 is_dir: true,
1233 index: &index,
1234 }),
1235 })
1236 .unwrap_err();
1237 let WhenError::Eval(msg) = err else {
1238 panic!("expected eval error");
1239 };
1240 assert!(msg.contains("must be a string"), "msg: {msg}");
1241 }
1242}