- 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
1582 lines
56 KiB
Rust
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 ¯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::<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(¶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<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,
|
|
}
|
|
}
|