Skip to main content

alint_core/
when.rs

1//! The `when` expression language — bounded DSL for gating rules on facts.
2//!
3//! Grammar (hand-written recursive-descent; no parser combinator):
4//!
5//! ```text
6//! expr       := or_expr
7//! or_expr    := and_expr ('or' and_expr)*
8//! and_expr   := not_expr ('and' not_expr)*
9//! not_expr   := ['not'] cmp_expr
10//! cmp_expr   := primary [cmp_op primary]
11//! cmp_op     := '==' | '!=' | '<' | '<=' | '>' | '>=' | 'in' | 'matches'
12//! primary    := literal | ident_or_call | '(' expr ')'
13//! literal    := STRING | INT | BOOL | 'null' | list
14//! list       := '[' [expr (',' expr)*] ']'
15//! ident_or_call := NS '.' NAME ['(' [expr (',' expr)*] ')']
16//! NS         := 'facts' | 'vars' | 'iter'
17//! ```
18//!
19//! Design choices (all load-bearing):
20//!
21//! - **No arithmetic.** Only comparison.
22//! - **Function calls limited to a fixed set on the `iter` namespace.**
23//!   `iter.has_file("Cargo.toml")` is supported; arbitrary user-defined
24//!   calls are not. Use declared `facts:` for repo-level computation.
25//! - **`iter.*` is only meaningful in iteration contexts** (per-iteration
26//!   `when_iter:` on `for_each_*`, and nested rules' `when:`). Outside
27//!   those, `iter.X` evaluates to `null` and `iter.has_file(_)` to `false`.
28//! - **`matches` RHS must be a string literal.** This lets us compile the
29//!   regex at parse time; dynamic patterns stay out of the hot path.
30//! - **Short-circuit `and` / `or`.** Unevaluated branches don't even touch
31//!   their subtree.
32//! - **Type coercion is explicit, not silent.** Comparing `Int` to `String`
33//!   is an error, not `false`.
34
35use 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// ─── Errors ──────────────────────────────────────────────────────────
46
47#[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// ─── Value (evaluation-time) ─────────────────────────────────────────
58
59#[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// ─── AST ─────────────────────────────────────────────────────────────
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum Namespace {
104    Facts,
105    Vars,
106    /// Per-iteration context. Available only when an `IterEnv`
107    /// is threaded into the evaluator (via
108    /// [`WhenEnv::with_iter`]). Outside those, `iter.X`
109    /// evaluates to `null` and `iter.has_file(_)` to `false` —
110    /// matching the "missing fact is falsy" rule.
111    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    /// `<ns>.<method>(args...)`. Currently only the `iter`
133    /// namespace exposes callable methods; an unknown
134    /// (namespace, method) pair is rejected at parse time so
135    /// typos don't silently coerce to `null` like value-style
136    /// idents do.
137    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    /// `left matches <compiled regex>` — RHS is compiled at parse time.
151    Matches {
152        left: Box<WhenExpr>,
153        pattern: Regex,
154    },
155    List(Vec<WhenExpr>),
156}
157
158// ─── Evaluation environment ──────────────────────────────────────────
159
160#[derive(Debug)]
161pub struct WhenEnv<'a> {
162    pub facts: &'a FactValues,
163    pub vars: &'a HashMap<String, String>,
164    /// Per-iteration context, populated when this `WhenEnv`
165    /// gates an iterated rule (`for_each_dir` /
166    /// `for_each_file` / `every_matching_has`). `None` for
167    /// top-level rule gating, where `iter.*` references
168    /// resolve to falsy / null per the "unknown fact is
169    /// falsy" convention.
170    pub iter: Option<IterEnv<'a>>,
171}
172
173impl<'a> WhenEnv<'a> {
174    /// Construct a `WhenEnv` without iteration context — the
175    /// shape every existing call site uses. `iter.*` references
176    /// in the expression resolve to null / false.
177    #[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    /// Attach an iteration context. The same `WhenEnv` shape can
187    /// then evaluate `iter.path`, `iter.basename`, and
188    /// `iter.has_file(...)` against the supplied path + index.
189    #[must_use]
190    pub fn with_iter(mut self, iter: IterEnv<'a>) -> Self {
191        self.iter = Some(iter);
192        self
193    }
194}
195
196/// Iteration context exposed to `when:` expressions through the
197/// `iter.*` namespace. Built once per iterated entry by
198/// `for_each_*` rules and threaded into both the outer
199/// `when_iter:` filter and any nested rule's `when:`.
200#[derive(Debug, Clone, Copy)]
201pub struct IterEnv<'a> {
202    /// Relative path of the iterated entry (as walker reported).
203    pub path: &'a Path,
204    /// Whether the iterated entry is a directory. `iter.has_file`
205    /// only does meaningful work when this is `true`; for files
206    /// it returns `false`.
207    pub is_dir: bool,
208    /// File index, used by `iter.has_file(pattern)` to look up
209    /// children of the iterated path.
210    pub index: &'a FileIndex,
211}
212
213// ─── Public entry points ─────────────────────────────────────────────
214
215pub 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// ─── Lexer ───────────────────────────────────────────────────────────
231
232#[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        // whitespace
266        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
430/// Closed list of methods callable on `iter`. Adding new ones is
431/// a deliberate API extension — typos in user configs surface as
432/// "unknown iter method" rather than silently coercing to false.
433fn is_known_iter_method(name: &str) -> bool {
434    matches!(name, "has_file")
435}
436
437// ─── Parser ──────────────────────────────────────────────────────────
438
439struct 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)] // Single match per primary form keeps the dispatch obvious; splitting it costs more than it saves.
555    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                // Optional `(args...)` — function-call syntax.
622                if matches!(self.peek(), Some(Tok::LParen)) {
623                    self.advance(); // consume '('
624                    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
675// ─── Evaluator ───────────────────────────────────────────────────────
676
677fn 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            // Parser rejects calls on non-iter namespaces, but be
694            // defensive in case the AST is hand-built somewhere.
695            _ => 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
739/// Resolve an `iter.<name>` value-style reference. Returns
740/// `Null` when no iteration context is attached or the name is
741/// unrecognised — matching the "missing is falsy" convention so
742/// that a stray `iter.X` outside an iteration doesn't error.
743fn 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
774/// Resolve an `iter.<method>(args...)` call. The parser
775/// guarantees `method` is one of the known callables (currently
776/// just `has_file`); arity / arg-type errors surface as
777/// [`WhenError::Eval`] at evaluation time so a parse-clean
778/// expression with bad args still reports clearly.
779fn 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
805/// Implementation of `iter.has_file(pattern)`. `pattern` is a
806/// Git-style glob evaluated relative to the iterated path —
807/// `iter.has_file("Cargo.toml")` matches any tracked file at
808/// `<iter.path>/Cargo.toml`; `iter.has_file("**/*.bzl")` matches
809/// any `.bzl` under the iterated dir at any depth. Returns
810/// `false` when the iteration context is absent or the iterated
811/// entry isn't a directory (files don't "contain" anything).
812fn 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// ─── Tests ───────────────────────────────────────────────────────────
883
884#[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        // Precedence: and binds tighter than or, so this is
965        // `is_node or (is_rust and is_node)` == false or (true and false) == false.
966    }
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    // ─── iter namespace ──────────────────────────────────────────
1054
1055    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        // Outside an iteration, `iter.X` resolves to null and
1176        // `iter.has_file(...)` to false — same "missing fact"
1177        // convention that `facts.unknown` already follows.
1178        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}