doot/crates/doot-dotfile/src/builtins.rs

393 lines
13 KiB
Rust

//! 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<String, Thunk> = 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::<FileRef>().is_some() => TaskData::EncFile {
key: k.clone(),
path: a.downcast_ref::<FileRef>().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<String, Thunk>, k: &str) -> Option<String> {
m.get(k).and_then(|t| match i.force(t) {
Value::Str(s) => Some((*s).clone()),
_ => None,
})
}
fn field_bool(i: &Interp, m: &BTreeMap<String, Thunk>, k: &str) -> Option<bool> {
m.get(k).and_then(|t| match i.force(t) {
Value::Bool(b) => Some(b),
_ => None,
})
}
fn field_int(i: &Interp, m: &BTreeMap<String, Thunk>, k: &str) -> Option<i64> {
m.get(k).and_then(|t| match i.force(t) {
Value::Int(n) => Some(n),
_ => None,
})
}
fn field_str_list(i: &Interp, m: &BTreeMap<String, Thunk>, k: &str) -> Vec<String> {
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<String, Thunk>, k: &str) -> Vec<Perm> {
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<BTreeMap<String, Thunk>> {
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<String>; 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()
}