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).
812///
813/// When `pattern` is a literal filename (no glob metacharacters)
814/// the fast path consults the index's hash-set directly — O(1)
815/// per call. The slow path falls back to a scope match against
816/// every file in the index. At 1M files in a 5,000-package
817/// monorepo, `for_each_dir` rules with
818/// `when_iter: 'iter.has_file("Cargo.toml")'` would otherwise
819/// be O(D × N); the fast path collapses them to O(D).
820fn 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// ─── Tests ───────────────────────────────────────────────────────────
902
903#[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        // Precedence: and binds tighter than or, so this is
984        // `is_node or (is_rust and is_node)` == false or (true and false) == false.
985    }
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    // ─── iter namespace ──────────────────────────────────────────
1073
1074    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        // Outside an iteration, `iter.X` resolves to null and
1195        // `iter.has_file(...)` to false — same "missing fact"
1196        // convention that `facts.unknown` already follows.
1197        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}