//! 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 = 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>>>>); impl AsyncValue { pub fn new(task: smol::Task>) -> 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), Struct(String, IndexMap), Enum(String, String), Function(FnDecl, Env), Lambda(Vec, 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::>() .join(":") } Value::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 = items.iter().map(|v| v.to_string_repr()).collect(); format!("[{}]", parts.join(", ")) } Value::Struct(name, fields) => { let parts: Vec = 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!("", f.name), Value::Lambda(_, _, _) => "".to_string(), Value::Future(_) => "".to_string(), Value::None => "none".to_string(), } } } /// Runtime environment with variable bindings. #[derive(Clone, Debug, Default)] pub struct Env { scopes: Vec>, functions: HashMap, structs: HashMap, enums: HashMap, macros: HashMap, } 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 { 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 { 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), } /// 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, pub owner: Option, pub deploy: DeployMode, pub link_patterns: Vec, pub copy_patterns: Vec, } /// Evaluated dotfile configuration. #[derive(Clone, Debug)] pub struct DotfileConfig { pub source: PathBuf, pub target: PathBuf, pub template: bool, pub permissions: Vec, pub owner: Option, pub deploy: DeployMode, pub link_patterns: Vec, pub copy_patterns: Vec, /// Target paths to skip during directory deploy (specialized by explicit dotfile blocks). pub exclude_paths: Vec, /// Source paths to skip during directory deploy (when explicit block targets elsewhere). pub exclude_sources: Vec, } /// Evaluated package configuration. #[derive(Clone, Debug)] pub struct PackageConfig { pub default: Option, pub brew: Option, pub apt: Option, pub pacman: Option, pub yay: Option, pub xbps: Option, } /// Evaluated secret file configuration. #[derive(Clone, Debug)] pub struct SecretConfig { pub source: PathBuf, pub target: PathBuf, pub mode: Option, } /// 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, pub dotfile_patterns: Vec, pub packages: Vec, pub secrets: Vec, pub hooks: Vec, pub encrypted_vars: HashMap, pub encrypted_files: HashMap, 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, } 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 { 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 { 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 { 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 { self.env.get_raw_variables() } #[async_recursion(?Send)] #[tracing::instrument(level = "trace", skip_all)] async fn eval_statement(&mut self, stmt: &Statement) -> Result, 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 ¯o_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::>(); // 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 { 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(¶ms, &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 = 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { self.call_function(func, func_env, args).await } } /// Cache for command_exists checks. static COMMAND_CACHE: OnceLock>> = 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, } }