1use std::collections::HashMap;
36use std::path::Path;
37
38use regex::Regex;
39use thiserror::Error;
40
41use crate::facts::{FactValue, FactValues};
42use crate::walker::FileIndex;
43
44#[derive(Debug, Error)]
47pub enum WhenError {
48 #[error("when parse error at column {pos}: {message}")]
49 Parse { pos: usize, message: String },
50 #[error("when evaluation error: {0}")]
51 Eval(String),
52 #[error("invalid regex in `matches`: {0}")]
53 Regex(String),
54}
55
56#[derive(Debug, Clone)]
59pub enum Value {
60 Bool(bool),
61 Int(i64),
62 String(String),
63 List(Vec<Value>),
64 Null,
65}
66
67impl Value {
68 pub fn truthy(&self) -> bool {
69 match self {
70 Self::Bool(b) => *b,
71 Self::Int(n) => *n != 0,
72 Self::String(s) => !s.is_empty(),
73 Self::List(v) => !v.is_empty(),
74 Self::Null => false,
75 }
76 }
77
78 fn type_name(&self) -> &'static str {
79 match self {
80 Self::Bool(_) => "bool",
81 Self::Int(_) => "int",
82 Self::String(_) => "string",
83 Self::List(_) => "list",
84 Self::Null => "null",
85 }
86 }
87}
88
89impl From<&FactValue> for Value {
90 fn from(f: &FactValue) -> Self {
91 match f {
92 FactValue::Bool(b) => Self::Bool(*b),
93 FactValue::Int(n) => Self::Int(*n),
94 FactValue::String(s) => Self::String(s.clone()),
95 }
96 }
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum Namespace {
103 Facts,
104 Vars,
105 Iter,
111 Env,
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub enum CmpOp {
121 Eq,
122 Ne,
123 Lt,
124 Le,
125 Gt,
126 Ge,
127 In,
128}
129
130#[derive(Debug, Clone)]
131pub enum WhenExpr {
132 Literal(Value),
133 Ident {
134 ns: Namespace,
135 name: String,
136 },
137 Call {
143 ns: Namespace,
144 method: String,
145 args: Vec<WhenExpr>,
146 },
147 Not(Box<WhenExpr>),
148 And(Box<WhenExpr>, Box<WhenExpr>),
149 Or(Box<WhenExpr>, Box<WhenExpr>),
150 Cmp {
151 left: Box<WhenExpr>,
152 op: CmpOp,
153 right: Box<WhenExpr>,
154 },
155 Matches {
157 left: Box<WhenExpr>,
158 pattern: Regex,
159 },
160 List(Vec<WhenExpr>),
161}
162
163#[derive(Debug)]
166pub struct WhenEnv<'a> {
167 pub facts: &'a FactValues,
168 pub vars: &'a HashMap<String, String>,
169 pub iter: Option<IterEnv<'a>>,
176 pub env: Option<&'a HashMap<String, String>>,
184}
185
186impl<'a> WhenEnv<'a> {
187 #[must_use]
192 pub fn new(facts: &'a FactValues, vars: &'a HashMap<String, String>) -> Self {
193 Self {
194 facts,
195 vars,
196 iter: None,
197 env: None,
198 }
199 }
200
201 #[must_use]
205 pub fn with_iter(mut self, iter: IterEnv<'a>) -> Self {
206 self.iter = Some(iter);
207 self
208 }
209
210 #[must_use]
214 pub fn with_env(mut self, env: &'a HashMap<String, String>) -> Self {
215 self.env = Some(env);
216 self
217 }
218}
219
220#[derive(Debug, Clone, Copy)]
225pub struct IterEnv<'a> {
226 pub path: &'a Path,
228 pub is_dir: bool,
232 pub index: &'a FileIndex,
235}
236
237pub fn parse(src: &str) -> Result<WhenExpr, WhenError> {
240 parse_inner(src).map_err(|e| enrich_diagnostic(src, e))
241}
242
243fn parse_inner(src: &str) -> Result<WhenExpr, WhenError> {
244 let tokens = lex(src)?;
245 let mut p = Parser::new(tokens);
246 let expr = p.parse_expr()?;
247 p.expect_eof()?;
248 Ok(expr)
249}
250
251fn enrich_diagnostic(src: &str, err: WhenError) -> WhenError {
263 let WhenError::Parse { pos, message } = err else {
264 return err;
267 };
268 let hint = symbol_keyword_hint(src, pos).or_else(|| method_call_hint(src, pos));
269 match hint {
270 Some(h) => WhenError::Parse {
271 pos,
272 message: format!("{message}\n hint: {h}"),
273 },
274 None => WhenError::Parse { pos, message },
275 }
276}
277
278fn symbol_keyword_hint(src: &str, pos: usize) -> Option<&'static str> {
281 let bytes = src.as_bytes();
282 let at = bytes.get(pos).copied();
283 let next = bytes.get(pos + 1).copied();
284 let prev = pos.checked_sub(1).and_then(|p| bytes.get(p).copied());
285
286 let _ = next; match at {
288 Some(b'&') if prev != Some(b'&') => {
289 Some("`&&` is not a `when:` operator. Use the keyword `and` instead.")
290 }
291 Some(b'|') if prev != Some(b'|') => {
292 Some("`||` is not a `when:` operator. Use the keyword `or` instead.")
293 }
294 Some(b'!') => Some("`!` is not a `when:` operator. Use the keyword `not` instead."),
295 _ => None,
296 }
297}
298
299fn method_call_hint(src: &str, _pos: usize) -> Option<&'static str> {
312 static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
313 let re = RE.get_or_init(|| {
314 regex::Regex::new(r"\biter\.\w+\.\w+\s*\(").expect("static regex")
319 });
320 if re.is_match(src) {
321 return Some(
322 "`iter.*` accessors are a fixed set; method calls aren't supported. Use the `matches` \
323 operator for regex matching, e.g. `iter.path matches \"node_modules\"`. The supported \
324 accessors are documented in `docs/development/CONFIG-AUTHORING.md` § 12b.",
325 );
326 }
327 None
328}
329
330impl WhenExpr {
331 pub fn evaluate(&self, env: &WhenEnv<'_>) -> Result<bool, WhenError> {
332 let v = eval(self, env)?;
333 Ok(v.truthy())
334 }
335}
336
337mod eval;
338mod lexer;
339mod parser;
340
341use eval::eval;
342use lexer::lex;
343use parser::Parser;
344
345#[cfg(test)]
348mod tests {
349 use super::*;
350
351 fn env() -> (FactValues, HashMap<String, String>) {
352 let mut f = FactValues::new();
353 f.insert("is_rust".into(), FactValue::Bool(true));
354 f.insert("is_node".into(), FactValue::Bool(false));
355 f.insert("n_files".into(), FactValue::Int(42));
356 f.insert("primary".into(), FactValue::String("Rust".into()));
357 let mut v = HashMap::new();
358 v.insert("org".into(), "Acme Corp".into());
359 v.insert("year".into(), "2026".into());
360 (f, v)
361 }
362
363 fn check(src: &str) -> bool {
364 let (facts, vars) = env();
365 let expr = parse(src).unwrap();
366 expr.evaluate(&WhenEnv {
367 facts: &facts,
368 vars: &vars,
369 iter: None,
370 env: None,
371 })
372 .unwrap()
373 }
374
375 #[test]
376 fn simple_facts() {
377 assert!(check("facts.is_rust"));
378 assert!(!check("facts.is_node"));
379 assert!(check("not facts.is_node"));
380 }
381
382 #[test]
383 fn integer_comparison() {
384 assert!(check("facts.n_files > 0"));
385 assert!(check("facts.n_files == 42"));
386 assert!(!check("facts.n_files < 10"));
387 assert!(check("facts.n_files >= 42"));
388 }
389
390 #[test]
391 fn string_equality() {
392 assert!(check("facts.primary == \"Rust\""));
393 assert!(!check("facts.primary == \"Go\""));
394 }
395
396 #[test]
397 fn logical_ops_short_circuit() {
398 assert!(check("facts.is_rust and facts.n_files > 0"));
399 assert!(check("facts.is_node or facts.is_rust"));
400 assert!(!check("facts.is_node and facts.nonexistent == 5"));
401 }
402
403 #[test]
404 fn in_list() {
405 assert!(check("facts.primary in [\"Rust\", \"Go\"]"));
406 assert!(!check("facts.primary in [\"Python\", \"Java\"]"));
407 }
408
409 #[test]
410 fn in_string_is_substring() {
411 assert!(check("\"cme\" in vars.org"));
412 assert!(!check("\"Xyz\" in vars.org"));
413 }
414
415 #[test]
416 fn matches_regex() {
417 assert!(check("vars.org matches \"^Acme\""));
418 assert!(check("vars.year matches \"^\\\\d{4}$\""));
419 assert!(!check("vars.org matches \"^Xyz\""));
420 }
421
422 #[test]
423 fn parentheses_override_precedence() {
424 assert!(check(
425 "(facts.is_node or facts.is_rust) and facts.n_files > 0"
426 ));
427 assert!(!check("facts.is_node or facts.is_rust and facts.is_node"));
428 }
431
432 #[test]
433 fn unknown_facts_are_null_and_falsy() {
434 assert!(!check("facts.nonexistent"));
435 assert!(check("not facts.nonexistent"));
436 }
437
438 #[test]
439 fn unknown_vars_are_null() {
440 assert!(!check("vars.not_set"));
441 }
442
443 #[test]
444 fn null_equals_null() {
445 assert!(check("facts.nonexistent == null"));
446 }
447
448 #[test]
449 fn parse_rejects_bare_equals() {
450 let e = parse("facts.x = 1").unwrap_err();
451 matches!(e, WhenError::Parse { .. });
452 }
453
454 #[test]
455 fn parse_rejects_bang_alone() {
456 let e = parse("!facts.x").unwrap_err();
457 matches!(e, WhenError::Parse { .. });
458 }
459
460 #[test]
461 fn parse_rejects_invalid_identifier_namespace() {
462 let e = parse("ctx.x").unwrap_err();
463 let WhenError::Parse { message, .. } = e else {
464 panic!();
465 };
466 assert!(message.contains("facts.NAME"));
467 }
468
469 #[test]
470 fn parse_rejects_matches_with_non_literal_rhs() {
471 let e = parse("vars.org matches vars.pattern").unwrap_err();
472 let WhenError::Parse { message, .. } = e else {
473 panic!();
474 };
475 assert!(message.contains("string literal"));
476 }
477
478 #[test]
479 fn parse_rejects_invalid_regex() {
480 let e = parse("vars.org matches \"[unclosed\"").unwrap_err();
481 matches!(e, WhenError::Regex(_));
482 }
483
484 #[test]
485 fn evaluate_rejects_ordering_mixed_types() {
486 let (facts, vars) = env();
487 let expr = parse("facts.primary > facts.n_files").unwrap();
488 let result = expr.evaluate(&WhenEnv {
489 facts: &facts,
490 vars: &vars,
491 iter: None,
492 env: None,
493 });
494 assert!(result.is_err());
495 }
496
497 #[test]
498 fn string_escapes() {
499 let (facts, vars) = env();
500 let expr = parse("vars.org == \"Acme Corp\"").unwrap();
501 assert!(
502 expr.evaluate(&WhenEnv {
503 facts: &facts,
504 vars: &vars,
505 iter: None,
506 env: None,
507 })
508 .unwrap()
509 );
510 }
511
512 #[test]
513 fn nested_not_and_or() {
514 assert!(check(
515 "not (facts.is_node or (facts.n_files == 0 and facts.is_rust))"
516 ));
517 }
518
519 fn check_env(src: &str, vars_env: &[(&str, &str)]) -> bool {
522 let (facts, vars) = env();
523 let env_map: HashMap<String, String> = vars_env
524 .iter()
525 .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
526 .collect();
527 let expr = parse(src).unwrap();
528 expr.evaluate(&WhenEnv::new(&facts, &vars).with_env(&env_map))
529 .unwrap()
530 }
531
532 #[test]
533 fn env_namespace_resolves_injected_value() {
534 assert!(check_env("env.CI == \"true\"", &[("CI", "true")]));
535 assert!(check_env(
536 "env.GITHUB_ACTIONS == \"true\" or env.CI == \"true\"",
537 &[("CI", "true")],
538 ));
539 }
540
541 #[test]
542 fn env_unset_var_is_null_and_falsy() {
543 assert!(!check_env("env.NOT_SET", &[]));
544 assert!(check_env("env.NOT_SET == null", &[]));
545 assert!(!check_env("env.NOT_SET == \"true\"", &[]));
546 }
547
548 #[test]
549 fn env_composes_with_facts_and_vars() {
550 assert!(check_env(
551 "facts.is_rust and env.CI == \"true\"",
552 &[("CI", "true")],
553 ));
554 assert!(!check_env(
555 "facts.is_node and env.CI == \"true\"",
556 &[("CI", "true")],
557 ));
558 }
559
560 #[test]
561 fn env_values_are_always_strings_compare_against_string_literals() {
562 assert!(check_env("env.PORT == \"8080\"", &[("PORT", "8080")]));
566 assert!(!check_env("env.PORT == 8080", &[("PORT", "8080")]));
567 }
568
569 #[test]
570 fn env_matches_and_in_operators() {
571 assert!(check_env(
572 "env.REF matches \"^refs/tags/\"",
573 &[("REF", "refs/tags/v1.0",)]
574 ));
575 assert!(check_env(
576 "\"prod\" in env.ENVIRONMENT",
577 &[("ENVIRONMENT", "prod-east",)]
578 ));
579 }
580
581 #[test]
582 fn env_parses_as_valid_namespace() {
583 assert!(parse("env.CI == \"true\"").is_ok());
587 let WhenError::Parse { message, .. } = parse("environ.CI").unwrap_err() else {
588 panic!("expected parse error");
589 };
590 assert!(message.contains("env.NAME"), "msg: {message}");
591 }
592
593 use crate::walker::{FileEntry, FileIndex};
596 use std::path::Path;
597
598 fn idx(paths: &[(&str, bool)]) -> FileIndex {
599 FileIndex::from_entries(
600 paths
601 .iter()
602 .map(|(p, is_dir)| FileEntry {
603 path: Path::new(p).into(),
604 is_dir: *is_dir,
605 size: 1,
606 })
607 .collect(),
608 )
609 }
610
611 fn check_iter(src: &str, iter_path: &Path, is_dir: bool, index: &FileIndex) -> bool {
612 let (facts, vars) = env();
613 let expr = parse(src).unwrap();
614 expr.evaluate(&WhenEnv {
615 facts: &facts,
616 vars: &vars,
617 iter: Some(IterEnv {
618 path: iter_path,
619 is_dir,
620 index,
621 }),
622 env: None,
623 })
624 .unwrap()
625 }
626
627 #[test]
628 fn iter_namespace_parses_and_resolves_value_fields() {
629 let index = idx(&[("crates/alint-core", true)]);
630 assert!(check_iter(
631 "iter.path == \"crates/alint-core\"",
632 Path::new("crates/alint-core"),
633 true,
634 &index,
635 ));
636 assert!(check_iter(
637 "iter.basename == \"alint-core\"",
638 Path::new("crates/alint-core"),
639 true,
640 &index,
641 ));
642 assert!(check_iter(
643 "iter.parent_name == \"crates\"",
644 Path::new("crates/alint-core"),
645 true,
646 &index,
647 ));
648 assert!(check_iter(
649 "iter.is_dir",
650 Path::new("crates/alint-core"),
651 true,
652 &index,
653 ));
654 }
655
656 #[test]
657 fn iter_has_file_matches_literal_child() {
658 let index = idx(&[
659 ("crates/alint-core", true),
660 ("crates/alint-core/Cargo.toml", false),
661 ("crates/alint-core/src", true),
662 ("crates/alint-core/src/lib.rs", false),
663 ("crates/other", true),
664 ("crates/other/Cargo.toml", false),
665 ]);
666 assert!(check_iter(
667 "iter.has_file(\"Cargo.toml\")",
668 Path::new("crates/alint-core"),
669 true,
670 &index,
671 ));
672 assert!(!check_iter(
673 "iter.has_file(\"package.json\")",
674 Path::new("crates/alint-core"),
675 true,
676 &index,
677 ));
678 }
679
680 #[test]
681 fn iter_has_file_supports_recursive_glob() {
682 let index = idx(&[
683 ("pkg", true),
684 ("pkg/src", true),
685 ("pkg/src/main.rs", false),
686 ("pkg/src/inner", true),
687 ("pkg/src/inner/lib.rs", false),
688 ]);
689 assert!(check_iter(
690 "iter.has_file(\"**/*.rs\")",
691 Path::new("pkg"),
692 true,
693 &index,
694 ));
695 assert!(!check_iter(
696 "iter.has_file(\"**/*.py\")",
697 Path::new("pkg"),
698 true,
699 &index,
700 ));
701 }
702
703 #[test]
704 fn iter_has_file_returns_false_for_file_iteration() {
705 let index = idx(&[("a.rs", false)]);
706 assert!(!check_iter(
707 "iter.has_file(\"x\")",
708 Path::new("a.rs"),
709 false,
710 &index,
711 ));
712 }
713
714 #[test]
715 fn iter_references_outside_iter_context_are_falsy() {
716 assert!(!check("iter.path"));
720 assert!(check("iter.path == null"));
721 assert!(!check("iter.has_file(\"X\")"));
722 }
723
724 #[test]
725 fn iter_has_file_can_compose_with_boolean_logic() {
726 let index = idx(&[("pkg", true), ("pkg/Cargo.toml", false), ("other", true)]);
727 assert!(check_iter(
728 "iter.has_file(\"Cargo.toml\") and iter.is_dir",
729 Path::new("pkg"),
730 true,
731 &index,
732 ));
733 assert!(!check_iter(
734 "iter.has_file(\"BUILD\") or iter.has_file(\"BUILD.bazel\")",
735 Path::new("pkg"),
736 true,
737 &index,
738 ));
739 }
740
741 #[test]
742 fn parse_rejects_call_on_non_iter_namespace() {
743 let e = parse("facts.something(\"x\")").unwrap_err();
744 let WhenError::Parse { message, .. } = e else {
745 panic!("expected parse error, got {e:?}");
746 };
747 assert!(
748 message.contains("only available on `iter`"),
749 "msg: {message}"
750 );
751 }
752
753 #[test]
754 fn parse_rejects_unknown_iter_method() {
755 let e = parse("iter.bogus(\"x\")").unwrap_err();
756 let WhenError::Parse { message, .. } = e else {
757 panic!("expected parse error, got {e:?}");
758 };
759 assert!(message.contains("unknown iter method"), "msg: {message}");
760 }
761
762 #[test]
763 fn evaluate_rejects_has_file_with_non_string_arg() {
764 let (facts, vars) = env();
765 let index = FileIndex::default();
766 let expr = parse("iter.has_file(42)").unwrap();
767 let err = expr
768 .evaluate(&WhenEnv {
769 facts: &facts,
770 vars: &vars,
771 iter: Some(IterEnv {
772 path: Path::new("p"),
773 is_dir: true,
774 index: &index,
775 }),
776 env: None,
777 })
778 .unwrap_err();
779 let WhenError::Eval(msg) = err else {
780 panic!("expected eval error");
781 };
782 assert!(msg.contains("must be a string"), "msg: {msg}");
783 }
784}