doot/crates/doot-lang/src/evaluator.rs
Ray Andrew 0eb4d38392
feat(utils): introduce doot-utils crate with XDG directory helpers
- Add new `doot-utils` crate with `xdg` module for consistent cross-platform directory resolution
- Replace `dirs` crate usage with `doot_utils::xdg` functions in cli, core, and lang crates
- Use XDG layout on all platforms (including macOS) for config, data, cache, and state directories
- Add home_dir(), config_home(), data_home(), cache_home(), and state_home() helpers
- Update dependencies to use doot-utils workspace reference
- Remove unused dirs and related crates from Cargo.lock
- Improve error handling in template rendering and package installation
- Add InstalledCache for package managers to reduce process spawning
- Optimize brew package installation with parallel fetching and sequential installing
- Fix path canonicalization in e2e tests for consistent symlink handling
- Add clippy allowance for large parser errors in doot-lang
2026-06-09 23:24:46 -07:00

1582 lines
56 KiB
Rust

//! Runtime evaluator for the doot language.
use crate::ast::*;
use crate::builtins;
use async_recursion::async_recursion;
use indexmap::IndexMap;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::OnceLock;
use thiserror::Error;
/// Cached system information (never changes during runtime).
struct SystemInfo {
os: &'static str,
distro: String,
pkg_manager: String,
hostname: String,
arch: &'static str,
}
static SYSTEM_INFO: OnceLock<SystemInfo> = OnceLock::new();
fn get_system_info() -> &'static SystemInfo {
SYSTEM_INFO.get_or_init(|| {
let os = std::env::consts::OS;
let distro = detect_distro();
let pkg_manager = detect_pkg_manager();
let hostname = hostname::get()
.map(|h| h.to_string_lossy().to_string())
.unwrap_or_default();
let arch = std::env::consts::ARCH;
SystemInfo {
os,
distro,
pkg_manager,
hostname,
arch,
}
})
}
/// Runtime evaluation errors.
#[derive(Error, Debug)]
pub enum EvalError {
#[error("undefined variable: {0}")]
UndefinedVariable(String),
#[error("undefined function: {0}")]
UndefinedFunction(String),
#[error("type error: {0}")]
TypeError(String),
#[error("division by zero")]
DivisionByZero,
#[error("index out of bounds: {index} for list of length {len}")]
IndexOutOfBounds { index: i64, len: usize },
#[error("field not found: {field} on {ty}")]
FieldNotFound { ty: String, field: String },
#[error("cannot iterate over {0}")]
NotIterable(String),
#[error("io error: {0}")]
IoError(#[from] std::io::Error),
#[error("async error: {0}")]
AsyncError(String),
}
/// Wrapper for an async task result. Cloning shares the same task handle.
/// The inner Option is taken on first await; subsequent awaits return an error.
#[derive(Clone, Debug)]
#[allow(clippy::type_complexity)]
pub struct AsyncValue(pub Arc<std::sync::Mutex<Option<smol::Task<Result<Value, EvalError>>>>>);
impl AsyncValue {
pub fn new(task: smol::Task<Result<Value, EvalError>>) -> Self {
Self(Arc::new(std::sync::Mutex::new(Some(task))))
}
}
/// Runtime value types.
#[derive(Clone, Debug)]
pub enum Value {
Int(i64),
Float(f64),
Str(String),
Bool(bool),
Path(PathBuf),
List(Vec<Value>),
Struct(String, IndexMap<String, Value>),
Enum(String, String),
Function(FnDecl, Env),
Lambda(Vec<FnParam>, Expr, Env),
Future(AsyncValue),
None,
}
impl Value {
/// Returns the type name as a string.
pub fn type_name(&self) -> &'static str {
match self {
Value::Int(_) => "int",
Value::Float(_) => "float",
Value::Str(_) => "str",
Value::Bool(_) => "bool",
Value::Path(_) => "path",
Value::List(_) => "list",
Value::Struct(_, _) => "struct",
Value::Enum(_, _) => "enum",
Value::Function(_, _) => "function",
Value::Lambda(_, _, _) => "lambda",
Value::Future(_) => "future",
Value::None => "none",
}
}
/// Returns true for truthy values in conditionals.
pub fn is_truthy(&self) -> bool {
match self {
Value::Bool(b) => *b,
Value::Int(n) => *n != 0,
Value::Float(n) => *n != 0.0,
Value::Str(s) => !s.is_empty(),
Value::List(l) => !l.is_empty(),
Value::None => false,
_ => true,
}
}
/// Converts the value to a display string.
/// Converts value to a string suitable for environment variables.
pub fn to_env_string(&self) -> String {
match self {
Value::Int(n) => n.to_string(),
Value::Float(n) => n.to_string(),
Value::Str(s) => s.clone(),
Value::Bool(b) => if *b { "1" } else { "0" }.to_string(),
Value::Path(p) => p.display().to_string(),
Value::List(items) => {
// Join list items with colon (PATH-style)
items
.iter()
.map(|v| v.to_env_string())
.collect::<Vec<_>>()
.join(":")
}
Value::Future(_) => "<future>".to_string(),
Value::None => String::new(),
_ => self.to_string_repr(),
}
}
pub fn to_string_repr(&self) -> String {
match self {
Value::Int(n) => n.to_string(),
Value::Float(n) => n.to_string(),
Value::Str(s) => s.clone(),
Value::Bool(b) => b.to_string(),
Value::Path(p) => p.display().to_string(),
Value::List(items) => {
let parts: Vec<String> = items.iter().map(|v| v.to_string_repr()).collect();
format!("[{}]", parts.join(", "))
}
Value::Struct(name, fields) => {
let parts: Vec<String> = fields
.iter()
.map(|(k, v)| format!("{} = {}", k, v.to_string_repr()))
.collect();
format!("{} {{ {} }}", name, parts.join(", "))
}
Value::Enum(ty, variant) => format!("{}::{}", ty, variant),
Value::Function(f, _) => format!("<fn {}>", f.name),
Value::Lambda(_, _, _) => "<lambda>".to_string(),
Value::Future(_) => "<future>".to_string(),
Value::None => "none".to_string(),
}
}
}
/// Runtime environment with variable bindings.
#[derive(Clone, Debug, Default)]
pub struct Env {
scopes: Vec<HashMap<String, Value>>,
functions: HashMap<String, (FnDecl, Env)>,
structs: HashMap<String, StructDecl>,
enums: HashMap<String, EnumDecl>,
macros: HashMap<String, MacroDecl>,
}
impl Env {
/// Creates a new empty environment.
pub fn new() -> Self {
Self {
scopes: vec![HashMap::new()],
functions: HashMap::new(),
structs: HashMap::new(),
enums: HashMap::new(),
macros: HashMap::new(),
}
}
pub fn push_scope(&mut self) {
self.scopes.push(HashMap::new());
}
pub fn pop_scope(&mut self) {
self.scopes.pop();
}
pub fn define(&mut self, name: String, value: Value) {
if let Some(scope) = self.scopes.last_mut() {
scope.insert(name, value);
}
}
pub fn get(&self, name: &str) -> Option<&Value> {
for scope in self.scopes.iter().rev() {
if let Some(v) = scope.get(name) {
return Some(v);
}
}
None
}
pub fn define_function(&mut self, name: String, func: FnDecl, env: Env) {
self.functions.insert(name, (func, env));
}
pub fn get_function(&self, name: &str) -> Option<&(FnDecl, Env)> {
self.functions.get(name)
}
pub fn define_struct(&mut self, name: String, decl: StructDecl) {
self.structs.insert(name, decl);
}
pub fn get_struct(&self, name: &str) -> Option<&StructDecl> {
self.structs.get(name)
}
pub fn define_enum(&mut self, name: String, decl: EnumDecl) {
self.enums.insert(name, decl);
}
pub fn define_macro(&mut self, name: String, decl: MacroDecl) {
self.macros.insert(name, decl);
}
pub fn get_macro(&self, name: &str) -> Option<&MacroDecl> {
self.macros.get(name)
}
/// Returns all variables as string key-value pairs for use as environment variables.
pub fn get_all_variables(&self) -> HashMap<String, String> {
let mut vars = HashMap::new();
for scope in &self.scopes {
for (name, value) in scope {
vars.insert(
format!("DOOT_{}", name.to_uppercase()),
value.to_env_string(),
);
}
}
vars
}
/// Returns all variables as raw Values (for template engine integration).
pub fn get_raw_variables(&self) -> HashMap<String, Value> {
let mut vars = HashMap::new();
for scope in &self.scopes {
for (name, value) in scope {
vars.insert(name.clone(), value.clone());
}
}
vars
}
}
/// Deploy mode for dotfiles.
#[derive(Clone, Copy, Debug, PartialEq, Default)]
pub enum DeployMode {
#[default]
Copy,
Link,
}
/// Permission rule for deployed files.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)]
pub enum PermissionRule {
Single(u32),
Pattern { pattern: String, mode: u32 },
}
/// Source for a dotfiles glob block.
#[derive(Debug, Clone)]
pub enum DotfilesSource {
/// Glob pattern string to expand later (e.g. "config/*").
Pattern(String),
/// Pre-expanded list of paths (e.g. from glob() function call).
Paths(Vec<PathBuf>),
}
/// Unexpanded dotfiles pattern from a `dotfiles:` block.
#[derive(Debug, Clone)]
pub struct DotfilesPattern {
pub source: DotfilesSource,
pub target_base: PathBuf,
pub template: bool,
pub permissions: Vec<PermissionRule>,
pub owner: Option<String>,
pub deploy: DeployMode,
pub link_patterns: Vec<String>,
pub copy_patterns: Vec<String>,
}
/// Evaluated dotfile configuration.
#[derive(Clone, Debug)]
pub struct DotfileConfig {
pub source: PathBuf,
pub target: PathBuf,
pub template: bool,
pub permissions: Vec<PermissionRule>,
pub owner: Option<String>,
pub deploy: DeployMode,
pub link_patterns: Vec<String>,
pub copy_patterns: Vec<String>,
/// Target paths to skip during directory deploy (specialized by explicit dotfile blocks).
pub exclude_paths: Vec<PathBuf>,
/// Source paths to skip during directory deploy (when explicit block targets elsewhere).
pub exclude_sources: Vec<PathBuf>,
}
/// Evaluated package configuration.
#[derive(Clone, Debug)]
pub struct PackageConfig {
pub default: Option<String>,
pub brew: Option<String>,
pub apt: Option<String>,
pub pacman: Option<String>,
pub yay: Option<String>,
pub xbps: Option<String>,
}
/// Evaluated secret file configuration.
#[derive(Clone, Debug)]
pub struct SecretConfig {
pub source: PathBuf,
pub target: PathBuf,
pub mode: Option<u32>,
}
/// Evaluated hook configuration.
#[derive(Clone, Debug)]
pub struct HookConfig {
pub stage: HookStage,
pub run: String,
}
/// Result of evaluating a doot program.
#[derive(Clone)]
pub struct EvalResult {
pub dotfiles: Vec<DotfileConfig>,
pub dotfile_patterns: Vec<DotfilesPattern>,
pub packages: Vec<PackageConfig>,
pub secrets: Vec<SecretConfig>,
pub hooks: Vec<HookConfig>,
pub encrypted_vars: HashMap<String, String>,
pub encrypted_files: HashMap<String, PathBuf>,
pub sandbox: bool,
}
impl Default for EvalResult {
fn default() -> Self {
Self {
dotfiles: Vec::new(),
dotfile_patterns: Vec::new(),
packages: Vec::new(),
secrets: Vec::new(),
hooks: Vec::new(),
encrypted_vars: HashMap::new(),
encrypted_files: HashMap::new(),
sandbox: true,
}
}
}
/// Evaluates doot AST and collects configuration.
#[derive(Clone)]
pub struct Evaluator {
env: Env,
result: EvalResult,
/// Base directory of the config file. Used to resolve relative glob patterns
/// so they expand correctly regardless of the current working directory.
source_dir: Option<std::path::PathBuf>,
}
impl Evaluator {
/// Creates a new evaluator with built-in bindings.
#[tracing::instrument(level = "trace")]
pub fn new() -> Self {
let mut env = Env::new();
Self::init_builtins(&mut env);
Self {
env,
result: EvalResult::default(),
source_dir: None,
}
}
/// Sets the config source directory so relative glob patterns are resolved
/// relative to the config file rather than the current working directory.
pub fn with_source_dir(mut self, dir: std::path::PathBuf) -> Self {
self.source_dir = Some(dir);
self
}
#[tracing::instrument(level = "trace", skip_all)]
fn init_builtins(env: &mut Env) {
// Register the Os enum so Os::Linux, Os::MacOS, etc. can be used
env.define_enum(
"Os".to_string(),
EnumDecl {
name: "Os".to_string(),
variants: vec![
EnumVariant {
name: "Linux".to_string(),
fields: None,
},
EnumVariant {
name: "MacOS".to_string(),
fields: None,
},
EnumVariant {
name: "Windows".to_string(),
fields: None,
},
],
},
);
// Use cached system info
let sys = get_system_info();
let os_val = match sys.os {
"linux" => Value::Enum("Os".to_string(), "Linux".to_string()),
"macos" => Value::Enum("Os".to_string(), "MacOS".to_string()),
"windows" => Value::Enum("Os".to_string(), "Windows".to_string()),
_ => Value::Enum("Os".to_string(), "Linux".to_string()),
};
env.define("os".to_string(), os_val);
env.define("distro".to_string(), Value::Str(sys.distro.clone()));
env.define(
"pkg_manager".to_string(),
Value::Str(sys.pkg_manager.clone()),
);
env.define("hostname".to_string(), Value::Str(sys.hostname.clone()));
env.define("arch".to_string(), Value::Str(sys.arch.to_string()));
}
/// Evaluates the program and returns collected configuration.
#[tracing::instrument(level = "trace", skip_all)]
pub async fn eval(&mut self, program: &Program) -> Result<EvalResult, EvalError> {
for stmt in &program.statements {
self.eval_statement(&stmt.node).await?;
}
Ok(std::mem::take(&mut self.result))
}
/// Synchronous entry point. Runs the async evaluator on smol's executor.
pub fn eval_sync(&mut self, program: &Program) -> Result<EvalResult, EvalError> {
smol::block_on(self.eval(program))
}
/// Returns all variables as environment variables for hooks.
#[tracing::instrument(level = "trace", skip(self))]
pub fn get_hook_env(&self) -> std::collections::HashMap<String, String> {
let mut vars = self.env.get_all_variables();
// Add doot global variables
vars.insert(
"DOOT_HOME".to_string(),
Self::home_dir().display().to_string(),
);
vars.insert(
"DOOT_CONFIG_DIR".to_string(),
doot_utils::xdg::config_home()
.join("doot")
.display()
.to_string(),
);
vars.insert("DOOT_OS".to_string(), std::env::consts::OS.to_string());
vars.insert("DOOT_ARCH".to_string(), std::env::consts::ARCH.to_string());
vars
}
/// Returns all variables as raw Values for template rendering.
pub fn get_template_variables(&self) -> HashMap<String, Value> {
self.env.get_raw_variables()
}
#[async_recursion(?Send)]
#[tracing::instrument(level = "trace", skip_all)]
async fn eval_statement(&mut self, stmt: &Statement) -> Result<Option<Value>, EvalError> {
match stmt {
Statement::VarDecl(decl) => {
tracing::trace!(name = %decl.name, "eval var declaration");
let value = self.eval_expr(&decl.value).await?;
// Handle special config variables
if decl.name == "sandbox"
&& let Value::Bool(b) = &value
{
self.result.sandbox = *b;
}
self.env.define(decl.name.clone(), value);
Ok(None)
}
Statement::FnDecl(decl) => {
tracing::trace!(name = %decl.name, "eval fn declaration");
self.env
.define_function(decl.name.clone(), decl.clone(), self.env.clone());
Ok(None)
}
Statement::StructDecl(decl) => {
self.env.define_struct(decl.name.clone(), decl.clone());
Ok(None)
}
Statement::EnumDecl(decl) => {
self.env.define_enum(decl.name.clone(), decl.clone());
Ok(None)
}
Statement::MacroDecl(decl) => {
self.env.define_macro(decl.name.clone(), decl.clone());
Ok(None)
}
Statement::MacroCall(call) => {
if let Some(macro_decl) = self.env.get_macro(&call.name).cloned() {
self.env.push_scope();
for (param, arg) in macro_decl.params.iter().zip(call.args.iter()) {
let value = self.eval_expr(arg).await?;
self.env.define(param.clone(), value);
}
for body_stmt in &macro_decl.body {
self.eval_statement(&body_stmt.node).await?;
}
self.env.pop_scope();
}
Ok(None)
}
Statement::ForLoop(for_loop) => {
let iter_val = self.eval_expr(&for_loop.iter).await?;
let items = match iter_val {
Value::List(items) => items,
Value::Str(s) => s.chars().map(|c| Value::Str(c.to_string())).collect(),
_ => return Err(EvalError::NotIterable(iter_val.type_name().to_string())),
};
for item in items {
self.env.push_scope();
self.env.define(for_loop.var.clone(), item);
for body_stmt in &for_loop.body {
if let Some(v) = self.eval_statement(&body_stmt.node).await? {
self.env.pop_scope();
return Ok(Some(v));
}
}
self.env.pop_scope();
}
Ok(None)
}
Statement::If(if_stmt) => {
let cond = self.eval_expr(&if_stmt.condition).await?;
if cond.is_truthy() {
self.env.push_scope();
for body_stmt in &if_stmt.then_body {
if let Some(v) = self.eval_statement(&body_stmt.node).await? {
self.env.pop_scope();
return Ok(Some(v));
}
}
self.env.pop_scope();
} else if let Some(ref else_body) = if_stmt.else_body {
self.env.push_scope();
for body_stmt in else_body {
if let Some(v) = self.eval_statement(&body_stmt.node).await? {
self.env.pop_scope();
return Ok(Some(v));
}
}
self.env.pop_scope();
}
Ok(None)
}
Statement::Match(match_stmt) => {
let value = self.eval_expr(&match_stmt.expr).await?;
for arm in &match_stmt.arms {
if self.pattern_matches(&arm.pattern, &value) {
let result = self.eval_expr(&arm.body).await?;
return Ok(Some(result));
}
}
Ok(None)
}
Statement::Dotfile(dotfile) => {
tracing::trace!("eval dotfile");
if let Some(ref when) = dotfile.when {
let cond = self.eval_expr(when).await?;
if !cond.is_truthy() {
return Ok(None);
}
}
let source_val = self.eval_expr(&dotfile.source).await?;
let deploy = match dotfile.deploy {
crate::ast::DeployMode::Copy => DeployMode::Copy,
crate::ast::DeployMode::Link => DeployMode::Link,
};
let permissions = dotfile
.permissions
.iter()
.map(|p| match p {
crate::ast::PermissionRule::Single(mode) => PermissionRule::Single(*mode),
crate::ast::PermissionRule::Pattern { pattern, mode } => {
PermissionRule::Pattern {
pattern: pattern.clone(),
mode: *mode,
}
}
})
.collect::<Vec<_>>();
// Detect glob patterns or lists and store as DotfilesPattern
let is_glob = |s: &str| s.contains('*') || s.contains('?') || s.contains('[');
match &source_val {
Value::Str(s) if is_glob(s) => {
let target_base = self.eval_to_path(&dotfile.target).await?;
self.result.dotfile_patterns.push(DotfilesPattern {
source: DotfilesSource::Pattern(s.clone()),
target_base,
template: dotfile.template.unwrap_or(false),
permissions,
owner: dotfile.owner.clone(),
deploy,
link_patterns: dotfile.link_patterns.clone(),
copy_patterns: dotfile.copy_patterns.clone(),
});
}
Value::Path(p) if is_glob(&p.display().to_string()) => {
let target_base = self.eval_to_path(&dotfile.target).await?;
self.result.dotfile_patterns.push(DotfilesPattern {
source: DotfilesSource::Pattern(p.display().to_string()),
target_base,
template: dotfile.template.unwrap_or(false),
permissions,
owner: dotfile.owner.clone(),
deploy,
link_patterns: dotfile.link_patterns.clone(),
copy_patterns: dotfile.copy_patterns.clone(),
});
}
Value::List(items) => {
let paths = items
.iter()
.filter_map(|v| match v {
Value::Path(p) => Some(p.clone()),
Value::Str(s) => Some(PathBuf::from(s)),
_ => None,
})
.collect();
let target_base = self.eval_to_path(&dotfile.target).await?;
self.result.dotfile_patterns.push(DotfilesPattern {
source: DotfilesSource::Paths(paths),
target_base,
template: dotfile.template.unwrap_or(false),
permissions,
owner: dotfile.owner.clone(),
deploy,
link_patterns: dotfile.link_patterns.clone(),
copy_patterns: dotfile.copy_patterns.clone(),
});
}
_ => {
let source = Self::value_to_path(&source_val)?;
let target = self.eval_to_path(&dotfile.target).await?;
self.result.dotfiles.push(DotfileConfig {
source,
target,
template: dotfile.template.unwrap_or(false),
permissions,
owner: dotfile.owner.clone(),
deploy,
link_patterns: dotfile.link_patterns.clone(),
copy_patterns: dotfile.copy_patterns.clone(),
exclude_paths: vec![],
exclude_sources: vec![],
});
}
}
Ok(None)
}
Statement::Package(pkg) => {
tracing::trace!("eval package");
if let Some(ref when) = pkg.when {
let cond = self.eval_expr(when).await?;
if !cond.is_truthy() {
return Ok(None);
}
}
let default = if let Some(ref d) = pkg.default {
Some(self.eval_to_string(d).await?)
} else {
None
};
let brew = if let Some(ref s) = pkg.brew {
Some(self.eval_to_string(&s.name).await?)
} else {
None
};
let apt = if let Some(ref s) = pkg.apt {
Some(self.eval_to_string(&s.name).await?)
} else {
None
};
let pacman = if let Some(ref s) = pkg.pacman {
Some(self.eval_to_string(&s.name).await?)
} else {
None
};
let yay = if let Some(ref s) = pkg.yay {
Some(self.eval_to_string(&s.name).await?)
} else {
None
};
let xbps = if let Some(ref s) = pkg.xbps {
Some(self.eval_to_string(&s.name).await?)
} else {
None
};
self.result.packages.push(PackageConfig {
default,
brew,
apt,
pacman,
yay,
xbps,
});
Ok(None)
}
Statement::Secret(secret) => {
let source = self.eval_to_path(&secret.source).await?;
let target = self.eval_to_path(&secret.target).await?;
self.result.secrets.push(SecretConfig {
source,
target,
mode: secret.mode,
});
Ok(None)
}
Statement::Encrypted(encrypted) => {
for entry in &encrypted.entries {
match entry {
crate::ast::EncryptedEntry::Var(name, expr) => {
let val = self.eval_expr(expr).await?;
match val {
Value::Str(s) => {
self.result.encrypted_vars.insert(name.clone(), s);
}
_ => {
return Err(EvalError::TypeError(format!(
"encrypted var '{}' must be a string, got {}",
name,
val.type_name()
)));
}
}
}
crate::ast::EncryptedEntry::File(name, expr) => {
let path = self.eval_to_path(expr).await?;
self.result.encrypted_files.insert(name.clone(), path);
}
}
}
Ok(None)
}
Statement::Hook(hook) => {
tracing::trace!("eval hook");
if let Some(ref when) = hook.when {
let cond = self.eval_expr(when).await?;
if !cond.is_truthy() {
return Ok(None);
}
}
let run = self.eval_to_string(&hook.run).await?;
self.result.hooks.push(HookConfig {
stage: hook.stage.clone(),
run,
});
Ok(None)
}
Statement::Return(expr) => {
let value = if let Some(e) = expr {
self.eval_expr(e).await?
} else {
Value::None
};
Ok(Some(value))
}
Statement::Expr(expr) => {
self.eval_expr(expr).await?;
Ok(None)
}
_ => Ok(None),
}
}
#[async_recursion(?Send)]
#[tracing::instrument(level = "trace", skip_all)]
async fn eval_expr(&mut self, expr: &Expr) -> Result<Value, EvalError> {
match expr {
Expr::Literal(lit) => Ok(match lit {
Literal::Int(n) => Value::Int(*n),
Literal::Float(n) => Value::Float(*n),
Literal::Str(s) => Value::Str(s.clone()),
Literal::Bool(b) => Value::Bool(*b),
Literal::None => Value::None,
}),
Expr::Ident(name) => {
if let Some(v) = self.env.get(name) {
Ok(v.clone())
} else if self.env.get_function(name).is_some() {
let (func, env) = self.env.get_function(name).unwrap().clone();
Ok(Value::Function(func, env))
} else {
Err(EvalError::UndefinedVariable(name.clone()))
}
}
Expr::Binary(left, op, right) => {
let left_val = self.eval_expr(left).await?;
let right_val = self.eval_expr(right).await?;
self.eval_binary_op(&left_val, op, &right_val)
}
Expr::Unary(op, expr) => {
let val = self.eval_expr(expr).await?;
match op {
UnaryOp::Neg => match val {
Value::Int(n) => Ok(Value::Int(-n)),
Value::Float(n) => Ok(Value::Float(-n)),
_ => Err(EvalError::TypeError(format!(
"cannot negate {}",
val.type_name()
))),
},
UnaryOp::Not => Ok(Value::Bool(!val.is_truthy())),
}
}
Expr::Call(callee, args) => {
// Check for built-in functions first (before evaluating callee)
if let Expr::Ident(name) = callee.as_ref() {
// First check if it's defined in the environment
if self.env.get(name).is_none() && self.env.get_function(name).is_none() {
// Not in env, try as a builtin
let mut arg_vals = Vec::with_capacity(args.len());
for a in args {
arg_vals.push(self.eval_expr(a).await?);
}
// Try calling as builtin - if it succeeds, return the result
match self.call_builtin(name, &arg_vals, args).await {
Ok(result) => return Ok(result),
Err(EvalError::UndefinedFunction(_)) => {
// Not a builtin either, fall through to report undefined variable
return Err(EvalError::UndefinedVariable(name.clone()));
}
Err(e) => return Err(e),
}
}
}
let callee_val = self.eval_expr(callee).await?;
let mut arg_vals = Vec::with_capacity(args.len());
for a in args {
arg_vals.push(self.eval_expr(a).await?);
}
match callee_val {
Value::Function(func, func_env) => {
self.call_function(&func, &func_env, &arg_vals).await
}
Value::Lambda(params, body, lambda_env) => {
self.call_lambda(&params, &body, &lambda_env, &arg_vals)
.await
}
_ => Err(EvalError::TypeError(format!(
"cannot call {}",
callee_val.type_name()
))),
}
}
Expr::MethodCall(obj, method, args) => {
let obj_val = self.eval_expr(obj).await?;
let mut arg_vals = Vec::with_capacity(args.len());
for a in args {
arg_vals.push(self.eval_expr(a).await?);
}
self.call_method(&obj_val, method, &arg_vals, args).await
}
Expr::Field(obj, field) => {
let obj_val = self.eval_expr(obj).await?;
match obj_val {
Value::Struct(name, fields) => {
fields
.get(field)
.cloned()
.ok_or_else(|| EvalError::FieldNotFound {
ty: name,
field: field.clone(),
})
}
_ => Err(EvalError::TypeError(format!(
"cannot access field on {}",
obj_val.type_name()
))),
}
}
Expr::Index(obj, idx) => {
let obj_val = self.eval_expr(obj).await?;
let idx_val = self.eval_expr(idx).await?;
match (obj_val, idx_val) {
(Value::List(items), Value::Int(i)) => {
let index = if i < 0 { items.len() as i64 + i } else { i };
items
.get(index as usize)
.cloned()
.ok_or(EvalError::IndexOutOfBounds {
index: i,
len: items.len(),
})
}
(Value::Str(s), Value::Int(i)) => {
let index = if i < 0 { s.len() as i64 + i } else { i };
s.chars()
.nth(index as usize)
.map(|c| Value::Str(c.to_string()))
.ok_or(EvalError::IndexOutOfBounds {
index: i,
len: s.len(),
})
}
_ => Err(EvalError::TypeError("invalid index operation".to_string())),
}
}
Expr::List(items) => {
let mut values = Vec::with_capacity(items.len());
for i in items {
values.push(self.eval_expr(i).await?);
}
Ok(Value::List(values))
}
Expr::StructInit(name, fields) => {
let mut values = IndexMap::new();
if let Some(decl) = self.env.get_struct(name).cloned() {
for field in &decl.fields {
if let Some(expr) = fields.get(&field.name) {
values.insert(field.name.clone(), self.eval_expr(expr).await?);
} else if let Some(ref default) = field.default {
values.insert(field.name.clone(), self.eval_expr(default).await?);
}
}
} else {
for (k, v) in fields {
values.insert(k.clone(), self.eval_expr(v).await?);
}
}
Ok(Value::Struct(name.clone(), values))
}
Expr::EnumVariant(ty, variant) => Ok(Value::Enum(ty.clone(), variant.clone())),
Expr::If(cond, then_expr, else_expr) => {
let cond_val = self.eval_expr(cond).await?;
if cond_val.is_truthy() {
self.eval_expr(then_expr).await
} else if let Some(else_e) = else_expr {
self.eval_expr(else_e).await
} else {
Ok(Value::None)
}
}
Expr::Lambda(params, body, ..) => Ok(Value::Lambda(
params.clone(),
*body.clone(),
self.env.clone(),
)),
Expr::Await(expr) => {
let val = self.eval_expr(expr).await?;
match val {
Value::Future(async_val) => {
let task = async_val
.0
.lock()
.map_err(|e| EvalError::AsyncError(e.to_string()))?
.take()
.ok_or_else(|| {
EvalError::AsyncError("future already consumed".into())
})?;
task.await
}
other => Ok(other), // Non-futures pass through
}
}
Expr::Path(left, right) => {
let left_val = self.eval_expr(left).await?;
let right_val = self.eval_expr(right).await?;
let has_glob = |s: &str| s.contains('*') || s.contains('?') || s.contains('[');
// Resolve a glob pattern to an absolute base using source_dir when
// available, so patterns like "config" / "*" expand relative to the
// config file rather than the current working directory.
let resolve_glob_base = |p: &std::path::Path| -> std::path::PathBuf {
if p.is_relative()
&& let Some(ref sd) = self.source_dir
{
return sd.join(p);
}
p.to_path_buf()
};
// If left is a list (from a previous glob), map over it
if let Value::List(items) = left_val {
let right_path = Self::value_to_path(&right_val)?;
let mut results = Vec::with_capacity(items.len());
for item in items {
let item_path = Self::value_to_path(&item)?;
let joined = item_path.join(&right_path);
let joined_str = joined.to_string_lossy();
if has_glob(&joined_str) {
let effective = resolve_glob_base(&joined);
for entry in glob::glob(&effective.to_string_lossy())
.map_err(|e| EvalError::TypeError(format!("invalid glob: {}", e)))?
.flatten()
{
results.push(Value::Path(entry));
}
} else {
results.push(Value::Path(joined));
}
}
return Ok(Value::List(results));
}
let left_path = Self::value_to_path(&left_val)?;
let right_path = Self::value_to_path(&right_val)?;
let joined = left_path.join(right_path);
// Expand glob wildcards using source_dir as the base for relative
// patterns, so "config" / "*" resolves from the config directory
// rather than from wherever the user ran doot.
let joined_str = joined.to_string_lossy();
if has_glob(&joined_str) {
let effective = resolve_glob_base(&joined);
let paths: Vec<Value> = glob::glob(&effective.to_string_lossy())
.map_err(|e| EvalError::TypeError(format!("invalid glob: {}", e)))?
.filter_map(|e| e.ok())
.map(Value::Path)
.collect();
Ok(Value::List(paths))
} else {
Ok(Value::Path(joined))
}
}
Expr::HomePath(path) => {
let home = Self::home_dir();
let path_val = self.eval_expr(path).await?;
match path_val {
Value::Str(s) if s.is_empty() => Ok(Value::Path(home)),
Value::Str(s) => Ok(Value::Path(home.join(s))),
Value::Path(p) => Ok(Value::Path(home.join(p))),
_ => Ok(Value::Path(home)),
}
}
Expr::Interpolated(parts) => {
let mut result = String::new();
for part in parts {
match part {
InterpolatedPart::Literal(s) => result.push_str(s),
InterpolatedPart::Expr(e) => {
let val = self.eval_expr(e).await?;
result.push_str(&val.to_string_repr());
}
}
}
Ok(Value::Str(result))
}
}
}
#[tracing::instrument(level = "trace", skip_all)]
fn eval_binary_op(&self, left: &Value, op: &BinOp, right: &Value) -> Result<Value, EvalError> {
match op {
BinOp::Add => match (left, right) {
(Value::Int(a), Value::Int(b)) => Ok(Value::Int(a + b)),
(Value::Float(a), Value::Float(b)) => Ok(Value::Float(a + b)),
(Value::Int(a), Value::Float(b)) => Ok(Value::Float(*a as f64 + b)),
(Value::Float(a), Value::Int(b)) => Ok(Value::Float(a + *b as f64)),
(Value::Str(a), Value::Str(b)) => Ok(Value::Str(format!("{}{}", a, b))),
_ => Err(EvalError::TypeError(format!(
"cannot add {} and {}",
left.type_name(),
right.type_name()
))),
},
BinOp::Sub => match (left, right) {
(Value::Int(a), Value::Int(b)) => Ok(Value::Int(a - b)),
(Value::Float(a), Value::Float(b)) => Ok(Value::Float(a - b)),
(Value::Int(a), Value::Float(b)) => Ok(Value::Float(*a as f64 - b)),
(Value::Float(a), Value::Int(b)) => Ok(Value::Float(a - *b as f64)),
_ => Err(EvalError::TypeError(format!(
"cannot subtract {} and {}",
left.type_name(),
right.type_name()
))),
},
BinOp::Mul => match (left, right) {
(Value::Int(a), Value::Int(b)) => Ok(Value::Int(a * b)),
(Value::Float(a), Value::Float(b)) => Ok(Value::Float(a * b)),
(Value::Int(a), Value::Float(b)) => Ok(Value::Float(*a as f64 * b)),
(Value::Float(a), Value::Int(b)) => Ok(Value::Float(a * *b as f64)),
_ => Err(EvalError::TypeError(format!(
"cannot multiply {} and {}",
left.type_name(),
right.type_name()
))),
},
BinOp::Div => match (left, right) {
(Value::Int(a), Value::Int(b)) => {
if *b == 0 {
Err(EvalError::DivisionByZero)
} else {
Ok(Value::Int(a / b))
}
}
(Value::Float(a), Value::Float(b)) => {
if *b == 0.0 {
Err(EvalError::DivisionByZero)
} else {
Ok(Value::Float(a / b))
}
}
(Value::Int(a), Value::Float(b)) => {
if *b == 0.0 {
Err(EvalError::DivisionByZero)
} else {
Ok(Value::Float(*a as f64 / b))
}
}
(Value::Float(a), Value::Int(b)) => {
if *b == 0 {
Err(EvalError::DivisionByZero)
} else {
Ok(Value::Float(a / *b as f64))
}
}
_ => Err(EvalError::TypeError(format!(
"cannot divide {} and {}",
left.type_name(),
right.type_name()
))),
},
BinOp::Mod => match (left, right) {
(Value::Int(a), Value::Int(b)) => {
if *b == 0 {
Err(EvalError::DivisionByZero)
} else {
Ok(Value::Int(a % b))
}
}
_ => Err(EvalError::TypeError(format!(
"cannot modulo {} and {}",
left.type_name(),
right.type_name()
))),
},
BinOp::Eq => Ok(Value::Bool(self.values_equal(left, right))),
BinOp::NotEq => Ok(Value::Bool(!self.values_equal(left, right))),
BinOp::Lt => match (left, right) {
(Value::Int(a), Value::Int(b)) => Ok(Value::Bool(a < b)),
(Value::Float(a), Value::Float(b)) => Ok(Value::Bool(a < b)),
(Value::Str(a), Value::Str(b)) => Ok(Value::Bool(a < b)),
_ => Err(EvalError::TypeError("cannot compare".to_string())),
},
BinOp::Gt => match (left, right) {
(Value::Int(a), Value::Int(b)) => Ok(Value::Bool(a > b)),
(Value::Float(a), Value::Float(b)) => Ok(Value::Bool(a > b)),
(Value::Str(a), Value::Str(b)) => Ok(Value::Bool(a > b)),
_ => Err(EvalError::TypeError("cannot compare".to_string())),
},
BinOp::LtEq => match (left, right) {
(Value::Int(a), Value::Int(b)) => Ok(Value::Bool(a <= b)),
(Value::Float(a), Value::Float(b)) => Ok(Value::Bool(a <= b)),
(Value::Str(a), Value::Str(b)) => Ok(Value::Bool(a <= b)),
_ => Err(EvalError::TypeError("cannot compare".to_string())),
},
BinOp::GtEq => match (left, right) {
(Value::Int(a), Value::Int(b)) => Ok(Value::Bool(a >= b)),
(Value::Float(a), Value::Float(b)) => Ok(Value::Bool(a >= b)),
(Value::Str(a), Value::Str(b)) => Ok(Value::Bool(a >= b)),
_ => Err(EvalError::TypeError("cannot compare".to_string())),
},
BinOp::And => Ok(Value::Bool(left.is_truthy() && right.is_truthy())),
BinOp::Or => Ok(Value::Bool(left.is_truthy() || right.is_truthy())),
BinOp::PathJoin => {
let left_path = match left {
Value::Path(p) => p.clone(),
Value::Str(s) => PathBuf::from(s),
_ => return Err(EvalError::TypeError("expected path".to_string())),
};
let right_path = match right {
Value::Path(p) => p.clone(),
Value::Str(s) => PathBuf::from(s),
_ => return Err(EvalError::TypeError("expected path".to_string())),
};
Ok(Value::Path(left_path.join(right_path)))
}
BinOp::NullCoalesce => {
if matches!(left, Value::None) {
Ok(right.clone())
} else {
Ok(left.clone())
}
}
}
}
fn values_equal(&self, left: &Value, right: &Value) -> bool {
match (left, right) {
(Value::Int(a), Value::Int(b)) => a == b,
(Value::Float(a), Value::Float(b)) => (a - b).abs() < f64::EPSILON,
(Value::Str(a), Value::Str(b)) => a == b,
(Value::Bool(a), Value::Bool(b)) => a == b,
(Value::Path(a), Value::Path(b)) => a == b,
(Value::None, Value::None) => true,
(Value::Enum(t1, v1), Value::Enum(t2, v2)) => t1 == t2 && v1 == v2,
(Value::List(a), Value::List(b)) => {
a.len() == b.len() && a.iter().zip(b.iter()).all(|(x, y)| self.values_equal(x, y))
}
_ => false,
}
}
#[tracing::instrument(level = "trace", skip_all)]
fn pattern_matches(&self, pattern: &Pattern, value: &Value) -> bool {
match pattern {
Pattern::Wildcard => true,
Pattern::Literal(lit) => match (lit, value) {
(Literal::Int(a), Value::Int(b)) => *a == *b,
(Literal::Float(a), Value::Float(b)) => (*a - *b).abs() < f64::EPSILON,
(Literal::Str(a), Value::Str(b)) => a == b,
(Literal::Bool(a), Value::Bool(b)) => *a == *b,
(Literal::None, Value::None) => true,
_ => false,
},
Pattern::Ident(_) => true,
Pattern::EnumVariant { ty, variant } => match value {
Value::Enum(t, v) => ty == t && variant == v,
_ => false,
},
}
}
#[async_recursion(?Send)]
#[tracing::instrument(level = "trace", skip_all, fields(name = %func.name))]
pub async fn call_function(
&mut self,
func: &FnDecl,
func_env: &Env,
args: &[Value],
) -> Result<Value, EvalError> {
let mut new_env = func_env.clone();
new_env.push_scope();
for (param, arg) in func.params.iter().zip(args.iter()) {
new_env.define(param.name.clone(), arg.clone());
}
let old_env = std::mem::replace(&mut self.env, new_env);
let mut result = Value::None;
for stmt in &func.body {
if let Some(v) = self.eval_statement(&stmt.node).await? {
result = v;
break;
}
}
self.env = old_env;
Ok(result)
}
#[async_recursion(?Send)]
#[tracing::instrument(level = "trace", skip_all)]
async fn call_lambda(
&mut self,
params: &[FnParam],
body: &Expr,
lambda_env: &Env,
args: &[Value],
) -> Result<Value, EvalError> {
let mut new_env = lambda_env.clone();
new_env.push_scope();
for (param, arg) in params.iter().zip(args.iter()) {
new_env.define(param.name.clone(), arg.clone());
}
let old_env = std::mem::replace(&mut self.env, new_env);
let result = self.eval_expr(body).await?;
self.env = old_env;
Ok(result)
}
#[tracing::instrument(level = "trace", skip_all, fields(name))]
async fn call_builtin(
&mut self,
name: &str,
args: &[Value],
arg_exprs: &[Expr],
) -> Result<Value, EvalError> {
builtins::call_builtin(self, name, args, arg_exprs).await
}
#[tracing::instrument(level = "trace", skip_all, fields(method))]
async fn call_method(
&mut self,
obj: &Value,
method: &str,
args: &[Value],
arg_exprs: &[Expr],
) -> Result<Value, EvalError> {
builtins::call_method(self, obj, method, args, arg_exprs).await
}
/// Converts a Value to a PathBuf without needing an expression.
fn value_to_path(val: &Value) -> Result<PathBuf, EvalError> {
match val {
Value::Path(p) => Ok(p.clone()),
Value::Str(s) => {
if let Some(stripped) = s.strip_prefix('~') {
let home = Self::home_dir();
Ok(home.join(stripped.strip_prefix('/').unwrap_or(stripped)))
} else {
Ok(PathBuf::from(s))
}
}
_ => Err(EvalError::TypeError(format!(
"expected path, got {}",
val.type_name()
))),
}
}
#[tracing::instrument(level = "trace", skip_all)]
async fn eval_to_path(&mut self, expr: &Expr) -> Result<PathBuf, EvalError> {
let val = self.eval_expr(expr).await?;
Self::value_to_path(&val)
}
/// Returns DOOT_HOME if set, otherwise the real home directory.
#[tracing::instrument(level = "trace")]
fn home_dir() -> PathBuf {
doot_utils::xdg::home_dir()
}
#[tracing::instrument(level = "trace", skip_all)]
async fn eval_to_string(&mut self, expr: &Expr) -> Result<String, EvalError> {
let val = self.eval_expr(expr).await?;
Ok(val.to_string_repr())
}
pub fn env(&self) -> &Env {
&self.env
}
pub fn env_mut(&mut self) -> &mut Env {
&mut self.env
}
}
impl Default for Evaluator {
fn default() -> Self {
Self::new()
}
}
impl Evaluator {
#[async_recursion(?Send)]
#[tracing::instrument(level = "trace", skip_all)]
pub async fn eval_in_env(&mut self, expr: &Expr, env: Env) -> Result<Value, EvalError> {
let old_env = std::mem::replace(&mut self.env, env);
let result = self.eval_expr(expr).await;
self.env = old_env;
result
}
#[async_recursion(?Send)]
#[tracing::instrument(level = "trace", skip_all, fields(name = %func.name))]
pub async fn call_fn(
&mut self,
func: &FnDecl,
func_env: &Env,
args: &[Value],
) -> Result<Value, EvalError> {
self.call_function(func, func_env, args).await
}
}
/// Cache for command_exists checks.
static COMMAND_CACHE: OnceLock<std::sync::Mutex<HashMap<String, bool>>> = OnceLock::new();
/// Checks if a command exists in PATH or common bin directories (cached).
fn command_exists(cmd: &str) -> bool {
let cache = COMMAND_CACHE.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
let mut cache = cache.lock().unwrap();
if let Some(&exists) = cache.get(cmd) {
return exists;
}
// Check PATH first using `which`
let exists = if std::process::Command::new("which")
.arg(cmd)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
{
true
} else {
// Fallback to hardcoded paths
let paths = ["/usr/bin/", "/usr/local/bin/", "/bin/"];
paths
.iter()
.any(|p| std::path::Path::new(&format!("{}{}", p, cmd)).exists())
};
cache.insert(cmd.to_string(), exists);
exists
}
/// Detects the package manager.
fn detect_pkg_manager() -> String {
match std::env::consts::OS {
"macos" => "brew".to_string(),
"linux" => {
// Check AUR helpers first (they wrap pacman)
if command_exists("yay") {
"yay"
} else if command_exists("paru") {
"paru"
} else if command_exists("pacman") {
"pacman"
} else if command_exists("apt") {
"apt"
} else if command_exists("dnf") {
"dnf"
} else if command_exists("nix") {
"nix"
} else {
"unknown"
}
.to_string()
}
_ => "unknown".to_string(),
}
}
/// Detects the current distro/environment.
/// Checks for custom distros first, then falls back to os_info.
fn detect_distro() -> String {
// Check for custom distros/environments first (by config directory presence)
let config_dir = doot_utils::xdg::config_home();
// Omarchy - Arch-based custom environment
if config_dir.join("omarchy").exists() {
return "omarchy".to_string();
}
// Add more custom distro checks here as needed:
// if config_dir.join("some-custom-distro").exists() {
// return "some-custom-distro".to_string();
// }
// Fall back to os_info detection with normalization
let info = os_info::get();
let distro_raw = info.os_type().to_string().to_lowercase();
normalize_distro_name(&distro_raw).to_string()
}
/// Normalizes distro names for easier matching.
fn normalize_distro_name(distro_raw: &str) -> &str {
match distro_raw {
"arch linux" => "arch",
"ubuntu linux" | "ubuntu" => "ubuntu",
"debian gnu/linux" | "debian linux" => "debian",
"fedora linux" => "fedora",
"centos linux" => "centos",
"red hat enterprise linux" | "rhel" => "rhel",
"linux mint" => "mint",
"pop!_os" | "pop os" => "pop_os",
"manjaro linux" => "manjaro",
"opensuse" | "opensuse leap" | "opensuse tumbleweed" => "opensuse",
"nixos" => "nixos",
"void linux" => "void",
"gentoo" | "gentoo linux" => "gentoo",
"alpine linux" => "alpine",
"macos" | "mac os" | "mac os x" => "macos",
other => other,
}
}