use std::collections::HashMap;
use std::path::Path;
use regex::Regex;
use thiserror::Error;
use crate::facts::{FactValue, FactValues};
use crate::walker::FileIndex;
#[derive(Debug, Error)]
pub enum WhenError {
#[error("when parse error at column {pos}: {message}")]
Parse { pos: usize, message: String },
#[error("when evaluation error: {0}")]
Eval(String),
#[error("invalid regex in `matches`: {0}")]
Regex(String),
}
#[derive(Debug, Clone)]
pub enum Value {
Bool(bool),
Int(i64),
String(String),
List(Vec<Value>),
Null,
}
impl Value {
pub fn truthy(&self) -> bool {
match self {
Self::Bool(b) => *b,
Self::Int(n) => *n != 0,
Self::String(s) => !s.is_empty(),
Self::List(v) => !v.is_empty(),
Self::Null => false,
}
}
fn type_name(&self) -> &'static str {
match self {
Self::Bool(_) => "bool",
Self::Int(_) => "int",
Self::String(_) => "string",
Self::List(_) => "list",
Self::Null => "null",
}
}
}
impl From<&FactValue> for Value {
fn from(f: &FactValue) -> Self {
match f {
FactValue::Bool(b) => Self::Bool(*b),
FactValue::Int(n) => Self::Int(*n),
FactValue::String(s) => Self::String(s.clone()),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Namespace {
Facts,
Vars,
Iter,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CmpOp {
Eq,
Ne,
Lt,
Le,
Gt,
Ge,
In,
}
#[derive(Debug, Clone)]
pub enum WhenExpr {
Literal(Value),
Ident {
ns: Namespace,
name: String,
},
Call {
ns: Namespace,
method: String,
args: Vec<WhenExpr>,
},
Not(Box<WhenExpr>),
And(Box<WhenExpr>, Box<WhenExpr>),
Or(Box<WhenExpr>, Box<WhenExpr>),
Cmp {
left: Box<WhenExpr>,
op: CmpOp,
right: Box<WhenExpr>,
},
Matches {
left: Box<WhenExpr>,
pattern: Regex,
},
List(Vec<WhenExpr>),
}
#[derive(Debug)]
pub struct WhenEnv<'a> {
pub facts: &'a FactValues,
pub vars: &'a HashMap<String, String>,
pub iter: Option<IterEnv<'a>>,
}
impl<'a> WhenEnv<'a> {
#[must_use]
pub fn new(facts: &'a FactValues, vars: &'a HashMap<String, String>) -> Self {
Self {
facts,
vars,
iter: None,
}
}
#[must_use]
pub fn with_iter(mut self, iter: IterEnv<'a>) -> Self {
self.iter = Some(iter);
self
}
}
#[derive(Debug, Clone, Copy)]
pub struct IterEnv<'a> {
pub path: &'a Path,
pub is_dir: bool,
pub index: &'a FileIndex,
}
pub fn parse(src: &str) -> Result<WhenExpr, WhenError> {
parse_inner(src).map_err(|e| enrich_diagnostic(src, e))
}
fn parse_inner(src: &str) -> Result<WhenExpr, WhenError> {
let tokens = lex(src)?;
let mut p = Parser::new(tokens);
let expr = p.parse_expr()?;
p.expect_eof()?;
Ok(expr)
}
fn enrich_diagnostic(src: &str, err: WhenError) -> WhenError {
let WhenError::Parse { pos, message } = err else {
return err;
};
let hint = symbol_keyword_hint(src, pos).or_else(|| method_call_hint(src, pos));
match hint {
Some(h) => WhenError::Parse {
pos,
message: format!("{message}\n hint: {h}"),
},
None => WhenError::Parse { pos, message },
}
}
fn symbol_keyword_hint(src: &str, pos: usize) -> Option<&'static str> {
let bytes = src.as_bytes();
let at = bytes.get(pos).copied();
let next = bytes.get(pos + 1).copied();
let prev = pos.checked_sub(1).and_then(|p| bytes.get(p).copied());
let _ = next; match at {
Some(b'&') if prev != Some(b'&') => {
Some("`&&` is not a `when:` operator. Use the keyword `and` instead.")
}
Some(b'|') if prev != Some(b'|') => {
Some("`||` is not a `when:` operator. Use the keyword `or` instead.")
}
Some(b'!') => Some("`!` is not a `when:` operator. Use the keyword `not` instead."),
_ => None,
}
}
fn method_call_hint(src: &str, _pos: usize) -> Option<&'static str> {
static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
let re = RE.get_or_init(|| {
regex::Regex::new(r"\biter\.\w+\.\w+\s*\(").expect("static regex")
});
if re.is_match(src) {
return Some(
"`iter.*` accessors are a fixed set; method calls aren't supported. Use the `matches` \
operator for regex matching, e.g. `iter.path matches \"node_modules\"`. The supported \
accessors are documented in `docs/development/CONFIG-AUTHORING.md` § 12b.",
);
}
None
}
impl WhenExpr {
pub fn evaluate(&self, env: &WhenEnv<'_>) -> Result<bool, WhenError> {
let v = eval(self, env)?;
Ok(v.truthy())
}
}
mod eval;
mod lexer;
mod parser;
use eval::eval;
use lexer::lex;
use parser::Parser;
#[cfg(test)]
mod tests {
use super::*;
fn env() -> (FactValues, HashMap<String, String>) {
let mut f = FactValues::new();
f.insert("is_rust".into(), FactValue::Bool(true));
f.insert("is_node".into(), FactValue::Bool(false));
f.insert("n_files".into(), FactValue::Int(42));
f.insert("primary".into(), FactValue::String("Rust".into()));
let mut v = HashMap::new();
v.insert("org".into(), "Acme Corp".into());
v.insert("year".into(), "2026".into());
(f, v)
}
fn check(src: &str) -> bool {
let (facts, vars) = env();
let expr = parse(src).unwrap();
expr.evaluate(&WhenEnv {
facts: &facts,
vars: &vars,
iter: None,
})
.unwrap()
}
#[test]
fn simple_facts() {
assert!(check("facts.is_rust"));
assert!(!check("facts.is_node"));
assert!(check("not facts.is_node"));
}
#[test]
fn integer_comparison() {
assert!(check("facts.n_files > 0"));
assert!(check("facts.n_files == 42"));
assert!(!check("facts.n_files < 10"));
assert!(check("facts.n_files >= 42"));
}
#[test]
fn string_equality() {
assert!(check("facts.primary == \"Rust\""));
assert!(!check("facts.primary == \"Go\""));
}
#[test]
fn logical_ops_short_circuit() {
assert!(check("facts.is_rust and facts.n_files > 0"));
assert!(check("facts.is_node or facts.is_rust"));
assert!(!check("facts.is_node and facts.nonexistent == 5"));
}
#[test]
fn in_list() {
assert!(check("facts.primary in [\"Rust\", \"Go\"]"));
assert!(!check("facts.primary in [\"Python\", \"Java\"]"));
}
#[test]
fn in_string_is_substring() {
assert!(check("\"cme\" in vars.org"));
assert!(!check("\"Xyz\" in vars.org"));
}
#[test]
fn matches_regex() {
assert!(check("vars.org matches \"^Acme\""));
assert!(check("vars.year matches \"^\\\\d{4}$\""));
assert!(!check("vars.org matches \"^Xyz\""));
}
#[test]
fn parentheses_override_precedence() {
assert!(check(
"(facts.is_node or facts.is_rust) and facts.n_files > 0"
));
assert!(!check("facts.is_node or facts.is_rust and facts.is_node"));
}
#[test]
fn unknown_facts_are_null_and_falsy() {
assert!(!check("facts.nonexistent"));
assert!(check("not facts.nonexistent"));
}
#[test]
fn unknown_vars_are_null() {
assert!(!check("vars.not_set"));
}
#[test]
fn null_equals_null() {
assert!(check("facts.nonexistent == null"));
}
#[test]
fn parse_rejects_bare_equals() {
let e = parse("facts.x = 1").unwrap_err();
matches!(e, WhenError::Parse { .. });
}
#[test]
fn parse_rejects_bang_alone() {
let e = parse("!facts.x").unwrap_err();
matches!(e, WhenError::Parse { .. });
}
#[test]
fn parse_rejects_invalid_identifier_namespace() {
let e = parse("ctx.x").unwrap_err();
let WhenError::Parse { message, .. } = e else {
panic!();
};
assert!(message.contains("facts.NAME"));
}
#[test]
fn parse_rejects_matches_with_non_literal_rhs() {
let e = parse("vars.org matches vars.pattern").unwrap_err();
let WhenError::Parse { message, .. } = e else {
panic!();
};
assert!(message.contains("string literal"));
}
#[test]
fn parse_rejects_invalid_regex() {
let e = parse("vars.org matches \"[unclosed\"").unwrap_err();
matches!(e, WhenError::Regex(_));
}
#[test]
fn evaluate_rejects_ordering_mixed_types() {
let (facts, vars) = env();
let expr = parse("facts.primary > facts.n_files").unwrap();
let result = expr.evaluate(&WhenEnv {
facts: &facts,
vars: &vars,
iter: None,
});
assert!(result.is_err());
}
#[test]
fn string_escapes() {
let (facts, vars) = env();
let expr = parse("vars.org == \"Acme Corp\"").unwrap();
assert!(
expr.evaluate(&WhenEnv {
facts: &facts,
vars: &vars,
iter: None,
})
.unwrap()
);
}
#[test]
fn nested_not_and_or() {
assert!(check(
"not (facts.is_node or (facts.n_files == 0 and facts.is_rust))"
));
}
use crate::walker::{FileEntry, FileIndex};
use std::path::Path;
fn idx(paths: &[(&str, bool)]) -> FileIndex {
FileIndex::from_entries(
paths
.iter()
.map(|(p, is_dir)| FileEntry {
path: Path::new(p).into(),
is_dir: *is_dir,
size: 1,
})
.collect(),
)
}
fn check_iter(src: &str, iter_path: &Path, is_dir: bool, index: &FileIndex) -> bool {
let (facts, vars) = env();
let expr = parse(src).unwrap();
expr.evaluate(&WhenEnv {
facts: &facts,
vars: &vars,
iter: Some(IterEnv {
path: iter_path,
is_dir,
index,
}),
})
.unwrap()
}
#[test]
fn iter_namespace_parses_and_resolves_value_fields() {
let index = idx(&[("crates/alint-core", true)]);
assert!(check_iter(
"iter.path == \"crates/alint-core\"",
Path::new("crates/alint-core"),
true,
&index,
));
assert!(check_iter(
"iter.basename == \"alint-core\"",
Path::new("crates/alint-core"),
true,
&index,
));
assert!(check_iter(
"iter.parent_name == \"crates\"",
Path::new("crates/alint-core"),
true,
&index,
));
assert!(check_iter(
"iter.is_dir",
Path::new("crates/alint-core"),
true,
&index,
));
}
#[test]
fn iter_has_file_matches_literal_child() {
let index = idx(&[
("crates/alint-core", true),
("crates/alint-core/Cargo.toml", false),
("crates/alint-core/src", true),
("crates/alint-core/src/lib.rs", false),
("crates/other", true),
("crates/other/Cargo.toml", false),
]);
assert!(check_iter(
"iter.has_file(\"Cargo.toml\")",
Path::new("crates/alint-core"),
true,
&index,
));
assert!(!check_iter(
"iter.has_file(\"package.json\")",
Path::new("crates/alint-core"),
true,
&index,
));
}
#[test]
fn iter_has_file_supports_recursive_glob() {
let index = idx(&[
("pkg", true),
("pkg/src", true),
("pkg/src/main.rs", false),
("pkg/src/inner", true),
("pkg/src/inner/lib.rs", false),
]);
assert!(check_iter(
"iter.has_file(\"**/*.rs\")",
Path::new("pkg"),
true,
&index,
));
assert!(!check_iter(
"iter.has_file(\"**/*.py\")",
Path::new("pkg"),
true,
&index,
));
}
#[test]
fn iter_has_file_returns_false_for_file_iteration() {
let index = idx(&[("a.rs", false)]);
assert!(!check_iter(
"iter.has_file(\"x\")",
Path::new("a.rs"),
false,
&index,
));
}
#[test]
fn iter_references_outside_iter_context_are_falsy() {
assert!(!check("iter.path"));
assert!(check("iter.path == null"));
assert!(!check("iter.has_file(\"X\")"));
}
#[test]
fn iter_has_file_can_compose_with_boolean_logic() {
let index = idx(&[("pkg", true), ("pkg/Cargo.toml", false), ("other", true)]);
assert!(check_iter(
"iter.has_file(\"Cargo.toml\") and iter.is_dir",
Path::new("pkg"),
true,
&index,
));
assert!(!check_iter(
"iter.has_file(\"BUILD\") or iter.has_file(\"BUILD.bazel\")",
Path::new("pkg"),
true,
&index,
));
}
#[test]
fn parse_rejects_call_on_non_iter_namespace() {
let e = parse("facts.something(\"x\")").unwrap_err();
let WhenError::Parse { message, .. } = e else {
panic!("expected parse error, got {e:?}");
};
assert!(
message.contains("only available on `iter`"),
"msg: {message}"
);
}
#[test]
fn parse_rejects_unknown_iter_method() {
let e = parse("iter.bogus(\"x\")").unwrap_err();
let WhenError::Parse { message, .. } = e else {
panic!("expected parse error, got {e:?}");
};
assert!(message.contains("unknown iter method"), "msg: {message}");
}
#[test]
fn evaluate_rejects_has_file_with_non_string_arg() {
let (facts, vars) = env();
let index = FileIndex::default();
let expr = parse("iter.has_file(42)").unwrap();
let err = expr
.evaluate(&WhenEnv {
facts: &facts,
vars: &vars,
iter: Some(IterEnv {
path: Path::new("p"),
is_dir: true,
index: &index,
}),
})
.unwrap_err();
let WhenError::Eval(msg) = err else {
panic!("expected eval error");
};
assert!(msg.contains("must be a string"), "msg: {msg}");
}
}