//! The dotfile vocabulary: effect builtins, host facts, and the `Config`/`Os` //! schema, registered into an [`Engine`]. Each effect builtin takes an attrset //! (or string) and yields a `Task` node carrying a [`TaskData`] payload. use std::collections::BTreeMap; use std::rc::Rc; use doot_lang::lang::ast::{EnumDecl, Type}; use doot_lang::lang::engine::{BuiltinScheme, Engine}; use doot_lang::lang::eval::{ Interp, Thunk, Value, as_int, as_str, empty_list, forced, list_from_vec, }; use crate::payload::{Deploy, FileRef, Perm, Stage, TaskData, config_struct}; /// Register the dotfile vocabulary into `engine`. pub fn register_dotfile(e: &mut Engine) { let var = Type::Var; let fun = |a: Type, b: Type| Type::Fun(Box::new(a), Box::new(b)); let task = || Type::Task(Box::new(Type::Dyn)); let effect = || BuiltinScheme::poly(1, fun(var(0), task())); e.register_builtin("pkg", effect(), 1, |i, a| b_pkg(i, &a[0])); e.register_builtin("package", effect(), 1, |i, a| b_pkg(i, &a[0])); e.register_builtin("apt", effect(), 1, |i, a| { pkg_task(i, one_pkg("apt", as_str(&i.force(&a[0])))) }); e.register_builtin("pacman", effect(), 1, |i, a| { pkg_task(i, one_pkg("pacman", as_str(&i.force(&a[0])))) }); e.register_builtin("yay", effect(), 1, |i, a| { pkg_task(i, one_pkg("yay", as_str(&i.force(&a[0])))) }); e.register_builtin("xbps", effect(), 1, |i, a| { pkg_task(i, one_pkg("xbps", as_str(&i.force(&a[0])))) }); e.register_builtin("brew", effect(), 1, |i, a| b_brew(i, &a[0])); e.register_builtin("dotfile", effect(), 1, |i, a| b_dotfile(i, &a[0])); e.register_builtin("hook", effect(), 1, |i, a| b_hook(i, &a[0])); e.register_builtin("secret", effect(), 1, |i, a| b_secret(i, &a[0])); e.register_builtin("tap", effect(), 1, |i, a| { let name = as_str(&i.force(&a[0])); Value::Task(i.make_task( format!("tap:{name}"), Rc::new(TaskData::Tap { name }), &empty_list(), )) }); e.register_builtin("formula", effect(), 1, |i, a| { let name = as_str(&i.force(&a[0])); Value::Task(i.make_task( format!("formula:{name}"), Rc::new(TaskData::Formula { name }), &empty_list(), )) }); e.register_builtin( "file", BuiltinScheme::mono(fun(Type::Str, Type::Dyn)), 1, |i, a| Value::Foreign(Rc::new(FileRef(as_str(&i.force(&a[0]))))), ); e.register_builtin( "encrypted", BuiltinScheme::poly(1, fun(var(0), Type::List(Box::new(task())))), 1, |i, a| b_encrypted(i, &a[0]), ); // host facts, exposed as plain string values let s = |v: String| Value::Str(Rc::new(v)); let home = || doot_utils::xdg::home_dir().to_string_lossy().into_owned(); let conf = || { doot_utils::xdg::config_home() .to_string_lossy() .into_owned() }; e.register_value("home_dir", BuiltinScheme::mono(Type::Str), s(home())); e.register_value("config_dir", BuiltinScheme::mono(Type::Str), s(conf())); e.register_value("os", BuiltinScheme::mono(Type::Str), s(current_os())); e.register_value("distro", BuiltinScheme::mono(Type::Str), s(detect_distro())); // host context record: host.os : Os, host.distro/configDir/homeDir : Str let os_variant = match current_os().as_str() { "macos" => "MacOS", "linux" => "Linux", _ => "Other", }; let host_fields: BTreeMap = BTreeMap::from([ ( "os".to_string(), forced(Value::Enum( Rc::new("Os".into()), Rc::new(os_variant.into()), )), ), ("distro".to_string(), forced(s(detect_distro()))), ("configDir".to_string(), forced(s(conf()))), ("homeDir".to_string(), forced(s(home()))), ]); let host_ty = Type::Record(BTreeMap::from([ ("os".to_string(), Type::Enum("Os".to_string())), ("distro".to_string(), Type::Str), ("configDir".to_string(), Type::Str), ("homeDir".to_string(), Type::Str), ])); e.register_value( "host", BuiltinScheme::mono(host_ty), Value::Attr(Some(Rc::new("Host".into())), Rc::new(host_fields)), ); // the built-in Config schema and Os enum e.register_struct(config_struct()); e.register_enum(EnumDecl { name: "Os".to_string(), variants: vec!["Linux".into(), "MacOS".into(), "Other".into()], methods: Vec::new(), span: doot_lang::lang::diag::Span::point(0), }); } fn pkg_task(i: &Interp, data: TaskData) -> Value { let name = match &data { TaskData::Package { default, brew, cask, apt, pacman, yay, xbps, } => default .clone() .or_else(|| brew.clone()) .or_else(|| cask.clone()) .or_else(|| apt.clone()) .or_else(|| pacman.clone()) .or_else(|| yay.clone()) .or_else(|| xbps.clone()) .unwrap_or_else(|| "pkg".into()), _ => "pkg".into(), }; Value::Task(i.make_task(format!("pkg:{name}"), Rc::new(data), &empty_list())) } // `package "name"` shorthand or `package { default = ..; xbps = ..; }` fn b_pkg(i: &Interp, arg: &Thunk) -> Value { let arg = i.force(arg); let data = match &arg { Value::Str(s) => TaskData::Package { default: Some((**s).clone()), brew: None, cask: None, apt: None, pacman: None, yay: None, xbps: None, }, Value::Attr(_, m) => TaskData::Package { default: field_str(i, m, "default"), brew: field_str(i, m, "brew"), cask: field_str(i, m, "cask"), apt: field_str(i, m, "apt"), pacman: field_str(i, m, "pacman"), yay: field_str(i, m, "yay"), xbps: field_str(i, m, "xbps"), }, _ => panic!("package expects a string or attrset"), }; pkg_task(i, data) } // `brew "x"` -> formula; `brew { package = "x"; cask = true; }` -> cask fn b_brew(i: &Interp, arg: &Thunk) -> Value { let v = i.force(arg); let data = match &v { Value::Str(s) => one_pkg("brew", (**s).clone()), Value::Attr(_, m) => { let name = field_str(i, m, "package").unwrap_or_default(); let cask = field_bool(i, m, "cask").unwrap_or(false); one_pkg(if cask { "cask" } else { "brew" }, name) } _ => panic!("brew expects a string or attrset"), }; pkg_task(i, data) } fn b_dotfile(i: &Interp, arg: &Thunk) -> Value { let arg = i.force(arg); let m = as_attr(&arg); let source = field_str(i, &m, "source").unwrap_or_default(); let target = field_str(i, &m, "target").unwrap_or_default(); let template = field_bool(i, &m, "template").unwrap_or(false); let owner = field_str(i, &m, "owner"); let deploy = match field_str(i, &m, "deploy").as_deref() { Some("link") => Deploy::Link, _ => Deploy::Copy, }; let link_patterns = field_str_list(i, &m, "link_patterns"); let copy_patterns = field_str_list(i, &m, "copy_patterns"); let permissions = field_perms(i, &m, "permissions"); let label = format!("dotfile:{target}"); let data = TaskData::Dotfile { source, target, template, permissions, owner, deploy, link_patterns, copy_patterns, }; Value::Task(i.make_task(label, Rc::new(data), &arg)) } fn b_hook(i: &Interp, arg: &Thunk) -> Value { let arg = i.force(arg); let m = as_attr(&arg); let run = field_str(i, &m, "run").unwrap_or_default(); let stage = match field_str(i, &m, "stage").as_deref() { Some("before_deploy") => Stage::BeforeDeploy, Some("before_package") => Stage::BeforePackage, Some("after_package") => Stage::AfterPackage, _ => Stage::AfterDeploy, }; let short: String = run.chars().take(28).collect(); Value::Task(i.make_task( format!("hook:{short}"), Rc::new(TaskData::Hook { run, stage }), &arg, )) } fn b_secret(i: &Interp, arg: &Thunk) -> Value { let arg = i.force(arg); let m = as_attr(&arg); let source = field_str(i, &m, "source").unwrap_or_default(); let target = field_str(i, &m, "target").unwrap_or_default(); let mode = field_int(i, &m, "mode").map(|n| n as u32); let label = format!("secret:{target}"); Value::Task(i.make_task( label, Rc::new(TaskData::Secret { source, target, mode, }), &arg, )) } // `encrypted { K = "b64"; K2 = file("p"); }` -> one node per entry fn b_encrypted(i: &Interp, arg: &Thunk) -> Value { let arg = i.force(arg); let m = as_attr(&arg); let mut out = Vec::new(); for (k, t) in m.iter() { let v = i.force(t); let data = match &v { Value::Str(s) => TaskData::EncVar { key: k.clone(), value: (**s).clone(), }, Value::Foreign(a) if a.downcast_ref::().is_some() => TaskData::EncFile { key: k.clone(), path: a.downcast_ref::().unwrap().0.clone(), }, _ => panic!("encrypted `{k}` must be a string or file(...)"), }; let id = i.make_task(format!("enc:{k}"), Rc::new(data), &empty_list()); out.push(forced(Value::Task(id))); } list_from_vec(out) } fn field_str(i: &Interp, m: &BTreeMap, k: &str) -> Option { m.get(k).and_then(|t| match i.force(t) { Value::Str(s) => Some((*s).clone()), _ => None, }) } fn field_bool(i: &Interp, m: &BTreeMap, k: &str) -> Option { m.get(k).and_then(|t| match i.force(t) { Value::Bool(b) => Some(b), _ => None, }) } fn field_int(i: &Interp, m: &BTreeMap, k: &str) -> Option { m.get(k).and_then(|t| match i.force(t) { Value::Int(n) => Some(n), _ => None, }) } fn field_str_list(i: &Interp, m: &BTreeMap, k: &str) -> Vec { match m.get(k).map(|t| i.force(t)) { Some(v @ (Value::Nil | Value::Cons(_, _))) => i .list_to_vec(&v) .iter() .map(|t| as_str(&i.force(t))) .collect(), _ => Vec::new(), } } /// `permissions` is either a single mode int, or a list of `[pattern, mode]`. fn field_perms(i: &Interp, m: &BTreeMap, k: &str) -> Vec { match m.get(k).map(|t| i.force(t)) { Some(Value::Int(n)) => vec![Perm::Mode(n as u32)], Some(v @ (Value::Nil | Value::Cons(_, _))) => i .list_to_vec(&v) .iter() .map(|t| { let pair = i.list_to_vec(&i.force(t)); if pair.len() != 2 { panic!("permission entry must be [pattern, mode]"); } Perm::Pattern { pattern: as_str(&i.force(&pair[0])), mode: as_int(i.force(&pair[1])) as u32, } }) .collect(), _ => Vec::new(), } } fn as_attr(v: &Value) -> Rc> { match v { Value::Attr(_, m) => m.clone(), _ => panic!("expected attrset"), } } // build a Package payload with a single manager field set fn one_pkg(field: &str, name: String) -> TaskData { let mut p: [Option; 7] = Default::default(); let idx = match field { "brew" => 1, "cask" => 2, "apt" => 3, "pacman" => 4, "yay" => 5, "xbps" => 6, _ => 0, // default }; p[idx] = Some(name); let [default, brew, cask, apt, pacman, yay, xbps] = p; TaskData::Package { default, brew, cask, apt, pacman, yay, xbps, } } pub fn current_os() -> String { if cfg!(target_os = "macos") { "macos" } else if cfg!(target_os = "linux") { "linux" } else { "other" } .to_string() } pub fn detect_distro() -> String { // custom environments first (by config-dir presence), then os_info if doot_utils::xdg::config_home().join("omarchy").exists() { return "omarchy".to_string(); } let raw = os_info::get().os_type().to_string().to_lowercase(); match raw.as_str() { "arch linux" => "arch", "ubuntu linux" | "ubuntu" => "ubuntu", "debian gnu/linux" | "debian linux" => "debian", "fedora linux" => "fedora", "manjaro linux" => "manjaro", "void linux" => "void", "nixos" => "nixos", "alpine linux" => "alpine", "macos" | "mac os" | "mac os x" => "macos", other => other, } .to_string() }