feat(lang): implement v2 language

This commit is contained in:
Ray Andrew 2026-06-13 19:20:44 -07:00
parent cc4684072d
commit 59eae012de
Signed by: rayandrew
SSH key fingerprint: SHA256:iGurnBY6QgoHsQWxP3NgvMEA4F3GjTcszIJnLk2jinw
75 changed files with 6569 additions and 11152 deletions

1460
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -11,6 +11,8 @@ repository = "https://github.com/rayandrew/doot"
[workspace.dependencies]
doot-utils = { path = "crates/doot-utils" }
doot-lang = { path = "crates/doot-lang" }
doot-std = { path = "crates/doot-std" }
doot-dotfile = { path = "crates/doot-dotfile" }
doot-core = { path = "crates/doot-core" }
chumsky = "0.9"

View file

@ -9,7 +9,7 @@ path = "src/main.rs"
[dependencies]
doot-utils.workspace = true
doot-lang.workspace = true
doot-dotfile.workspace = true
doot-core.workspace = true
clap.workspace = true
serde.workspace = true

File diff suppressed because it is too large Load diff

View file

@ -1,22 +1,25 @@
use super::{find_config_file, parse_config, type_check};
use super::{find_config_file, load};
use std::path::PathBuf;
/// Validates a config file (parse + type check).
/// Validates a config file (parse + type check + evaluate).
#[tracing::instrument(skip_all)]
pub fn run(config_path: Option<PathBuf>) -> anyhow::Result<()> {
let path = find_config_file(config_path)?;
let source = std::fs::read_to_string(&path)?;
tracing::debug!(path = %path.display(), "checking config");
let program = parse_config(&path)?;
println!("syntax: ok");
type_check(&program, &source, &path.display().to_string())?;
println!("types: ok");
println!("\nconfig is valid");
println!(" statements: {}", program.statements.len());
let (result, _vars) = load(&path)?;
println!("config is valid");
println!(
" dotfiles: {}",
result.dotfiles.len() + result.dotfile_patterns.len()
);
println!(" packages: {}", result.packages.len());
println!(" hooks: {}", result.hooks.len());
println!(
" secrets: {}",
result.secrets.len() + result.encrypted_files.len()
);
Ok(())
}

View file

@ -1,4 +1,4 @@
use doot_core::{Config, encryption::AgeEncryption};
use doot_core::{Settings, encryption::AgeEncryption};
use std::io::Write;
use std::path::PathBuf;
@ -17,7 +17,7 @@ pub fn run(
identity: Option<PathBuf>,
output: Option<PathBuf>,
) -> anyhow::Result<()> {
let config = Config::default();
let config = Settings::default();
let identity_raw = if let Some(path) = identity {
std::fs::read_to_string(&path)?
} else if let Ok(key) = std::env::var("DOOT_AGE_IDENTITY") {

View file

@ -1,7 +1,6 @@
use super::{find_config_file, parse_config};
use doot_core::Config;
use doot_lang::Evaluator;
use doot_lang::builtins::crypto::base64_decode;
use super::{find_config_file, load};
use doot_core::Settings;
use doot_core::builtins::crypto::base64_decode;
use std::io::Read;
use std::path::PathBuf;
@ -34,11 +33,8 @@ pub fn run(
identity: Option<PathBuf>,
) -> anyhow::Result<()> {
let path = find_config_file(config_path)?;
let program = parse_config(&path)?;
let source_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf();
let mut evaluator = Evaluator::new().with_source_dir(source_dir.clone());
let result = evaluator.eval_sync(&program)?;
let (result, _vars) = load(&path)?;
if result.encrypted_vars.is_empty() && result.encrypted_files.is_empty() {
println!("no encrypted entries found");
@ -46,7 +42,7 @@ pub fn run(
}
// Resolve identity
let config = Config::new(source_dir.clone());
let config = Settings::new(source_dir.clone());
let identity_raw: String = if let Some(ref path) = identity {
std::fs::read_to_string(path)?
} else if let Ok(key) = std::env::var("DOOT_AGE_IDENTITY") {

View file

@ -1,5 +1,5 @@
use doot_core::Config;
use doot_lang::builtins::crypto::base64_decode;
use doot_core::Settings;
use doot_core::builtins::crypto::base64_decode;
use std::io::{self, Read};
use std::path::PathBuf;
@ -10,7 +10,7 @@ fn resolve_identity(identity: Option<PathBuf>) -> anyhow::Result<age::x25519::Id
} else if let Ok(key) = std::env::var("DOOT_AGE_IDENTITY") {
key
} else {
let id_file = Config::default_config_dir().join("identity.txt");
let id_file = Settings::default_config_dir().join("identity.txt");
if id_file.exists() {
std::fs::read_to_string(&id_file)?
} else {

View file

@ -0,0 +1,265 @@
//! Deploy helpers shared by `apply` and `status`: glob-pattern expansion,
//! explicit/glob merge, target-collision lints, and template freshness checks.
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use doot_core::deploy::TemplateEngine;
use doot_core::evaluator::{DotfileConfig, DotfilesPattern, DotfilesSource};
use doot_core::state::StateStore;
/// Extracts the directory prefix before any wildcard in a glob pattern.
/// "config/*" -> "config", "a/b/**/*.rs" -> "a/b", "*" -> ""
fn glob_base_dir(pattern: &str) -> PathBuf {
let wildcard_pos = pattern.find(['*', '?', '[']);
let prefix = match wildcard_pos {
Some(pos) => &pattern[..pos],
None => pattern,
};
match prefix.rfind('/') {
Some(pos) => PathBuf::from(&prefix[..pos]),
None => PathBuf::new(),
}
}
/// Finds the common path prefix of a list of paths.
fn common_path_prefix(paths: &[PathBuf]) -> PathBuf {
if paths.is_empty() {
return PathBuf::new();
}
let mut prefix = paths[0].parent().unwrap_or(Path::new("")).to_path_buf();
for path in &paths[1..] {
while !path.starts_with(&prefix) {
if !prefix.pop() {
return PathBuf::new();
}
}
}
prefix
}
/// Renders a template file and returns its BLAKE3 hash.
fn rendered_template_hash(
engine: &TemplateEngine,
source_path: &Path,
) -> anyhow::Result<Option<String>> {
if !source_path.is_file() {
return Ok(None);
}
let content = std::fs::read_to_string(source_path)?;
let rendered = engine
.render(&content)
.map_err(|e| anyhow::anyhow!("template render error: {}", e))?;
let hash = blake3::hash(rendered.as_bytes()).to_hex().to_string();
Ok(Some(hash))
}
/// Returns true if the rendered template output differs from the last deployed content.
pub fn template_outdated(
state: &StateStore,
engine: &TemplateEngine,
source_path: &Path,
target_path: &Path,
) -> anyhow::Result<bool> {
if !source_path.is_file() {
return Ok(false);
}
let Some(record) = state.get_deployment(target_path) else {
return Ok(false);
};
if !record.template {
return Ok(false);
}
if let Some(rendered_hash) = rendered_template_hash(engine, source_path)? {
return Ok(rendered_hash != record.target_hash);
}
Ok(false)
}
/// Returns warnings for explicit dotfile blocks whose target is exactly a glob
/// pattern's directory target. For a directory target, doot appends the source
/// filename at deploy time, so the explicit entry lands on the *same path* the
/// glob already produces - they collide instead of the explicit block overriding
/// the glob. Targeting the specific file (`... / "<name>"`) is what makes the
/// override fire.
pub fn directory_target_collisions(
dotfiles: &[DotfileConfig],
patterns: &[DotfilesPattern],
) -> Vec<String> {
let mut warnings = Vec::new();
for df in dotfiles {
for pat in patterns {
if df.target == pat.target_base {
let file = df
.source
.file_name()
.map(|f| f.to_string_lossy().into_owned())
.unwrap_or_else(|| "<name>".to_string());
warnings.push(format!(
"dotfile '{}' targets the directory '{}', which is also a glob target. \
It will collide with the glob instead of overriding it. \
Did you mean: target = ... / \"{}\"",
df.source.display(),
df.target.display(),
file,
));
}
}
}
warnings
}
/// Expands dotfile patterns (from `dotfiles:` blocks) into individual DotfileConfig
/// entries. Returns the number of entries added.
pub fn expand_dotfile_patterns(
dotfiles: &mut Vec<DotfileConfig>,
patterns: &[DotfilesPattern],
source_dir: &Path,
) -> usize {
let before = dotfiles.len();
for pattern in patterns {
let (sources, base) = match &pattern.source {
DotfilesSource::Pattern(pat) => {
let base_rel = glob_base_dir(pat);
let full_pattern = source_dir.join(pat);
let paths: Vec<PathBuf> = glob::glob(&full_pattern.to_string_lossy())
.into_iter()
.flatten()
.filter_map(|e| e.ok())
.collect();
(paths, source_dir.join(&base_rel))
}
DotfilesSource::Paths(paths) => {
let base = common_path_prefix(paths);
(paths.clone(), base)
}
};
for source_path in sources {
let rel_to_source = source_path.strip_prefix(source_dir).unwrap_or(&source_path);
let suffix = source_path.strip_prefix(&base).unwrap_or(&source_path);
let target = pattern.target_base.join(suffix);
dotfiles.push(DotfileConfig {
source: rel_to_source.to_path_buf(),
target,
template: pattern.template,
permissions: pattern.permissions.clone(),
owner: pattern.owner.clone(),
deploy: pattern.deploy,
link_patterns: pattern.link_patterns.clone(),
copy_patterns: pattern.copy_patterns.clone(),
exclude_paths: vec![],
exclude_sources: vec![],
});
}
}
dotfiles.len() - before
}
/// Merges explicit dotfile blocks into glob-expanded entries.
///
/// Three merge cases:
/// 1. Same target: explicit replaces glob-expanded entry entirely.
/// 2. Target inside directory target: adds the file's target to exclude_paths.
/// 3. Source inside directory source: adds the file's source to exclude_sources.
pub fn merge_specializations(dotfiles: &mut Vec<DotfileConfig>, glob_count: usize) {
let total = dotfiles.len();
let explicit_end = total - glob_count;
let glob_start = explicit_end;
let mut glob_to_remove: HashSet<usize> = HashSet::new();
// First pass: find same-target replacements
for exp_idx in 0..explicit_end {
for glob_idx in glob_start..total {
if glob_to_remove.contains(&glob_idx) {
continue;
}
if dotfiles[exp_idx].target == dotfiles[glob_idx].target {
glob_to_remove.insert(glob_idx);
}
}
}
// Second pass: collect exclusions for directory entries
for exp_idx in 0..explicit_end {
for glob_idx in glob_start..total {
if glob_to_remove.contains(&glob_idx) {
continue;
}
let exp_target = dotfiles[exp_idx].target.clone();
let glob_target = &dotfiles[glob_idx].target;
if exp_target.starts_with(glob_target) && exp_target != *glob_target {
dotfiles[glob_idx].exclude_paths.push(exp_target);
}
let exp_source = dotfiles[exp_idx].source.clone();
let glob_source = dotfiles[glob_idx].source.clone();
if exp_source.starts_with(&glob_source)
&& exp_source != glob_source
&& let Ok(relative) = exp_source.strip_prefix(&glob_source)
{
dotfiles[glob_idx]
.exclude_sources
.push(relative.to_path_buf());
}
}
}
let mut remove_sorted: Vec<usize> = glob_to_remove.into_iter().collect();
remove_sorted.sort_unstable_by(|a, b| b.cmp(a));
for idx in remove_sorted {
dotfiles.remove(idx);
}
}
#[cfg(test)]
mod tests {
use super::*;
use doot_core::evaluator::{DeployMode, DotfilesSource};
fn explicit(source: &str, target: &str) -> DotfileConfig {
DotfileConfig {
source: PathBuf::from(source),
target: PathBuf::from(target),
template: false,
permissions: vec![],
owner: None,
deploy: DeployMode::default(),
link_patterns: vec![],
copy_patterns: vec![],
exclude_paths: vec![],
exclude_sources: vec![],
}
}
fn glob_pattern(pattern: &str, target_base: &str) -> DotfilesPattern {
DotfilesPattern {
source: DotfilesSource::Pattern(pattern.to_string()),
target_base: PathBuf::from(target_base),
template: false,
permissions: vec![],
owner: None,
deploy: DeployMode::default(),
link_patterns: vec![],
copy_patterns: vec![],
}
}
#[test]
fn warns_when_explicit_target_is_glob_directory() {
let dotfiles = vec![explicit("bin/wb", "/home/u/.local/bin")];
let patterns = vec![glob_pattern("bin/*", "/home/u/.local/bin")];
let warnings = directory_target_collisions(&dotfiles, &patterns);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("wb"));
assert!(warnings[0].contains("collide"));
}
#[test]
fn no_warning_for_specific_file_target() {
let dotfiles = vec![explicit("bin/wb", "/home/u/.local/bin/wb")];
let patterns = vec![glob_pattern("bin/*", "/home/u/.local/bin")];
assert!(directory_target_collisions(&dotfiles, &patterns).is_empty());
}
}

View file

@ -1,23 +1,15 @@
use super::{find_config_file, parse_config, type_check};
use super::{find_config_file, load};
use doot_core::deploy::DiffDisplay;
use doot_core::evaluator::PermissionRule;
use doot_core::state::{expected_mode_for_file, get_file_mode};
use doot_lang::Evaluator;
use doot_lang::evaluator::PermissionRule;
use std::path::{Path, PathBuf};
/// Shows diffs between source and deployed dotfiles.
#[tracing::instrument(skip_all, fields(all))]
pub fn run(config_path: Option<PathBuf>, all: bool) -> anyhow::Result<()> {
let path = find_config_file(config_path)?;
let source = std::fs::read_to_string(&path)?;
let program = parse_config(&path)?;
type_check(&program, &source, &path.display().to_string())?;
let source_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf();
let mut evaluator = Evaluator::new().with_source_dir(source_dir.clone());
let result = evaluator.eval_sync(&program)?;
let (result, _vars) = load(&path)?;
let mut has_changes = false;

View file

@ -1,10 +1,9 @@
use super::{find_config_file, parse_config, type_check};
use super::{find_config_file, load};
use doot_core::{
Config,
Settings,
deploy::{Linker, TemplateEngine},
state::{DeployMode, StateStore},
};
use doot_lang::Evaluator;
use std::collections::HashMap;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
@ -19,16 +18,9 @@ pub fn run(
skip_prompt: bool,
) -> anyhow::Result<()> {
let path = find_config_file(config_path)?;
let source = std::fs::read_to_string(&path)?;
let program = parse_config(&path)?;
type_check(&program, &source, &path.display().to_string())?;
let source_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf();
let mut evaluator = Evaluator::new().with_source_dir(source_dir.clone());
let result = evaluator.eval_sync(&program)?;
let mut template_vars = evaluator.get_template_variables();
let config = Config::new(source_dir.clone());
let (result, mut template_vars) = load(&path)?;
let config = Settings::new(source_dir.clone());
super::decrypt_encrypted_vars_with_source_dir(
&result,
@ -107,13 +99,13 @@ fn hash_file(path: &PathBuf) -> String {
fn apply_single(
source: &PathBuf,
target: &PathBuf,
dotfile: &doot_lang::evaluator::DotfileConfig,
config: &Config,
template_vars: &HashMap<String, doot_lang::evaluator::Value>,
dotfile: &doot_core::evaluator::DotfileConfig,
config: &Settings,
template_vars: &HashMap<String, doot_core::evaluator::Value>,
) -> anyhow::Result<()> {
let deploy_mode = match dotfile.deploy {
doot_lang::evaluator::DeployMode::Copy => DeployMode::Copy,
doot_lang::evaluator::DeployMode::Link => DeployMode::Link,
doot_core::evaluator::DeployMode::Copy => DeployMode::Copy,
doot_core::evaluator::DeployMode::Link => DeployMode::Link,
};
let mut state = StateStore::new(&config.state_file);
@ -204,10 +196,10 @@ fn expand_tilde(path: &str) -> PathBuf {
#[tracing::instrument(skip_all)]
fn find_source_and_dotfile<'a>(
target: &PathBuf,
dotfiles: &'a [doot_lang::evaluator::DotfileConfig],
dotfiles: &'a [doot_core::evaluator::DotfileConfig],
source_dir: &Path,
state: &StateStore,
) -> anyhow::Result<(PathBuf, Option<&'a doot_lang::evaluator::DotfileConfig>)> {
) -> anyhow::Result<(PathBuf, Option<&'a doot_core::evaluator::DotfileConfig>)> {
// Exact match with dotfile targets
for df in dotfiles {
if &df.target == target {

View file

@ -1,4 +1,4 @@
use doot_core::{Config, encryption::AgeEncryption};
use doot_core::{Settings, encryption::AgeEncryption};
use std::path::PathBuf;
/// Encrypts a file using age encryption with multi-recipient support.
@ -12,7 +12,7 @@ pub fn run(file: PathBuf, recipients: Vec<String>) -> anyhow::Result<()> {
.filter(|l| !l.is_empty())
.collect()
} else {
let key_file = Config::default_config_dir().join("recipient.txt");
let key_file = Settings::default_config_dir().join("recipient.txt");
if key_file.exists() {
std::fs::read_to_string(&key_file)?
.lines()

View file

@ -1,5 +1,5 @@
use doot_core::Config;
use doot_lang::builtins::crypto::base64_encode;
use doot_core::Settings;
use doot_core::builtins::crypto::base64_encode;
use std::io::{self, Read, Write};
/// Resolves recipient keys from CLI flags, env var, or recipient.txt (supports multiple keys).
@ -12,7 +12,7 @@ fn resolve_recipients(recipients: Vec<String>) -> anyhow::Result<Vec<age::x25519
.filter(|l| !l.is_empty())
.collect()
} else {
let key_file = Config::default_config_dir().join("recipient.txt");
let key_file = Settings::default_config_dir().join("recipient.txt");
if key_file.exists() {
std::fs::read_to_string(&key_file)?
.lines()

View file

@ -2,22 +2,31 @@ use super::find_config_file;
use doot_core::deploy::diff::DiffDisplay;
use std::path::PathBuf;
/// Formats or checks formatting of a `.doot` config file.
/// Formats or checks formatting of a `.doot` config file by parsing it and
/// reprinting the AST in canonical layout (comments, literal forms, and
/// multiline strings preserved).
#[tracing::instrument(skip_all, fields(check))]
pub fn run(config_path: Option<PathBuf>, check: bool) -> anyhow::Result<()> {
let path = find_config_file(config_path)?;
let source = std::fs::read_to_string(&path)?;
let formatted = format_source(&source);
let formatted = match doot_dotfile::format(&source) {
Ok(s) => s,
Err(diags) => {
for d in &diags {
eprintln!("{}:{}", path.display(), d.render(&source));
}
anyhow::bail!("cannot format: {} parse error(s)", diags.len());
}
};
if check {
if formatted != source {
let diff = DiffDisplay::diff_strings(&source, &formatted);
eprintln!("{}\n{}", path.display(), diff);
std::process::exit(1);
} else {
println!("{} is formatted correctly", path.display());
}
println!("{} is formatted correctly", path.display());
} else if formatted != source {
std::fs::write(&path, &formatted)?;
println!("formatted {}", path.display());
@ -27,208 +36,3 @@ pub fn run(config_path: Option<PathBuf>, check: bool) -> anyhow::Result<()> {
Ok(())
}
#[tracing::instrument(level = "trace", skip_all)]
fn format_source(source: &str) -> String {
// Use an indent stack to track nesting levels from raw whitespace.
// When indentation increases, push a new level; when it decreases,
// pop back. This handles files with inconsistent indent widths.
let mut result = String::new();
let mut prev_was_blank = false;
let mut indent_stack: Vec<usize> = vec![0]; // raw whitespace widths
for line in source.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
if !prev_was_blank {
result.push('\n');
prev_was_blank = true;
}
continue;
}
prev_was_blank = false;
let leading = line.len() - line.trim_start().len();
if leading > *indent_stack.last().unwrap() {
// Deeper nesting
indent_stack.push(leading);
} else if leading < *indent_stack.last().unwrap() {
// Dedent: pop until we find a level <= current
while indent_stack.len() > 1 && *indent_stack.last().unwrap() > leading {
indent_stack.pop();
}
}
let level = indent_stack.len() - 1;
result.push_str(&" ".repeat(level));
result.push_str(trimmed);
result.push('\n');
}
// Trim trailing blank lines
while result.ends_with("\n\n") {
result.pop();
}
if !result.ends_with('\n') {
result.push('\n');
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_preserves_top_level_blocks() {
let input = "\
dotfile:
src: fish
dst: ~/.config/fish
dotfile:
src: nvim
dst: ~/.config/nvim
dotfile:
src: git
dst: ~/.config/git
";
let result = format_source(input);
assert_eq!(result, input);
}
#[test]
fn test_normalizes_4space_to_2space() {
let input = "\
dotfile:
src: fish
dst: ~/.config/fish
";
let expected = "\
dotfile:
src: fish
dst: ~/.config/fish
";
assert_eq!(format_source(input), expected);
}
#[test]
fn test_nested_blocks() {
let input = "\
dotfile:
src: fish
if os == \"linux\":
package: apt
else:
package: brew
";
let result = format_source(input);
assert_eq!(result, input);
}
#[test]
fn test_collapses_consecutive_blank_lines() {
let input = "\
dotfile:
src: fish
dst: ~/.config/fish
";
let expected = "\
dotfile:
src: fish
dst: ~/.config/fish
";
assert_eq!(format_source(input), expected);
}
#[test]
fn test_trailing_blank_lines_trimmed() {
let input = "\
dotfile:
src: fish
";
let expected = "\
dotfile:
src: fish
";
assert_eq!(format_source(input), expected);
}
#[test]
fn test_comments_preserve_indent() {
let input = "\
# top-level comment
dotfile:
# nested comment
src: fish
";
let result = format_source(input);
assert_eq!(result, input);
}
#[test]
fn test_no_indented_lines() {
let input = "\
one
two
three
";
let result = format_source(input);
assert_eq!(result, input);
}
#[test]
fn test_mixed_indent_normalizes() {
let input = "\
dotfile:
src: fish
if cond:
package: apt
";
let expected = "\
dotfile:
src: fish
if cond:
package: apt
";
assert_eq!(format_source(input), expected);
}
#[test]
fn test_inconsistent_indent_widths() {
// Mix of 6-space, 2-space, and 4-space indentation (GCD = 2)
let input = "\
dotfile:
source = \"config/*\"
target = config_dir()
if cond:
package: \"fish\"
if other:
package: \"bat\"
";
let expected = "\
dotfile:
source = \"config/*\"
target = config_dir()
if cond:
package: \"fish\"
if other:
package: \"bat\"
";
assert_eq!(format_source(input), expected);
}
}

View file

@ -1,12 +1,12 @@
use doot_core::Config;
use doot_core::Settings;
use std::path::{Path, PathBuf};
/// Initializes a new doot project directory structure.
#[tracing::instrument(skip_all)]
pub fn run(path: Option<PathBuf>) -> anyhow::Result<()> {
let source_dir = path.unwrap_or_else(Config::default_source_dir);
let config = Config::new(source_dir.clone());
let is_default = source_dir == Config::default_config_dir();
let source_dir = path.unwrap_or_else(Settings::default_source_dir);
let config = Settings::new(source_dir.clone());
let is_default = source_dir == Settings::default_config_dir();
tracing::debug!(
config_dir = %config.config_dir.display(),

View file

@ -1,11 +1,11 @@
use doot_core::{Config, encryption::AgeEncryption};
use doot_core::{Settings, encryption::AgeEncryption};
use std::io::{self, Write};
use std::path::PathBuf;
/// Generates an age keypair, writing identity to identity.txt and appending public key to recipient.txt.
#[tracing::instrument(skip_all)]
pub fn run(output: Option<PathBuf>, force: bool) -> anyhow::Result<()> {
let config_dir = Config::default_config_dir();
let config_dir = Settings::default_config_dir();
let identity_file = output.unwrap_or_else(|| config_dir.join("identity.txt"));
let recipient_file = identity_file
.parent()

View file

@ -5,6 +5,7 @@ pub mod check;
pub mod decrypt;
pub mod decrypt_entries;
pub mod decrypt_var;
pub mod deploy_util;
pub mod diff;
pub mod edit;
pub mod encrypt;
@ -14,19 +15,59 @@ pub mod init;
pub mod keygen;
pub mod lsp;
pub mod package;
pub mod plan;
pub mod reencrypt;
pub mod rollback;
pub mod snapshot;
pub mod status;
pub mod tui;
use doot_core::Config;
use doot_lang::evaluator::{EvalResult, Value};
use doot_lang::{Lexer, Parser, TypeChecker};
use doot_core::Settings;
use doot_core::evaluator::{EvalResult, Value};
use indexmap::IndexMap;
use std::collections::HashMap;
use std::path::PathBuf;
/// Load a config file: parse, type-check, and evaluate to an
/// `EvalResult` plus template variables. Type errors abort.
#[tracing::instrument(skip_all, fields(path = %path.display()))]
pub fn load(path: &PathBuf) -> anyhow::Result<(EvalResult, HashMap<String, Value>)> {
let source = std::fs::read_to_string(path)?;
let (result, vars, errors) = doot_dotfile::compile_eval_result(&source);
if !errors.is_empty() {
for e in &errors {
eprintln!("{}:{}", path.display(), e.render(&source));
}
anyhow::bail!("{} error(s) found", errors.len());
}
Ok((result, vars))
}
/// Build the environment exposed to hook scripts: string-valued template vars
/// plus the DOOT_* globals.
pub fn hook_env(
vars: &HashMap<String, Value>,
source_dir: &std::path::Path,
) -> HashMap<String, String> {
let mut env = HashMap::new();
for (k, v) in vars {
if let Value::Str(s) = v {
env.insert(k.clone(), s.clone());
}
}
env.insert(
"DOOT_HOME".to_string(),
std::env::var("HOME").unwrap_or_default(),
);
env.insert(
"DOOT_CONFIG_DIR".to_string(),
source_dir.display().to_string(),
);
env.insert("DOOT_OS".to_string(), std::env::consts::OS.to_string());
env.insert("DOOT_ARCH".to_string(), std::env::consts::ARCH.to_string());
env
}
/// Resolves the config file path, checking the given path or default locations.
/// Always returns an absolute path so source_dir is correct regardless of CWD.
#[tracing::instrument(skip_all)]
@ -38,7 +79,7 @@ pub fn find_config_file(base: Option<PathBuf>) -> anyhow::Result<PathBuf> {
anyhow::bail!("config file not found: {}", path.display());
}
let candidates = vec![PathBuf::from("doot.doot"), Config::default_config_file()];
let candidates = vec![PathBuf::from("doot.doot"), Settings::default_config_file()];
for candidate in candidates {
if candidate.exists() {
@ -48,70 +89,14 @@ pub fn find_config_file(base: Option<PathBuf>) -> anyhow::Result<PathBuf> {
anyhow::bail!(
"no config file found. searched:\n - ./doot.doot\n - {}",
Config::default_config_file().display()
Settings::default_config_file().display()
)
}
fn byte_offset_to_line(source: &str, offset: usize) -> usize {
source[..offset.min(source.len())]
.chars()
.filter(|&c| c == '\n')
.count()
+ 1
}
/// Parses a `.doot` config file into a program AST.
#[tracing::instrument(skip_all, fields(path = %path.display()))]
pub fn parse_config(path: &PathBuf) -> anyhow::Result<doot_lang::Program> {
let source = std::fs::read_to_string(path)?;
let tokens = Lexer::lex(&source).map_err(|errs| {
let msg = errs
.iter()
.map(|e| {
let line = byte_offset_to_line(&source, e.span().start);
format!("{}:{}: {}", path.display(), line, e)
})
.collect::<Vec<_>>()
.join("\n");
anyhow::anyhow!("lexer errors:\n{}", msg)
})?;
let program = Parser::parse(tokens).map_err(|errs| {
let msg = errs
.iter()
.map(|e| {
let line = byte_offset_to_line(&source, e.span().start);
format!("{}:{}: {}", path.display(), line, e)
})
.collect::<Vec<_>>()
.join("\n");
anyhow::anyhow!("parser errors:\n{}", msg)
})?;
Ok(program)
}
/// Runs the type checker on a parsed program.
#[tracing::instrument(skip_all)]
pub fn type_check(
program: &doot_lang::Program,
source: &str,
filename: &str,
) -> anyhow::Result<()> {
let mut checker = TypeChecker::new();
if let Err(errors) = checker.check(program) {
for error in &errors {
error.report(source, filename);
}
anyhow::bail!("{} type error(s) found", errors.len());
}
Ok(())
}
/// Decrypts encrypted vars and files, resolving file paths relative to `source_dir`.
pub fn decrypt_encrypted_vars_with_source_dir(
result: &EvalResult,
config: &Config,
config: &Settings,
template_vars: &mut HashMap<String, Value>,
source_dir: Option<&std::path::Path>,
) -> anyhow::Result<()> {
@ -145,7 +130,7 @@ pub fn decrypt_encrypted_vars_with_source_dir(
// Decrypt inline vars
for (name, ciphertext_b64) in &result.encrypted_vars {
let encrypted_bytes = doot_lang::builtins::crypto::base64_decode(ciphertext_b64)
let encrypted_bytes = doot_core::builtins::crypto::base64_decode(ciphertext_b64)
.map_err(|e| anyhow::anyhow!("invalid base64 for encrypted var '{}': {}", name, e))?;
let decryptor = match age::Decryptor::new(&encrypted_bytes[..])

View file

@ -1,19 +1,11 @@
use super::{find_config_file, parse_config, type_check};
use doot_lang::Evaluator;
use super::{find_config_file, load};
use std::path::PathBuf;
/// Installs packages defined in the config.
#[tracing::instrument(skip_all)]
pub fn install(config_path: Option<PathBuf>) -> anyhow::Result<()> {
let path = find_config_file(config_path)?;
let source = std::fs::read_to_string(&path)?;
let source_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf();
let program = parse_config(&path)?;
type_check(&program, &source, &path.display().to_string())?;
let mut evaluator = Evaluator::new().with_source_dir(source_dir);
let result = evaluator.eval_sync(&program)?;
let (result, _vars) = load(&path)?;
if result.packages.is_empty() {
println!("no packages configured");
@ -72,14 +64,7 @@ pub fn update() -> anyhow::Result<()> {
#[tracing::instrument(skip_all)]
pub fn list(config_path: Option<PathBuf>) -> anyhow::Result<()> {
let path = find_config_file(config_path)?;
let source = std::fs::read_to_string(&path)?;
let source_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf();
let program = parse_config(&path)?;
type_check(&program, &source, &path.display().to_string())?;
let mut evaluator = Evaluator::new().with_source_dir(source_dir);
let result = evaluator.eval_sync(&program)?;
let (result, _vars) = load(&path)?;
if result.packages.is_empty() {
println!("no packages configured");

View file

@ -0,0 +1,68 @@
use super::find_config_file;
use doot_core::evaluator::DotfilesSource;
use doot_dotfile::Task;
use std::path::PathBuf;
/// Show the inferred dependency DAG: tasks grouped into topological layers, where
/// each layer is independent (parallelizable) and runs only after earlier layers.
/// Read-only; performs no filesystem changes.
#[tracing::instrument(skip_all)]
pub fn run(config_path: Option<PathBuf>) -> anyhow::Result<()> {
let path = find_config_file(config_path)?;
let source = std::fs::read_to_string(&path)?;
let (plan, errors) = doot_dotfile::compile_exec_plan(&source);
if !errors.is_empty() {
for e in &errors {
eprintln!("{}:{}", path.display(), e.render(&source));
}
anyhow::bail!("{} error(s) found", errors.len());
}
println!(
"execution plan: {} tasks in {} layer(s)",
plan.len(),
plan.layers.len()
);
for (i, layer) in plan.layers.iter().enumerate() {
println!("\nlayer {i} ({} parallel):", layer.len());
for task in layer {
println!(" {}", describe(task));
}
}
Ok(())
}
fn describe(task: &Task) -> String {
match task {
Task::Dotfile(d) => format!("dotfile {} -> {}", d.source.display(), d.target.display()),
Task::DotfilePattern(p) => {
let src = match &p.source {
DotfilesSource::Pattern(s) => s.clone(),
DotfilesSource::Paths(ps) => format!("{} path(s)", ps.len()),
};
format!("dotfiles {} -> {}", src, p.target_base.display())
}
Task::Package(p) => {
let name = p
.default
.clone()
.or_else(|| p.brew.clone())
.or_else(|| p.cask.clone())
.or_else(|| p.apt.clone())
.or_else(|| p.pacman.clone())
.or_else(|| p.yay.clone())
.or_else(|| p.xbps.clone())
.unwrap_or_else(|| "?".into());
format!("package {name}")
}
Task::Hook(h) => {
let first = h.run.lines().next().unwrap_or("").trim();
format!("hook [{:?}] {}", h.stage, first)
}
Task::Secret(s) => format!("secret {}", s.target.display()),
Task::Tap(n) => format!("tap {n}"),
Task::Formula(n) => format!("formula {n}"),
Task::EncVar { key, .. } => format!("enc-var {key}"),
Task::EncFile { key, .. } => format!("enc-file {key}"),
}
}

View file

@ -1,4 +1,4 @@
use doot_core::{Config, encryption::AgeEncryption};
use doot_core::{Settings, encryption::AgeEncryption};
use std::path::PathBuf;
/// Re-encrypts all .age files in the secrets directory with current recipients.
@ -7,7 +7,7 @@ use std::path::PathBuf;
pub fn run(config_path: Option<PathBuf>, recipients: Vec<String>) -> anyhow::Result<()> {
let path = super::find_config_file(config_path)?;
let source_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf();
let config = Config::new(source_dir.clone());
let config = Settings::new(source_dir.clone());
// Resolve identity for decryption
let identity_raw = if let Ok(key) = std::env::var("DOOT_AGE_IDENTITY") {
@ -36,7 +36,7 @@ pub fn run(config_path: Option<PathBuf>, recipients: Vec<String>) -> anyhow::Res
.filter(|l| !l.is_empty())
.collect()
} else {
let key_file = Config::default_config_dir().join("recipient.txt");
let key_file = Settings::default_config_dir().join("recipient.txt");
if key_file.exists() {
std::fs::read_to_string(&key_file)?
.lines()
@ -56,9 +56,7 @@ pub fn run(config_path: Option<PathBuf>, recipients: Vec<String>) -> anyhow::Res
}
// Also re-encrypt encrypted vars from the doot config
let program = super::parse_config(&path)?;
let mut evaluator = doot_lang::Evaluator::new().with_source_dir(source_dir.clone());
let result = evaluator.eval_sync(&program)?;
let (result, _vars) = super::load(&path)?;
let mut count = 0;

View file

@ -1,5 +1,5 @@
use doot_core::{
Config,
Settings,
state::{DeployMode, Snapshot},
};
use std::path::PathBuf;
@ -7,7 +7,7 @@ use std::path::PathBuf;
/// Rolls back to a previous snapshot.
#[tracing::instrument(skip_all)]
pub fn run(_config_path: Option<PathBuf>, snapshot_name: Option<String>) -> anyhow::Result<()> {
let config = Config::default();
let config = Settings::default();
let name = if let Some(n) = snapshot_name {
if n == "last" || n == "latest" {

View file

@ -1,5 +1,5 @@
use doot_core::{
Config,
Settings,
state::{Snapshot, StateStore},
};
use std::path::PathBuf;
@ -7,7 +7,7 @@ use std::path::PathBuf;
/// Creates a named snapshot of the current deployment state.
#[tracing::instrument(skip_all, fields(name = %name))]
pub fn run(_config_path: Option<PathBuf>, name: String) -> anyhow::Result<()> {
let config = Config::default();
let config = Settings::default();
config.ensure_dirs()?;
let mut state = StateStore::new(&config.state_file);

View file

@ -1,33 +1,23 @@
use super::{
apply::template_outdated, decrypt_encrypted_vars_with_source_dir, find_config_file,
parse_config, type_check,
decrypt_encrypted_vars_with_source_dir, deploy_util::template_outdated, find_config_file, load,
};
use doot_core::Config;
use doot_core::Settings;
use doot_core::deploy::TemplateEngine;
use doot_core::state::{StateStore, SyncStatus};
use doot_lang::Evaluator;
use std::path::PathBuf;
/// Shows the deployment status of managed dotfiles.
#[tracing::instrument(skip_all)]
pub fn run(config_path: Option<PathBuf>) -> anyhow::Result<()> {
let path = find_config_file(config_path)?;
let source = std::fs::read_to_string(&path)?;
let program = parse_config(&path)?;
type_check(&program, &source, &path.display().to_string())?;
let source_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf();
let mut evaluator = Evaluator::new().with_source_dir(source_dir.clone());
let result = evaluator.eval_sync(&program)?;
let (result, mut template_vars) = load(&path)?;
// Prepare template variables early for preview rendering
let config = Config::new(source_dir.clone());
let config = Settings::new(source_dir.clone());
let state_file = config.state_file.clone();
let state = StateStore::new(&state_file);
let mut template_vars = evaluator.get_template_variables();
decrypt_encrypted_vars_with_source_dir(
&result,
&config,

View file

@ -1,13 +1,12 @@
use super::{find_config_file, parse_config, type_check};
use super::{find_config_file, load};
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use doot_core::config::Config;
use doot_core::config::Settings;
use doot_core::deploy::Linker;
use doot_core::state::{DeployMode, StateStore};
use doot_lang::Evaluator;
use ratatui::{
Frame, Terminal,
backend::CrosstermBackend,
@ -113,16 +112,10 @@ impl App {
#[tracing::instrument(skip_all)]
fn new(config_path: Option<PathBuf>) -> anyhow::Result<Self> {
let path = find_config_file(config_path)?;
let source = std::fs::read_to_string(&path)?;
let program = parse_config(&path)?;
type_check(&program, &source, &path.display().to_string())?;
let source_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf();
let (result, _vars) = load(&path)?;
let mut evaluator = Evaluator::new().with_source_dir(source_dir.clone());
let result = evaluator.eval_sync(&program)?;
let config = Config::new(source_dir.clone());
let config = Settings::new(source_dir.clone());
let state = StateStore::new(&config.state_file);
let dotfiles: Vec<DotfileItem> = result
@ -131,8 +124,8 @@ impl App {
.map(|d| {
let full_source = source_dir.join(&d.source);
let deploy_mode = match d.deploy {
doot_lang::evaluator::DeployMode::Copy => DeployMode::Copy,
doot_lang::evaluator::DeployMode::Link => DeployMode::Link,
doot_core::evaluator::DeployMode::Copy => DeployMode::Copy,
doot_core::evaluator::DeployMode::Link => DeployMode::Link,
};
let status = if !full_source.exists() {
@ -440,7 +433,7 @@ impl App {
self.apply_progress = 0;
// Apply dotfiles
let config = Config::new(self.source_dir.clone());
let config = Settings::new(self.source_dir.clone());
let linker = Linker::new(config.clone());
let mut state = StateStore::new(&config.state_file);

View file

@ -96,6 +96,9 @@ enum Commands {
/// Validate config (parse + type check): `doot check`
Check,
/// Show the inferred dependency DAG and execution order: `doot plan`
Plan,
/// Format config file: `doot fmt [--check]`
Fmt {
/// Check formatting without modifying (exits 1 if unformatted)
@ -290,6 +293,7 @@ fn main() -> anyhow::Result<()> {
Commands::Diff { all } => commands::diff::run(cli.config, all),
Commands::Status => commands::status::run(cli.config),
Commands::Check => commands::check::run(cli.config),
Commands::Plan => commands::plan::run(cli.config),
Commands::Fmt { check } => commands::fmt::run(cli.config, check),
Commands::Rollback { snapshot } => commands::rollback::run(cli.config, snapshot),
Commands::Snapshot { name } => commands::snapshot::run(cli.config, name),

View file

@ -101,12 +101,7 @@ fn test_init_creates_structure() {
#[test]
fn test_check_valid_config() {
let sandbox = Sandbox::new("check-valid");
sandbox.write_config(
r#"
package: "ripgrep"
package: "fd"
"#,
);
sandbox.write_config(r#"Config { packages = [ (package "ripgrep") (package "fd") ]; }"#);
let output = sandbox.run(&["check"]);
assert!(output.status.success(), "check failed: {:?}", output);
@ -116,11 +111,7 @@ package: "fd"
fn test_apply_dry_run() {
let sandbox = Sandbox::new("apply-dry");
sandbox.write_config(
r#"
dotfile:
source = "config/test.conf"
target = "~/.config/test/test.conf"
"#,
r#"Config { dotfiles = [ (dotfile { source = "config/test.conf"; target = "~/.config/test/test.conf"; }) ]; }"#,
);
sandbox.write_source("config/test.conf", "test content");
@ -135,12 +126,7 @@ dotfile:
fn test_apply_creates_symlink() {
let sandbox = Sandbox::new("apply-symlink");
sandbox.write_config(
r#"
dotfile:
source = "config/app.conf"
target = "~/.config/app/app.conf"
deploy = "link"
"#,
r#"Config { dotfiles = [ (dotfile { source = "config/app.conf"; target = "~/.config/app/app.conf"; deploy = "link"; }) ]; }"#,
);
sandbox.write_source("config/app.conf", "app config content");
@ -162,7 +148,7 @@ dotfile:
fn test_apply_unchanged_on_rerun() {
let sandbox = Sandbox::new("apply-unchanged");
sandbox.write_config(
"dotfile:\n source = \"config/app.conf\"\n target = \"~/.config/app/app.conf\"\n deploy = \"link\"\n",
"Config { dotfiles = [ (dotfile { source = \"config/app.conf\"; target = \"~/.config/app/app.conf\"; deploy = \"link\"; }) ]; }",
);
sandbox.write_source("config/app.conf", "content");
@ -184,11 +170,7 @@ fn test_apply_unchanged_on_rerun() {
fn test_apply_creates_copy() {
let sandbox = Sandbox::new("apply-copy");
sandbox.write_config(
r#"
dotfile:
source = "config/app.conf"
target = "~/.config/app/app.conf"
"#,
r#"Config { dotfiles = [ (dotfile { source = "config/app.conf"; target = "~/.config/app/app.conf"; }) ]; }"#,
);
sandbox.write_source("config/app.conf", "app config content");
@ -210,7 +192,7 @@ dotfile:
fn test_apply_copy_unchanged_on_rerun() {
let sandbox = Sandbox::new("apply-copy-unchanged");
sandbox.write_config(
"dotfile:\n source = \"config/app.conf\"\n target = \"~/.config/app/app.conf\"\n",
"Config { dotfiles = [ (dotfile { source = \"config/app.conf\"; target = \"~/.config/app/app.conf\"; }) ]; }",
);
sandbox.write_source("config/app.conf", "content");
@ -229,12 +211,7 @@ fn test_apply_copy_unchanged_on_rerun() {
fn test_template_redeploys_when_env_changes() {
let sandbox = Sandbox::new("template-env-change");
sandbox.write_config(
r#"
dotfile:
source = "templates/app.conf"
target = "~/.config/app/app.conf"
template = true
"#,
r#"Config { dotfiles = [ (dotfile { source = "templates/app.conf"; target = "~/.config/app/app.conf"; template = true; }) ]; }"#,
);
sandbox.write_source("templates/app.conf", "value = {{ env.TEMPLATE_VAL }}\n");
@ -274,11 +251,7 @@ dotfile:
fn test_status_shows_state() {
let sandbox = Sandbox::new("status");
sandbox.write_config(
r#"
dotfile:
source = "config/app.conf"
target = "~/.config/app/app.conf"
"#,
r#"Config { dotfiles = [ (dotfile { source = "config/app.conf"; target = "~/.config/app/app.conf"; }) ]; }"#,
);
sandbox.write_source("config/app.conf", "content");
sandbox.run(&["apply"]);
@ -291,11 +264,7 @@ dotfile:
fn test_snapshot_and_rollback() {
let sandbox = Sandbox::new("snapshot");
sandbox.write_config(
r#"
dotfile:
source = "config/app.conf"
target = "~/.config/app/app.conf"
"#,
r#"Config { dotfiles = [ (dotfile { source = "config/app.conf"; target = "~/.config/app/app.conf"; }) ]; }"#,
);
sandbox.write_source("config/app.conf", "v1");
sandbox.run(&["apply"]);
@ -316,11 +285,7 @@ fn test_dotfile_with_when_condition() {
let sandbox = Sandbox::new("conditional");
// Test that 'when' condition works - only deploy if condition is true
let config = r#"dotfile:
source = "config/test.conf"
target = "~/.config/test.conf"
when = true
"#;
let config = r#"Config { dotfiles = optionals true [ (dotfile { source = "config/test.conf"; target = "~/.config/test.conf"; }) ]; }"#;
sandbox.write_config(config);
sandbox.write_source("config/test.conf", "test content");
@ -338,11 +303,7 @@ fn test_dotfile_with_when_condition() {
fn test_dotfile_when_false_skips() {
let sandbox = Sandbox::new("when-false");
let config = r#"dotfile:
source = "config/skip.conf"
target = "~/.config/skip.conf"
when = false
"#;
let config = r#"Config { dotfiles = optionals false [ (dotfile { source = "config/skip.conf"; target = "~/.config/skip.conf"; }) ]; }"#;
sandbox.write_config(config);
sandbox.write_source("config/skip.conf", "should not deploy");
@ -360,11 +321,7 @@ fn test_dotfile_when_false_skips() {
fn test_diff_shows_changes() {
let sandbox = Sandbox::new("diff");
sandbox.write_config(
r#"
dotfile:
source = "config/app.conf"
target = "~/.config/app/app.conf"
"#,
r#"Config { dotfiles = [ (dotfile { source = "config/app.conf"; target = "~/.config/app/app.conf"; }) ]; }"#,
);
sandbox.write_source("config/app.conf", "new content");
@ -696,11 +653,7 @@ fn test_reencrypt_age_files() {
)
.unwrap();
sandbox.write_config(
r#"
package: "git"
"#,
);
sandbox.write_config(r#"Config { packages = [ (package "git") ]; }"#);
// Write the first identity back (reencrypt needs it to decrypt)
std::fs::write(sandbox.config_dir().join("identity.txt"), &identity_content).unwrap();
@ -746,7 +699,7 @@ package: "git"
#[test]
fn test_reencrypt_no_identity_fails() {
let sandbox = Sandbox::new("reencrypt-no-id");
sandbox.write_config("package: \"git\"\n");
sandbox.write_config("Config { packages = [ (package \"git\") ]; }");
let output = sandbox.run(&[
"reencrypt",
@ -769,7 +722,7 @@ fn test_reencrypt_no_age_files_reports_zero() {
.trim()
.to_string();
sandbox.write_config("package: \"git\"\n");
sandbox.write_config("Config { packages = [ (package \"git\") ]; }");
let output = sandbox.run(&["reencrypt", "--recipient", &pubkey]);
assert!(output.status.success(), "reencrypt failed: {:?}", output);

View file

@ -5,7 +5,6 @@ edition.workspace = true
[dependencies]
doot-utils.workspace = true
doot-lang.workspace = true
serde.workspace = true
serde_json.workspace = true
toml.workspace = true
@ -20,7 +19,11 @@ anyhow.workspace = true
hostname = "0.4"
regex-lite = "0.1"
glob = "0.3"
indexmap = "2"
rayon.workspace = true
minijinja = { version = "2", features = ["builtins"] }
which = "7"
tracing.workspace = true
[dev-dependencies]
tempfile = "3"

View file

@ -0,0 +1,67 @@
//! Pure base64 helpers used by the CLI for encrypted-var handling.
pub fn base64_encode(data: &[u8]) -> String {
const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut result = String::new();
for chunk in data.chunks(3) {
let b0 = chunk[0] as usize;
let b1 = chunk.get(1).copied().unwrap_or(0) as usize;
let b2 = chunk.get(2).copied().unwrap_or(0) as usize;
result.push(ALPHABET[b0 >> 2] as char);
result.push(ALPHABET[((b0 & 0x03) << 4) | (b1 >> 4)] as char);
if chunk.len() > 1 {
result.push(ALPHABET[((b1 & 0x0f) << 2) | (b2 >> 6)] as char);
} else {
result.push('=');
}
if chunk.len() > 2 {
result.push(ALPHABET[b2 & 0x3f] as char);
} else {
result.push('=');
}
}
result
}
pub fn base64_decode(s: &str) -> Result<Vec<u8>, String> {
const DECODE: [i8; 256] = {
let mut table = [-1i8; 256];
let alphabet = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut i = 0;
while i < 64 {
table[alphabet[i] as usize] = i as i8;
i += 1;
}
table
};
let s = s.trim_end_matches('=');
let mut result = Vec::with_capacity(s.len() * 3 / 4);
let chars: Vec<u8> = s.bytes().collect();
for chunk in chars.chunks(4) {
let mut buf = [0u8; 4];
for (i, &c) in chunk.iter().enumerate() {
let val = DECODE[c as usize];
if val < 0 {
return Err(format!("invalid base64 character: {}", c as char));
}
buf[i] = val as u8;
}
result.push((buf[0] << 2) | (buf[1] >> 4));
if chunk.len() > 2 {
result.push((buf[1] << 4) | (buf[2] >> 2));
}
if chunk.len() > 3 {
result.push((buf[2] << 6) | buf[3]);
}
}
Ok(result)
}

View file

@ -0,0 +1,3 @@
//! Pure utility helpers retained from the language runtime.
pub mod crypto;

View file

@ -5,7 +5,7 @@ use std::path::PathBuf;
/// Doot runtime configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub struct Settings {
/// Directory containing dotfile sources.
pub source_dir: PathBuf,
/// Doot configuration directory.
@ -26,7 +26,7 @@ pub struct Config {
pub verbose: bool,
}
impl Config {
impl Settings {
/// Creates a new config with the given source directory.
#[tracing::instrument(skip_all, fields(source_dir = %source_dir.display()))]
pub fn new(source_dir: PathBuf) -> Self {
@ -101,7 +101,7 @@ impl Config {
}
}
impl Default for Config {
impl Default for Settings {
fn default() -> Self {
Self::new(Self::default_source_dir())
}

View file

@ -1,18 +1,18 @@
//! Symlink management.
use super::{DeployAction, DeployError};
use crate::config::Config;
use crate::config::Settings;
use std::path::PathBuf;
/// Creates and manages symlinks.
pub struct Linker {
config: Config,
config: Settings,
}
impl Linker {
/// Creates a new linker.
#[tracing::instrument(skip_all)]
pub fn new(config: Config) -> Self {
pub fn new(config: Settings) -> Self {
Self { config }
}

View file

@ -4,10 +4,10 @@ pub mod diff;
pub mod linker;
pub mod template;
use crate::config::Config;
use crate::config::Settings;
use crate::evaluator::DotfileConfig;
use crate::state::StateStore;
use crate::state::store::DeployMode;
use doot_lang::evaluator::DotfileConfig;
use glob::Pattern;
use indicatif::ProgressBar;
use rayon::prelude::*;
@ -96,7 +96,7 @@ pub struct DeployErrorInfo {
/// Handles dotfile deployment.
pub struct Deployer {
config: Arc<Config>,
config: Arc<Settings>,
linker: Arc<Linker>,
template_engine: Arc<TemplateEngine>,
state: Arc<Mutex<StateStore>>,
@ -107,9 +107,9 @@ impl Deployer {
/// Creates a new deployer.
#[tracing::instrument(skip_all)]
pub fn new(
config: Config,
config: Settings,
sandbox: bool,
template_vars: Option<&std::collections::HashMap<String, doot_lang::evaluator::Value>>,
template_vars: Option<&std::collections::HashMap<String, crate::evaluator::Value>>,
) -> Self {
let state = StateStore::new(&config.state_file);
let linker = Linker::new(config.clone());
@ -132,7 +132,7 @@ impl Deployer {
return Ok(());
}
let home = crate::config::Config::home_dir();
let home = crate::config::Settings::home_dir();
let target_canonical = target
.canonicalize()
.unwrap_or_else(|_| target.to_path_buf());
@ -497,8 +497,8 @@ impl Deployer {
.to_string();
let base_mode = match dotfile.deploy {
doot_lang::evaluator::DeployMode::Copy => DeployMode::Copy,
doot_lang::evaluator::DeployMode::Link => DeployMode::Link,
crate::evaluator::DeployMode::Copy => DeployMode::Copy,
crate::evaluator::DeployMode::Link => DeployMode::Link,
};
tracing::trace!(mode = ?base_mode, "resolved deploy mode");
@ -637,7 +637,7 @@ fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
Ok(())
}
use doot_lang::evaluator::PermissionRule;
use crate::evaluator::PermissionRule;
#[tracing::instrument(level = "trace", skip_all)]
fn apply_permissions(target: &Path, rules: &[PermissionRule]) -> Result<(), DeployError> {

View file

@ -31,7 +31,7 @@ impl TemplateEngine {
}
/// Sets multiple variables from doot evaluator values.
pub fn set_doot_variables(&mut self, vars: &HashMap<String, doot_lang::evaluator::Value>) {
pub fn set_doot_variables(&mut self, vars: &HashMap<String, crate::evaluator::Value>) {
for (key, value) in vars {
self.variables
.insert(key.clone(), doot_value_to_minijinja(value));
@ -444,8 +444,8 @@ fn json_to_minijinja(json: &serde_json::Value) -> Value {
}
/// Converts a doot evaluator Value to a minijinja Value.
fn doot_value_to_minijinja(val: &doot_lang::evaluator::Value) -> Value {
use doot_lang::evaluator::Value as DootValue;
fn doot_value_to_minijinja(val: &crate::evaluator::Value) -> Value {
use crate::evaluator::Value as DootValue;
match val {
DootValue::Int(n) => Value::from(*n),
DootValue::Float(n) => Value::from(*n),
@ -465,7 +465,6 @@ fn doot_value_to_minijinja(val: &doot_lang::evaluator::Value) -> Value {
}
DootValue::Enum(_, variant) => Value::from(variant.as_str()),
DootValue::None => Value::UNDEFINED,
_ => Value::UNDEFINED, // Function, Lambda, Future
}
}

View file

@ -0,0 +1,216 @@
//! Shared evaluated-config data types: the target the language compiles to,
//! consumed by the deploy layer.
use indexmap::IndexMap;
use std::collections::HashMap;
use std::path::PathBuf;
/// Hook execution stage.
#[derive(Clone, Debug, PartialEq)]
pub enum HookStage {
BeforeDeploy,
AfterDeploy,
BeforePackage,
AfterPackage,
}
/// Runtime value (template/data values exposed to the deploy layer).
#[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),
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::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 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) => items
.iter()
.map(|v| v.to_env_string())
.collect::<Vec<_>>()
.join(":"),
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::None => "none".to_string(),
}
}
}
/// 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 glob source.
#[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.
pub exclude_paths: Vec<PathBuf>,
/// Source paths to skip during directory deploy.
pub exclude_sources: Vec<PathBuf>,
}
/// Evaluated package configuration.
#[derive(Clone, Debug)]
pub struct PackageConfig {
pub default: Option<String>,
pub brew: Option<String>,
/// Homebrew cask name (macOS); installed via `brew install --cask`.
pub cask: 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>,
/// Homebrew taps to register, in declaration order.
pub brew_taps: Vec<String>,
/// Brew-only formulae to install.
pub brew_formulae: Vec<String>,
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(),
brew_taps: Vec::new(),
brew_formulae: Vec::new(),
secrets: Vec::new(),
hooks: Vec::new(),
encrypted_vars: HashMap::new(),
encrypted_files: HashMap::new(),
sandbox: true,
}
}
}

View file

@ -1,6 +1,6 @@
//! Lifecycle hook execution.
use doot_lang::HookStage;
use crate::evaluator::HookStage;
use std::process::Command;
use thiserror::Error;

View file

@ -3,17 +3,20 @@
//! Provides configuration, deployment, encryption, package management,
//! and state tracking.
pub mod builtins;
pub mod config;
pub mod deploy;
pub mod encryption;
pub mod evaluator;
pub mod hooks;
pub mod os;
pub mod package;
pub mod state;
pub use config::Config;
pub use config::Settings;
pub use deploy::{DeployAction, DeployResult, Deployer};
pub use encryption::AgeEncryption;
pub use evaluator::HookStage;
pub use hooks::HookRunner;
pub use os::OsInfo;
pub use package::PackageManager;

View file

@ -1,6 +1,6 @@
//! State persistence for doot.
use doot_lang::evaluator::PermissionRule;
use crate::evaluator::PermissionRule;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};

View file

@ -0,0 +1,15 @@
[package]
name = "doot-dotfile"
version.workspace = true
edition.workspace = true
[dependencies]
doot-lang.workspace = true
doot-std.workspace = true
doot-core.workspace = true
doot-utils.workspace = true
os_info.workspace = true
indexmap = "2"
[dev-dependencies]
tempfile = "3"

View file

@ -0,0 +1,194 @@
//! Maps a [`Plan`] onto the deploy layer's [`EvalResult`].
use std::path::PathBuf;
use doot_core::HookStage;
use doot_core::evaluator::{
DeployMode, DotfileConfig, DotfilesPattern, DotfilesSource, EvalResult, HookConfig,
PackageConfig, PermissionRule, SecretConfig,
};
use doot_lang::lang::plan::Plan;
use crate::payload::{Deploy, Perm, Stage, TaskData};
/// A single effect, converted from a plan node's [`TaskData`] payload into the
/// deploy layer's config types. Used both to build a flat [`EvalResult`] and to
/// drive DAG-ordered execution (see [`crate::exec`]).
pub enum Task {
Dotfile(DotfileConfig),
DotfilePattern(DotfilesPattern),
Package(PackageConfig),
Hook(HookConfig),
Secret(SecretConfig),
Tap(String),
Formula(String),
EncVar { key: String, value: String },
EncFile { key: String, path: PathBuf },
}
/// The execution phase a node belongs to, mirroring the fixed deploy order so
/// hook stages can be turned into ordering edges: a node in a higher phase
/// depends on every node in a lower phase. `before_deploy(0) < dotfiles/secrets(1)
/// < after_deploy(2) < before_package(3) < taps(4) < packages/formulae(5) <
/// after_package(6)`. Encrypted entries carry no ordering (phase 0).
pub fn phase_of(data: &TaskData) -> u8 {
match data {
TaskData::Hook { stage, .. } => match stage {
Stage::BeforeDeploy => 0,
Stage::AfterDeploy => 2,
Stage::BeforePackage => 3,
Stage::AfterPackage => 6,
},
TaskData::EncVar { .. } | TaskData::EncFile { .. } => 0,
TaskData::Dotfile { .. } | TaskData::Secret { .. } => 1,
TaskData::Tap { .. } => 4,
TaskData::Package { .. } | TaskData::Formula { .. } => 5,
}
}
/// Convert one node payload into a typed [`Task`]. Glob dotfile sources become
/// `DotfilePattern` (expanded at deploy); concrete sources become `Dotfile`.
pub fn task_of(data: &TaskData) -> Task {
match data {
TaskData::Dotfile {
source,
target,
template,
permissions,
owner,
deploy,
link_patterns,
copy_patterns,
} => {
let permissions: Vec<PermissionRule> = permissions.iter().map(to_perm_rule).collect();
let deploy = match deploy {
Deploy::Copy => DeployMode::Copy,
Deploy::Link => DeployMode::Link,
};
if is_glob(source) {
Task::DotfilePattern(DotfilesPattern {
source: DotfilesSource::Pattern(source.clone()),
target_base: tilde(target),
template: *template,
permissions,
owner: owner.clone(),
deploy,
link_patterns: link_patterns.clone(),
copy_patterns: copy_patterns.clone(),
})
} else {
Task::Dotfile(DotfileConfig {
source: tilde(source),
target: tilde(target),
template: *template,
permissions,
owner: owner.clone(),
deploy,
link_patterns: link_patterns.clone(),
copy_patterns: copy_patterns.clone(),
exclude_paths: Vec::new(),
exclude_sources: Vec::new(),
})
}
}
TaskData::Package {
default,
brew,
cask,
apt,
pacman,
yay,
xbps,
} => Task::Package(PackageConfig {
default: default.clone(),
brew: brew.clone(),
cask: cask.clone(),
apt: apt.clone(),
pacman: pacman.clone(),
yay: yay.clone(),
xbps: xbps.clone(),
}),
TaskData::Hook { run, stage } => Task::Hook(HookConfig {
stage: match stage {
Stage::BeforeDeploy => HookStage::BeforeDeploy,
Stage::AfterDeploy => HookStage::AfterDeploy,
Stage::BeforePackage => HookStage::BeforePackage,
Stage::AfterPackage => HookStage::AfterPackage,
},
run: run.clone(),
}),
TaskData::Secret {
source,
target,
mode,
} => Task::Secret(SecretConfig {
source: tilde(source),
target: tilde(target),
mode: *mode,
}),
TaskData::Tap { name } => Task::Tap(name.clone()),
TaskData::Formula { name } => Task::Formula(name.clone()),
TaskData::EncVar { key, value } => Task::EncVar {
key: key.clone(),
value: value.clone(),
},
TaskData::EncFile { key, path } => Task::EncFile {
key: key.clone(),
path: PathBuf::from(path),
},
}
}
/// Route a typed [`Task`] into the flat [`EvalResult`] collections.
fn push_task(r: &mut EvalResult, task: Task) {
match task {
Task::Dotfile(d) => r.dotfiles.push(d),
Task::DotfilePattern(p) => r.dotfile_patterns.push(p),
Task::Package(p) => r.packages.push(p),
Task::Hook(h) => r.hooks.push(h),
Task::Secret(s) => r.secrets.push(s),
Task::Tap(name) => r.brew_taps.push(name),
Task::Formula(name) => r.brew_formulae.push(name),
Task::EncVar { key, value } => {
r.encrypted_vars.insert(key, value);
}
Task::EncFile { key, path } => {
r.encrypted_files.insert(key, path);
}
}
}
pub fn to_eval_result(plan: &Plan) -> EvalResult {
let mut r = EvalResult::default();
for node in &plan.nodes {
if let Some(data) = node.data.downcast_ref::<TaskData>() {
push_task(&mut r, task_of(data));
}
}
r
}
pub(crate) fn is_glob(s: &str) -> bool {
s.contains('*') || s.contains('?') || s.contains('[')
}
// expand a leading `~` to the home directory (DOOT_HOME-aware); deploy expects
// absolute target paths
pub(crate) fn tilde(s: &str) -> PathBuf {
if let Some(rest) = s.strip_prefix('~') {
let rest = rest.strip_prefix('/').unwrap_or(rest);
doot_utils::xdg::home_dir().join(rest)
} else {
PathBuf::from(s)
}
}
fn to_perm_rule(p: &Perm) -> PermissionRule {
match p {
Perm::Mode(n) => PermissionRule::Single(*n),
Perm::Pattern { pattern, mode } => PermissionRule::Pattern {
pattern: pattern.clone(),
mode: *mode,
},
}
}

View file

@ -0,0 +1,393 @@
//! 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()
}

View file

@ -0,0 +1,36 @@
//! The inferred dependency DAG exposed as typed tasks in topological layers, so
//! deploy can honor dependencies across task kinds (e.g. a hook that `needs` a
//! set of dotfiles runs strictly after them, regardless of stage).
use std::collections::HashMap;
use doot_core::evaluator::Value as TemplateValue;
use crate::bridge::Task;
/// A DAG-ordered execution plan.
pub struct ExecPlan {
/// Topologically ordered layers. Tasks within a layer are mutually
/// independent (safe to run concurrently); layer `N` runs only after every
/// task in layers `0..N` has completed.
pub layers: Vec<Vec<Task>>,
pub template_vars: HashMap<String, TemplateValue>,
}
impl ExecPlan {
pub fn empty() -> Self {
ExecPlan {
layers: Vec::new(),
template_vars: HashMap::new(),
}
}
/// Total task count across all layers.
pub fn len(&self) -> usize {
self.layers.iter().map(|l| l.len()).sum()
}
pub fn is_empty(&self) -> bool {
self.layers.iter().all(|l| l.is_empty())
}
}

View file

@ -0,0 +1,851 @@
//! The dotfile domain layer: registers the dotfile vocabulary into the language
//! engine, evaluates a config, and reflects the result into the deploy layer's
//! `EvalResult`. This is the only crate that knows both the language and the
//! deploy backend.
pub mod bridge;
pub mod builtins;
pub mod exec;
pub mod payload;
pub mod reflect;
use std::collections::HashMap;
use doot_core::evaluator::{EvalResult, Value as TemplateValue};
use doot_lang::lang::ast::Program;
use doot_lang::lang::check::Checker;
use doot_lang::lang::diag::Diagnostic;
use doot_lang::lang::engine::Engine;
use doot_lang::lang::parser::parse;
use doot_lang::lang::plan::Plan;
use crate::payload::TaskData;
pub use bridge::{Task, to_eval_result};
pub use doot_lang::lang::diag::{Diagnostic as Diag, Span};
pub use exec::ExecPlan;
/// The full engine: the general standard library plus the dotfile vocabulary.
pub fn engine() -> Engine {
let mut e = Engine::default();
doot_std::register(&mut e);
builtins::register_dotfile(&mut e);
e
}
/// Parse against `engine`'s registered nominal names.
fn parse_program(src: &str, engine: &Engine) -> Result<Program, Diagnostic> {
parse(src, &engine.struct_names(), &engine.enum_names())
}
/// Parse and pretty-print a config to canonical source (the `doot fmt` formatter).
/// Preserves comments, integer literal forms, and multiline strings.
pub fn format(src: &str) -> Result<String, Vec<Diagnostic>> {
let eng = engine();
let prog = parse_program(src, &eng).map_err(|d| vec![d])?;
Ok(doot_lang::lang::fmt::format(&prog))
}
/// Parse + type-check, returning any diagnostics (empty = valid).
pub fn check(src: &str) -> Vec<Diagnostic> {
let eng = engine();
let prog = match parse_program(src, &eng) {
Ok(p) => p,
Err(d) => return vec![d],
};
let mut c = Checker::with_engine(&prog, &eng);
c.check(&prog.body);
c.errors.into_iter().map(Diagnostic::message).collect()
}
/// Parse, type-check, and evaluate to a [`Plan`]. Diagnostics are returned
/// alongside the plan rather than aborting (a parse error yields an empty plan).
pub fn compile(src: &str) -> (Plan, Vec<Diagnostic>) {
let eng = engine();
let prog = match parse_program(src, &eng) {
Ok(p) => p,
Err(d) => return (Plan::default(), vec![d]),
};
let mut c = Checker::with_engine(&prog, &eng);
c.check(&prog.body);
let diags = c.errors.into_iter().map(Diagnostic::message).collect();
(reflect::build_plan(&prog, &eng), diags)
}
/// The full bridge output the CLI needs: an `EvalResult` plus template variables.
pub fn compile_eval_result(
src: &str,
) -> (EvalResult, HashMap<String, TemplateValue>, Vec<Diagnostic>) {
let eng = engine();
let prog = match parse_program(src, &eng) {
Ok(p) => p,
Err(d) => return (EvalResult::default(), HashMap::new(), vec![d]),
};
let mut c = Checker::with_engine(&prog, &eng);
c.check(&prog.body);
let diags = c.errors.into_iter().map(Diagnostic::message).collect();
let s = reflect::compile_sections(&prog, &eng);
let mut r = to_eval_result(&s.plan);
r.brew_taps.extend(s.taps);
r.encrypted_vars.extend(s.encrypted_vars);
r.encrypted_files.extend(s.encrypted_files);
(r, s.template_vars, diags)
}
/// Compile to a DAG-ordered [`ExecPlan`]: the inferred dependency graph as typed
/// tasks in topological layers. Cross-kind dependencies (a hook that `needs`
/// dotfiles) and hook stages (turned into ordering edges) are both honored by
/// the layering. `Config { ... }` section taps/encrypted (not graph nodes) form
/// the first, dependency-free setup layer.
pub fn compile_exec_plan(src: &str) -> (ExecPlan, Vec<Diagnostic>) {
let eng = engine();
let prog = match parse_program(src, &eng) {
Ok(p) => p,
Err(d) => return (ExecPlan::empty(), vec![d]),
};
let mut c = Checker::with_engine(&prog, &eng);
c.check(&prog.body);
let diags = c.errors.into_iter().map(Diagnostic::message).collect();
let s = reflect::compile_sections(&prog, &eng);
// Turn hook stages into ordering edges: a node in a higher phase depends on
// every node in a lower phase, so e.g. after_package hooks run strictly after
// packages. Combined with the inferred `needs` edges already in the plan.
let mut plan = s.plan;
let phases: Vec<Option<u8>> = plan
.nodes
.iter()
.map(|n| n.data.downcast_ref::<TaskData>().map(bridge::phase_of))
.collect();
for i in 0..plan.nodes.len() {
for j in 0..plan.nodes.len() {
if let (Some(pi), Some(pj)) = (phases[i], phases[j])
&& pi > pj
{
plan.edges.push((i, j));
}
}
}
let mut layers: Vec<Vec<Task>> = plan
.parallel_layers()
.iter()
.map(|layer| {
layer
.iter()
.filter_map(|&i| {
plan.nodes[i]
.data
.downcast_ref::<TaskData>()
.map(bridge::task_of)
})
.collect()
})
.collect();
// `Config { ... }` taps/encrypted are read from sections, not graph nodes.
// They are setup with no dependencies (and taps must precede package
// installs), so they form the first layer.
let mut extras: Vec<Task> = Vec::new();
extras.extend(s.taps.into_iter().map(Task::Tap));
extras.extend(
s.encrypted_vars
.into_iter()
.map(|(key, value)| Task::EncVar { key, value }),
);
extras.extend(
s.encrypted_files
.into_iter()
.map(|(key, path)| Task::EncFile { key, path }),
);
if !extras.is_empty() {
layers.insert(0, extras);
}
(
ExecPlan {
layers,
template_vars: s.template_vars,
},
diags,
)
}
#[cfg(test)]
mod tests {
use super::*;
// Edges fall out of `needs`/value references - no `after` written anywhere.
#[test]
fn edges_inferred_from_data() {
let src = r#"
let
fonts = map (\n -> dotfile { source = n; target = "/f" / n; })
[ "a.ttf" "b.ttf" ];
pkgs = map pkg [ "git" "fd" ];
fc = hook { run = "fc-cache"; needs = fonts; };
in { dotfiles = fonts; packages = pkgs; hooks = [ fc ]; }
"#;
let (plan, errs) = compile(src);
assert!(errs.is_empty(), "unexpected errors: {errs:?}");
let fc = plan
.nodes
.iter()
.position(|n| n.label.starts_with("hook:"))
.unwrap();
// fc-cache depends on both font dotfiles
assert_eq!(plan.deps_of(fc).len(), 2);
let layers = plan.parallel_layers();
// dotfiles + packages run first (parallel), the hook strictly after.
let fc_layer = layers.iter().position(|l| l.contains(&fc)).unwrap();
assert!(fc_layer > 0);
assert!(layers[0].len() >= 4);
}
#[test]
fn typed_merge_accepts_valid() {
let src = r#"
struct Host { name : Str; port : Int = 22; }
let
web : Host = { name = "web"; port = 8080; };
prod = web // { port = 443; };
in prod
"#;
assert!(check(src).is_empty());
}
#[test]
fn typed_merge_rejects_bad_override_and_construction() {
let src = r#"
struct Host { name : Str; port : Int = 22; }
let
web : Host = { name = "web"; };
bad1 = web // { port = "x"; };
bad2 = web // { prot = 9; };
bad3 : Host = { port = 1; };
in web
"#;
let errs = check(src);
assert_eq!(errs.len(), 3, "got: {errs:?}");
}
#[test]
fn bridges_plan_to_eval_result() {
let src = r#"
let
fonts = map (\n -> dotfile { source = n; target = "/f" / n; template = true; })
[ "a.ttf" "b.ttf" ];
pkgs = map pkg [ "git" "fd" ];
fc = hook { run = "fc-cache"; needs = fonts; };
t = tap "homebrew/cask-fonts";
in { dotfiles = fonts; packages = pkgs; hooks = [ fc ]; taps = [ t ]; }
"#;
let (plan, errs) = compile(src);
assert!(errs.is_empty(), "{errs:?}");
let r = to_eval_result(&plan);
assert_eq!(r.dotfiles.len(), 2);
assert_eq!(r.packages.len(), 2);
assert_eq!(r.hooks.len(), 1);
assert_eq!(r.brew_taps, vec!["homebrew/cask-fonts".to_string()]);
assert!(r.dotfiles.iter().all(|d| d.template));
assert_eq!(r.packages[0].default.as_deref(), Some("git"));
}
#[test]
fn domain_parity_dotfile_and_package_fields() {
use doot_core::evaluator::{DeployMode, PermissionRule};
let src = r#"
let
ssh = dotfile { source = "config"/"ssh"; target = home_dir / ".ssh/config"; permissions = 0o600; deploy = "link"; };
svc = dotfile { source = "config"/"service"; target = config_dir / "service";
permissions = [ [ "*/run" 0o755 ] [ "*/log/run" 0o755 ] ]; };
yz = package { default = "yazi"; yay = "yazi-nightly-bin"; };
rg = package "ripgrep";
linux = optionals (os == "linux") (map package [ "brightnessctl" ]);
in { dotfiles = [ ssh svc ]; packages = [ yz rg ] ++ linux; }
"#;
let (plan, errs) = compile(src);
assert!(errs.is_empty(), "{errs:?}");
let r = to_eval_result(&plan);
assert_eq!(r.dotfiles.len(), 2);
assert_eq!(r.dotfiles[0].deploy, DeployMode::Link);
assert_eq!(
r.dotfiles[0].permissions,
vec![PermissionRule::Single(0o600)]
);
assert!(r.dotfiles[0].target.ends_with(".ssh/config"));
assert_eq!(r.dotfiles[1].permissions.len(), 2);
let yz = &r.packages[0];
assert_eq!(yz.default.as_deref(), Some("yazi"));
assert_eq!(yz.yay.as_deref(), Some("yazi-nightly-bin"));
assert_eq!(r.packages[1].default.as_deref(), Some("ripgrep"));
}
#[test]
fn domain_parity_secrets_hooks_brew_encrypted_globs() {
use doot_core::HookStage;
let src = r#"
let
glob = dotfile { source = "config"/"*"; target = config_dir; };
conf = dotfile { source = "config"/"ssh"; target = home_dir / ".ssh"; };
sec = secret { source = "secrets"/"id"; target = home_dir / ".ssh/id"; mode = 0o600; };
post = hook { run = "fc-cache"; stage = "after_package"; };
enc = encrypted { API = "base64data"; WB = file ("secrets"/"wb.age"); };
forms = [ (formula "bun") ];
in { dotfiles = [ glob conf ]; secrets = [ sec ]; hooks = [ post ]; enc = enc; brew = forms; }
"#;
let (plan, errs) = compile(src);
assert!(errs.is_empty(), "{errs:?}");
let r = to_eval_result(&plan);
assert_eq!(r.dotfile_patterns.len(), 1); // the glob
assert_eq!(r.dotfiles.len(), 1); // the concrete one
assert_eq!(r.secrets.len(), 1);
assert_eq!(r.secrets[0].mode, Some(0o600));
assert_eq!(r.hooks[0].stage, HookStage::AfterPackage);
assert_eq!(r.brew_formulae, vec!["bun".to_string()]);
assert_eq!(
r.encrypted_vars.get("API").map(String::as_str),
Some("base64data")
);
assert!(r.encrypted_files.contains_key("WB"));
}
#[test]
fn exposes_template_variables() {
use doot_core::evaluator::Value as V;
let src = r##"
struct Colors { base00 : Str; base0D : Str; }
let
colors = Colors { base00 = "#232136"; base0D = "#c4a7e7"; };
name = "ray";
fonts = map (\n -> dotfile { source = n; target = "/f"/n; }) [ "a" ];
in { dotfiles = fonts; }
"##;
let (_r, vars, errs) = compile_eval_result(src);
assert!(errs.is_empty(), "{errs:?}");
assert!(matches!(vars.get("name"), Some(V::Str(s)) if s == "ray"));
match vars.get("colors") {
Some(V::Struct(_, m)) => {
assert_eq!(m.len(), 2);
assert!(matches!(m.get("base0D"), Some(V::Str(s)) if s == "#c4a7e7"));
}
_ => panic!("expected colors struct"),
}
}
#[test]
fn free_functions_named_and_multiparam() {
// `let f a b = ...` sugar and `\a b -> ...` multi-param lambda
let src = r#"
let
cfg name tmpl = dotfile { source = name; target = "/c" / name; template = tmpl; };
pair = \a b -> [ a b ];
in { dotfiles = [ (cfg "nvim" true) ] ++ map (\n -> cfg n false) (pair "ghostty" "tmux"); }
"#;
let (plan, errs) = compile(src);
assert!(errs.is_empty(), "{errs:?}");
let r = to_eval_result(&plan);
assert_eq!(r.dotfiles.len(), 3);
assert!(r.dotfiles.iter().filter(|d| d.template).count() == 1);
}
#[test]
fn recursion_self_and_mutual() {
// `let` is recursive: a function sees itself and its siblings.
let src = r#"
let
f x = if x then [ "a" ] else f true; # self-recursion (1 level)
isEven n = if n then true else isOdd n; # mutual recursion
isOdd n = if n then false else isEven n;
in { dotfiles = map (\name -> dotfile { source = name; target = name; }) (f false); ok = isEven true; }
"#;
let (plan, errs) = compile(src);
assert!(errs.is_empty(), "{errs:?}");
assert_eq!(to_eval_result(&plan).dotfiles.len(), 1);
}
#[test]
fn arithmetic_precedence_and_path_overload() {
// 1 + 2*3 - 4 = 3 ; 2**3**2 = 512 (right-assoc) ; 17 % 5 = 2 ; 10/2 = 5
let src = r#"
let
a = 1 + 2 * 3 - 4;
b = 2 ** 3 ** 2;
c = 17 % 5;
d = 10 / 2;
p = "etc" / "ssh";
in { a = a; b = b; c = c; d = d; p = p; }
"#;
let (_p, vars, errs) = compile_eval_result(src);
assert!(errs.is_empty(), "{errs:?}");
use doot_core::evaluator::Value as V;
let int = |k: &str| match vars.get(k) {
Some(V::Int(n)) => *n,
other => panic!("{k} = {other:?}"),
};
assert_eq!(int("a"), 3);
assert_eq!(int("b"), 512);
assert_eq!(int("c"), 2);
assert_eq!(int("d"), 5);
assert!(matches!(vars.get("p"), Some(V::Str(s)) if s == "etc/ssh"));
}
#[test]
fn infinite_lists_are_lazy() {
use doot_core::evaluator::Value as V;
// self-referential infinite list, an infinite generator, and lazy map
// over it - consumed safely with `take`. Infinite lists are kept in inner
// lets so only the finite results are exposed as template vars.
let src = r#"
let
a = let ones = cons 1 ones; in take 3 ones;
b = let gen = \n -> cons n (gen (n + 1)); in take 4 (gen 10);
c = let gen = \n -> cons n (gen (n + 1)); in take 3 (map (\x -> x * x) (gen 1));
in { a = a; b = b; c = c; }
"#;
let (_p, vars, errs) = compile_eval_result(src);
assert!(errs.is_empty(), "{errs:?}");
let ints = |k: &str| match vars.get(k) {
Some(V::List(xs)) => xs
.iter()
.map(|v| match v {
V::Int(n) => *n,
other => panic!("{other:?}"),
})
.collect::<Vec<_>>(),
other => panic!("{k} = {other:?}"),
};
assert_eq!(ints("a"), vec![1, 1, 1]);
assert_eq!(ints("b"), vec![10, 11, 12, 13]);
assert_eq!(ints("c"), vec![1, 4, 9]);
}
#[test]
fn tail_calls_do_not_overflow() {
use doot_core::evaluator::Value as V;
// 100k-deep tail recursion: loops via the trampoline, would blow the
// (small) test-thread stack without TCO.
let src = r#"
let
countdown n = if n == 0 then "done" else countdown (n - 1);
result = countdown 100000;
in { x = result; }
"#;
let (_p, vars, errs) = compile_eval_result(src);
assert!(errs.is_empty(), "{errs:?}");
assert!(matches!(vars.get("result"), Some(V::Str(s)) if s == "done"));
}
#[test]
fn strict_foldl_constant_space() {
use doot_core::evaluator::Value as V;
// strict foldl keeps the accumulator forced each step (no accumulator
// thunk-chain); the iterative Drop lets the long cons spine be freed too.
let src = r#"
let
ones = cons 1 ones;
total = foldl (\acc x -> acc + x) 0 (take 100000 ones);
in { t = total; }
"#;
let (_p, vars, errs) = compile_eval_result(src);
assert!(errs.is_empty(), "{errs:?}");
assert!(matches!(vars.get("total"), Some(V::Int(n)) if *n == 100000));
}
#[test]
fn infinite_top_level_binding_does_not_hang() {
// an infinite top-level binding is skipped as a template var (budgeted),
// not hung on; finite siblings still resolve.
use doot_core::evaluator::Value as V;
let src = r#"
let
ones = cons 1 ones;
name = "ok";
in { x = take 2 ones; }
"#;
let (_p, vars, errs) = compile_eval_result(src);
assert!(errs.is_empty(), "{errs:?}");
assert!(matches!(vars.get("name"), Some(V::Str(s)) if s == "ok"));
assert!(!vars.contains_key("ones")); // skipped, not hung
}
#[test]
fn hm_let_polymorphism() {
// a single `id` used at two different types: classic let-polymorphism
let src = r#"
let id = \x -> x;
in { a = id 1; b = id "x"; c = id (cons 1 nil); }
"#;
assert!(check(src).is_empty(), "{:?}", check(src));
}
#[test]
fn hm_catches_type_errors() {
// each of these is a real type error HM now catches (Dyn used to miss them)
assert!(!check("let x = 1 + \"s\"; in x").is_empty()); // int + str
assert!(!check("let x = head 5; in x").is_empty()); // head wants a list
assert!(!check("let x = map 5 (cons 1 nil); in x").is_empty()); // map wants a fn
assert!(!check("let x = if 1 then 2 else 3; in x").is_empty()); // cond not Bool
}
#[test]
fn deep_non_tail_recursion_via_cek() {
use doot_core::evaluator::Value as V;
// `head xs + sum (tail xs)` is NOT tail-recursive (the add happens after the
// call returns). 100k deep: the CEK machine keeps the depth on its heap
// continuation stack, so Rust's stack does not overflow.
let src = r#"
let
ones = cons 1 ones;
sum = \xs -> if empty xs then 0 else head xs + sum (tail xs);
total = sum (take 100000 ones);
in { t = total; }
"#;
let (_p, vars, errs) = compile_eval_result(src);
assert!(errs.is_empty(), "{errs:?}");
assert!(matches!(vars.get("total"), Some(V::Int(n)) if *n == 100000));
}
#[test]
fn nested_list_value_drops_iteratively() {
// A large List (List Int) - built, walked by collect_tasks, and freed by
// the iterative Drop, none recursing on Rust's stack. (Self-nesting trees
// are rejected by HM as infinite types, so long/nested lists are the only
// deep well-typed shapes.)
let src = r#"
let
ones = cons 1 ones;
grid = map (\row -> take 300 ones) (take 300 ones);
in { x = grid; }
"#;
let (plan, errs) = compile(src);
assert!(errs.is_empty(), "{errs:?}");
assert_eq!(plan.nodes.len(), 0);
}
#[test]
fn long_task_list_bridge_is_iterative() {
// 50k tasks in one list: collect_tasks walks the whole plan without
// recursing (a recursive walk would overflow at this length).
let src = r#"
let mk = \n -> if n == 0 then nil else cons (dotfile { source = "s"; target = "t"; }) (mk (n - 1));
in { dotfiles = mk 50000; }
"#;
let (plan, errs) = compile(src);
assert!(errs.is_empty(), "{errs:?}");
assert_eq!(plan.nodes.len(), 50000);
}
#[test]
fn long_list_to_template_var_is_iterative() {
// converting a 50k-element list to a template value does not recurse
use doot_core::evaluator::Value as V;
let src = r#"
let big = let ones = cons 1 ones; in take 50000 ones;
in { x = big; }
"#;
let (_p, vars, errs) = compile_eval_result(src);
assert!(errs.is_empty(), "{errs:?}");
match vars.get("big") {
Some(V::List(xs)) => assert_eq!(xs.len(), 50000),
other => panic!("{other:?}"),
}
}
#[test]
fn enums_variants_and_equality() {
use doot_core::evaluator::Value as V;
let src = r#"
enum Os { Linux, MacOS, Other }
let
cur = Os.Linux;
isLinux = cur == Os.Linux;
isMac = cur == Os.MacOS;
in { a = isLinux; b = isMac; }
"#;
let (_p, vars, errs) = compile_eval_result(src);
assert!(errs.is_empty(), "{errs:?}");
assert!(matches!(vars.get("isLinux"), Some(V::Bool(true))));
assert!(matches!(vars.get("isMac"), Some(V::Bool(false))));
// unknown variant is a type error
assert!(!check("enum E { A, B } let x = E.C; in x == E.A").is_empty());
}
#[test]
fn struct_and_enum_methods() {
use doot_core::evaluator::Value as V;
let src = r##"
struct Host {
name : Str;
port : Int = 22;
fn url self = "https://" ++ self.name;
fn bumped self n = self.port + n;
}
enum Os {
Linux, MacOS, Other,
fn isLinux self = self == Os.Linux;
}
let
h = Host { name = "web"; port = 8080; };
u = h.url; # method, self bound
b = h.bumped 100; # method with an extra arg
ml = Os.Linux.isLinux; # enum method
mm = Os.MacOS.isLinux;
in { u = u; b = b; ml = ml; mm = mm; }
"##;
let (_p, vars, errs) = compile_eval_result(src);
assert!(errs.is_empty(), "{errs:?}");
assert!(matches!(vars.get("u"), Some(V::Str(s)) if s == "https://web"));
assert!(matches!(vars.get("b"), Some(V::Int(8180))));
assert!(matches!(vars.get("ml"), Some(V::Bool(true))));
assert!(matches!(vars.get("mm"), Some(V::Bool(false))));
// typo: no such field or method
assert!(!check("struct A { x : Int; } let a = A { x = 1; }; in a.nope").is_empty());
}
#[test]
fn type_classes_dispatch_and_safety() {
use doot_core::evaluator::Value as V;
let src = r#"
class Show a { show : a -> Str; }
enum Os { Linux, MacOS, Other }
impl Show for Bool { show = \b -> if b then "yes" else "no"; }
impl Show for Os { show = \o -> if o == Os.Linux then "linux" else "other"; }
let
a = show true; # Bool instance (free function)
b = show Os.Linux; # Os instance
c = Os.MacOS.show; # . sugar -> show Os.MacOS
in { a = a; b = b; c = c; }
"#;
let (_p, vars, errs) = compile_eval_result(src);
assert!(errs.is_empty(), "{errs:?}");
assert!(matches!(vars.get("a"), Some(V::Str(s)) if s == "yes"));
assert!(matches!(vars.get("b"), Some(V::Str(s)) if s == "linux"));
assert!(matches!(vars.get("c"), Some(V::Str(s)) if s == "other"));
// no instance for Int -> type error (the safety you wanted)
let bad = check("class Show a { show : a -> Str; } let x = show 5; in x");
assert!(
bad.iter().any(|e| e.message.contains("no instance")),
"{bad:?}"
);
// coherence: duplicate instance is an error
let dup = check(
"class Show a { show : a -> Str; } impl Show for Bool { show = \\b -> \"x\"; } impl Show for Bool { show = \\b -> \"y\"; } let x = show true; in x",
);
assert!(
dup.iter().any(|e| e.message.contains("duplicate instance")),
"{dup:?}"
);
}
#[test]
fn host_record_and_builtin_os() {
use doot_core::evaluator::Value as V;
// host.os is an Os enum (built-in), host.distro/configDir/homeDir are Str
let src = r#"
let
onLinux = host.os == Os.Linux;
d = host.distro;
in { a = onLinux; b = d; }
"#;
let (_p, vars, errs) = compile_eval_result(src);
assert!(errs.is_empty(), "{errs:?}");
assert!(matches!(vars.get("onLinux"), Some(V::Bool(_))));
assert!(matches!(vars.get("d"), Some(V::Str(_))));
}
#[test]
fn manager_constructors() {
let src = r#"
{ packages =
map package [ "ripgrep" "fd" ]
++ map xbps [ "swayfx" ]
++ [ (brew "bun") (brew { package = "dockdoor"; cask = true; }) (yay "yazi-nightly-bin") ];
}
"#;
let (plan, errs) = compile(src);
assert!(errs.is_empty(), "{errs:?}");
let r = to_eval_result(&plan);
assert_eq!(r.packages.len(), 6);
assert!(
r.packages
.iter()
.any(|p| p.default.as_deref() == Some("ripgrep"))
);
assert!(
r.packages
.iter()
.any(|p| p.xbps.as_deref() == Some("swayfx"))
);
assert!(r.packages.iter().any(|p| p.brew.as_deref() == Some("bun")));
assert!(
r.packages
.iter()
.any(|p| p.cask.as_deref() == Some("dockdoor"))
);
assert!(
r.packages
.iter()
.any(|p| p.yay.as_deref() == Some("yazi-nightly-bin"))
);
}
#[test]
fn config_schema_sections() {
use doot_core::evaluator::Value as V;
let src = r##"
struct Colors { base0D : Str; }
let
colors = Colors { base0D = "#c4a7e7"; };
in Config {
vars = { colors = colors; };
dotfiles = [ (dotfile { source = "a"; target = "b"; }) ];
packages = map package [ "git" "fd" ] ++ [ (brew "bun") ];
encrypted = { API = "base64data"; WB = file "secrets/wb.age"; };
brew = { taps = [ "oven-sh/bun" ]; };
}
"##;
let (r, vars, errs) = compile_eval_result(src);
assert!(errs.is_empty(), "{errs:?}");
assert_eq!(r.dotfiles.len(), 1);
assert_eq!(r.packages.len(), 3);
assert_eq!(r.brew_taps, vec!["oven-sh/bun".to_string()]);
assert_eq!(
r.encrypted_vars.get("API").map(String::as_str),
Some("base64data")
);
assert!(r.encrypted_files.contains_key("WB"));
assert!(matches!(vars.get("colors"), Some(V::Struct(_, _))));
// section-name typo is a type error
assert!(!check("Config { dotflies = nil; }").is_empty());
}
#[test]
fn operator_overloading() {
use doot_core::evaluator::Value as V;
// `/` overloaded: Int division, Str path-join (built-in), and a user Path
let src = r#"
struct Path { p : Str; }
impl Div for Path { div = \a b -> Path { p = a.p ++ "/" ++ b.p; }; }
let
n = 10 / 2;
s = "etc" / "ssh";
pp = (Path { p = "a"; }) / (Path { p = "b"; });
joined = pp.p;
in { x = n; }
"#;
let (_p, vars, errs) = compile_eval_result(src);
assert!(errs.is_empty(), "{errs:?}");
assert!(matches!(vars.get("n"), Some(V::Int(5))));
assert!(matches!(vars.get("s"), Some(V::Str(t)) if t == "etc/ssh"));
assert!(matches!(vars.get("joined"), Some(V::Str(t)) if t == "a/b"));
// `+` on a type with no Add instance is a type error
assert!(
!check("struct P { x : Int; } let a = P { x = 1; } + P { x = 2; }; in a").is_empty()
);
}
#[test]
fn indented_multiline_strings() {
use doot_core::evaluator::Value as V;
// ''...'' strips the common indentation and the blank first/last lines
let src = "let s = ''\n line one\n line two\n ''; in { x = s; }";
let (_p, vars, errs) = compile_eval_result(src);
assert!(errs.is_empty(), "{errs:?}");
assert!(matches!(vars.get("s"), Some(V::Str(t)) if t == "line one\nline two"));
}
#[test]
fn defaults_are_filled_in_plan() {
// construction with a default still evaluates fine
let src = r#"
struct Spec { name : Str; opt : Bool = false; }
let s = Spec { name = "x"; };
in { v = s; }
"#;
let (_plan, errs) = compile(src);
assert!(errs.is_empty(), "{errs:?}");
}
#[test]
fn fmt_preserves_comments_literals_and_is_idempotent() {
let src = "# top comment\nlet\n # a note\n perm = 0o755;\n mask = 0xff;\n pkgs = [ (package \"a\") (package \"b\") ];\nin { packages = pkgs; }\n";
let once = format(src).expect("format ok");
let twice = format(&once).expect("format ok");
assert_eq!(once, twice, "formatter is not idempotent:\n{once}");
assert!(
once.contains("# top comment"),
"dropped top comment:\n{once}"
);
assert!(once.contains("# a note"), "dropped inner comment:\n{once}");
assert!(once.contains("0o755"), "octal not preserved:\n{once}");
assert!(once.contains("0xff"), "hex not preserved:\n{once}");
// still type-checks after formatting
assert!(check(&once).is_empty(), "formatted output has errors");
}
#[test]
fn parse_and_lex_errors_are_located_diagnostics() {
// a parse error carries a span and does not panic
let errs = check("let x = ; in x");
assert_eq!(errs.len(), 1, "{errs:?}");
assert!(errs[0].span.is_some());
assert!(errs[0].message.contains("unexpected"));
// a lexical error is reported the same way
let errs = check("let x = 1 @ 2; in x");
assert!(errs[0].message.contains("unexpected character"));
assert!(errs[0].span.is_some());
// the rendered form points at the source line with a caret
let rendered = errs[0].render("let x = 1 @ 2; in x");
assert!(rendered.contains("^"), "{rendered}");
}
#[test]
fn exec_plan_orders_cross_kind_dependencies() {
// a hook that `needs` font dotfiles must land in a strictly later layer,
// regardless of kind - the inferred DAG edges drive the ordering.
let src = r#"
let
fonts = map (\n -> dotfile { source = n; target = "/f"/n; }) [ "a" "b" ];
fc = hook { run = "fc-cache"; needs = fonts; };
in { dotfiles = fonts; hooks = [ fc ]; }
"#;
let (plan, errs) = compile_exec_plan(src);
assert!(errs.is_empty(), "{errs:?}");
assert_eq!(plan.len(), 3); // two dotfiles + one hook
let layer_of =
|want: fn(&Task) -> bool| plan.layers.iter().position(|l| l.iter().any(want));
let dot = layer_of(|t| matches!(t, Task::Dotfile(_))).unwrap();
let hook = layer_of(|t| matches!(t, Task::Hook(_))).unwrap();
assert!(hook > dot, "hook layer {hook} should follow dotfiles {dot}");
}
#[test]
fn exec_plan_turns_stages_into_edges() {
// an after_package hook must land in a later layer than packages, even
// with no explicit `needs` - the stage becomes an ordering edge.
let src = r#"
let
pkgs = map package [ "a" "b" ];
post = hook { run = "rebuild"; stage = "after_package"; };
in { packages = pkgs; hooks = [ post ]; }
"#;
let (plan, errs) = compile_exec_plan(src);
assert!(errs.is_empty(), "{errs:?}");
let layer_of =
|want: fn(&Task) -> bool| plan.layers.iter().position(|l| l.iter().any(want));
let pkg = layer_of(|t| matches!(t, Task::Package(_))).unwrap();
let hook = layer_of(|t| matches!(t, Task::Hook(_))).unwrap();
assert!(
hook > pkg,
"after_package hook layer {hook} must follow packages {pkg}"
);
}
}

View file

@ -0,0 +1,109 @@
//! The dotfile vocabulary's node payloads. These are the domain-specific data
//! an effect node carries; the resource graph treats them opaquely.
use std::rc::Rc;
use doot_lang::lang::ast::{Expr, FieldDecl, StructDecl, Type};
/// A file-mode rule: a single mode or a glob-pattern -> mode mapping.
#[derive(Debug, Clone)]
pub enum Perm {
Mode(u32),
Pattern { pattern: String, mode: u32 },
}
#[derive(Debug, Clone, Copy)]
pub enum Deploy {
Copy,
Link,
}
#[derive(Debug, Clone, Copy)]
pub enum Stage {
BeforeDeploy,
AfterDeploy,
BeforePackage,
AfterPackage,
}
/// Structured payload of an effect node. Stored opaquely in the plan and
/// downcast back by the bridge.
#[derive(Debug, Clone)]
pub enum TaskData {
Dotfile {
source: String,
target: String,
template: bool,
permissions: Vec<Perm>,
owner: Option<String>,
deploy: Deploy,
link_patterns: Vec<String>,
copy_patterns: Vec<String>,
},
Package {
default: Option<String>,
brew: Option<String>,
cask: Option<String>,
apt: Option<String>,
pacman: Option<String>,
yay: Option<String>,
xbps: Option<String>,
},
Hook {
run: String,
stage: Stage,
},
Secret {
source: String,
target: String,
mode: Option<u32>,
},
Tap {
name: String,
},
/// brew-only formula (from a `brew:` block)
Formula {
name: String,
},
/// inline encrypted value: `KEY = "base64..."`
EncVar {
key: String,
value: String,
},
/// encrypted file reference: `KEY = file("path.age")`
EncFile {
key: String,
path: String,
},
}
/// The value `file("path")` evaluates to: a foreign marker distinguishing a file
/// reference from an inline string. Carried in `Value::Foreign`.
pub struct FileRef(pub String);
/// The built-in `Config` schema. All sections default (empty), so a config only
/// writes the ones it uses. Section values are `Dyn` (permissive), but section
/// *names* are checked - a typo like `dotflies` is a type error.
pub fn config_struct() -> StructDecl {
let rec = || Some(Rc::new(Expr::Record(Vec::new())));
let nil = || Some(Rc::new(Expr::Var("nil".to_string())));
let f = |name: &str, default: Option<Rc<Expr>>| FieldDecl {
name: name.to_string(),
ty: Type::Dyn,
default,
};
StructDecl {
name: "Config".to_string(),
fields: vec![
f("vars", rec()),
f("dotfiles", nil()),
f("packages", nil()),
f("hooks", nil()),
f("secrets", nil()),
f("encrypted", rec()),
f("brew", rec()),
],
methods: Vec::new(),
span: doot_lang::lang::diag::Span::point(0),
}
}

View file

@ -0,0 +1,219 @@
//! Reflecting an evaluated program into the deploy layer: build its plan, read a
//! `Config { ... }` body's non-task sections, and convert data values to the
//! deploy-layer template `Value`.
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::rc::Rc;
use indexmap::IndexMap;
use doot_core::evaluator::Value as TemplateValue;
use doot_lang::lang::ast::{Expr, Program};
use doot_lang::lang::engine::Engine;
use doot_lang::lang::eval::{Interp, Thunk, Value, as_str, interp_with_engine};
use doot_lang::lang::plan::Plan;
use crate::builtins::{current_os, detect_distro};
use crate::payload::FileRef;
/// Evaluate a program to its plan. (Run the checker first to surface type errors.)
pub fn build_plan(program: &Program, engine: &Engine) -> Plan {
let interp = interp_with_engine(program, engine);
let result = interp.eval(&program.body, &interp.global_scope());
// "Realize": force the plan deeply so every Task node is instantiated and its
// edges inferred. (Pure eval already ran; this only walks the data.)
let mut roots = Vec::new();
interp.collect_tasks(&result, &mut roots);
interp.into_plan()
}
/// Data read from a `Config { ... }` body's non-task sections, plus the plan.
pub struct Sections {
pub plan: Plan,
pub taps: Vec<String>,
pub encrypted_vars: HashMap<String, String>,
pub encrypted_files: HashMap<String, PathBuf>,
pub template_vars: HashMap<String, TemplateValue>,
}
/// Evaluate a program and extract its plan + `Config` sections. If the body is a
/// `Config { ... }`, `vars`/`encrypted`/`brew.taps` are read from the sections;
/// otherwise template variables fall back to harvesting top-level `let` bindings.
pub fn compile_sections(program: &Program, engine: &Engine) -> Sections {
let interp = interp_with_engine(program, engine);
let config = interp.eval(&program.body, &interp.global_scope());
let mut roots = Vec::new();
interp.collect_tasks(&config, &mut roots); // forces everything -> builds the plan
let mut taps = Vec::new();
let mut encrypted_vars = HashMap::new();
let mut encrypted_files = HashMap::new();
let mut tvars = HashMap::new();
let is_config = matches!(&config, Value::Attr(Some(n), _) if n.as_str() == "Config");
if let (true, Value::Attr(_, m)) = (is_config, &config) {
if let Some(Value::Attr(_, vm)) = m.get("vars").map(|t| interp.force(t)) {
for (k, vt) in vm.iter() {
if let Some(cv) = to_template_value(&interp, &interp.force(vt)) {
tvars.insert(k.clone(), cv);
}
}
}
if let Some(Value::Attr(_, em)) = m.get("encrypted").map(|t| interp.force(t)) {
for (k, vt) in em.iter() {
match interp.force(vt) {
Value::Str(s) => {
encrypted_vars.insert(k.clone(), (*s).clone());
}
Value::Foreign(a) => {
if let Some(f) = a.downcast_ref::<FileRef>() {
encrypted_files.insert(k.clone(), PathBuf::from(f.0.clone()));
}
}
_ => {}
}
}
}
if let Some(Value::Attr(_, bm)) = m.get("brew").map(|t| interp.force(t))
&& let Some(lv) = bm.get("taps").map(|t| interp.force(t))
{
for el in interp.list_to_vec(&lv) {
taps.push(as_str(&interp.force(&el)));
}
}
} else {
tvars = template_vars(program, engine); // legacy: harvest let bindings
}
// Expose host facts to templates, matching the `Os` enum form ("MacOS"/
// "Linux"/"Other") that configs compare against. User `vars` win on collision.
let os_variant = match current_os().as_str() {
"macos" => "MacOS",
"linux" => "Linux",
_ => "Other",
};
for (k, v) in [
("os", os_variant.to_string()),
("distro", detect_distro()),
(
"home_dir",
doot_utils::xdg::home_dir().to_string_lossy().into_owned(),
),
(
"config_dir",
doot_utils::xdg::config_home()
.to_string_lossy()
.into_owned(),
),
] {
tvars.entry(k.to_string()).or_insert(TemplateValue::Str(v));
}
Sections {
plan: interp.into_plan(),
taps,
encrypted_vars,
encrypted_files,
template_vars: tvars,
}
}
/// Top-level `let` bindings that are plain data, converted to deploy-layer values
/// for the template engine.
pub fn template_vars(program: &Program, engine: &Engine) -> HashMap<String, TemplateValue> {
let interp = interp_with_engine(program, engine);
let mut out = HashMap::new();
if let Expr::Let(binds, _) = &*program.body {
for (name, val) in interp.harvest_bindings(binds) {
// non-data (functions) and cyclic lists are skipped - not template values
if let Some(v) = to_template_value(&interp, &val) {
out.insert(name, v);
}
}
}
out
}
/// Convert a data value to the deploy-layer `Value` for templating. Iterative (no
/// Rust recursion) so deeply-nested values are fine. Non-data (tasks, functions,
/// file refs) yields `None`; a self-referential (cyclic) list also yields `None` -
/// it cannot be a finite template value. Genuinely productive-infinite values are
/// non-materializable by definition (as in Nix).
pub fn to_template_value(interp: &Interp, root: &Value) -> Option<TemplateValue> {
use TemplateValue as V1;
enum W {
Eval(Value),
MakeList(usize),
MakeAttr(Option<Rc<String>>, Vec<String>),
}
let mut work = vec![W::Eval(root.clone())];
let mut out: Vec<Option<V1>> = Vec::new();
while let Some(w) = work.pop() {
match w {
W::Eval(v) => match v {
Value::Int(n) => out.push(Some(V1::Int(n))),
Value::Str(s) => out.push(Some(V1::Str((*s).clone()))),
Value::Bool(b) => out.push(Some(V1::Bool(b))),
Value::Enum(e, v) => out.push(Some(V1::Enum((*e).clone(), (*v).clone()))),
Value::Nil => out.push(Some(V1::List(Vec::new()))),
Value::Cons(_, _) => {
// materialize this spine, detecting self-reference
let mut elems = Vec::new();
let mut seen: HashSet<usize> = HashSet::new();
let mut cur = v;
loop {
match cur {
Value::Nil => break,
Value::Cons(h, t) => {
if !seen.insert(Rc::as_ptr(&t) as usize) {
return None; // cyclic list: not a template value
}
elems.push(h);
cur = interp.force(&t);
}
_ => break,
}
}
work.push(W::MakeList(elems.len()));
for h in elems.into_iter().rev() {
work.push(W::Eval(interp.force(&h)));
}
}
Value::Attr(name, m) => {
let keys: Vec<String> = m.keys().cloned().collect();
let entries: Vec<Thunk> = m.values().cloned().collect();
work.push(W::MakeAttr(name, keys));
for t in entries.into_iter().rev() {
work.push(W::Eval(interp.force(&t)));
}
}
_ => out.push(None), // task / lambda / native / foreign
},
W::MakeList(n) => {
let mut items: Vec<Option<V1>> = Vec::with_capacity(n);
for _ in 0..n {
items.push(out.pop().unwrap());
}
items.reverse();
out.push(Some(V1::List(items.into_iter().flatten().collect())));
}
W::MakeAttr(name, keys) => {
let mut vals: Vec<Option<V1>> = Vec::with_capacity(keys.len());
for _ in 0..keys.len() {
vals.push(out.pop().unwrap());
}
vals.reverse();
let mut map = IndexMap::new();
for (k, v) in keys.into_iter().zip(vals) {
if let Some(cv) = v {
map.insert(k, cv);
}
}
let tag = name.map(|n| (*n).clone()).unwrap_or_default();
out.push(Some(V1::Struct(tag, map)));
}
}
}
out.pop().flatten()
}

View file

@ -4,28 +4,3 @@ version.workspace = true
edition.workspace = true
[dependencies]
doot-utils.workspace = true
chumsky.workspace = true
ariadne.workspace = true
serde.workspace = true
serde_json.workspace = true
toml.workspace = true
smol.workspace = true
async-recursion.workspace = true
futures-lite.workspace = true
surf.workspace = true
rayon.workspace = true
walkdir.workspace = true
blake3.workspace = true
os_info.workspace = true
thiserror.workspace = true
anyhow.workspace = true
indexmap = "2"
glob = "0.3"
hostname = "0.4"
age = "0.10"
ordered-float = "5"
tracing.workspace = true
[dev-dependencies]
tempfile = "3"

View file

@ -1,357 +0,0 @@
//! Abstract syntax tree definitions for the doot language.
use crate::lexer::Span;
use std::collections::HashMap;
/// Identifier type alias.
pub type Ident = String;
/// A parsed doot program.
#[derive(Clone, Debug, PartialEq)]
pub struct Program {
pub statements: Vec<Spanned<Statement>>,
}
/// Wraps a node with source location information.
#[derive(Clone, Debug, PartialEq)]
pub struct Spanned<T> {
pub node: T,
pub span: Span,
}
impl<T> Spanned<T> {
/// Creates a new spanned node.
pub fn new(node: T, span: Span) -> Self {
Self { node, span }
}
}
/// Top-level statement types.
#[derive(Clone, Debug, PartialEq)]
pub enum Statement {
VarDecl(VarDecl),
FnDecl(FnDecl),
StructDecl(StructDecl),
EnumDecl(EnumDecl),
TypeAlias(TypeAlias),
Import(Import),
Dotfile(Box<Dotfile>),
Package(Box<Package>),
Brew(BrewConfig),
Secret(Secret),
Encrypted(EncryptedVars),
Hook(Hook),
MacroDecl(MacroDecl),
MacroCall(MacroCall),
ForLoop(ForLoop),
If(IfStatement),
Match(MatchStatement),
Expr(Expr),
Return(Option<Expr>),
}
/// Variable declaration.
#[derive(Clone, Debug, PartialEq)]
pub struct VarDecl {
pub name: Ident,
pub ty: Option<TypeAnnotation>,
pub value: Expr,
}
/// Function declaration.
#[derive(Clone, Debug, PartialEq)]
pub struct FnDecl {
pub name: Ident,
pub is_async: bool,
pub params: Vec<FnParam>,
pub return_type: Option<TypeAnnotation>,
pub body: Vec<Spanned<Statement>>,
}
/// Function parameter.
#[derive(Clone, Debug, PartialEq)]
pub struct FnParam {
pub name: Ident,
pub ty: TypeAnnotation,
pub default: Option<Expr>,
}
/// Struct type declaration.
#[derive(Clone, Debug, PartialEq)]
pub struct StructDecl {
pub name: Ident,
pub fields: Vec<StructField>,
pub methods: Vec<FnDecl>,
}
/// Struct field definition.
#[derive(Clone, Debug, PartialEq)]
pub struct StructField {
pub name: Ident,
pub ty: TypeAnnotation,
pub default: Option<Expr>,
}
/// Enum type declaration.
#[derive(Clone, Debug, PartialEq)]
pub struct EnumDecl {
pub name: Ident,
pub variants: Vec<EnumVariant>,
}
/// Enum variant definition.
#[derive(Clone, Debug, PartialEq)]
pub struct EnumVariant {
pub name: Ident,
pub fields: Option<Vec<TypeAnnotation>>,
}
/// Type alias declaration.
#[derive(Clone, Debug, PartialEq)]
pub struct TypeAlias {
pub name: Ident,
pub ty: TypeAnnotation,
}
/// Module import statement.
#[derive(Clone, Debug, PartialEq)]
pub struct Import {
pub path: String,
pub alias: Option<Ident>,
}
/// Deploy mode for dotfiles.
#[derive(Clone, Copy, Debug, PartialEq, Default)]
pub enum DeployMode {
#[default]
Copy,
Link,
}
/// Permission rule - either a single mode or pattern-based.
#[derive(Clone, Debug, PartialEq)]
pub enum PermissionRule {
Single(u32),
Pattern { pattern: String, mode: u32 },
}
/// Dotfile deployment declaration.
#[derive(Clone, Debug, PartialEq)]
pub struct Dotfile {
pub source: Expr,
pub target: Expr,
pub when: Option<Expr>,
pub template: Option<bool>,
pub permissions: Vec<PermissionRule>,
pub owner: Option<String>,
pub deploy: DeployMode,
pub link_patterns: Vec<String>,
pub copy_patterns: Vec<String>,
/// Span of the source expression (for error reporting).
pub source_span: Option<Span>,
/// Span of the target expression (for error reporting).
pub target_span: Option<Span>,
/// Span of the when expression (for error reporting).
pub when_span: Option<Span>,
}
/// Package installation declaration.
#[derive(Clone, Debug, PartialEq)]
pub struct Package {
pub default: Option<Expr>,
pub brew: Option<PackageSpec>,
/// Homebrew cask (macOS GUI app); installed via `brew install --cask`.
pub cask: Option<PackageSpec>,
pub apt: Option<PackageSpec>,
pub pacman: Option<PackageSpec>,
pub yay: Option<PackageSpec>,
pub xbps: Option<PackageSpec>,
pub when: Option<Expr>,
}
/// Package manager-specific specification.
#[derive(Clone, Debug, PartialEq)]
pub struct PackageSpec {
pub name: Expr,
}
/// Homebrew-specific configuration (`brew:` block): taps and brew-only formulae.
/// macOS-only; ignored on other platforms.
#[derive(Clone, Debug, PartialEq, Default)]
pub struct BrewConfig {
/// Repositories to register via `brew tap` (list expression).
pub taps: Option<Expr>,
/// Brew-only formulae to install (list expression).
pub formulae: Option<Expr>,
}
/// Encrypted secret file declaration.
#[derive(Clone, Debug, PartialEq)]
pub struct Secret {
pub source: Expr,
pub target: Expr,
pub mode: Option<u32>,
}
/// Entry in an `encrypted:` block — either an inline var or a file reference.
#[derive(Clone, Debug, PartialEq)]
pub enum EncryptedEntry {
/// Inline base64-encoded encrypted value: `KEY = "base64..."`
Var(Ident, Expr),
/// Encrypted file reference: `KEY = file("path/to/file.age")`
File(Ident, Expr),
}
/// Encrypted variable declarations (for template use).
#[derive(Clone, Debug, PartialEq)]
pub struct EncryptedVars {
pub entries: Vec<EncryptedEntry>,
}
/// Lifecycle hook declaration.
#[derive(Clone, Debug, PartialEq)]
pub struct Hook {
pub stage: HookStage,
pub run: Expr,
pub when: Option<Expr>,
}
/// Hook execution stage.
#[derive(Clone, Debug, PartialEq)]
pub enum HookStage {
BeforeDeploy,
AfterDeploy,
BeforePackage,
AfterPackage,
}
/// Macro definition.
#[derive(Clone, Debug, PartialEq)]
pub struct MacroDecl {
pub name: Ident,
pub params: Vec<Ident>,
pub body: Vec<Spanned<Statement>>,
}
/// Macro invocation.
#[derive(Clone, Debug, PartialEq)]
pub struct MacroCall {
pub name: Ident,
pub args: Vec<Expr>,
}
/// For loop statement.
#[derive(Clone, Debug, PartialEq)]
pub struct ForLoop {
pub var: Ident,
pub iter: Expr,
pub body: Vec<Spanned<Statement>>,
}
/// Conditional statement.
#[derive(Clone, Debug, PartialEq)]
pub struct IfStatement {
pub condition: Expr,
pub then_body: Vec<Spanned<Statement>>,
pub else_body: Option<Vec<Spanned<Statement>>>,
}
/// Pattern matching statement.
#[derive(Clone, Debug, PartialEq)]
pub struct MatchStatement {
pub expr: Expr,
pub arms: Vec<MatchArm>,
}
/// Single arm in a match statement.
#[derive(Clone, Debug, PartialEq)]
pub struct MatchArm {
pub pattern: Pattern,
pub body: Expr,
}
/// Match pattern types.
#[derive(Clone, Debug, PartialEq)]
pub enum Pattern {
Literal(Literal),
Ident(Ident),
EnumVariant { ty: Ident, variant: Ident },
Wildcard,
}
/// Expression types.
#[derive(Clone, Debug, PartialEq)]
pub enum Expr {
Literal(Literal),
Ident(Ident),
Path(Box<Expr>, Box<Expr>),
Binary(Box<Expr>, BinOp, Box<Expr>),
Unary(UnaryOp, Box<Expr>),
Call(Box<Expr>, Vec<Expr>),
MethodCall(Box<Expr>, Ident, Vec<Expr>),
Index(Box<Expr>, Box<Expr>),
Field(Box<Expr>, Ident),
EnumVariant(Ident, Ident),
StructInit(Ident, HashMap<Ident, Expr>),
List(Vec<Expr>),
If(Box<Expr>, Box<Expr>, Option<Box<Expr>>),
Lambda(Vec<FnParam>, Box<Expr>),
Await(Box<Expr>),
Interpolated(Vec<InterpolatedPart>),
HomePath(Box<Expr>),
}
/// Part of an interpolated string.
#[derive(Clone, Debug, PartialEq)]
pub enum InterpolatedPart {
Literal(String),
Expr(Expr),
}
/// Literal value types.
#[derive(Clone, Debug, PartialEq)]
pub enum Literal {
Int(i64),
Float(f64),
Str(String),
Bool(bool),
None,
}
/// Binary operators.
#[derive(Clone, Debug, PartialEq)]
pub enum BinOp {
Add,
Sub,
Mul,
Div,
Mod,
Eq,
NotEq,
Lt,
Gt,
LtEq,
GtEq,
And,
Or,
PathJoin,
NullCoalesce,
}
/// Unary operators.
#[derive(Clone, Debug, PartialEq)]
pub enum UnaryOp {
Neg,
Not,
}
/// Type annotation in source code.
#[derive(Clone, Debug, PartialEq)]
pub enum TypeAnnotation {
Simple(Ident),
List(Box<TypeAnnotation>),
Optional(Box<TypeAnnotation>),
Function(Vec<TypeAnnotation>, Box<TypeAnnotation>),
Union(Vec<TypeAnnotation>),
Literal(Literal),
}

View file

@ -1,275 +0,0 @@
use crate::evaluator::{AsyncValue, EvalError, Value};
#[tracing::instrument(level = "trace", skip_all)]
pub async fn all(args: &[Value]) -> Result<Value, EvalError> {
let mut results = Vec::with_capacity(args.len());
for arg in args {
match arg {
Value::Future(av) => {
let task =
av.0.lock()
.map_err(|e| EvalError::AsyncError(e.to_string()))?
.take()
.ok_or_else(|| EvalError::AsyncError("future already consumed".into()))?;
results.push(task.await?);
}
other => results.push(other.clone()),
}
}
Ok(Value::List(results))
}
#[tracing::instrument(level = "trace", skip_all)]
pub async fn race(args: &[Value]) -> Result<Value, EvalError> {
let mut tasks = Vec::new();
for arg in args {
match arg {
Value::Future(av) => {
let task =
av.0.lock()
.map_err(|e| EvalError::AsyncError(e.to_string()))?
.take()
.ok_or_else(|| EvalError::AsyncError("future already consumed".into()))?;
tasks.push(task);
}
other => return Ok(other.clone()), // Non-future wins immediately
}
}
match tasks.len() {
0 => Ok(Value::None),
1 => tasks.remove(0).await,
_ => {
let mut combined = tasks.remove(0);
for t in tasks {
combined = smol::spawn(futures_lite::future::race(combined, t));
}
combined.await
}
}
}
#[tracing::instrument(level = "trace", skip_all)]
pub async fn fetch(args: &[Value]) -> Result<Value, EvalError> {
let url = match args.first() {
Some(Value::Str(s)) => s.clone(),
_ => {
return Err(EvalError::TypeError(
"fetch expects a URL string".to_string(),
));
}
};
let task = smol::spawn(async move {
let mut response = surf::get(&url)
.await
.map_err(|e| EvalError::AsyncError(e.to_string()))?;
let body = response
.body_string()
.await
.map_err(|e| EvalError::AsyncError(e.to_string()))?;
Ok(Value::Str(body))
});
Ok(Value::Future(AsyncValue::new(task)))
}
#[tracing::instrument(level = "trace", skip_all)]
pub async fn fetch_json(args: &[Value]) -> Result<Value, EvalError> {
let url = match args.first() {
Some(Value::Str(s)) => s.clone(),
_ => {
return Err(EvalError::TypeError(
"fetch_json expects a URL string".to_string(),
));
}
};
let task = smol::spawn(async move {
let mut response = surf::get(&url)
.await
.map_err(|e| EvalError::AsyncError(e.to_string()))?;
let json: serde_json::Value = response
.body_json()
.await
.map_err(|e| EvalError::AsyncError(e.to_string()))?;
Ok(json_to_value(&json))
});
Ok(Value::Future(AsyncValue::new(task)))
}
#[tracing::instrument(level = "trace", skip_all)]
pub async fn fetch_bytes(args: &[Value]) -> Result<Value, EvalError> {
let url = match args.first() {
Some(Value::Str(s)) => s.clone(),
_ => {
return Err(EvalError::TypeError(
"fetch_bytes expects a URL string".to_string(),
));
}
};
let task = smol::spawn(async move {
let mut response = surf::get(&url)
.await
.map_err(|e| EvalError::AsyncError(e.to_string()))?;
let bytes = response
.body_bytes()
.await
.map_err(|e| EvalError::AsyncError(e.to_string()))?;
let values: Vec<Value> = bytes.iter().map(|b| Value::Int(*b as i64)).collect();
Ok(Value::List(values))
});
Ok(Value::Future(AsyncValue::new(task)))
}
#[tracing::instrument(level = "trace", skip_all)]
pub async fn post(args: &[Value]) -> Result<Value, EvalError> {
let url = match args.first() {
Some(Value::Str(s)) => s.clone(),
_ => {
return Err(EvalError::TypeError(
"post expects a URL string".to_string(),
));
}
};
let body = match args.get(1) {
Some(Value::Str(s)) => s.clone(),
_ => String::new(),
};
let task = smol::spawn(async move {
let mut response = surf::post(&url)
.body(body)
.await
.map_err(|e| EvalError::AsyncError(e.to_string()))?;
let result = response
.body_string()
.await
.map_err(|e| EvalError::AsyncError(e.to_string()))?;
Ok(Value::Str(result))
});
Ok(Value::Future(AsyncValue::new(task)))
}
#[tracing::instrument(level = "trace", skip_all)]
pub async fn post_json(args: &[Value]) -> Result<Value, EvalError> {
let url = match args.first() {
Some(Value::Str(s)) => s.clone(),
_ => {
return Err(EvalError::TypeError(
"post_json expects a URL string".to_string(),
));
}
};
let data = args.get(1).unwrap_or(&Value::None);
let json = value_to_json(data);
let task = smol::spawn(async move {
let mut response = surf::post(&url)
.body_json(&json)
.map_err(|e| EvalError::AsyncError(e.to_string()))?
.await
.map_err(|e| EvalError::AsyncError(e.to_string()))?;
let result: serde_json::Value = response
.body_json()
.await
.map_err(|e| EvalError::AsyncError(e.to_string()))?;
Ok(json_to_value(&result))
});
Ok(Value::Future(AsyncValue::new(task)))
}
#[tracing::instrument(level = "trace", skip_all)]
pub async fn download(args: &[Value]) -> Result<Value, EvalError> {
let url = match args.first() {
Some(Value::Str(s)) => s.clone(),
_ => {
return Err(EvalError::TypeError(
"download expects a URL string".to_string(),
));
}
};
let path = match args.get(1) {
Some(Value::Path(p)) => p.clone(),
Some(Value::Str(s)) => std::path::PathBuf::from(s),
_ => {
return Err(EvalError::TypeError(
"download requires destination path".to_string(),
));
}
};
let task = smol::spawn(async move {
let mut response = surf::get(&url)
.await
.map_err(|e| EvalError::AsyncError(e.to_string()))?;
let bytes = response
.body_bytes()
.await
.map_err(|e| EvalError::AsyncError(e.to_string()))?;
std::fs::write(&path, bytes)?;
Ok(Value::Bool(true))
});
Ok(Value::Future(AsyncValue::new(task)))
}
fn json_to_value(json: &serde_json::Value) -> Value {
match json {
serde_json::Value::Null => Value::None,
serde_json::Value::Bool(b) => Value::Bool(*b),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
Value::Int(i)
} else if let Some(f) = n.as_f64() {
Value::Float(f)
} else {
Value::None
}
}
serde_json::Value::String(s) => Value::Str(s.clone()),
serde_json::Value::Array(arr) => Value::List(arr.iter().map(json_to_value).collect()),
serde_json::Value::Object(obj) => {
let fields: indexmap::IndexMap<String, Value> = obj
.iter()
.map(|(k, v)| (k.clone(), json_to_value(v)))
.collect();
Value::Struct("object".to_string(), fields)
}
}
}
fn value_to_json(val: &Value) -> serde_json::Value {
match val {
Value::Int(n) => serde_json::Value::Number(serde_json::Number::from(*n)),
Value::Float(n) => serde_json::Number::from_f64(*n)
.map(serde_json::Value::Number)
.unwrap_or(serde_json::Value::Null),
Value::Str(s) => serde_json::Value::String(s.clone()),
Value::Bool(b) => serde_json::Value::Bool(*b),
Value::Path(p) => serde_json::Value::String(p.display().to_string()),
Value::List(items) => serde_json::Value::Array(items.iter().map(value_to_json).collect()),
Value::Struct(_, fields) => {
let map: serde_json::Map<String, serde_json::Value> = fields
.iter()
.map(|(k, v)| (k.clone(), value_to_json(v)))
.collect();
serde_json::Value::Object(map)
}
Value::None => serde_json::Value::Null,
_ => serde_json::Value::Null,
}
}

View file

@ -1,443 +0,0 @@
use crate::ast::Expr;
use crate::evaluator::{EvalError, Evaluator, Value};
use async_recursion::async_recursion;
#[async_recursion(?Send)]
#[tracing::instrument(level = "trace", skip_all)]
pub async fn map(
eval: &mut Evaluator,
args: &[Value],
_arg_exprs: &[Expr],
) -> Result<Value, EvalError> {
let list = match args.first() {
Some(Value::List(items)) => items.clone(),
Some(v) => {
return Err(EvalError::TypeError(format!(
"map expects list, got {}",
v.type_name()
)));
}
None => {
return Err(EvalError::TypeError(
"map requires a list argument".to_string(),
));
}
};
match args.get(1) {
Some(Value::Lambda(params, body, env)) => {
let mut results = Vec::new();
for item in list {
let mut local_env = env.clone();
local_env.push_scope();
if let Some(param) = params.first() {
local_env.define(param.name.clone(), item);
}
let result = eval.eval_in_env(body, local_env).await?;
results.push(result);
}
Ok(Value::List(results))
}
Some(Value::Function(func, func_env)) => {
let mut results = Vec::new();
for item in list {
let result = eval.call_fn(func, func_env, &[item]).await?;
results.push(result);
}
Ok(Value::List(results))
}
_ => Err(EvalError::TypeError("map requires a function".to_string())),
}
}
#[async_recursion(?Send)]
#[tracing::instrument(level = "trace", skip_all)]
pub async fn filter(
eval: &mut Evaluator,
args: &[Value],
_arg_exprs: &[Expr],
) -> Result<Value, EvalError> {
let list = match args.first() {
Some(Value::List(items)) => items.clone(),
Some(v) => {
return Err(EvalError::TypeError(format!(
"filter expects list, got {}",
v.type_name()
)));
}
None => {
return Err(EvalError::TypeError(
"filter requires a list argument".to_string(),
));
}
};
match args.get(1) {
Some(Value::Lambda(params, body, env)) => {
let mut results = Vec::new();
for item in list {
let mut local_env = env.clone();
local_env.push_scope();
if let Some(param) = params.first() {
local_env.define(param.name.clone(), item.clone());
}
let result = eval.eval_in_env(body, local_env).await?;
if result.is_truthy() {
results.push(item);
}
}
Ok(Value::List(results))
}
Some(Value::Function(func, func_env)) => {
let mut results = Vec::new();
for item in list {
let result = eval
.call_fn(func, func_env, std::slice::from_ref(&item))
.await?;
if result.is_truthy() {
results.push(item);
}
}
Ok(Value::List(results))
}
_ => Err(EvalError::TypeError(
"filter requires a function".to_string(),
)),
}
}
#[async_recursion(?Send)]
#[tracing::instrument(level = "trace", skip_all)]
pub async fn fold(
eval: &mut Evaluator,
args: &[Value],
_arg_exprs: &[Expr],
) -> Result<Value, EvalError> {
let list = match args.first() {
Some(Value::List(items)) => items.clone(),
Some(v) => {
return Err(EvalError::TypeError(format!(
"fold expects list, got {}",
v.type_name()
)));
}
None => {
return Err(EvalError::TypeError(
"fold requires a list argument".to_string(),
));
}
};
let init = args.get(1).cloned().unwrap_or(Value::None);
match args.get(2) {
Some(Value::Lambda(params, body, env)) => {
let mut acc = init;
for item in list {
let mut local_env = env.clone();
local_env.push_scope();
if let Some(acc_param) = params.first() {
local_env.define(acc_param.name.clone(), acc.clone());
}
if let Some(item_param) = params.get(1) {
local_env.define(item_param.name.clone(), item);
}
acc = eval.eval_in_env(body, local_env).await?;
}
Ok(acc)
}
Some(Value::Function(func, func_env)) => {
let mut acc = init;
for item in list {
acc = eval.call_fn(func, func_env, &[acc, item]).await?;
}
Ok(acc)
}
_ => Err(EvalError::TypeError("fold requires a function".to_string())),
}
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn flatten(args: &[Value]) -> Result<Value, EvalError> {
let list = match args.first() {
Some(Value::List(items)) => items,
_ => return Err(EvalError::TypeError("flatten expects a list".to_string())),
};
let mut result = Vec::new();
for item in list {
match item {
Value::List(inner) => result.extend(inner.clone()),
v => result.push(v.clone()),
}
}
Ok(Value::List(result))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn concat(args: &[Value]) -> Result<Value, EvalError> {
let mut result = Vec::new();
for arg in args {
match arg {
Value::List(items) => result.extend(items.clone()),
v => result.push(v.clone()),
}
}
Ok(Value::List(result))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn zip(args: &[Value]) -> Result<Value, EvalError> {
if args.len() < 2 {
return Err(EvalError::TypeError(
"zip requires at least 2 lists".to_string(),
));
}
let lists: Result<Vec<&Vec<Value>>, _> = args
.iter()
.map(|a| match a {
Value::List(items) => Ok(items),
_ => Err(EvalError::TypeError("zip expects lists".to_string())),
})
.collect();
let lists = lists?;
let min_len = lists.iter().map(|l| l.len()).min().unwrap_or(0);
let mut result = Vec::new();
for i in 0..min_len {
let tuple: Vec<Value> = lists.iter().map(|l| l[i].clone()).collect();
result.push(Value::List(tuple));
}
Ok(Value::List(result))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn enumerate(args: &[Value]) -> Result<Value, EvalError> {
let list = match args.first() {
Some(Value::List(items)) => items,
_ => return Err(EvalError::TypeError("enumerate expects a list".to_string())),
};
let result: Vec<Value> = list
.iter()
.enumerate()
.map(|(i, v)| Value::List(vec![Value::Int(i as i64), v.clone()]))
.collect();
Ok(Value::List(result))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn first(args: &[Value]) -> Result<Value, EvalError> {
match args.first() {
Some(Value::List(items)) => Ok(items.first().cloned().unwrap_or(Value::None)),
_ => Err(EvalError::TypeError("first expects a list".to_string())),
}
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn last(args: &[Value]) -> Result<Value, EvalError> {
match args.first() {
Some(Value::List(items)) => Ok(items.last().cloned().unwrap_or(Value::None)),
_ => Err(EvalError::TypeError("last expects a list".to_string())),
}
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn len(args: &[Value]) -> Result<Value, EvalError> {
match args.first() {
Some(Value::List(items)) => Ok(Value::Int(items.len() as i64)),
Some(Value::Str(s)) => Ok(Value::Int(s.len() as i64)),
_ => Err(EvalError::TypeError(
"len expects a list or string".to_string(),
)),
}
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn contains(args: &[Value]) -> Result<Value, EvalError> {
let list = match args.first() {
Some(Value::List(items)) => items,
_ => return Err(EvalError::TypeError("contains expects a list".to_string())),
};
let needle = args.get(1).unwrap_or(&Value::None);
Ok(Value::Bool(list.iter().any(|v| values_equal(v, needle))))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn unique(args: &[Value]) -> Result<Value, EvalError> {
let list = match args.first() {
Some(Value::List(items)) => items,
_ => return Err(EvalError::TypeError("unique expects a list".to_string())),
};
let mut seen = Vec::new();
let mut result = Vec::new();
for item in list {
if !seen.iter().any(|s| values_equal(s, item)) {
seen.push(item.clone());
result.push(item.clone());
}
}
Ok(Value::List(result))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn sort(args: &[Value]) -> Result<Value, EvalError> {
let list = match args.first() {
Some(Value::List(items)) => items.clone(),
_ => return Err(EvalError::TypeError("sort expects a list".to_string())),
};
let mut sortable: Vec<(Value, String)> = list
.into_iter()
.map(|v| {
let key = match &v {
Value::Int(n) => format!("{:020}", n),
Value::Float(n) => format!("{:020.10}", n),
Value::Str(s) => s.clone(),
_ => v.to_string_repr(),
};
(v, key)
})
.collect();
sortable.sort_by(|a, b| a.1.cmp(&b.1));
Ok(Value::List(sortable.into_iter().map(|(v, _)| v).collect()))
}
#[async_recursion(?Send)]
#[tracing::instrument(level = "trace", skip_all)]
pub async fn sort_by(
eval: &mut Evaluator,
args: &[Value],
_arg_exprs: &[Expr],
) -> Result<Value, EvalError> {
let list = match args.first() {
Some(Value::List(items)) => items.clone(),
_ => return Err(EvalError::TypeError("sort_by expects a list".to_string())),
};
match args.get(1) {
Some(Value::Lambda(params, body, env)) => {
let mut keyed: Vec<(Value, String)> = Vec::new();
for item in list {
let mut local_env = env.clone();
local_env.push_scope();
if let Some(param) = params.first() {
local_env.define(param.name.clone(), item.clone());
}
let key = eval.eval_in_env(body, local_env).await?;
keyed.push((item, key.to_string_repr()));
}
keyed.sort_by(|a, b| a.1.cmp(&b.1));
Ok(Value::List(keyed.into_iter().map(|(v, _)| v).collect()))
}
_ => Err(EvalError::TypeError(
"sort_by requires a function".to_string(),
)),
}
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn reverse(args: &[Value]) -> Result<Value, EvalError> {
let list = match args.first() {
Some(Value::List(items)) => items.clone(),
_ => return Err(EvalError::TypeError("reverse expects a list".to_string())),
};
let mut reversed = list;
reversed.reverse();
Ok(Value::List(reversed))
}
#[async_recursion(?Send)]
#[tracing::instrument(level = "trace", skip_all)]
pub async fn seq(
eval: &mut Evaluator,
args: &[Value],
_arg_exprs: &[Expr],
) -> Result<Value, EvalError> {
let list = match args.first() {
Some(Value::List(items)) => items.clone(),
_ => return Err(EvalError::TypeError("seq expects a list".to_string())),
};
match args.get(1) {
Some(Value::Lambda(params, body, env)) => {
let mut results = Vec::new();
for item in list {
let mut local_env = env.clone();
local_env.push_scope();
if let Some(param) = params.first() {
local_env.define(param.name.clone(), item);
}
let result = eval.eval_in_env(body, local_env).await?;
results.push(result);
}
Ok(Value::List(results))
}
_ => Err(EvalError::TypeError("seq requires a function".to_string())),
}
}
#[async_recursion(?Send)]
#[tracing::instrument(level = "trace", skip_all)]
pub async fn batch(
eval: &mut Evaluator,
args: &[Value],
_arg_exprs: &[Expr],
) -> Result<Value, EvalError> {
let list = match args.first() {
Some(Value::List(items)) => items.clone(),
_ => return Err(EvalError::TypeError("batch expects a list".to_string())),
};
let batch_size = match args.get(1) {
Some(Value::Int(n)) => *n as usize,
_ => {
return Err(EvalError::TypeError(
"batch requires batch size".to_string(),
));
}
};
match args.get(2) {
Some(Value::Lambda(params, body, env)) => {
let mut results = Vec::new();
for chunk in list.chunks(batch_size) {
for item in chunk {
let mut local_env = env.clone();
local_env.push_scope();
if let Some(param) = params.first() {
local_env.define(param.name.clone(), item.clone());
}
let result = eval.eval_in_env(body, local_env).await?;
results.push(result);
}
}
Ok(Value::List(results))
}
_ => Err(EvalError::TypeError(
"batch requires a function".to_string(),
)),
}
}
fn values_equal(a: &Value, b: &Value) -> bool {
match (a, b) {
(Value::Int(x), Value::Int(y)) => x == y,
(Value::Float(x), Value::Float(y)) => (x - y).abs() < f64::EPSILON,
(Value::Str(x), Value::Str(y)) => x == y,
(Value::Bool(x), Value::Bool(y)) => x == y,
(Value::None, Value::None) => true,
(Value::Enum(t1, v1), Value::Enum(t2, v2)) => t1 == t2 && v1 == v2,
_ => false,
}
}

View file

@ -1,192 +0,0 @@
use crate::evaluator::{EvalError, Value};
use std::path::PathBuf;
#[tracing::instrument(level = "trace", skip_all)]
pub fn hash_file(args: &[Value]) -> Result<Value, EvalError> {
let path = match args.first() {
Some(Value::Path(p)) => p.clone(),
Some(Value::Str(s)) => PathBuf::from(s),
_ => return Err(EvalError::TypeError("hash_file expects a path".to_string())),
};
let content = std::fs::read(&path)?;
let hash = blake3::hash(&content);
Ok(Value::Str(hash.to_hex().to_string()))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn hash_str(args: &[Value]) -> Result<Value, EvalError> {
let s = match args.first() {
Some(Value::Str(s)) => s,
_ => {
return Err(EvalError::TypeError(
"hash_str expects a string".to_string(),
));
}
};
let hash = blake3::hash(s.as_bytes());
Ok(Value::Str(hash.to_hex().to_string()))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn encrypt_age(args: &[Value]) -> Result<Value, EvalError> {
let content = match args.first() {
Some(Value::Str(s)) => s,
_ => {
return Err(EvalError::TypeError(
"encrypt_age expects content string".to_string(),
));
}
};
let recipient = match args.get(1) {
Some(Value::Str(s)) => s,
_ => {
return Err(EvalError::TypeError(
"encrypt_age requires recipient public key".to_string(),
));
}
};
let recipient = recipient
.parse::<age::x25519::Recipient>()
.map_err(|e| EvalError::TypeError(format!("invalid recipient: {}", e)))?;
let encryptor = age::Encryptor::with_recipients(vec![Box::new(recipient)])
.expect("failed to create encryptor");
let mut encrypted = vec![];
let mut writer = encryptor
.wrap_output(&mut encrypted)
.map_err(|e| EvalError::TypeError(format!("encryption error: {}", e)))?;
use std::io::Write;
writer
.write_all(content.as_bytes())
.map_err(|e| EvalError::TypeError(format!("encryption error: {}", e)))?;
writer
.finish()
.map_err(|e| EvalError::TypeError(format!("encryption error: {}", e)))?;
Ok(Value::Str(base64_encode(&encrypted)))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn decrypt_age(args: &[Value]) -> Result<Value, EvalError> {
let encrypted = match args.first() {
Some(Value::Str(s)) => s,
_ => {
return Err(EvalError::TypeError(
"decrypt_age expects encrypted string".to_string(),
));
}
};
let identity_str = match args.get(1) {
Some(Value::Str(s)) => s,
_ => {
return Err(EvalError::TypeError(
"decrypt_age requires identity".to_string(),
));
}
};
let identity = identity_str
.parse::<age::x25519::Identity>()
.map_err(|e| EvalError::TypeError(format!("invalid identity: {}", e)))?;
let encrypted_bytes = base64_decode(encrypted)
.map_err(|e| EvalError::TypeError(format!("invalid base64: {}", e)))?;
let decryptor = match age::Decryptor::new(&encrypted_bytes[..])
.map_err(|e| EvalError::TypeError(format!("decryption error: {}", e)))?
{
age::Decryptor::Recipients(d) => d,
_ => {
return Err(EvalError::TypeError(
"unexpected decryptor type".to_string(),
));
}
};
let mut decrypted = vec![];
let mut reader = decryptor
.decrypt(std::iter::once(&identity as &dyn age::Identity))
.map_err(|e| EvalError::TypeError(format!("decryption error: {}", e)))?;
use std::io::Read;
reader
.read_to_end(&mut decrypted)
.map_err(|e| EvalError::TypeError(format!("decryption error: {}", e)))?;
Ok(Value::Str(String::from_utf8(decrypted).map_err(|e| {
EvalError::TypeError(format!("invalid UTF-8: {}", e))
})?))
}
pub fn base64_encode(data: &[u8]) -> String {
const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut result = String::new();
for chunk in data.chunks(3) {
let b0 = chunk[0] as usize;
let b1 = chunk.get(1).copied().unwrap_or(0) as usize;
let b2 = chunk.get(2).copied().unwrap_or(0) as usize;
result.push(ALPHABET[b0 >> 2] as char);
result.push(ALPHABET[((b0 & 0x03) << 4) | (b1 >> 4)] as char);
if chunk.len() > 1 {
result.push(ALPHABET[((b1 & 0x0f) << 2) | (b2 >> 6)] as char);
} else {
result.push('=');
}
if chunk.len() > 2 {
result.push(ALPHABET[b2 & 0x3f] as char);
} else {
result.push('=');
}
}
result
}
pub fn base64_decode(s: &str) -> Result<Vec<u8>, String> {
const DECODE: [i8; 256] = {
let mut table = [-1i8; 256];
let alphabet = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut i = 0;
while i < 64 {
table[alphabet[i] as usize] = i as i8;
i += 1;
}
table
};
let s = s.trim_end_matches('=');
let mut result = Vec::with_capacity(s.len() * 3 / 4);
let chars: Vec<u8> = s.bytes().collect();
for chunk in chars.chunks(4) {
let mut buf = [0u8; 4];
for (i, &c) in chunk.iter().enumerate() {
let val = DECODE[c as usize];
if val < 0 {
return Err(format!("invalid base64 character: {}", c as char));
}
buf[i] = val as u8;
}
result.push((buf[0] << 2) | (buf[1] >> 4));
if chunk.len() > 2 {
result.push((buf[1] << 4) | (buf[2] >> 2));
}
if chunk.len() > 3 {
result.push((buf[2] << 6) | buf[3]);
}
}
Ok(result)
}

View file

@ -1,428 +0,0 @@
use crate::evaluator::{EvalError, Value};
use std::path::PathBuf;
use std::process::Command;
use walkdir::WalkDir;
#[tracing::instrument(level = "trace", skip_all)]
pub fn read_file(args: &[Value]) -> Result<Value, EvalError> {
let path = get_path(args)?;
let content = std::fs::read_to_string(&path)?;
Ok(Value::Str(content))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn read_file_lines(args: &[Value]) -> Result<Value, EvalError> {
let path = get_path(args)?;
let content = std::fs::read_to_string(&path)?;
let lines: Vec<Value> = content.lines().map(|l| Value::Str(l.to_string())).collect();
Ok(Value::List(lines))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn write_file(args: &[Value]) -> Result<Value, EvalError> {
let path = get_path(args)?;
let content = match args.get(1) {
Some(Value::Str(s)) => s,
_ => {
return Err(EvalError::TypeError(
"write_file requires content string".to_string(),
));
}
};
std::fs::write(&path, content)?;
Ok(Value::Bool(true))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn copy_file(args: &[Value]) -> Result<Value, EvalError> {
let src = get_path(args)?;
let dst = match args.get(1) {
Some(Value::Path(p)) => p.clone(),
Some(Value::Str(s)) => expand_path(s),
_ => {
return Err(EvalError::TypeError(
"copy_file requires destination path".to_string(),
));
}
};
std::fs::copy(&src, &dst)?;
Ok(Value::Bool(true))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn delete_file(args: &[Value]) -> Result<Value, EvalError> {
let path = get_path(args)?;
std::fs::remove_file(&path)?;
Ok(Value::Bool(true))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn file_exists(args: &[Value]) -> Result<Value, EvalError> {
let path = get_path(args)?;
Ok(Value::Bool(path.is_file()))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn dir_exists(args: &[Value]) -> Result<Value, EvalError> {
let path = get_path(args)?;
Ok(Value::Bool(path.is_dir()))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn create_dir_all(args: &[Value]) -> Result<Value, EvalError> {
let path = get_path(args)?;
std::fs::create_dir_all(&path)?;
Ok(Value::Bool(true))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn list_dir(args: &[Value]) -> Result<Value, EvalError> {
let path = get_path(args)?;
let entries: Vec<Value> = std::fs::read_dir(&path)?
.filter_map(|e| e.ok())
.map(|e| Value::Path(e.path()))
.collect();
Ok(Value::List(entries))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn walk_dir(args: &[Value]) -> Result<Value, EvalError> {
let path = get_path(args)?;
let entries: Vec<Value> = WalkDir::new(&path)
.into_iter()
.filter_map(|e| e.ok())
.map(|e| Value::Path(e.path().to_path_buf()))
.collect();
Ok(Value::List(entries))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn temp_dir() -> Result<Value, EvalError> {
Ok(Value::Path(std::env::temp_dir()))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn temp_file(args: &[Value]) -> Result<Value, EvalError> {
let prefix = match args.first() {
Some(Value::Str(s)) => s.as_str(),
_ => "doot",
};
let suffix = match args.get(1) {
Some(Value::Str(s)) => s.as_str(),
_ => "",
};
let path = std::env::temp_dir().join(format!("{}_{}{}", prefix, uuid_simple(), suffix));
Ok(Value::Path(path))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn is_symlink(args: &[Value]) -> Result<Value, EvalError> {
let path = get_path(args)?;
Ok(Value::Bool(path.is_symlink()))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn read_link(args: &[Value]) -> Result<Value, EvalError> {
let path = get_path(args)?;
let target = std::fs::read_link(&path)?;
Ok(Value::Path(target))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn path_join(args: &[Value]) -> Result<Value, EvalError> {
let mut result = PathBuf::new();
for arg in args {
match arg {
Value::Path(p) => result.push(p),
Value::Str(s) => result.push(s),
_ => {
return Err(EvalError::TypeError(
"path_join expects paths or strings".to_string(),
));
}
}
}
Ok(Value::Path(result))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn path_parent(args: &[Value]) -> Result<Value, EvalError> {
let path = get_path(args)?;
Ok(Value::Path(
path.parent().map(|p| p.to_path_buf()).unwrap_or_default(),
))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn path_filename(args: &[Value]) -> Result<Value, EvalError> {
let path = get_path(args)?;
Ok(Value::Str(
path.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default(),
))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn path_extension(args: &[Value]) -> Result<Value, EvalError> {
let path = get_path(args)?;
Ok(Value::Str(
path.extension()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default(),
))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn home_dir() -> Result<Value, EvalError> {
Ok(Value::Path(doot_utils::xdg::home_dir()))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn config_dir() -> Result<Value, EvalError> {
Ok(Value::Path(doot_utils::xdg::config_home()))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn data_dir() -> Result<Value, EvalError> {
Ok(Value::Path(doot_utils::xdg::data_home()))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn cache_dir() -> Result<Value, EvalError> {
Ok(Value::Path(doot_utils::xdg::cache_home()))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn exec(args: &[Value]) -> Result<Value, EvalError> {
let cmd = match args.first() {
Some(Value::Str(s)) => s,
_ => {
return Err(EvalError::TypeError(
"exec expects a command string".to_string(),
));
}
};
let output = Command::new("sh").arg("-c").arg(cmd).output()?;
Ok(Value::Str(
String::from_utf8_lossy(&output.stdout).to_string(),
))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn exec_with_status(args: &[Value]) -> Result<Value, EvalError> {
let cmd = match args.first() {
Some(Value::Str(s)) => s,
_ => {
return Err(EvalError::TypeError(
"exec_with_status expects a command string".to_string(),
));
}
};
let status = Command::new("sh").arg("-c").arg(cmd).status()?;
Ok(Value::Int(status.code().unwrap_or(-1) as i64))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn shell(args: &[Value]) -> Result<Value, EvalError> {
exec(args)
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn which(args: &[Value]) -> Result<Value, EvalError> {
let cmd = match args.first() {
Some(Value::Str(s)) => s,
_ => {
return Err(EvalError::TypeError(
"which expects a command name".to_string(),
));
}
};
let output = Command::new("which").arg(cmd).output()?;
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
Ok(Value::Path(PathBuf::from(path)))
} else {
Ok(Value::None)
}
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn to_json(args: &[Value]) -> Result<Value, EvalError> {
let val = args.first().unwrap_or(&Value::None);
let json = value_to_json(val);
Ok(Value::Str(json.to_string()))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn from_json(args: &[Value]) -> Result<Value, EvalError> {
let s = match args.first() {
Some(Value::Str(s)) => s,
_ => {
return Err(EvalError::TypeError(
"from_json expects a string".to_string(),
));
}
};
let json: serde_json::Value = serde_json::from_str(s)
.map_err(|e| EvalError::TypeError(format!("invalid JSON: {}", e)))?;
Ok(json_to_value(&json))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn to_toml(args: &[Value]) -> Result<Value, EvalError> {
let val = args.first().unwrap_or(&Value::None);
let toml_val = value_to_toml(val);
let s = toml::to_string(&toml_val)
.map_err(|e| EvalError::TypeError(format!("TOML serialization error: {}", e)))?;
Ok(Value::Str(s))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn from_toml(args: &[Value]) -> Result<Value, EvalError> {
let s = match args.first() {
Some(Value::Str(s)) => s,
_ => {
return Err(EvalError::TypeError(
"from_toml expects a string".to_string(),
));
}
};
let toml_val: toml::Value =
toml::from_str(s).map_err(|e| EvalError::TypeError(format!("invalid TOML: {}", e)))?;
Ok(toml_to_value(&toml_val))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn to_yaml(args: &[Value]) -> Result<Value, EvalError> {
let val = args.first().unwrap_or(&Value::None);
let json = value_to_json(val);
Ok(Value::Str(
serde_json::to_string_pretty(&json).unwrap_or_default(),
))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn from_yaml(args: &[Value]) -> Result<Value, EvalError> {
from_json(args)
}
fn get_path(args: &[Value]) -> Result<PathBuf, EvalError> {
match args.first() {
Some(Value::Path(p)) => Ok(p.clone()),
Some(Value::Str(s)) => Ok(expand_path(s)),
_ => Err(EvalError::TypeError("expected path or string".to_string())),
}
}
fn expand_path(s: &str) -> PathBuf {
if let Some(stripped) = s.strip_prefix('~') {
let home = doot_utils::xdg::home_dir();
home.join(stripped.strip_prefix('/').unwrap_or(stripped))
} else {
PathBuf::from(s)
}
}
fn uuid_simple() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
format!("{:x}", nanos)
}
fn value_to_json(val: &Value) -> serde_json::Value {
match val {
Value::Int(n) => serde_json::Value::Number(serde_json::Number::from(*n)),
Value::Float(n) => serde_json::Number::from_f64(*n)
.map(serde_json::Value::Number)
.unwrap_or(serde_json::Value::Null),
Value::Str(s) => serde_json::Value::String(s.clone()),
Value::Bool(b) => serde_json::Value::Bool(*b),
Value::Path(p) => serde_json::Value::String(p.display().to_string()),
Value::List(items) => serde_json::Value::Array(items.iter().map(value_to_json).collect()),
Value::Struct(_, fields) => {
let map: serde_json::Map<String, serde_json::Value> = fields
.iter()
.map(|(k, v)| (k.clone(), value_to_json(v)))
.collect();
serde_json::Value::Object(map)
}
Value::None => serde_json::Value::Null,
_ => serde_json::Value::Null,
}
}
fn json_to_value(json: &serde_json::Value) -> Value {
match json {
serde_json::Value::Null => Value::None,
serde_json::Value::Bool(b) => Value::Bool(*b),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
Value::Int(i)
} else if let Some(f) = n.as_f64() {
Value::Float(f)
} else {
Value::None
}
}
serde_json::Value::String(s) => Value::Str(s.clone()),
serde_json::Value::Array(arr) => Value::List(arr.iter().map(json_to_value).collect()),
serde_json::Value::Object(obj) => {
let fields: indexmap::IndexMap<String, Value> = obj
.iter()
.map(|(k, v)| (k.clone(), json_to_value(v)))
.collect();
Value::Struct("object".to_string(), fields)
}
}
}
fn value_to_toml(val: &Value) -> toml::Value {
match val {
Value::Int(n) => toml::Value::Integer(*n),
Value::Float(n) => toml::Value::Float(*n),
Value::Str(s) => toml::Value::String(s.clone()),
Value::Bool(b) => toml::Value::Boolean(*b),
Value::Path(p) => toml::Value::String(p.display().to_string()),
Value::List(items) => toml::Value::Array(items.iter().map(value_to_toml).collect()),
Value::Struct(_, fields) => {
let map: toml::map::Map<String, toml::Value> = fields
.iter()
.map(|(k, v)| (k.clone(), value_to_toml(v)))
.collect();
toml::Value::Table(map)
}
_ => toml::Value::String(String::new()),
}
}
fn toml_to_value(toml: &toml::Value) -> Value {
match toml {
toml::Value::Boolean(b) => Value::Bool(*b),
toml::Value::Integer(i) => Value::Int(*i),
toml::Value::Float(f) => Value::Float(*f),
toml::Value::String(s) => Value::Str(s.clone()),
toml::Value::Array(arr) => Value::List(arr.iter().map(toml_to_value).collect()),
toml::Value::Table(table) => {
let fields: indexmap::IndexMap<String, Value> = table
.iter()
.map(|(k, v)| (k.clone(), toml_to_value(v)))
.collect();
Value::Struct("table".to_string(), fields)
}
toml::Value::Datetime(dt) => Value::Str(dt.to_string()),
}
}

View file

@ -1,477 +0,0 @@
//! Built-in functions for the doot language.
pub mod async_ops;
pub mod collections;
pub mod crypto;
pub mod io;
pub mod parallel;
pub mod strings;
use crate::ast::Expr;
use crate::evaluator::{EvalError, Evaluator, Value};
use async_recursion::async_recursion;
/// Dispatches a built-in function call.
#[async_recursion(?Send)]
#[tracing::instrument(level = "trace", skip_all, fields(name))]
pub async fn call_builtin(
eval: &mut Evaluator,
name: &str,
args: &[Value],
arg_exprs: &[Expr],
) -> Result<Value, EvalError> {
match name {
// Collections (async - take &mut Evaluator)
"map" => collections::map(eval, args, arg_exprs).await,
"filter" => collections::filter(eval, args, arg_exprs).await,
"fold" => collections::fold(eval, args, arg_exprs).await,
"sort_by" => collections::sort_by(eval, args, arg_exprs).await,
"seq" => collections::seq(eval, args, arg_exprs).await,
"batch" => collections::batch(eval, args, arg_exprs).await,
// Collections (sync)
"flatten" => collections::flatten(args),
"concat" => collections::concat(args),
"zip" => collections::zip(args),
"enumerate" => collections::enumerate(args),
"first" => collections::first(args),
"last" => collections::last(args),
"len" => collections::len(args),
"contains" => collections::contains(args),
"unique" => collections::unique(args),
"sort" => collections::sort(args),
"reverse" => collections::reverse(args),
// Strings
"join" => strings::join(args),
"split" => strings::split(args),
"upper" => strings::upper(args),
"lower" => strings::lower(args),
"trim" => strings::trim(args),
"replace" => strings::replace(args),
"starts_with" => strings::starts_with(args),
"ends_with" => strings::ends_with(args),
"format" => strings::format(args),
// Options
"unwrap" => options_unwrap(args),
"unwrap_or" => options_unwrap_or(args),
"is_some" => options_is_some(args),
"is_none" => options_is_none(args),
// I/O
"read_file" => io::read_file(args),
"read_file_lines" => io::read_file_lines(args),
"write_file" => io::write_file(args),
"copy_file" => io::copy_file(args),
"delete_file" => io::delete_file(args),
"file_exists" => io::file_exists(args),
"dir_exists" => io::dir_exists(args),
"create_dir_all" => io::create_dir_all(args),
"list_dir" => io::list_dir(args),
"walk_dir" => io::walk_dir(args),
"temp_dir" => io::temp_dir(),
"temp_file" => io::temp_file(args),
"is_symlink" => io::is_symlink(args),
"read_link" => io::read_link(args),
// Paths
"path_join" => io::path_join(args),
"path_parent" => io::path_parent(args),
"path_filename" => io::path_filename(args),
"path_extension" => io::path_extension(args),
"home_dir" => io::home_dir(),
"config_dir" => io::config_dir(),
"data_dir" => io::data_dir(),
"cache_dir" => io::cache_dir(),
// Process
"exec" => io::exec(args),
"exec_with_status" => io::exec_with_status(args),
"shell" => io::shell(args),
"which" => io::which(args),
// Serialization
"to_json" => io::to_json(args),
"from_json" => io::from_json(args),
"to_toml" => io::to_toml(args),
"from_toml" => io::from_toml(args),
"to_yaml" => io::to_yaml(args),
"from_yaml" => io::from_yaml(args),
// Crypto
"hash_file" => crypto::hash_file(args),
"hash_str" => crypto::hash_str(args),
"encrypt_age" => crypto::encrypt_age(args),
"decrypt_age" => crypto::decrypt_age(args),
// Parallel (rayon)
"par_map" => parallel::par_map(eval, args, arg_exprs),
"par_filter" => parallel::par_filter(eval, args, arg_exprs),
"par_sort_by" => parallel::par_sort_by(eval, args, arg_exprs),
"par_batch" => parallel::par_batch(eval, args, arg_exprs),
"par_flat_map" => parallel::par_flat_map(eval, args, arg_exprs),
"par_any" => parallel::par_any(eval, args, arg_exprs),
"par_all" => parallel::par_all(eval, args, arg_exprs),
"par_find" => parallel::par_find(eval, args, arg_exprs),
"par_partition" => parallel::par_partition(eval, args, arg_exprs),
"par_reduce" => parallel::par_reduce(eval, args, arg_exprs),
"par_min_by" => parallel::par_min_by(eval, args, arg_exprs),
"par_max_by" => parallel::par_max_by(eval, args, arg_exprs),
"par_for_each" => parallel::par_for_each(eval, args, arg_exprs),
// Async
"all" => async_ops::all(args).await,
"race" => async_ops::race(args).await,
// Network
"fetch" => async_ops::fetch(args).await,
"fetch_json" => async_ops::fetch_json(args).await,
"fetch_bytes" => async_ops::fetch_bytes(args).await,
"post" => async_ops::post(args).await,
"post_json" => async_ops::post_json(args).await,
"download" => async_ops::download(args).await,
// Environment
"env" => env_get(args),
// Debug
"print" => print_values(args),
"println" => println_values(args),
"dbg" => dbg_values(args),
_ => Err(EvalError::UndefinedFunction(name.to_string())),
}
}
/// Dispatches a method call on a value.
#[async_recursion(?Send)]
#[tracing::instrument(level = "trace", skip_all, fields(method))]
pub async fn call_method(
eval: &mut Evaluator,
obj: &Value,
method: &str,
args: &[Value],
arg_exprs: &[Expr],
) -> Result<Value, EvalError> {
match obj {
Value::List(items) => match method {
"len" => Ok(Value::Int(items.len() as i64)),
"first" => Ok(items.first().cloned().unwrap_or(Value::None)),
"last" => Ok(items.last().cloned().unwrap_or(Value::None)),
"contains" => {
if let Some(needle) = args.first() {
Ok(Value::Bool(items.iter().any(|v| values_equal(v, needle))))
} else {
Ok(Value::Bool(false))
}
}
"map" => {
let all_args = std::iter::once(obj.clone())
.chain(args.iter().cloned())
.collect::<Vec<_>>();
collections::map(eval, &all_args, arg_exprs).await
}
"filter" => {
let all_args = std::iter::once(obj.clone())
.chain(args.iter().cloned())
.collect::<Vec<_>>();
collections::filter(eval, &all_args, arg_exprs).await
}
"fold" => {
let all_args = std::iter::once(obj.clone())
.chain(args.iter().cloned())
.collect::<Vec<_>>();
collections::fold(eval, &all_args, arg_exprs).await
}
"join" => {
let sep = args
.first()
.map(|v| match v {
Value::Str(s) => s.as_str(),
_ => "",
})
.unwrap_or("");
let result = items
.iter()
.map(|v| v.to_string_repr())
.collect::<Vec<_>>()
.join(sep);
Ok(Value::Str(result))
}
"sort" => {
let all_args = std::iter::once(obj.clone())
.chain(args.iter().cloned())
.collect::<Vec<_>>();
collections::sort(&all_args)
}
"reverse" => {
let mut reversed = items.clone();
reversed.reverse();
Ok(Value::List(reversed))
}
"unique" => {
let all_args = std::iter::once(obj.clone())
.chain(args.iter().cloned())
.collect::<Vec<_>>();
collections::unique(&all_args)
}
"par_map" => {
let all_args = std::iter::once(obj.clone())
.chain(args.iter().cloned())
.collect::<Vec<_>>();
parallel::par_map(eval, &all_args, arg_exprs)
}
"par_filter" => {
let all_args = std::iter::once(obj.clone())
.chain(args.iter().cloned())
.collect::<Vec<_>>();
parallel::par_filter(eval, &all_args, arg_exprs)
}
"par_flat_map" => {
let all_args = std::iter::once(obj.clone())
.chain(args.iter().cloned())
.collect::<Vec<_>>();
parallel::par_flat_map(eval, &all_args, arg_exprs)
}
"par_sort_by" => {
let all_args = std::iter::once(obj.clone())
.chain(args.iter().cloned())
.collect::<Vec<_>>();
parallel::par_sort_by(eval, &all_args, arg_exprs)
}
"par_any" => {
let all_args = std::iter::once(obj.clone())
.chain(args.iter().cloned())
.collect::<Vec<_>>();
parallel::par_any(eval, &all_args, arg_exprs)
}
"par_all" => {
let all_args = std::iter::once(obj.clone())
.chain(args.iter().cloned())
.collect::<Vec<_>>();
parallel::par_all(eval, &all_args, arg_exprs)
}
"par_find" => {
let all_args = std::iter::once(obj.clone())
.chain(args.iter().cloned())
.collect::<Vec<_>>();
parallel::par_find(eval, &all_args, arg_exprs)
}
"par_partition" => {
let all_args = std::iter::once(obj.clone())
.chain(args.iter().cloned())
.collect::<Vec<_>>();
parallel::par_partition(eval, &all_args, arg_exprs)
}
"par_reduce" => {
let all_args = std::iter::once(obj.clone())
.chain(args.iter().cloned())
.collect::<Vec<_>>();
parallel::par_reduce(eval, &all_args, arg_exprs)
}
"par_min_by" => {
let all_args = std::iter::once(obj.clone())
.chain(args.iter().cloned())
.collect::<Vec<_>>();
parallel::par_min_by(eval, &all_args, arg_exprs)
}
"par_max_by" => {
let all_args = std::iter::once(obj.clone())
.chain(args.iter().cloned())
.collect::<Vec<_>>();
parallel::par_max_by(eval, &all_args, arg_exprs)
}
"par_batch" => {
let all_args = std::iter::once(obj.clone())
.chain(args.iter().cloned())
.collect::<Vec<_>>();
parallel::par_batch(eval, &all_args, arg_exprs)
}
"par_for_each" => {
let all_args = std::iter::once(obj.clone())
.chain(args.iter().cloned())
.collect::<Vec<_>>();
parallel::par_for_each(eval, &all_args, arg_exprs)
}
_ => Err(EvalError::UndefinedFunction(format!("list.{}", method))),
},
Value::Str(s) => match method {
"len" => Ok(Value::Int(s.len() as i64)),
"upper" => Ok(Value::Str(s.to_uppercase())),
"lower" => Ok(Value::Str(s.to_lowercase())),
"trim" => Ok(Value::Str(s.trim().to_string())),
"split" => {
let sep = args
.first()
.map(|v| match v {
Value::Str(s) => s.as_str(),
_ => " ",
})
.unwrap_or(" ");
let parts: Vec<Value> = s.split(sep).map(|p| Value::Str(p.to_string())).collect();
Ok(Value::List(parts))
}
"replace" => {
if args.len() >= 2
&& let (Value::Str(from), Value::Str(to)) = (&args[0], &args[1])
{
return Ok(Value::Str(s.replace(from, to)));
}
Ok(Value::Str(s.clone()))
}
"starts_with" => {
if let Some(Value::Str(prefix)) = args.first() {
Ok(Value::Bool(s.starts_with(prefix)))
} else {
Ok(Value::Bool(false))
}
}
"ends_with" => {
if let Some(Value::Str(suffix)) = args.first() {
Ok(Value::Bool(s.ends_with(suffix)))
} else {
Ok(Value::Bool(false))
}
}
"contains" => {
if let Some(Value::Str(needle)) = args.first() {
Ok(Value::Bool(s.contains(needle)))
} else {
Ok(Value::Bool(false))
}
}
_ => Err(EvalError::UndefinedFunction(format!("str.{}", method))),
},
Value::Path(p) => match method {
"parent" => Ok(Value::Path(
p.parent().map(|p| p.to_path_buf()).unwrap_or_default(),
)),
"filename" => Ok(Value::Str(
p.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default(),
)),
"extension" => Ok(Value::Str(
p.extension()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default(),
)),
"exists" => Ok(Value::Bool(p.exists())),
"is_file" => Ok(Value::Bool(p.is_file())),
"is_dir" => Ok(Value::Bool(p.is_dir())),
"join" => {
if let Some(Value::Str(other)) = args.first() {
Ok(Value::Path(p.join(other)))
} else if let Some(Value::Path(other)) = args.first() {
Ok(Value::Path(p.join(other)))
} else {
Ok(Value::Path(p.clone()))
}
}
_ => Err(EvalError::UndefinedFunction(format!("path.{}", method))),
},
Value::Struct(name, fields) => {
if let Some(decl) = eval.env().get_struct(name).cloned() {
for m in &decl.methods {
if m.name == method {
let mut method_args = vec![obj.clone()];
method_args.extend(args.iter().cloned());
let env_clone = eval.env().clone();
return eval.call_function(m, &env_clone, &method_args).await;
}
}
}
if let Some(field) = fields.get(method)
&& let Value::Function(func, env) = field
{
return eval.call_function(func, env, args).await;
}
Err(EvalError::FieldNotFound {
ty: name.clone(),
field: method.to_string(),
})
}
_ => Err(EvalError::TypeError(format!(
"cannot call method {} on {}",
method,
obj.type_name()
))),
}
}
fn values_equal(a: &Value, b: &Value) -> bool {
match (a, b) {
(Value::Int(x), Value::Int(y)) => x == y,
(Value::Float(x), Value::Float(y)) => (x - y).abs() < f64::EPSILON,
(Value::Str(x), Value::Str(y)) => x == y,
(Value::Bool(x), Value::Bool(y)) => x == y,
(Value::None, Value::None) => true,
(Value::Enum(t1, v1), Value::Enum(t2, v2)) => t1 == t2 && v1 == v2,
_ => false,
}
}
fn options_unwrap(args: &[Value]) -> Result<Value, EvalError> {
match args.first() {
Some(Value::None) => Err(EvalError::TypeError("unwrap called on none".to_string())),
Some(v) => Ok(v.clone()),
None => Err(EvalError::TypeError(
"unwrap requires an argument".to_string(),
)),
}
}
fn options_unwrap_or(args: &[Value]) -> Result<Value, EvalError> {
match args.first() {
Some(Value::None) => Ok(args.get(1).cloned().unwrap_or(Value::None)),
Some(v) => Ok(v.clone()),
None => Ok(args.get(1).cloned().unwrap_or(Value::None)),
}
}
fn options_is_some(args: &[Value]) -> Result<Value, EvalError> {
Ok(Value::Bool(!matches!(
args.first(),
Some(Value::None) | None
)))
}
fn options_is_none(args: &[Value]) -> Result<Value, EvalError> {
Ok(Value::Bool(matches!(
args.first(),
Some(Value::None) | None
)))
}
fn env_get(args: &[Value]) -> Result<Value, EvalError> {
if let Some(Value::Str(key)) = args.first() {
Ok(std::env::var(key).map(Value::Str).unwrap_or(Value::None))
} else {
Ok(Value::None)
}
}
fn print_values(args: &[Value]) -> Result<Value, EvalError> {
let output: Vec<String> = args.iter().map(|v| v.to_string_repr()).collect();
print!("{}", output.join(" "));
Ok(Value::None)
}
fn println_values(args: &[Value]) -> Result<Value, EvalError> {
let output: Vec<String> = args.iter().map(|v| v.to_string_repr()).collect();
println!("{}", output.join(" "));
Ok(Value::None)
}
fn dbg_values(args: &[Value]) -> Result<Value, EvalError> {
for (i, arg) in args.iter().enumerate() {
eprintln!("[dbg {}] {:?}", i, arg);
}
// Return the last argument (or None) for easy chaining
Ok(args.last().cloned().unwrap_or(Value::None))
}

View file

@ -1,569 +0,0 @@
//! Parallel collection builtins using rayon.
//!
//! Each function clones the Evaluator per rayon task. Side effects (env mutations)
//! inside parallel callbacks are isolated per clone and lost after execution.
//! I/O side effects (file writes, exec, etc.) still happen.
use crate::ast::Expr;
use crate::evaluator::{EvalError, Evaluator, Value};
use rayon::prelude::*;
/// Helper: evaluate a lambda body with one parameter bound, using a cloned evaluator.
fn eval_lambda_sync(
eval: &Evaluator,
params: &[crate::ast::FnParam],
body: &Expr,
env: &crate::evaluator::Env,
item: Value,
) -> Result<Value, EvalError> {
let mut local_eval = eval.clone();
let mut local_env = env.clone();
local_env.push_scope();
if let Some(param) = params.first() {
local_env.define(param.name.clone(), item);
}
smol::block_on(local_eval.eval_in_env(body, local_env))
}
/// Helper: call a named function with args, using a cloned evaluator.
fn call_fn_sync(
eval: &Evaluator,
func: &crate::ast::FnDecl,
func_env: &crate::evaluator::Env,
args: &[Value],
) -> Result<Value, EvalError> {
let mut local_eval = eval.clone();
smol::block_on(local_eval.call_fn(func, func_env, args))
}
/// Extract a list from the first argument, or return a TypeError.
fn extract_list(args: &[Value], fn_name: &str) -> Result<Vec<Value>, EvalError> {
match args.first() {
Some(Value::List(items)) => Ok(items.clone()),
Some(v) => Err(EvalError::TypeError(format!(
"{} expects list, got {}",
fn_name,
v.type_name()
))),
None => Err(EvalError::TypeError(format!(
"{} requires a list argument",
fn_name
))),
}
}
// ---------------------------------------------------------------------------
// par_map
// ---------------------------------------------------------------------------
#[tracing::instrument(level = "trace", skip_all)]
pub fn par_map(
eval: &mut Evaluator,
args: &[Value],
_arg_exprs: &[Expr],
) -> Result<Value, EvalError> {
let list = extract_list(args, "par_map")?;
match args.get(1) {
Some(Value::Lambda(params, body, env)) => {
let results: Result<Vec<Value>, EvalError> = list
.into_par_iter()
.map(|item| eval_lambda_sync(eval, params, body, env, item))
.collect();
Ok(Value::List(results?))
}
Some(Value::Function(func, func_env)) => {
let results: Result<Vec<Value>, EvalError> = list
.into_par_iter()
.map(|item| call_fn_sync(eval, func, func_env, &[item]))
.collect();
Ok(Value::List(results?))
}
_ => Err(EvalError::TypeError(
"par_map requires a function".to_string(),
)),
}
}
// ---------------------------------------------------------------------------
// par_filter
// ---------------------------------------------------------------------------
#[tracing::instrument(level = "trace", skip_all)]
pub fn par_filter(
eval: &mut Evaluator,
args: &[Value],
_arg_exprs: &[Expr],
) -> Result<Value, EvalError> {
let list = extract_list(args, "par_filter")?;
match args.get(1) {
Some(Value::Lambda(params, body, env)) => {
let results: Result<Vec<Value>, EvalError> = list
.into_par_iter()
.map(|item| {
let keep = eval_lambda_sync(eval, params, body, env, item.clone())?;
Ok((item, keep.is_truthy()))
})
.collect::<Result<Vec<_>, _>>()
.map(|pairs| {
pairs
.into_iter()
.filter(|(_, keep)| *keep)
.map(|(v, _)| v)
.collect()
});
Ok(Value::List(results?))
}
Some(Value::Function(func, func_env)) => {
let results: Result<Vec<Value>, EvalError> = list
.into_par_iter()
.map(|item| {
let keep = call_fn_sync(eval, func, func_env, std::slice::from_ref(&item))?;
Ok((item, keep.is_truthy()))
})
.collect::<Result<Vec<_>, _>>()
.map(|pairs| {
pairs
.into_iter()
.filter(|(_, keep)| *keep)
.map(|(v, _)| v)
.collect()
});
Ok(Value::List(results?))
}
_ => Err(EvalError::TypeError(
"par_filter requires a function".to_string(),
)),
}
}
// ---------------------------------------------------------------------------
// par_sort_by
// ---------------------------------------------------------------------------
#[tracing::instrument(level = "trace", skip_all)]
pub fn par_sort_by(
eval: &mut Evaluator,
args: &[Value],
_arg_exprs: &[Expr],
) -> Result<Value, EvalError> {
let list = extract_list(args, "par_sort_by")?;
match args.get(1) {
Some(Value::Lambda(params, body, env)) => {
// Compute sort keys in parallel
let keyed: Result<Vec<(Value, String)>, EvalError> = list
.into_par_iter()
.map(|item| {
let key = eval_lambda_sync(eval, params, body, env, item.clone())?;
Ok((item, key.to_string_repr()))
})
.collect();
let mut keyed = keyed?;
// Sort sequentially (fast, already have keys)
keyed.sort_by(|a, b| a.1.cmp(&b.1));
Ok(Value::List(keyed.into_iter().map(|(v, _)| v).collect()))
}
_ => Err(EvalError::TypeError(
"par_sort_by requires a function".to_string(),
)),
}
}
// ---------------------------------------------------------------------------
// par_batch
// ---------------------------------------------------------------------------
#[tracing::instrument(level = "trace", skip_all)]
pub fn par_batch(
eval: &mut Evaluator,
args: &[Value],
_arg_exprs: &[Expr],
) -> Result<Value, EvalError> {
let list = extract_list(args, "par_batch")?;
let batch_size = match args.get(1) {
Some(Value::Int(n)) => *n as usize,
_ => {
return Err(EvalError::TypeError(
"par_batch requires batch size".to_string(),
));
}
};
match args.get(2) {
Some(Value::Lambda(params, body, env)) => {
let mut all_results = Vec::new();
// Process chunks sequentially, items within each chunk in parallel
for chunk in list.chunks(batch_size) {
let chunk_results: Result<Vec<Value>, EvalError> = chunk
.into_par_iter()
.map(|item| eval_lambda_sync(eval, params, body, env, item.clone()))
.collect();
all_results.extend(chunk_results?);
}
Ok(Value::List(all_results))
}
_ => Err(EvalError::TypeError(
"par_batch requires a function".to_string(),
)),
}
}
// ---------------------------------------------------------------------------
// par_flat_map
// ---------------------------------------------------------------------------
#[tracing::instrument(level = "trace", skip_all)]
pub fn par_flat_map(
eval: &mut Evaluator,
args: &[Value],
_arg_exprs: &[Expr],
) -> Result<Value, EvalError> {
let list = extract_list(args, "par_flat_map")?;
match args.get(1) {
Some(Value::Lambda(params, body, env)) => {
let results: Result<Vec<Vec<Value>>, EvalError> = list
.into_par_iter()
.map(|item| {
let val = eval_lambda_sync(eval, params, body, env, item)?;
match val {
Value::List(inner) => Ok(inner),
v => Ok(vec![v]),
}
})
.collect();
Ok(Value::List(results?.into_iter().flatten().collect()))
}
Some(Value::Function(func, func_env)) => {
let results: Result<Vec<Vec<Value>>, EvalError> = list
.into_par_iter()
.map(|item| {
let val = call_fn_sync(eval, func, func_env, &[item])?;
match val {
Value::List(inner) => Ok(inner),
v => Ok(vec![v]),
}
})
.collect();
Ok(Value::List(results?.into_iter().flatten().collect()))
}
_ => Err(EvalError::TypeError(
"par_flat_map requires a function".to_string(),
)),
}
}
// ---------------------------------------------------------------------------
// par_any
// ---------------------------------------------------------------------------
#[tracing::instrument(level = "trace", skip_all)]
pub fn par_any(
eval: &mut Evaluator,
args: &[Value],
_arg_exprs: &[Expr],
) -> Result<Value, EvalError> {
let list = extract_list(args, "par_any")?;
match args.get(1) {
Some(Value::Lambda(params, body, env)) => {
// Use find_any for early exit on first match
let found = list.into_par_iter().find_any(|item| {
eval_lambda_sync(eval, params, body, env, item.clone())
.map(|v| v.is_truthy())
.unwrap_or(false)
});
Ok(Value::Bool(found.is_some()))
}
Some(Value::Function(func, func_env)) => {
let found = list.into_par_iter().find_any(|item| {
call_fn_sync(eval, func, func_env, std::slice::from_ref(item))
.map(|v| v.is_truthy())
.unwrap_or(false)
});
Ok(Value::Bool(found.is_some()))
}
_ => Err(EvalError::TypeError(
"par_any requires a function".to_string(),
)),
}
}
// ---------------------------------------------------------------------------
// par_all
// ---------------------------------------------------------------------------
#[tracing::instrument(level = "trace", skip_all)]
pub fn par_all(
eval: &mut Evaluator,
args: &[Value],
_arg_exprs: &[Expr],
) -> Result<Value, EvalError> {
let list = extract_list(args, "par_all")?;
match args.get(1) {
Some(Value::Lambda(params, body, env)) => {
// find_any that does NOT match = early exit on first failure
let failed = list.into_par_iter().find_any(|item| {
eval_lambda_sync(eval, params, body, env, item.clone())
.map(|v| !v.is_truthy())
.unwrap_or(true) // error counts as failure
});
Ok(Value::Bool(failed.is_none()))
}
Some(Value::Function(func, func_env)) => {
let failed = list.into_par_iter().find_any(|item| {
call_fn_sync(eval, func, func_env, std::slice::from_ref(item))
.map(|v| !v.is_truthy())
.unwrap_or(true)
});
Ok(Value::Bool(failed.is_none()))
}
_ => Err(EvalError::TypeError(
"par_all requires a function".to_string(),
)),
}
}
// ---------------------------------------------------------------------------
// par_find
// ---------------------------------------------------------------------------
#[tracing::instrument(level = "trace", skip_all)]
pub fn par_find(
eval: &mut Evaluator,
args: &[Value],
_arg_exprs: &[Expr],
) -> Result<Value, EvalError> {
let list = extract_list(args, "par_find")?;
match args.get(1) {
Some(Value::Lambda(params, body, env)) => {
let found = list.into_par_iter().find_first(|item| {
eval_lambda_sync(eval, params, body, env, item.clone())
.map(|v| v.is_truthy())
.unwrap_or(false)
});
Ok(found.unwrap_or(Value::None))
}
Some(Value::Function(func, func_env)) => {
let found = list.into_par_iter().find_first(|item| {
call_fn_sync(eval, func, func_env, std::slice::from_ref(item))
.map(|v| v.is_truthy())
.unwrap_or(false)
});
Ok(found.unwrap_or(Value::None))
}
_ => Err(EvalError::TypeError(
"par_find requires a function".to_string(),
)),
}
}
// ---------------------------------------------------------------------------
// par_partition
// ---------------------------------------------------------------------------
#[tracing::instrument(level = "trace", skip_all)]
pub fn par_partition(
eval: &mut Evaluator,
args: &[Value],
_arg_exprs: &[Expr],
) -> Result<Value, EvalError> {
let list = extract_list(args, "par_partition")?;
match args.get(1) {
Some(Value::Lambda(params, body, env)) => {
let (matches, rest): (Vec<Value>, Vec<Value>) = list
.into_par_iter()
.map(|item| {
let keep = eval_lambda_sync(eval, params, body, env, item.clone())
.map(|v| v.is_truthy())
.unwrap_or(false);
(item, keep)
})
.partition_map(|(item, keep)| {
if keep {
rayon::iter::Either::Left(item)
} else {
rayon::iter::Either::Right(item)
}
});
Ok(Value::List(vec![Value::List(matches), Value::List(rest)]))
}
Some(Value::Function(func, func_env)) => {
let (matches, rest): (Vec<Value>, Vec<Value>) = list
.into_par_iter()
.map(|item| {
let keep = call_fn_sync(eval, func, func_env, std::slice::from_ref(&item))
.map(|v| v.is_truthy())
.unwrap_or(false);
(item, keep)
})
.partition_map(|(item, keep)| {
if keep {
rayon::iter::Either::Left(item)
} else {
rayon::iter::Either::Right(item)
}
});
Ok(Value::List(vec![Value::List(matches), Value::List(rest)]))
}
_ => Err(EvalError::TypeError(
"par_partition requires a function".to_string(),
)),
}
}
// ---------------------------------------------------------------------------
// par_reduce
// ---------------------------------------------------------------------------
#[tracing::instrument(level = "trace", skip_all)]
pub fn par_reduce(
eval: &mut Evaluator,
args: &[Value],
_arg_exprs: &[Expr],
) -> Result<Value, EvalError> {
let list = extract_list(args, "par_reduce")?;
if list.is_empty() {
return Ok(Value::None);
}
match args.get(1) {
Some(Value::Lambda(params, body, env)) => {
// rayon reduce requires the op to be associative.
// We evaluate the lambda with (acc, item) params in parallel.
let result = list.into_par_iter().reduce_with(|acc, item| {
let mut local_eval = eval.clone();
let mut local_env = env.clone();
local_env.push_scope();
if let Some(acc_param) = params.first() {
local_env.define(acc_param.name.clone(), acc);
}
if let Some(item_param) = params.get(1) {
local_env.define(item_param.name.clone(), item);
}
smol::block_on(local_eval.eval_in_env(body, local_env)).unwrap_or(Value::None)
});
Ok(result.unwrap_or(Value::None))
}
Some(Value::Function(func, func_env)) => {
let result = list.into_par_iter().reduce_with(|acc, item| {
call_fn_sync(eval, func, func_env, &[acc, item]).unwrap_or(Value::None)
});
Ok(result.unwrap_or(Value::None))
}
_ => Err(EvalError::TypeError(
"par_reduce requires a function".to_string(),
)),
}
}
// ---------------------------------------------------------------------------
// par_min_by
// ---------------------------------------------------------------------------
#[tracing::instrument(level = "trace", skip_all)]
pub fn par_min_by(
eval: &mut Evaluator,
args: &[Value],
_arg_exprs: &[Expr],
) -> Result<Value, EvalError> {
let list = extract_list(args, "par_min_by")?;
match args.get(1) {
Some(Value::Lambda(params, body, env)) => {
// Compute keys in parallel, then find min
let keyed: Result<Vec<(Value, String)>, EvalError> = list
.into_par_iter()
.map(|item| {
let key = eval_lambda_sync(eval, params, body, env, item.clone())?;
Ok((item, key.to_string_repr()))
})
.collect();
let keyed = keyed?;
let min = keyed.into_iter().min_by(|a, b| a.1.cmp(&b.1));
Ok(min.map(|(v, _)| v).unwrap_or(Value::None))
}
_ => Err(EvalError::TypeError(
"par_min_by requires a function".to_string(),
)),
}
}
// ---------------------------------------------------------------------------
// par_max_by
// ---------------------------------------------------------------------------
#[tracing::instrument(level = "trace", skip_all)]
pub fn par_max_by(
eval: &mut Evaluator,
args: &[Value],
_arg_exprs: &[Expr],
) -> Result<Value, EvalError> {
let list = extract_list(args, "par_max_by")?;
match args.get(1) {
Some(Value::Lambda(params, body, env)) => {
// Compute keys in parallel, then find max
let keyed: Result<Vec<(Value, String)>, EvalError> = list
.into_par_iter()
.map(|item| {
let key = eval_lambda_sync(eval, params, body, env, item.clone())?;
Ok((item, key.to_string_repr()))
})
.collect();
let keyed = keyed?;
let max = keyed.into_iter().max_by(|a, b| a.1.cmp(&b.1));
Ok(max.map(|(v, _)| v).unwrap_or(Value::None))
}
_ => Err(EvalError::TypeError(
"par_max_by requires a function".to_string(),
)),
}
}
// ---------------------------------------------------------------------------
// par_for_each
// ---------------------------------------------------------------------------
#[tracing::instrument(level = "trace", skip_all)]
pub fn par_for_each(
eval: &mut Evaluator,
args: &[Value],
_arg_exprs: &[Expr],
) -> Result<Value, EvalError> {
let list = extract_list(args, "par_for_each")?;
match args.get(1) {
Some(Value::Lambda(params, body, env)) => {
// Collect errors from parallel execution
let errors: Vec<EvalError> = list
.into_par_iter()
.filter_map(|item| eval_lambda_sync(eval, params, body, env, item).err())
.collect();
if let Some(err) = errors.into_iter().next() {
return Err(err);
}
Ok(Value::None)
}
Some(Value::Function(func, func_env)) => {
let errors: Vec<EvalError> = list
.into_par_iter()
.filter_map(|item| call_fn_sync(eval, func, func_env, &[item]).err())
.collect();
if let Some(err) = errors.into_iter().next() {
return Err(err);
}
Ok(Value::None)
}
_ => Err(EvalError::TypeError(
"par_for_each requires a function".to_string(),
)),
}
}

View file

@ -1,156 +0,0 @@
use crate::evaluator::{EvalError, Value};
#[tracing::instrument(level = "trace", skip_all)]
pub fn join(args: &[Value]) -> Result<Value, EvalError> {
let list = match args.first() {
Some(Value::List(items)) => items,
_ => return Err(EvalError::TypeError("join expects a list".to_string())),
};
let sep = match args.get(1) {
Some(Value::Str(s)) => s.as_str(),
_ => "",
};
let result = list
.iter()
.map(|v| v.to_string_repr())
.collect::<Vec<_>>()
.join(sep);
Ok(Value::Str(result))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn split(args: &[Value]) -> Result<Value, EvalError> {
let s = match args.first() {
Some(Value::Str(s)) => s,
_ => return Err(EvalError::TypeError("split expects a string".to_string())),
};
let sep = match args.get(1) {
Some(Value::Str(s)) => s.as_str(),
_ => " ",
};
let parts: Vec<Value> = s.split(sep).map(|p| Value::Str(p.to_string())).collect();
Ok(Value::List(parts))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn upper(args: &[Value]) -> Result<Value, EvalError> {
match args.first() {
Some(Value::Str(s)) => Ok(Value::Str(s.to_uppercase())),
_ => Err(EvalError::TypeError("upper expects a string".to_string())),
}
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn lower(args: &[Value]) -> Result<Value, EvalError> {
match args.first() {
Some(Value::Str(s)) => Ok(Value::Str(s.to_lowercase())),
_ => Err(EvalError::TypeError("lower expects a string".to_string())),
}
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn trim(args: &[Value]) -> Result<Value, EvalError> {
match args.first() {
Some(Value::Str(s)) => Ok(Value::Str(s.trim().to_string())),
_ => Err(EvalError::TypeError("trim expects a string".to_string())),
}
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn replace(args: &[Value]) -> Result<Value, EvalError> {
let s = match args.first() {
Some(Value::Str(s)) => s,
_ => return Err(EvalError::TypeError("replace expects a string".to_string())),
};
let from = match args.get(1) {
Some(Value::Str(s)) => s,
_ => {
return Err(EvalError::TypeError(
"replace requires from string".to_string(),
));
}
};
let to = match args.get(2) {
Some(Value::Str(s)) => s,
_ => {
return Err(EvalError::TypeError(
"replace requires to string".to_string(),
));
}
};
Ok(Value::Str(s.replace(from.as_str(), to.as_str())))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn starts_with(args: &[Value]) -> Result<Value, EvalError> {
let s = match args.first() {
Some(Value::Str(s)) => s,
_ => {
return Err(EvalError::TypeError(
"starts_with expects a string".to_string(),
));
}
};
let prefix = match args.get(1) {
Some(Value::Str(s)) => s,
_ => {
return Err(EvalError::TypeError(
"starts_with requires prefix".to_string(),
));
}
};
Ok(Value::Bool(s.starts_with(prefix.as_str())))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn ends_with(args: &[Value]) -> Result<Value, EvalError> {
let s = match args.first() {
Some(Value::Str(s)) => s,
_ => {
return Err(EvalError::TypeError(
"ends_with expects a string".to_string(),
));
}
};
let suffix = match args.get(1) {
Some(Value::Str(s)) => s,
_ => {
return Err(EvalError::TypeError(
"ends_with requires suffix".to_string(),
));
}
};
Ok(Value::Bool(s.ends_with(suffix.as_str())))
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn format(args: &[Value]) -> Result<Value, EvalError> {
let template = match args.first() {
Some(Value::Str(s)) => s.clone(),
_ => {
return Err(EvalError::TypeError(
"format expects a template string".to_string(),
));
}
};
let mut result = template;
for (i, arg) in args.iter().skip(1).enumerate() {
let placeholder = format!("{{{}}}", i);
result = result.replace(&placeholder, &arg.to_string_repr());
}
Ok(Value::Str(result))
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,190 @@
//! AST and surface types. A program is one expression evaluating to a plan.
use std::collections::BTreeMap;
use std::rc::Rc;
use super::diag::Span;
/// How an integer literal was written, so the formatter can round-trip it.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Radix {
Dec,
Oct,
Hex,
}
/// A surface type as written in annotations and struct fields.
#[derive(Debug, Clone, PartialEq)]
pub enum Type {
Int,
Str,
Bool,
List(Box<Type>),
/// Anonymous structural record.
Record(BTreeMap<String, Type>),
/// Nominal struct, by name.
Struct(String),
/// Nominal enum, by name.
Enum(String),
/// Function type `arg -> ret` (curried).
Fun(Box<Type>, Box<Type>),
/// An effect node yielding `T` when realized.
Task(Box<Type>),
/// Hindley-Milner unification variable.
Var(u32),
/// Gradual "top": unifies with anything. Escape hatch for records/merge and
/// effect builtins that aren't fully inferred yet.
Dyn,
}
impl Type {
pub fn show(&self) -> String {
match self {
Type::Int => "Int".into(),
Type::Str => "Str".into(),
Type::Bool => "Bool".into(),
Type::List(t) => format!("[{}]", t.show()),
Type::Struct(n) => n.clone(),
Type::Enum(n) => n.clone(),
Type::Fun(a, b) => format!("({} -> {})", a.show(), b.show()),
Type::Task(t) => format!("Task {}", t.show()),
Type::Var(id) => format!("t{id}"),
Type::Dyn => "?".into(),
Type::Record(m) => {
let inner: Vec<String> = m
.iter()
.map(|(k, t)| format!("{k} : {}", t.show()))
.collect();
format!("{{ {} }}", inner.join("; "))
}
}
}
}
/// Binary operators.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum BinOp {
/// `/` path join for strings, integer division for ints.
Slash,
/// `++` list or string concatenation.
Concat,
/// `==` structural equality.
Eq,
/// `&&` logical and (short-circuit).
And,
/// `||` logical or (short-circuit).
Or,
Add,
Sub,
Mul,
/// `%` integer remainder.
Mod,
/// `**` integer power.
Pow,
}
/// Expressions. Everything in a program is an expression.
#[derive(Debug)]
pub enum Expr {
Int(i64, Radix),
Str(String),
Bool(bool),
Var(String),
List(Vec<Rc<Expr>>),
/// `{ a = ..; b = ..; }` - anonymous record.
Record(Vec<(String, Rc<Expr>)>),
/// `Name { a = ..; }` - nominal struct construction (disambiguated at parse time
/// from function application by the set of declared struct names).
Construct(String, Vec<(String, Rc<Expr>)>),
/// `Enum.Variant` - a nominal enum variant.
EnumVariant(String, String),
/// `\x -> body`
Lam(String, Rc<Expr>),
/// `f x` (juxtaposition).
App(Rc<Expr>, Rc<Expr>),
/// `e.field`
Select(Rc<Expr>, String),
/// `a // b` - right-biased merge (type-aware over structs).
Merge(Rc<Expr>, Rc<Expr>),
/// `let name (: Type)? = expr; ... in body`
Let(Vec<Binding>, Rc<Expr>),
/// `if c then a else b`
If(Rc<Expr>, Rc<Expr>, Rc<Expr>),
Bin(BinOp, Rc<Expr>, Rc<Expr>),
}
/// A single `let` binding, with optional type annotation.
#[derive(Debug)]
pub struct Binding {
pub name: String,
pub ann: Option<Type>,
pub value: Rc<Expr>,
/// source span of the binding start (for attaching leading comments in fmt)
pub span: Span,
}
/// A field in a struct declaration.
#[derive(Debug)]
pub struct FieldDecl {
pub name: String,
pub ty: Type,
pub default: Option<Rc<Expr>>,
}
/// An inherent method: `fn name self p1 ... = body;`. `params[0]` is `self`.
#[derive(Debug)]
pub struct MethodDecl {
pub name: String,
pub params: Vec<String>,
pub body: Rc<Expr>,
}
/// `struct Name { field : Type (= default)?; fn m self ... = ..; ... }`
#[derive(Debug)]
pub struct StructDecl {
pub name: String,
pub fields: Vec<FieldDecl>,
pub methods: Vec<MethodDecl>,
pub span: Span,
}
/// `enum Name { Variant, ...; fn m self ... = ..; }`
#[derive(Debug)]
pub struct EnumDecl {
pub name: String,
pub variants: Vec<String>,
pub methods: Vec<MethodDecl>,
pub span: Span,
}
/// `class Name a { method : Type; ... }` - the param `a` appears in the sigs.
#[derive(Debug)]
pub struct ClassDecl {
pub name: String,
pub param: String,
pub methods: Vec<(String, Type)>,
pub span: Span,
}
/// `impl Class for Type { method = expr; ... }`
#[derive(Debug)]
pub struct ImplDecl {
pub class: String,
pub type_name: String,
pub methods: Vec<(String, Rc<Expr>)>,
pub span: Span,
}
/// A whole program: declarations followed by a body expression.
#[derive(Debug)]
pub struct Program {
pub structs: Vec<Rc<StructDecl>>,
pub enums: Vec<Rc<EnumDecl>>,
pub classes: Vec<Rc<ClassDecl>>,
pub impls: Vec<Rc<ImplDecl>>,
pub body: Rc<Expr>,
/// span of the body's first token (for placing comments before the body)
pub body_span: Span,
/// source comments `(span, text-without-#)`, in source order, for fmt
pub comments: Vec<(Span, String)>,
}

View file

@ -0,0 +1,822 @@
//! Hindley-Milner type inference (Algorithm W style) with let-polymorphism.
//!
//! Lambdas and application are fully inferred; list builtins carry polymorphic
//! schemes. Nominal structs, their construction and `//` merge keep their concrete
//! checks. `Type::Dyn` is a gradual top that unifies with anything - used for
//! records-meeting-vars and effect builtins (`pkg`/`dotfile`/...), which take
//! attrsets dynamically. Heterogeneous list literals (used as tuples, e.g.
//! permission pairs) degrade to `[?]` rather than erroring.
use std::collections::{BTreeMap, HashMap, HashSet};
use std::rc::Rc;
use super::ast::*;
use super::engine::{BuiltinScheme, Engine};
#[derive(Clone)]
struct Scheme {
vars: Vec<u32>,
/// type-class constraints `(class, var)` (var is one of `vars`)
constraints: Vec<(String, u32)>,
ty: Type,
}
fn mono(ty: Type) -> Scheme {
Scheme {
vars: Vec::new(),
constraints: Vec::new(),
ty,
}
}
fn fun(a: Type, b: Type) -> Type {
Type::Fun(Box::new(a), Box::new(b))
}
fn list(a: Type) -> Type {
Type::List(Box::new(a))
}
pub struct Checker {
structs: BTreeMap<String, Rc<StructDecl>>,
enums: BTreeMap<String, Rc<EnumDecl>>,
/// (type name, method name) inherent methods that exist
method_names: HashSet<(String, String)>,
/// class method name -> class name (for `x.method` class-method sugar)
class_methods: HashMap<String, String>,
/// (class, type head) instances that exist (coherence + resolution)
instances: HashSet<(String, String)>,
/// unresolved type-class constraints (class, type)
pending: Vec<(String, Type)>,
subst: Vec<Option<Type>>,
env: Vec<(String, Scheme)>,
pub errors: Vec<String>,
}
impl Checker {
/// Build a checker for `program` against `engine`'s registered surface
/// (builtins, values, structs/enums, classes, instances).
pub fn with_engine(program: &Program, engine: &Engine) -> Self {
let mut structs: BTreeMap<String, Rc<StructDecl>> = program
.structs
.iter()
.map(|d| (d.name.clone(), d.clone()))
.collect();
for d in &engine.structs {
structs.entry(d.name.clone()).or_insert_with(|| d.clone());
}
let mut enums: BTreeMap<String, Rc<EnumDecl>> = program
.enums
.iter()
.map(|d| (d.name.clone(), d.clone()))
.collect();
for d in &engine.enums {
enums.entry(d.name.clone()).or_insert_with(|| d.clone());
}
let mut c = Checker {
structs,
enums,
method_names: HashSet::new(),
class_methods: HashMap::new(),
instances: HashSet::new(),
pending: Vec::new(),
subst: Vec::new(),
env: Vec::new(),
errors: Vec::new(),
};
c.install_engine(engine);
// built-in classes (engine) + user classes
let mut classes = engine.classes.clone();
classes.extend(program.classes.iter().cloned());
c.install_classes(&classes, &program.impls);
for (cl, head) in &engine.instances {
c.instances.insert((cl.clone(), head.clone()));
}
c.check_methods();
c
}
/// Register method names and type-check each method body with `self` bound to
/// its nominal type, so errors inside methods are caught.
fn check_methods(&mut self) {
// (nominal type, params, body) collected owned to avoid borrow conflicts
let mut jobs: Vec<(Type, Vec<String>, Rc<Expr>)> = Vec::new();
for d in self.structs.values() {
for m in &d.methods {
self.method_names.insert((d.name.clone(), m.name.clone()));
jobs.push((
Type::Struct(d.name.clone()),
m.params.clone(),
m.body.clone(),
));
}
}
for d in self.enums.values() {
for m in &d.methods {
self.method_names.insert((d.name.clone(), m.name.clone()));
jobs.push((Type::Enum(d.name.clone()), m.params.clone(), m.body.clone()));
}
}
for (nominal, params, body) in jobs {
let mark = self.env.len();
if let Some(self_param) = params.first() {
self.env.push((self_param.clone(), mono(nominal)));
}
for p in params.iter().skip(1) {
let v = self.fresh();
self.env.push((p.clone(), mono(v)));
}
let _ = self.infer(&body);
self.env.truncate(mark);
}
}
fn install_classes(&mut self, classes: &[Rc<ClassDecl>], impls: &[Rc<ImplDecl>]) {
// register each class method as a constrained polymorphic function
for c in classes {
let pid = match self.fresh() {
Type::Var(i) => i,
_ => unreachable!(),
};
for (mname, sig) in &c.methods {
let ty = subst_param(sig, &c.param, &Type::Var(pid));
let scheme = Scheme {
vars: vec![pid],
constraints: vec![(c.name.clone(), pid)],
ty,
};
self.env.push((mname.clone(), scheme));
self.class_methods.insert(mname.clone(), c.name.clone());
}
}
// register instances (coherence) and type-check their method bodies
for im in impls {
if !self
.instances
.insert((im.class.clone(), im.type_name.clone()))
{
self.errors.push(format!(
"duplicate instance `{} for {}`",
im.class, im.type_name
));
}
let class = match classes.iter().find(|c| c.name == im.class) {
Some(c) => c.clone(),
None => {
self.errors.push(format!("unknown class `{}`", im.class));
continue;
}
};
let inst_ty = self.nominal_of(&im.type_name);
for (mname, sig) in &class.methods {
match im.methods.iter().find(|(n, _)| n == mname) {
Some((_, body)) => {
let expected = subst_param(sig, &class.param, &inst_ty);
// peel lambda params, binding each to its expected arg type,
// so the body sees `self`-like params at the instance type
let mark = self.env.len();
let mut e = body.as_ref();
let mut ty = expected.clone();
while let (Expr::Lam(p, inner), Type::Fun(arg, ret)) = (e, ty.clone()) {
self.env.push((p.clone(), mono(*arg)));
e = inner;
ty = *ret;
}
let got = self.infer(e);
if self.unify(&got, &ty).is_err() {
self.errors.push(format!(
"impl `{} for {}`: `{mname}` : expected {}, got {}",
im.class,
im.type_name,
self.resolve(&ty).show(),
self.resolve(&got).show()
));
}
self.env.truncate(mark);
}
None => self.errors.push(format!(
"impl `{} for {}` is missing method `{mname}`",
im.class, im.type_name
)),
}
}
}
}
/// `x.method` where `method` is a class method desugars to `method x`.
fn class_method_select(&mut self, recv: Type, field: &str) -> Option<Type> {
if !self.class_methods.contains_key(field) {
return None;
}
let scheme = self.lookup(field)?;
let mty = self.instantiate(&scheme); // Fun(arg, ret); pushes the constraint
let ret = self.fresh();
let _ = self.unify(&mty, &fun(recv, ret.clone()));
Some(ret)
}
fn nominal_of(&self, name: &str) -> Type {
match name {
"Int" => Type::Int,
"Str" => Type::Str,
"Bool" => Type::Bool,
_ if self.enums.contains_key(name) => Type::Enum(name.to_string()),
_ => Type::Struct(name.to_string()),
}
}
/// Discharge collected constraints: a concrete type must have an instance.
/// Constraints still on a type variable are left (polymorphic / unused).
fn resolve_pending(&mut self) {
let pending = std::mem::take(&mut self.pending);
for (class, ty) in pending {
if let Some(head) = type_head(&self.resolve(&ty))
&& !self.instances.contains(&(class.clone(), head.clone()))
{
self.errors
.push(format!("no instance `{class} for {head}`"));
}
}
}
/// Install the engine's builtins and global values into the environment,
/// each as a fresh-instantiated scheme.
fn install_engine(&mut self, engine: &Engine) {
for b in &engine.builtins {
let s = self.lower_scheme(&b.scheme);
self.env.push((b.name.clone(), s));
}
for v in &engine.values {
let s = self.lower_scheme(&v.scheme);
self.env.push((v.name.clone(), s));
}
}
/// Turn a [`BuiltinScheme`] (bound vars written as `Var(0..quantified)`) into
/// a real [`Scheme`] by allocating that many fresh inference vars and
/// substituting them in, so its quantified vars never collide with inference.
fn lower_scheme(&mut self, bs: &BuiltinScheme) -> Scheme {
let fresh: Vec<u32> = (0..bs.quantified)
.map(|_| match self.fresh() {
Type::Var(i) => i,
_ => unreachable!(),
})
.collect();
Scheme {
vars: fresh.clone(),
constraints: bs
.constraints
.iter()
.map(|(c, i)| (c.clone(), fresh[*i as usize]))
.collect(),
ty: lower_type(&bs.ty, &fresh),
}
}
// ---- type-variable plumbing -------------------------------------------
fn fresh(&mut self) -> Type {
let id = self.subst.len() as u32;
self.subst.push(None);
Type::Var(id)
}
fn prune(&self, t: &Type) -> Type {
match t {
Type::Var(id) => match self.subst.get(*id as usize).and_then(|o| o.clone()) {
Some(u) => self.prune(&u),
None => t.clone(),
},
_ => t.clone(),
}
}
/// Deeply follow substitutions (for generalization and display).
fn resolve(&self, t: &Type) -> Type {
match self.prune(t) {
Type::List(x) => list(self.resolve(&x)),
Type::Task(x) => Type::Task(Box::new(self.resolve(&x))),
Type::Fun(x, y) => fun(self.resolve(&x), self.resolve(&y)),
Type::Record(m) => Type::Record(
m.iter()
.map(|(k, v)| (k.clone(), self.resolve(v)))
.collect(),
),
other => other,
}
}
fn occurs(&self, id: u32, t: &Type) -> bool {
match self.prune(t) {
Type::Var(j) => id == j,
Type::List(x) | Type::Task(x) => self.occurs(id, &x),
Type::Fun(x, y) => self.occurs(id, &x) || self.occurs(id, &y),
Type::Record(m) => m.values().any(|v| self.occurs(id, v)),
_ => false,
}
}
fn bind(&mut self, id: u32, t: &Type) -> Result<(), String> {
if let Type::Var(j) = t
&& *j == id
{
return Ok(());
}
if self.occurs(id, t) {
return Err(format!(
"infinite type: t{id} occurs in {}",
self.resolve(t).show()
));
}
self.subst[id as usize] = Some(t.clone());
Ok(())
}
fn unify(&mut self, a: &Type, b: &Type) -> Result<(), String> {
let a = self.prune(a);
let b = self.prune(b);
match (&a, &b) {
(Type::Dyn, _) | (_, Type::Dyn) => Ok(()),
(Type::Var(i), Type::Var(j)) if i == j => Ok(()),
(Type::Var(i), _) => self.bind(*i, &b),
(_, Type::Var(j)) => self.bind(*j, &a),
(Type::Int, Type::Int) | (Type::Str, Type::Str) | (Type::Bool, Type::Bool) => Ok(()),
(Type::List(x), Type::List(y)) => self.unify(x, y),
(Type::Task(x), Type::Task(y)) => self.unify(x, y),
(Type::Fun(a1, r1), Type::Fun(a2, r2)) => {
self.unify(a1, a2)?;
self.unify(r1, r2)
}
(Type::Struct(n), Type::Struct(m)) if n == m => Ok(()),
(Type::Enum(n), Type::Enum(m)) if n == m => Ok(()),
(Type::Record(m1), Type::Record(m2)) if m1.keys().eq(m2.keys()) => {
for (k, v1) in m1 {
self.unify(v1, &m2[k])?;
}
Ok(())
}
_ => Err(format!("expected {}, got {}", a.show(), b.show())),
}
}
fn want(&mut self, a: &Type, b: &Type) {
if let Err(e) = self.unify(a, b) {
self.errors.push(e);
}
}
fn instantiate(&mut self, s: &Scheme) -> Type {
let mapping: HashMap<u32, Type> = s.vars.iter().map(|v| (*v, self.fresh())).collect();
// each instantiation of a constrained scheme adds a pending constraint
for (class, v) in &s.constraints {
if let Some(t) = mapping.get(v) {
self.pending.push((class.clone(), t.clone()));
}
}
fn go(t: &Type, m: &HashMap<u32, Type>) -> Type {
match t {
Type::Var(id) => m.get(id).cloned().unwrap_or(Type::Var(*id)),
Type::List(x) => list(go(x, m)),
Type::Task(x) => Type::Task(Box::new(go(x, m))),
Type::Fun(x, y) => fun(go(x, m), go(y, m)),
Type::Record(r) => {
Type::Record(r.iter().map(|(k, v)| (k.clone(), go(v, m))).collect())
}
other => other.clone(),
}
}
go(&s.ty, &mapping)
}
fn generalize(&self, t: &Type) -> Scheme {
let t = self.resolve(t);
let mut env_fv: HashSet<u32> = HashSet::new();
for (_, s) in &self.env {
let rt = self.resolve(&s.ty);
let mut fv = Vec::new();
free_vars(&rt, &mut fv);
for id in fv {
if !s.vars.contains(&id) {
env_fv.insert(id);
}
}
}
let mut tv = Vec::new();
free_vars(&t, &mut tv);
let mut vars = Vec::new();
for id in tv {
if !env_fv.contains(&id) && !vars.contains(&id) {
vars.push(id);
}
}
Scheme {
vars,
constraints: Vec::new(),
ty: t,
}
}
fn lookup(&self, n: &str) -> Option<Scheme> {
self.env
.iter()
.rev()
.find(|(k, _)| k == n)
.map(|(_, s)| s.clone())
}
fn struct_fields(&self, name: &str) -> Option<BTreeMap<String, Type>> {
self.structs.get(name).map(|d| {
d.fields
.iter()
.map(|f| (f.name.clone(), f.ty.clone()))
.collect()
})
}
// ---- inference ---------------------------------------------------------
pub fn check(&mut self, e: &Expr) -> Type {
let t = self.infer(e);
self.resolve_pending();
t
}
fn infer(&mut self, e: &Expr) -> Type {
match e {
Expr::Int(..) => Type::Int,
Expr::Str(_) => Type::Str,
Expr::Bool(_) => Type::Bool,
Expr::Var(n) => match self.lookup(n) {
Some(s) => self.instantiate(&s),
None => {
self.errors.push(format!("unbound variable `{n}`"));
Type::Dyn
}
},
Expr::Lam(p, body) => {
let pv = self.fresh();
self.env.push((p.clone(), mono(pv.clone())));
let bt = self.infer(body);
self.env.pop();
fun(pv, bt)
}
Expr::App(f, a) => {
let ft = self.infer(f);
let at = self.infer(a);
let rv = self.fresh();
let expected = fun(at, rv.clone());
if let Err(e) = self.unify(&ft, &expected) {
self.errors.push(format!("application: {e}"));
return Type::Dyn;
}
rv
}
// list literal: homogeneous -> [t]; heterogeneous (tuple-like) -> [?]
Expr::List(es) => {
let ev = self.fresh();
let mut homogeneous = true;
for e in es {
let t = self.infer(e);
if self.unify(&ev, &t).is_err() {
homogeneous = false;
}
}
if homogeneous {
list(ev)
} else {
list(Type::Dyn)
}
}
Expr::Record(fields) => {
let mut m = BTreeMap::new();
for (k, e) in fields {
let t = self.infer(e);
m.insert(k.clone(), t);
}
Type::Record(m)
}
Expr::Construct(name, fields) => self.check_construct(name, fields),
Expr::EnumVariant(name, variant) => {
match self.enums.get(name) {
Some(d) if d.variants.iter().any(|v| v == variant) => {}
Some(_) => self
.errors
.push(format!("enum `{name}` has no variant `{variant}`")),
None => self.errors.push(format!("unknown enum `{name}`")),
}
Type::Enum(name.clone())
}
Expr::Select(obj, field) => {
let ot = self.infer(obj);
match self.prune(&ot) {
Type::Record(m) => m.get(field).cloned().unwrap_or_else(|| {
self.errors
.push(format!("no field `{field}` on {}", ot.show()));
Type::Dyn
}),
// field, then inherent method, then class-method (`x.m` == `m x`)
Type::Struct(n) => {
if let Some(ft) = self.struct_fields(&n).and_then(|m| m.get(field).cloned())
{
ft
} else if self.method_names.contains(&(n.clone(), field.clone())) {
Type::Dyn
} else if let Some(t) =
self.class_method_select(Type::Struct(n.clone()), field)
{
t
} else {
self.errors
.push(format!("no field or method `{field}` on `{n}`"));
Type::Dyn
}
}
Type::Enum(n) => {
if self.method_names.contains(&(n.clone(), field.clone())) {
Type::Dyn
} else if let Some(t) =
self.class_method_select(Type::Enum(n.clone()), field)
{
t
} else {
self.errors.push(format!("no method `{field}` on `{n}`"));
Type::Dyn
}
}
_ => Type::Dyn, // var/dyn: cannot resolve statically
}
}
Expr::Merge(l, r) => {
let lt = self.infer(l);
let rt = self.infer(r);
self.infer_merge(lt, rt)
}
Expr::If(c, t, e) => {
let ct = self.infer(c);
self.want(&ct, &Type::Bool);
let tt = self.infer(t);
let et = self.infer(e);
self.want(&tt, &et);
tt
}
Expr::Bin(op, l, r) => self.infer_bin(*op, l, r),
Expr::Let(binds, body) => {
let mark = self.env.len();
// recursive: pre-bind each name to a fresh monomorphic var
let mut vars = Vec::new();
for b in binds {
let v = self.fresh();
vars.push(v.clone());
self.env.push((b.name.clone(), mono(v)));
}
for (i, b) in binds.iter().enumerate() {
let t = self.check_binding(b);
self.want(&vars[i].clone(), &t);
}
// generalize for the body (let-polymorphism)
self.env.truncate(mark);
for (i, b) in binds.iter().enumerate() {
let s = self.generalize(&vars[i].clone());
self.env.push((b.name.clone(), s));
}
let bt = self.infer(body);
self.env.truncate(mark);
bt
}
}
}
fn infer_bin(&mut self, op: BinOp, l: &Expr, r: &Expr) -> Type {
// arithmetic and `/` dispatch through operator classes (Add/Sub/.../Div),
// so `a op b : a` requires an instance for `a` (built-in for Int/Str).
if let Some((class, _)) = op_class(op) {
let lt = self.infer(l);
let rt = self.infer(r);
self.want(&lt, &rt);
let t = self.prune(&lt);
self.pending.push((class.to_string(), t.clone()));
return t;
}
match op {
BinOp::Eq => {
let lt = self.infer(l);
let rt = self.infer(r);
self.want(&lt, &rt);
Type::Bool
}
BinOp::And | BinOp::Or => {
let lt = self.infer(l);
let rt = self.infer(r);
self.want(&lt, &Type::Bool);
self.want(&rt, &Type::Bool);
Type::Bool
}
// `++` is string concat for strings, else list append
BinOp::Concat => {
let lt = self.infer(l);
let rt = self.infer(r);
if matches!(self.prune(&lt), Type::Str) {
self.want(&rt, &Type::Str);
Type::Str
} else {
let ev = self.fresh();
self.want(&lt, &list(ev.clone()));
self.want(&rt, &list(ev.clone()));
list(ev)
}
}
_ => unreachable!("op-class operators handled above"),
}
}
fn infer_merge(&mut self, lt: Type, rt: Type) -> Type {
let overrides = match self.prune(&rt) {
Type::Record(m) => m,
Type::Dyn => return self.prune(&lt),
other => {
self.errors.push(format!(
"right of `//` must be a record, got {}",
other.show()
));
return self.prune(&lt);
}
};
match self.prune(&lt) {
Type::Struct(name) => {
if let Some(schema) = self.struct_fields(&name) {
for (k, vt) in &overrides {
match schema.get(k) {
Some(ft) => {
if self.unify(ft, vt).is_err() {
self.errors.push(format!(
"`{name} // {{ {k} = .. }}` : `{name}.{k}` is {}, got {}",
ft.show(),
self.resolve(vt).show()
));
}
}
None => self
.errors
.push(format!("`{name}` has no field `{k}` to override")),
}
}
}
Type::Struct(name)
}
Type::Record(base) => {
let mut m = base;
for (k, v) in overrides {
m.insert(k, v);
}
Type::Record(m)
}
other => {
self.errors.push(format!(
"left of `//` must be a record/struct, got {}",
other.show()
));
other
}
}
}
fn check_construct(&mut self, name: &str, fields: &[(String, Rc<Expr>)]) -> Type {
let decl = match self.structs.get(name) {
Some(d) => d.clone(),
None => {
self.errors.push(format!("unknown struct `{name}`"));
return Type::Struct(name.into());
}
};
let mut given: BTreeMap<String, Type> = BTreeMap::new();
for (k, e) in fields {
let t = self.infer(e);
given.insert(k.clone(), t);
}
for f in &decl.fields {
match given.get(&f.name) {
Some(gt) => {
if self.unify(gt, &f.ty).is_err() {
self.errors.push(format!(
"`{name}.{}` : expected {}, got {}",
f.name,
f.ty.show(),
self.resolve(gt).show()
));
}
}
None if f.default.is_some() => {}
None => self
.errors
.push(format!("`{name}` missing required field `{}`", f.name)),
}
}
for k in given.keys() {
if !decl.fields.iter().any(|f| &f.name == k) {
self.errors.push(format!("`{name}` has no field `{k}`"));
}
}
Type::Struct(name.into())
}
fn check_binding(&mut self, b: &Binding) -> Type {
match (&b.ann, &*b.value) {
(Some(Type::Struct(name)), Expr::Record(fields)) => self.check_construct(name, fields),
(Some(ann), _) => {
let got = self.infer(&b.value);
if self.unify(&got, ann).is_err() {
self.errors.push(format!(
"`{}` : annotated {}, got {}",
b.name,
ann.show(),
self.resolve(&got).show()
));
}
ann.clone()
}
(None, _) => self.infer(&b.value),
}
}
}
/// The operator class + method an arithmetic/`/` operator desugars to.
pub fn op_class(op: BinOp) -> Option<(&'static str, &'static str)> {
match op {
BinOp::Add => Some(("Add", "add")),
BinOp::Sub => Some(("Sub", "sub")),
BinOp::Mul => Some(("Mul", "mul")),
BinOp::Slash => Some(("Div", "div")),
BinOp::Mod => Some(("Mod", "mod")),
BinOp::Pow => Some(("Pow", "pow")),
_ => None,
}
}
/// The nominal head of a type (for instance lookup), if it has one.
fn type_head(t: &Type) -> Option<String> {
match t {
Type::Int => Some("Int".into()),
Type::Str => Some("Str".into()),
Type::Bool => Some("Bool".into()),
Type::List(_) => Some("List".into()),
Type::Struct(n) | Type::Enum(n) => Some(n.clone()),
_ => None,
}
}
/// Replace the class parameter (parsed as `Struct(param)`) with `repl` in a sig.
fn subst_param(t: &Type, param: &str, repl: &Type) -> Type {
match t {
Type::Struct(n) if n == param => repl.clone(),
Type::List(x) => Type::List(Box::new(subst_param(x, param, repl))),
Type::Task(x) => Type::Task(Box::new(subst_param(x, param, repl))),
Type::Fun(x, y) => Type::Fun(
Box::new(subst_param(x, param, repl)),
Box::new(subst_param(y, param, repl)),
),
Type::Record(m) => Type::Record(
m.iter()
.map(|(k, v)| (k.clone(), subst_param(v, param, repl)))
.collect(),
),
other => other.clone(),
}
}
/// Rewrite a [`BuiltinScheme`]'s local bound vars `Var(0..)` to allocated `fresh` ids.
fn lower_type(t: &Type, fresh: &[u32]) -> Type {
match t {
Type::Var(id) => Type::Var(fresh[*id as usize]),
Type::List(x) => Type::List(Box::new(lower_type(x, fresh))),
Type::Task(x) => Type::Task(Box::new(lower_type(x, fresh))),
Type::Fun(x, y) => Type::Fun(
Box::new(lower_type(x, fresh)),
Box::new(lower_type(y, fresh)),
),
Type::Record(m) => Type::Record(
m.iter()
.map(|(k, v)| (k.clone(), lower_type(v, fresh)))
.collect(),
),
other => other.clone(),
}
}
fn free_vars(t: &Type, out: &mut Vec<u32>) {
match t {
Type::Var(id) => {
if !out.contains(id) {
out.push(*id);
}
}
Type::List(x) | Type::Task(x) => free_vars(x, out),
Type::Fun(x, y) => {
free_vars(x, out);
free_vars(y, out);
}
Type::Record(m) => {
for v in m.values() {
free_vars(v, out);
}
}
_ => {}
}
}

View file

@ -0,0 +1,69 @@
//! Source spans and diagnostics for parse/type errors.
/// A half-open range of char offsets into the source.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Span {
pub start: usize,
pub end: usize,
}
impl Span {
pub fn new(start: usize, end: usize) -> Self {
Span { start, end }
}
/// A zero-width span at `at` (for "unexpected end" style errors).
pub fn point(at: usize) -> Self {
Span { start: at, end: at }
}
}
/// A diagnostic with an optional source location.
#[derive(Debug, Clone)]
pub struct Diagnostic {
pub message: String,
pub span: Option<Span>,
}
impl Diagnostic {
pub fn new(message: impl Into<String>, span: Span) -> Self {
Diagnostic {
message: message.into(),
span: Some(span),
}
}
/// A diagnostic with no source location (e.g. a type error not yet tied to a span).
pub fn message(message: impl Into<String>) -> Self {
Diagnostic {
message: message.into(),
span: None,
}
}
/// Render the diagnostic against `src`: `line:col: message`, and when a span
/// is present, the offending source line with a caret underline.
pub fn render(&self, src: &str) -> String {
let Some(span) = self.span else {
return self.message.clone();
};
let chars: Vec<char> = src.chars().collect();
// line/col (1-based) of the span start
let mut line = 1;
let mut col = 1;
for &c in chars.iter().take(span.start.min(chars.len())) {
if c == '\n' {
line += 1;
col = 1;
} else {
col += 1;
}
}
// extract the source line text
let line_text: String = src.lines().nth(line - 1).unwrap_or("").to_string();
let width = (span.end.saturating_sub(span.start)).max(1);
let caret = format!("{}{}", " ".repeat(col - 1), "^".repeat(width));
format!(
"{line}:{col}: {}\n {}\n {}",
self.message, line_text, caret
)
}
}

View file

@ -0,0 +1,118 @@
//! The registration surface that decouples the language core from its vocabulary.
//!
//! An [`Engine`] holds the built-in definitions a program is checked and
//! evaluated against: functions (each with a type scheme and a native impl),
//! plain global values, and nominal structs/enums/classes/instances. The core
//! (`check`, `eval`) is built *from* an `Engine` rather than hardcoding any of
//! these, so a standard library or a domain layer registers its own surface
//! instead of editing the evaluator.
use std::rc::Rc;
use super::ast::{ClassDecl, EnumDecl, StructDecl, Type};
use super::eval::{Interp, Thunk};
use super::eval::{NativeDef, Value};
/// A polymorphic built-in type. Bound variables are written as `Type::Var(0)`,
/// `Type::Var(1)`, ... up to `quantified`; the checker allocates that many fresh
/// inference variables and substitutes them in when installing the scheme.
pub struct BuiltinScheme {
pub quantified: usize,
/// type-class constraints `(class, bound-var index)`
pub constraints: Vec<(String, u32)>,
pub ty: Type,
}
impl BuiltinScheme {
pub fn mono(ty: Type) -> Self {
BuiltinScheme {
quantified: 0,
constraints: Vec::new(),
ty,
}
}
pub fn poly(quantified: usize, ty: Type) -> Self {
BuiltinScheme {
quantified,
constraints: Vec::new(),
ty,
}
}
}
pub struct BuiltinReg {
pub name: String,
pub scheme: BuiltinScheme,
pub native: Rc<NativeDef>,
}
pub struct ValueReg {
pub name: String,
pub scheme: BuiltinScheme,
pub value: Value,
}
/// The set of built-ins a program is checked and evaluated against.
#[derive(Default)]
pub struct Engine {
pub builtins: Vec<BuiltinReg>,
pub values: Vec<ValueReg>,
pub structs: Vec<Rc<StructDecl>>,
pub enums: Vec<Rc<EnumDecl>>,
pub classes: Vec<Rc<ClassDecl>>,
/// built-in instance heads `(class, type head)` for coherence/resolution
pub instances: Vec<(String, String)>,
}
impl Engine {
/// Register a native function under `name` with its type scheme and impl.
pub fn register_builtin(
&mut self,
name: &str,
scheme: BuiltinScheme,
arity: usize,
func: impl Fn(&Interp, &[Thunk]) -> Value + 'static,
) {
self.builtins.push(BuiltinReg {
name: name.to_string(),
scheme,
native: Rc::new(NativeDef::new(arity, func)),
});
}
/// Register a plain global value (a constant, not a function).
pub fn register_value(&mut self, name: &str, scheme: BuiltinScheme, value: Value) {
self.values.push(ValueReg {
name: name.to_string(),
scheme,
value,
});
}
pub fn register_struct(&mut self, decl: StructDecl) {
self.structs.push(Rc::new(decl));
}
pub fn register_enum(&mut self, decl: EnumDecl) {
self.enums.push(Rc::new(decl));
}
pub fn register_class(&mut self, decl: ClassDecl) {
self.classes.push(Rc::new(decl));
}
pub fn register_instance(&mut self, class: &str, type_head: &str) {
self.instances
.push((class.to_string(), type_head.to_string()));
}
/// Names of registered structs (for the parser's construction disambiguation).
pub fn struct_names(&self) -> Vec<String> {
self.structs.iter().map(|d| d.name.clone()).collect()
}
/// Names of registered enums (for the parser's `Enum.Variant` disambiguation).
pub fn enum_names(&self) -> Vec<String> {
self.enums.iter().map(|d| d.name.clone()).collect()
}
}

View file

@ -0,0 +1,787 @@
//! Lazy, pure evaluator. Produces a [`Plan`]; no side effects. Bindings and
//! record/list fields are force-once thunks. Constructing a `Task` records an edge
//! to every other `Task` reachable in its data - dependencies are never hand-written.
use std::any::Any;
use std::cell::RefCell;
use std::collections::{BTreeMap, HashMap};
use std::rc::Rc;
use super::ast::*;
use super::engine::Engine;
use super::plan::{Node, Plan};
// values, thunks, environment
pub type Thunk = Rc<RefCell<ThunkState>>;
#[derive(Clone)]
pub enum ThunkState {
Expr(Rc<Expr>, Env),
Val(Value),
/// deferred Rust computation (lazy list ops); receives the interpreter at force
Native(Rc<dyn Fn(&Interp) -> Value>),
Black,
}
fn black_thunk() -> Thunk {
Rc::new(RefCell::new(ThunkState::Black))
}
// Strip a thunk-state's direct child thunks into `work`, replacing them so the
// state itself drops shallowly. Covers list cells (head + tail) and owned
// attrsets, which is where deep value structure lives.
fn take_children(st: &mut ThunkState, work: &mut Vec<Thunk>) {
if let ThunkState::Val(v) = st {
match v {
Value::Cons(h, t) => {
work.push(std::mem::replace(h, black_thunk()));
work.push(std::mem::replace(t, black_thunk()));
}
Value::Attr(_, m) => {
if let Some(map) = Rc::get_mut(m) {
for t in map.values_mut() {
work.push(std::mem::replace(t, black_thunk()));
}
}
}
_ => {}
}
}
}
// Dismantle nested thunks iteratively so dropping a deep value (a long list
// spine OR a deeply head-nested tree OR a nested attrset) never recurses on
// Rust's stack.
impl Drop for ThunkState {
fn drop(&mut self) {
let mut work: Vec<Thunk> = Vec::new();
take_children(self, &mut work);
while let Some(t) = work.pop() {
if let Ok(cell) = Rc::try_unwrap(t) {
let mut st = cell.into_inner();
take_children(&mut st, &mut work);
// `st` is now childless, so its own drop is shallow
}
}
}
}
#[derive(Clone)]
pub enum Value {
Int(i64),
Str(Rc<String>),
Bool(bool),
/// lazy lists: empty, or a head thunk and a tail thunk (tail can be infinite)
Nil,
Cons(Thunk, Thunk),
/// attrset; the optional name is the nominal struct name (None = bare record)
Attr(Option<Rc<String>>, Rc<BTreeMap<String, Thunk>>),
Lam(String, Rc<Expr>, Env),
/// a native function: its definition plus the args gathered so far (currying)
Native(Native),
/// reference to a plan node (an effect to realize)
Task(usize),
/// an opaque foreign value (e.g. a domain marker like `file("path")`)
Foreign(Rc<dyn Any>),
/// nominal enum variant: (enum name, variant name)
Enum(Rc<String>, Rc<String>),
/// a class method awaiting its receiver: (class, method)
ClassMethod(Rc<String>, Rc<String>),
}
/// A native (Rust-implemented) function. `func` receives its args as thunks once
/// `arity` of them are gathered, and forces only what it needs - this is what
/// keeps builtins like `optionals`/`cons`/`map` lazy. Partial application
/// accumulates args in `args` and returns a new `Native`.
pub type NativeFn = dyn Fn(&Interp, &[Thunk]) -> Value;
#[derive(Clone)]
pub struct Native {
def: Rc<NativeDef>,
args: Rc<Vec<Thunk>>,
}
pub struct NativeDef {
arity: usize,
func: Box<NativeFn>,
}
impl NativeDef {
pub fn new(arity: usize, func: impl Fn(&Interp, &[Thunk]) -> Value + 'static) -> Self {
NativeDef {
arity,
func: Box::new(func),
}
}
}
/// Wrap a registered native definition as a global binding with no args gathered.
pub fn native_global(def: Rc<NativeDef>) -> Value {
Value::Native(Native {
def,
args: Rc::new(Vec::new()),
})
}
pub type Env = Rc<Scope>;
pub struct Scope {
vars: RefCell<HashMap<String, Thunk>>,
parent: Option<Env>,
}
impl Scope {
fn lookup(&self, name: &str) -> Option<Thunk> {
if let Some(t) = self.vars.borrow().get(name) {
return Some(t.clone());
}
self.parent.as_ref().and_then(|p| p.lookup(name))
}
}
pub fn forced(v: Value) -> Thunk {
Rc::new(RefCell::new(ThunkState::Val(v)))
}
fn thunk_expr(e: &Rc<Expr>, env: &Env) -> Thunk {
Rc::new(RefCell::new(ThunkState::Expr(e.clone(), env.clone())))
}
pub fn native<F: Fn(&Interp) -> Value + 'static>(f: F) -> Thunk {
Rc::new(RefCell::new(ThunkState::Native(Rc::new(f))))
}
fn child(parent: &Env, name: String, t: Thunk) -> Env {
let mut vars = HashMap::new();
vars.insert(name, t);
Rc::new(Scope {
vars: RefCell::new(vars),
parent: Some(parent.clone()),
})
}
// CEK machine: control + heap continuation stack. Recursion depth (deep force
// chains, non-tail recursion) lives on the `Vec<Cont>`, not Rust's call stack.
enum Ctrl {
Eval(Rc<Expr>, Env),
Force(Thunk),
}
enum Cont {
Update(Thunk), // memoize a forced value back into its thunk
AppArg(Thunk), // applied a function value to this (lazy) arg
IfK(Rc<Expr>, Rc<Expr>, Env), // chose branch after the condition
SelectK(String),
AndR(Rc<Expr>, Env),
OrR(Rc<Expr>, Env),
MergeR(Rc<Expr>, Env),
MergeOp(Value),
BinR(BinOp, Rc<Expr>, Env),
BinOp2(BinOp, Value),
}
// interpreter
pub struct Interp {
structs: BTreeMap<String, Rc<StructDecl>>,
/// (type name, method name) -> the method as a `\self -> ...` lambda expr
methods: BTreeMap<(String, String), Rc<Expr>>,
/// (class, type head, method) -> the instance body (a lambda expr)
instances: BTreeMap<(String, String, String), Rc<Expr>>,
plan: RefCell<Plan>,
globals: Env,
}
/// Fold a method's params into a curried `\self -> \p1 -> ... body` lambda.
fn fold_method(m: &MethodDecl) -> Rc<Expr> {
let mut e = m.body.clone();
for p in m.params.iter().rev() {
e = Rc::new(Expr::Lam(p.clone(), e));
}
e
}
pub fn interp_with_engine(program: &Program, engine: &Engine) -> Interp {
let mut structs: BTreeMap<String, Rc<StructDecl>> = program
.structs
.iter()
.map(|d| (d.name.clone(), d.clone()))
.collect();
for d in &engine.structs {
structs.entry(d.name.clone()).or_insert_with(|| d.clone());
}
let mut methods: BTreeMap<(String, String), Rc<Expr>> = BTreeMap::new();
for d in program.structs.iter().chain(&engine.structs) {
for m in &d.methods {
methods.insert((d.name.clone(), m.name.clone()), fold_method(m));
}
}
for d in program.enums.iter().chain(&engine.enums) {
for m in &d.methods {
methods.insert((d.name.clone(), m.name.clone()), fold_method(m));
}
}
// class methods (name -> class) and instance bodies ((class, head, method) -> lam)
let mut class_of: BTreeMap<String, String> = BTreeMap::new();
for c in &program.classes {
for (m, _) in &c.methods {
class_of.insert(m.clone(), c.name.clone());
}
}
let mut instances: BTreeMap<(String, String, String), Rc<Expr>> = BTreeMap::new();
for im in &program.impls {
for (m, body) in &im.methods {
instances.insert(
(im.class.clone(), im.type_name.clone(), m.clone()),
body.clone(),
);
}
}
let mut g = HashMap::new();
for (m, c) in &class_of {
g.insert(
m.clone(),
forced(Value::ClassMethod(Rc::new(c.clone()), Rc::new(m.clone()))),
);
}
for b in &engine.builtins {
g.insert(b.name.clone(), forced(native_global(b.native.clone())));
}
for v in &engine.values {
g.insert(v.name.clone(), forced(v.value.clone()));
}
let globals: Env = Rc::new(Scope {
vars: RefCell::new(g),
parent: None,
});
Interp {
structs,
methods,
instances,
plan: RefCell::new(Plan::default()),
globals,
}
}
/// The default engine: the general stdlib plus the dotfile vocabulary. (These
/// two registration passes are what later split into separate crates.)
impl Interp {
pub fn make_task(&self, label: String, data: Rc<dyn Any>, deps: &Value) -> usize {
let id = {
let mut p = self.plan.borrow_mut();
let id = p.nodes.len();
p.nodes.push(Node { label, data });
id
};
let mut found = Vec::new();
self.collect_tasks(deps, &mut found);
let mut p = self.plan.borrow_mut();
for d in found {
if d != id {
p.edges.push((id, d));
}
}
id
}
// iterative worklist: depth/spine length lives on the heap `stack`
pub fn collect_tasks(&self, v: &Value, out: &mut Vec<usize>) {
let mut stack = vec![v.clone()];
while let Some(val) = stack.pop() {
match val {
Value::Task(id) => out.push(id),
Value::Cons(h, t) => {
stack.push(self.force(&t));
stack.push(self.force(&h));
}
Value::Attr(_, m) => {
for t in m.values() {
stack.push(self.force(t));
}
}
_ => {}
}
}
}
pub fn force(&self, t: &Thunk) -> Value {
if let ThunkState::Val(v) = &*t.borrow() {
return v.clone();
}
self.run(Ctrl::Force(t.clone()), Vec::new())
}
pub fn eval(&self, e: &Rc<Expr>, env: &Env) -> Value {
self.run(Ctrl::Eval(e.clone(), env.clone()), Vec::new())
}
/// The global scope (for the domain layer to evaluate the program body).
pub fn global_scope(&self) -> Env {
self.globals.clone()
}
/// Consume the interpreter, returning the accumulated plan.
pub fn into_plan(self) -> Plan {
self.plan.into_inner()
}
/// Evaluate top-level `let` bindings to WHNF in a recursive scope (so each
/// binding sees its siblings). Returns each name with its forced value; the
/// domain layer decides which are materializable template values.
pub fn harvest_bindings(&self, binds: &[Binding]) -> Vec<(String, Value)> {
let scope = Rc::new(Scope {
vars: RefCell::new(HashMap::new()),
parent: Some(self.globals.clone()),
});
for b in binds {
scope
.vars
.borrow_mut()
.insert(b.name.clone(), thunk_expr(&b.value, &scope));
}
binds
.iter()
.map(|b| {
let t = scope.vars.borrow().get(&b.name).cloned().unwrap();
(b.name.clone(), self.force(&t))
})
.collect()
}
/// The CEK loop. Reduces `ctrl` to a value, then feeds it through the
/// continuation stack `k`. All recursion depth lives on `k` (heap), so deep
/// non-tail recursion and deep force chains do not grow Rust's stack.
fn run(&self, ctrl0: Ctrl, mut k: Vec<Cont>) -> Value {
let mut ctrl = ctrl0;
loop {
// reduce control to a value, pushing continuations
let mut value = loop {
match ctrl {
Ctrl::Eval(expr, env) => match &*expr {
Expr::Int(n, _) => break Value::Int(*n),
Expr::Str(s) => break Value::Str(Rc::new(s.clone())),
Expr::Bool(b) => break Value::Bool(*b),
Expr::Lam(p, b) => break Value::Lam(p.clone(), b.clone(), env.clone()),
Expr::EnumVariant(e, v) => {
break Value::Enum(Rc::new(e.clone()), Rc::new(v.clone()));
}
Expr::List(es) => {
let mut acc = Value::Nil;
for e in es.iter().rev() {
acc = Value::Cons(thunk_expr(e, &env), forced(acc));
}
break acc;
}
Expr::Record(fields) => {
break Value::Attr(None, Rc::new(self.record_thunks(fields, &env)));
}
Expr::Construct(name, fields) => {
let mut m = self.record_thunks(fields, &env);
if let Some(decl) = self.structs.get(name) {
for f in &decl.fields {
if !m.contains_key(&f.name)
&& let Some(def) = &f.default
{
m.insert(f.name.clone(), thunk_expr(def, &self.globals));
}
}
}
break Value::Attr(Some(Rc::new(name.clone())), Rc::new(m));
}
Expr::Var(n) => {
let t = env
.lookup(n)
.unwrap_or_else(|| panic!("unbound variable: {n}"));
ctrl = Ctrl::Force(t);
}
Expr::App(f, a) => {
k.push(Cont::AppArg(thunk_expr(a, &env)));
ctrl = Ctrl::Eval(f.clone(), env.clone());
}
Expr::If(c, t, e) => {
k.push(Cont::IfK(t.clone(), e.clone(), env.clone()));
ctrl = Ctrl::Eval(c.clone(), env.clone());
}
Expr::Select(o, fld) => {
k.push(Cont::SelectK(fld.clone()));
ctrl = Ctrl::Eval(o.clone(), env.clone());
}
Expr::Merge(l, r) => {
k.push(Cont::MergeR(r.clone(), env.clone()));
ctrl = Ctrl::Eval(l.clone(), env.clone());
}
Expr::Let(binds, body) => {
let scope = Rc::new(Scope {
vars: RefCell::new(HashMap::new()),
parent: Some(env.clone()),
});
for b in binds {
scope
.vars
.borrow_mut()
.insert(b.name.clone(), thunk_expr(&b.value, &scope));
}
ctrl = Ctrl::Eval(body.clone(), scope);
}
Expr::Bin(BinOp::And, l, r) => {
k.push(Cont::AndR(r.clone(), env.clone()));
ctrl = Ctrl::Eval(l.clone(), env.clone());
}
Expr::Bin(BinOp::Or, l, r) => {
k.push(Cont::OrR(r.clone(), env.clone()));
ctrl = Ctrl::Eval(l.clone(), env.clone());
}
Expr::Bin(op, l, r) => {
k.push(Cont::BinR(*op, r.clone(), env.clone()));
ctrl = Ctrl::Eval(l.clone(), env.clone());
}
},
Ctrl::Force(t) => {
// match by ref: ThunkState has a Drop impl, so we cannot move out
let st = t.borrow().clone();
match &st {
ThunkState::Val(v) => break v.clone(),
ThunkState::Black => panic!("infinite recursion (black hole)"),
ThunkState::Expr(e, env) => {
*t.borrow_mut() = ThunkState::Black;
k.push(Cont::Update(t.clone()));
ctrl = Ctrl::Eval(e.clone(), env.clone());
}
// native ops compute one WHNF cell (re-enters bounded)
ThunkState::Native(f) => {
let f = f.clone();
*t.borrow_mut() = ThunkState::Black;
let v = f(self);
*t.borrow_mut() = ThunkState::Val(v.clone());
break v;
}
}
}
}
};
// feed the value through the continuation stack
loop {
match k.pop() {
None => return value,
Some(Cont::Update(t)) => {
*t.borrow_mut() = ThunkState::Val(value.clone());
}
Some(Cont::AppArg(arg)) => match value {
Value::Lam(p, body, lenv) => {
ctrl = Ctrl::Eval(body, child(&lenv, p, arg));
break;
}
Value::Native(n) => value = self.apply_native(n, arg),
// class methods (and any other callable) dispatch via apply
other => value = self.apply(other, arg),
},
Some(Cont::IfK(t, e, env)) => {
let branch = match value {
Value::Bool(true) => t,
Value::Bool(false) => e,
_ => panic!("if: condition not a bool"),
};
ctrl = Ctrl::Eval(branch, env);
break;
}
// field, then inherent method, then class method (`x.m` sugar)
Some(Cont::SelectK(fld)) => match value {
Value::Attr(name, m) => {
if let Some(th) = m.get(&fld) {
ctrl = Ctrl::Force(th.clone());
break;
}
let recv = Value::Attr(name.clone(), m.clone());
let f = self
.method(name.as_deref().map(|s| s.as_str()), &fld)
.or_else(|| self.class_method_value(&fld));
match f {
Some(f) => value = self.apply(f, forced(recv)),
None => panic!("no field or method `{fld}`"),
}
}
Value::Enum(en, var) => {
let recv = Value::Enum(en.clone(), var.clone());
let f = self
.method(Some(en.as_str()), &fld)
.or_else(|| self.class_method_value(&fld));
match f {
Some(f) => value = self.apply(f, forced(recv)),
None => panic!("no method `{fld}` on enum `{en}`"),
}
}
_ => panic!("select on non-attrset"),
},
Some(Cont::AndR(r, env)) => {
if matches!(value, Value::Bool(true)) {
ctrl = Ctrl::Eval(r, env);
break;
}
value = Value::Bool(false);
}
Some(Cont::OrR(r, env)) => {
if !matches!(value, Value::Bool(true)) {
ctrl = Ctrl::Eval(r, env);
break;
}
value = Value::Bool(true);
}
Some(Cont::MergeR(r, env)) => {
k.push(Cont::MergeOp(value));
ctrl = Ctrl::Eval(r, env);
break;
}
Some(Cont::MergeOp(lv)) => {
value = match (lv, value) {
// keep the left operand's nominal name (struct // record : struct)
(Value::Attr(n, a), Value::Attr(_, b)) => {
let mut m = (*a).clone();
for (key, v) in b.iter() {
m.insert(key.clone(), v.clone());
}
Value::Attr(n, Rc::new(m))
}
_ => panic!("// expects two attrsets"),
};
}
Some(Cont::BinR(op, r, env)) => {
k.push(Cont::BinOp2(op, value));
ctrl = Ctrl::Eval(r, env);
break;
}
Some(Cont::BinOp2(op, lv)) => value = self.combine(op, lv, value),
}
}
}
}
fn combine(&self, op: BinOp, l: Value, r: Value) -> Value {
if let BinOp::Concat = op {
return match (l, r) {
(Value::Str(a), Value::Str(b)) => Value::Str(Rc::new(format!("{a}{b}"))),
(l, r) => self.append(l, r),
};
}
// operator classes: built-in Int (and Str for `/`), else dispatch the
// user instance for the operand type
if let Some((class, method)) = crate::lang::check::op_class(op) {
let native = matches!(&l, Value::Int(_))
|| (matches!(op, BinOp::Slash) && matches!(&l, Value::Str(_)));
if !native {
let head = runtime_head(&l)
.unwrap_or_else(|| panic!("no `{class}` instance for this value"));
let body = self
.instances
.get(&(class.to_string(), head.clone(), method.to_string()))
.cloned()
.unwrap_or_else(|| panic!("no instance `{class} for {head}`"));
let f = self.eval(&body, &self.globals);
let partial = self.apply(f, forced(l));
return self.apply(partial, forced(r));
}
}
eval_bin(op, l, r)
}
fn record_thunks(&self, fields: &[(String, Rc<Expr>)], env: &Env) -> BTreeMap<String, Thunk> {
let mut m = BTreeMap::new();
for (k, e) in fields {
m.insert(k.clone(), thunk_expr(e, env));
}
m
}
/// Gather one more arg into a native function; fire it once `arity` is reached.
fn apply_native(&self, n: Native, arg: Thunk) -> Value {
let mut args = (*n.args).clone();
args.push(arg);
if args.len() == n.def.arity {
(n.def.func)(self, &args)
} else {
Value::Native(Native {
def: n.def,
args: Rc::new(args),
})
}
}
pub fn apply(&self, f: Value, arg: Thunk) -> Value {
match f {
Value::Lam(p, body, env) => self.run(Ctrl::Eval(body, child(&env, p, arg)), Vec::new()),
Value::Native(n) => self.apply_native(n, arg),
// type-class dispatch: pick the instance by the receiver's runtime type
Value::ClassMethod(class, method) => {
let recv = self.force(&arg);
let head = runtime_head(&recv)
.unwrap_or_else(|| panic!("no instance `{class}` for this value"));
let body = self
.instances
.get(&((*class).clone(), head.clone(), (*method).clone()))
.cloned()
.unwrap_or_else(|| panic!("no instance `{class} for {head}`"));
let f = self.eval(&body, &self.globals);
self.apply(f, forced(recv))
}
_ => panic!("apply: not a function"),
}
}
/// Look up an inherent method and evaluate it to a `\self -> ...` closure.
fn method(&self, type_name: Option<&str>, name: &str) -> Option<Value> {
let lam = self
.methods
.get(&(type_name?.to_string(), name.to_string()))?
.clone();
Some(self.eval(&lam, &self.globals))
}
/// If `name` is a class method, return its `ClassMethod` value (for `x.m` sugar).
fn class_method_value(&self, name: &str) -> Option<Value> {
match self.force(&self.globals.lookup(name)?) {
v @ Value::ClassMethod(_, _) => Some(v),
_ => None,
}
}
// lazy map: head and tail are deferred, so map over an infinite list is fine
pub fn map_list(&self, f: Value, xs: Value) -> Value {
match xs {
Value::Nil => Value::Nil,
Value::Cons(h, t) => {
let f_head = f.clone();
let head = native(move |i| i.apply(f_head.clone(), h.clone()));
let tail = native(move |i| {
let tv = i.force(&t);
i.map_list(f.clone(), tv)
});
Value::Cons(head, tail)
}
_ => panic!("map expects a list"),
}
}
pub fn take_list(&self, n: i64, xs: Value) -> Value {
if n <= 0 {
return Value::Nil;
}
match xs {
Value::Nil => Value::Nil,
Value::Cons(h, t) => {
let tail = native(move |i| {
let tv = i.force(&t);
i.take_list(n - 1, tv)
});
Value::Cons(h, tail)
}
_ => panic!("take expects a list"),
}
}
// lazy append: only the left spine is walked as it is demanded
fn append(&self, l: Value, r: Value) -> Value {
match l {
Value::Nil => r,
Value::Cons(h, t) => {
let tail = native(move |i| {
let tv = i.force(&t);
i.append(tv, r.clone())
});
Value::Cons(h, tail)
}
_ => panic!("++ expects two lists or two strings"),
}
}
// materialize a finite list spine into a Vec of element thunks
pub fn list_to_vec(&self, v: &Value) -> Vec<Thunk> {
let mut out = Vec::new();
let mut cur = v.clone();
loop {
match cur {
Value::Nil => break,
Value::Cons(h, t) => {
out.push(h);
cur = self.force(&t);
}
_ => panic!("expected list"),
}
}
out
}
}
// runtime nominal head of a value, for type-class instance lookup
fn runtime_head(v: &Value) -> Option<String> {
match v {
Value::Int(_) => Some("Int".into()),
Value::Str(_) => Some("Str".into()),
Value::Bool(_) => Some("Bool".into()),
Value::Nil | Value::Cons(_, _) => Some("List".into()),
Value::Enum(n, _) => Some((**n).clone()),
Value::Attr(Some(n), _) => Some((**n).clone()),
_ => None,
}
}
/// Structural equality of two values (the `==` semantics), for stdlib `elem`.
pub fn value_eq(a: &Value, b: &Value) -> bool {
matches!(eval_bin(BinOp::Eq, a.clone(), b.clone()), Value::Bool(true))
}
fn eval_bin(op: BinOp, l: Value, r: Value) -> Value {
match op {
// `/` is path join for strings, integer division for ints
BinOp::Slash => match (l, r) {
(Value::Str(a), Value::Str(b)) => Value::Str(Rc::new(format!("{a}/{b}"))),
(Value::Int(a), Value::Int(b)) => Value::Int(a / b),
_ => panic!("/ expects two strings or two ints"),
},
BinOp::Add => Value::Int(as_int(l) + as_int(r)),
BinOp::Sub => Value::Int(as_int(l) - as_int(r)),
BinOp::Mul => Value::Int(as_int(l) * as_int(r)),
BinOp::Mod => Value::Int(as_int(l) % as_int(r)),
BinOp::Pow => Value::Int(as_int(l).pow(as_int(r) as u32)),
BinOp::Eq => Value::Bool(match (l, r) {
(Value::Int(a), Value::Int(b)) => a == b,
(Value::Bool(a), Value::Bool(b)) => a == b,
(Value::Str(a), Value::Str(b)) => a == b,
(Value::Enum(e1, v1), Value::Enum(e2, v2)) => e1 == e2 && v1 == v2,
_ => false,
}),
BinOp::Concat | BinOp::And | BinOp::Or => {
unreachable!("handled in eval (string concat / short-circuit / lazy append)")
}
}
}
// build a Package payload with a single manager field set
pub fn empty_list() -> Value {
Value::Nil
}
pub fn list_from_vec(items: Vec<Thunk>) -> Value {
let mut acc = Value::Nil;
for t in items.into_iter().rev() {
acc = Value::Cons(t, forced(acc));
}
acc
}
pub fn as_str(v: &Value) -> String {
match v {
Value::Str(s) => (**s).clone(),
_ => panic!("expected string"),
}
}
pub fn as_bool(v: Value) -> bool {
match v {
Value::Bool(b) => b,
_ => panic!("expected bool"),
}
}
pub fn as_int(v: Value) -> i64 {
match v {
Value::Int(n) => n,
_ => panic!("expected int"),
}
}

View file

@ -0,0 +1,498 @@
//! AST pretty-printer for `doot fmt`. Reprints a parsed [`Program`] in a
//! canonical layout, preserving comments (via their source spans), integer
//! literal forms (`0o600`/`0x1f`), and multiline `''...''` strings. A node is
//! laid out flat when it fits within [`WIDTH`], otherwise broken across lines.
use std::rc::Rc;
use super::ast::*;
use super::diag::Span;
const WIDTH: usize = 100;
/// Pretty-print a program to canonical source.
pub fn format(prog: &Program) -> String {
let mut p = Printer {
comments: &prog.comments,
cursor: 0,
out: String::new(),
};
p.program(prog);
p.out
}
struct Printer<'a> {
comments: &'a [(Span, String)],
cursor: usize,
out: String,
}
impl Printer<'_> {
fn pad(&mut self, ind: usize) {
for _ in 0..ind {
self.out.push_str(" ");
}
}
/// Emit every pending comment whose span starts before `before`, each on its
/// own line at indentation `ind`.
fn flush_before(&mut self, before: usize, ind: usize) {
while self.cursor < self.comments.len() && self.comments[self.cursor].0.start < before {
let text = &self.comments[self.cursor].1;
self.pad(ind);
if text.is_empty() {
self.out.push('#');
} else {
self.out.push_str("# ");
self.out.push_str(text);
}
self.out.push('\n');
self.cursor += 1;
}
}
fn program(&mut self, prog: &Program) {
// declarations in source order (the AST groups them by kind)
let mut decls: Vec<(Span, Decl)> = Vec::new();
decls.extend(prog.structs.iter().map(|d| (d.span, Decl::Struct(d))));
decls.extend(prog.enums.iter().map(|d| (d.span, Decl::Enum(d))));
decls.extend(prog.classes.iter().map(|d| (d.span, Decl::Class(d))));
decls.extend(prog.impls.iter().map(|d| (d.span, Decl::Impl(d))));
decls.sort_by_key(|(s, _)| s.start);
for (span, decl) in &decls {
self.flush_before(span.start, 0);
decl.print(self);
self.out.push('\n');
}
// blank line between declarations and the body
if !decls.is_empty() {
self.out.push('\n');
}
self.flush_before(prog.body_span.start, 0);
self.block(&prog.body, 0);
self.out.push('\n');
// any trailing comments
self.flush_before(usize::MAX, 0);
}
/// Emit an expression, flat if it fits on one line, otherwise broken.
fn block(&mut self, e: &Expr, ind: usize) {
if let Expr::Str(s) = e
&& s.contains('\n')
{
self.multiline_str(s, ind);
return;
}
let flat = flat(e);
if !flat.contains('\n') && ind * 2 + flat.len() <= WIDTH {
self.out.push_str(&flat);
return;
}
match e {
Expr::Record(fields) => self.record(None, fields, ind),
Expr::Construct(name, fields) => self.record(Some(name), fields, ind),
// a list of plain scalars stays on one line even if long
Expr::List(items) if items.iter().all(|i| is_scalar(i)) => self.out.push_str(&flat),
Expr::List(items) => self.list(items, ind),
Expr::App(_, _) => self.app(e, ind),
Expr::Bin(_, _, _) | Expr::Merge(_, _) => self.bin_chain(e, ind),
Expr::Let(binds, body) => self.let_in(binds, body, ind),
Expr::If(c, t, el) => {
self.out.push_str("if ");
self.out.push_str(&flat_p(c, 0));
self.out.push_str(" then\n");
self.pad(ind + 1);
self.block(t, ind + 1);
self.out.push('\n');
self.pad(ind);
self.out.push_str("else\n");
self.pad(ind + 1);
self.block(el, ind + 1);
}
// operators/app/select that overflow stay on one (long) line
_ => self.out.push_str(&flat),
}
}
fn record(&mut self, name: Option<&str>, fields: &[(String, Rc<Expr>)], ind: usize) {
if let Some(n) = name {
self.out.push_str(n);
self.out.push(' ');
}
self.out.push_str("{\n");
for (k, v) in fields {
self.pad(ind + 1);
self.out.push_str(k);
self.out.push_str(" = ");
self.block(v, ind + 1);
self.out.push_str(";\n");
}
self.pad(ind);
self.out.push('}');
}
fn list(&mut self, items: &[Rc<Expr>], ind: usize) {
self.out.push_str("[\n");
for it in items {
self.pad(ind + 1);
// list elements are juxtaposed, so non-atoms must be parenthesized
if is_atom(it) {
self.block(it, ind + 1);
} else {
self.out.push('(');
self.block(it, ind + 1);
self.out.push(')');
}
self.out.push('\n');
}
self.pad(ind);
self.out.push(']');
}
/// A function application `f a1 a2 ...` whose flat form overflows: print the
/// head and leading args flat, and break the final argument (typically a
/// record/list, possibly containing a multiline string) onto its own lines.
fn app(&mut self, e: &Expr, ind: usize) {
let mut spine: Vec<&Expr> = Vec::new();
let mut cur = e;
while let Expr::App(f, a) = cur {
spine.push(a);
cur = f;
}
spine.reverse();
self.out.push_str(&flat_p(cur, 8));
for (i, a) in spine.iter().enumerate() {
self.out.push(' ');
if i + 1 == spine.len() {
// last argument: allow it to break; parenthesize if not postfix-safe
if is_atom(a) {
self.block(a, ind);
} else {
self.out.push('(');
self.block(a, ind);
self.out.push(')');
}
} else {
self.out.push_str(&flat_p(a, 9));
}
}
}
/// A binary-operator chain (`a ++ b ++ c`, `x // y`) that overflows: print
/// the first operand inline, then each subsequent operand on its own line
/// with the operator leading it, aligned at `ind`.
fn bin_chain(&mut self, e: &Expr, ind: usize) {
let sym = chain_sym(e);
let ctx = chain_prec(e) + 1;
let oi = ind + 1; // operands and operators sit one level in
let mut operands: Vec<&Expr> = Vec::new();
collect_chain(e, sym, &mut operands);
self.bin_operand(operands[0], ctx, oi);
for o in &operands[1..] {
self.out.push('\n');
self.pad(oi);
self.out.push_str(sym);
self.out.push(' ');
self.bin_operand(o, ctx, oi);
}
}
fn bin_operand(&mut self, e: &Expr, ctx: u8, ind: usize) {
let f = flat_p(e, ctx);
if !f.contains('\n') && ind * 2 + f.len() <= WIDTH {
self.out.push_str(&f);
} else {
self.block(e, ind);
}
}
fn let_in(&mut self, binds: &[Binding], body: &Expr, ind: usize) {
self.out.push_str("let\n");
for b in binds {
self.flush_before(b.span.start, ind + 1);
self.pad(ind + 1);
self.out.push_str(&b.name);
if let Some(ann) = &b.ann {
self.out.push_str(" : ");
self.out.push_str(&ty(ann));
}
self.out.push_str(" = ");
self.block(&b.value, ind + 1);
self.out.push_str(";\n");
}
self.pad(ind);
self.out.push_str("in ");
self.block(body, ind);
}
/// Emit a multiline string as `''` ... `''`, indenting content one level. The
/// dedent on reparse strips that indent, so the value round-trips.
fn multiline_str(&mut self, s: &str, ind: usize) {
self.out.push_str("''\n");
for line in s.split('\n') {
if line.is_empty() {
self.out.push('\n');
} else {
self.pad(ind + 1);
self.out.push_str(line);
self.out.push('\n');
}
}
self.pad(ind + 1);
self.out.push_str("''");
}
}
enum Decl<'a> {
Struct(&'a StructDecl),
Enum(&'a EnumDecl),
Class(&'a ClassDecl),
Impl(&'a ImplDecl),
}
impl Decl<'_> {
fn print(&self, p: &mut Printer) {
match self {
Decl::Struct(d) => {
p.out.push_str(&format!("struct {} {{\n", d.name));
for f in &d.fields {
p.out.push_str(&format!(" {} : {}", f.name, ty(&f.ty)));
if let Some(def) = &f.default {
p.out.push_str(&format!(" = {}", flat(def)));
}
p.out.push_str(";\n");
}
for m in &d.methods {
p.out.push_str(&format!(" {}\n", method(m)));
}
p.out.push('}');
}
Decl::Enum(d) => {
p.out.push_str(&format!("enum {} {{\n", d.name));
if !d.variants.is_empty() {
p.out.push_str(&format!(" {}", d.variants.join(", ")));
p.out
.push_str(if d.methods.is_empty() { "\n" } else { ",\n" });
}
for m in &d.methods {
p.out.push_str(&format!(" {}\n", method(m)));
}
p.out.push('}');
}
Decl::Class(d) => {
p.out
.push_str(&format!("class {} {} {{\n", d.name, d.param));
for (name, sig) in &d.methods {
p.out.push_str(&format!(" {} : {};\n", name, ty(sig)));
}
p.out.push('}');
}
Decl::Impl(d) => {
p.out
.push_str(&format!("impl {} for {} {{\n", d.class, d.type_name));
for (name, body) in &d.methods {
p.out.push_str(&format!(" {} = {};\n", name, flat(body)));
}
p.out.push('}');
}
}
}
}
fn method(m: &MethodDecl) -> String {
let params = if m.params.is_empty() {
String::new()
} else {
format!(" {}", m.params.join(" "))
};
format!("fn {}{} = {};", m.name, params, flat(&m.body))
}
/// A surface type, rendered for source.
fn ty(t: &Type) -> String {
match t {
Type::Int => "Int".into(),
Type::Str => "Str".into(),
Type::Bool => "Bool".into(),
Type::List(x) => format!("[{}]", ty(x)),
Type::Struct(n) | Type::Enum(n) => n.clone(),
Type::Fun(a, b) => format!("{} -> {}", ty(a), ty(b)),
Type::Task(x) => format!("Task {}", ty(x)),
Type::Record(m) => {
let inner: Vec<String> = m.iter().map(|(k, v)| format!("{k} : {}", ty(v))).collect();
format!("{{ {} }}", inner.join("; "))
}
Type::Var(i) => format!("t{i}"),
Type::Dyn => "?".into(),
}
}
/// A plain scalar literal/name (a list of these stays on one line).
fn is_scalar(e: &Expr) -> bool {
matches!(
e,
Expr::Int(..) | Expr::Str(_) | Expr::Bool(_) | Expr::Var(_) | Expr::EnumVariant(_, _)
)
}
/// The operator symbol of a binary chain's top node.
fn chain_sym(e: &Expr) -> &'static str {
match e {
Expr::Bin(op, _, _) => binop_info(*op).1,
Expr::Merge(_, _) => "//",
_ => "",
}
}
/// The precedence of a binary chain's top node.
fn chain_prec(e: &Expr) -> u8 {
match e {
Expr::Bin(op, _, _) => binop_info(*op).0,
Expr::Merge(_, _) => 4,
_ => 0,
}
}
/// Flatten a left-associative run of the same operator `sym` into its operands.
fn collect_chain<'a>(e: &'a Expr, sym: &str, out: &mut Vec<&'a Expr>) {
match e {
Expr::Bin(op, l, r) if binop_info(*op).1 == sym => {
collect_chain(l, sym, out);
out.push(r);
}
Expr::Merge(l, r) if sym == "//" => {
collect_chain(l, sym, out);
out.push(r);
}
_ => out.push(e),
}
}
/// Postfix-safe expressions can appear as a bare list element / juxtaposition arg.
fn is_atom(e: &Expr) -> bool {
matches!(
e,
Expr::Int(..)
| Expr::Str(_)
| Expr::Bool(_)
| Expr::Var(_)
| Expr::EnumVariant(_, _)
| Expr::List(_)
| Expr::Record(_)
| Expr::Construct(_, _)
| Expr::Select(_, _)
)
}
/// Render an expression on a single line (with precedence parenthesization).
fn flat(e: &Expr) -> String {
flat_p(e, 0)
}
fn flat_p(e: &Expr, ctx: u8) -> String {
let (prec, s) = match e {
Expr::Int(n, Radix::Dec) => (9, n.to_string()),
Expr::Int(n, Radix::Oct) => (9, format!("0o{n:o}")),
Expr::Int(n, Radix::Hex) => (9, format!("0x{n:x}")),
Expr::Str(s) => (9, str_lit(s)),
Expr::Bool(b) => (9, b.to_string()),
Expr::Var(n) => (9, n.clone()),
Expr::EnumVariant(en, v) => (9, format!("{en}.{v}")),
Expr::List(items) => (
9,
if items.is_empty() {
"[]".into()
} else {
let parts: Vec<String> = items.iter().map(|x| flat_p(x, 9)).collect();
format!("[ {} ]", parts.join(" "))
},
),
Expr::Record(fields) => (9, record_flat(None, fields)),
Expr::Construct(name, fields) => (9, record_flat(Some(name), fields)),
Expr::Select(o, f) => (9, format!("{}.{}", flat_p(o, 9), f)),
Expr::App(f, a) => (8, format!("{} {}", flat_p(f, 8), flat_p(a, 9))),
Expr::Lam(_, _) => (0, lam_flat(e)),
Expr::Merge(l, r) => (4, format!("{} // {}", flat_p(l, 4), flat_p(r, 5))),
Expr::If(c, t, el) => (
0,
format!(
"if {} then {} else {}",
flat_p(c, 0),
flat_p(t, 0),
flat_p(el, 0)
),
),
Expr::Let(binds, body) => (0, let_flat(binds, body)),
Expr::Bin(op, l, r) => {
let (p, sym, rassoc) = binop_info(*op);
let (lc, rc) = if rassoc { (p + 1, p) } else { (p, p + 1) };
(p, format!("{} {} {}", flat_p(l, lc), sym, flat_p(r, rc)))
}
};
if prec < ctx { format!("({s})") } else { s }
}
fn record_flat(name: Option<&str>, fields: &[(String, Rc<Expr>)]) -> String {
let prefix = name.map(|n| format!("{n} ")).unwrap_or_default();
if fields.is_empty() {
return format!("{prefix}{{}}");
}
let parts: Vec<String> = fields
.iter()
.map(|(k, v)| format!("{k} = {}", flat_p(v, 0)))
.collect();
format!("{prefix}{{ {}; }}", parts.join("; "))
}
fn lam_flat(e: &Expr) -> String {
let mut params = Vec::new();
let mut cur = e;
while let Expr::Lam(p, body) = cur {
params.push(p.clone());
cur = body;
}
format!("\\{} -> {}", params.join(" "), flat_p(cur, 0))
}
fn let_flat(binds: &[Binding], body: &Expr) -> String {
let bs: Vec<String> = binds
.iter()
.map(|b| {
let ann = b
.ann
.as_ref()
.map(|a| format!(" : {}", ty(a)))
.unwrap_or_default();
format!("{}{} = {};", b.name, ann, flat_p(&b.value, 0))
})
.collect();
format!("let {} in {}", bs.join(" "), flat_p(body, 0))
}
fn str_lit(s: &str) -> String {
if s.contains('\n') {
// forces block mode (contains a newline); block() renders the `''` form
format!("''{s}''")
} else {
format!("\"{s}\"")
}
}
/// `(precedence, symbol, right-associative)` for a binary operator.
fn binop_info(op: BinOp) -> (u8, &'static str, bool) {
match op {
BinOp::Or => (1, "||", false),
BinOp::And => (2, "&&", false),
BinOp::Eq => (3, "==", false),
BinOp::Concat => (4, "++", false),
BinOp::Add => (5, "+", false),
BinOp::Sub => (5, "-", false),
BinOp::Mul => (6, "*", false),
BinOp::Slash => (6, "/", false),
BinOp::Mod => (6, "%", false),
BinOp::Pow => (7, "**", true),
}
}

View file

@ -0,0 +1,249 @@
//! Tokenizer.
use super::ast::Radix;
use super::diag::{Diagnostic, Span};
/// A token paired with its source span.
pub type Spanned = (Tok, Span);
/// Tokens plus the source comments (span + text without `#`), in source order.
pub struct Lexed {
pub tokens: Vec<Spanned>,
pub comments: Vec<(Span, String)>,
}
/// A lexical token.
#[derive(Debug, Clone, PartialEq)]
pub enum Tok {
Int(i64, Radix),
Str(String),
Ident(String),
// keywords
Let,
In,
If,
Then,
Else,
True,
False,
Struct,
Enum,
Fn,
Class,
Impl,
For,
// punctuation / operators
Assign, // =
EqEq, // ==
Colon, // :
Semi, // ;
Comma, // ,
Dot, // .
Slash, // /
Slashes, // //
Concat, // ++
OrOr, // ||
AndAnd, // &&
Plus, // +
Minus, // -
Star, // *
StarStar, // **
Percent, // %
Backslash, // \
Arrow, // ->
LParen,
RParen,
LBracket,
RBracket,
LBrace,
RBrace,
}
/// Tokenize source into spanned tokens + comments, or the first lexical error.
pub fn lex(src: &str) -> Result<Lexed, Diagnostic> {
let b: Vec<char> = src.chars().collect();
let mut i = 0;
let mut out = Vec::new();
let mut comments = Vec::new();
while i < b.len() {
let c = b[i];
if c.is_whitespace() {
i += 1;
continue;
}
if c == '#' {
let start = i;
i += 1;
let text_start = i;
while i < b.len() && b[i] != '\n' {
i += 1;
}
let text: String = b[text_start..i].iter().collect();
comments.push((Span::new(start, i), text.trim().to_string()));
continue;
}
let start = i;
// two-char operators first, then single-char, then literals/idents
let two = |x: char, y: char| c == x && i + 1 < b.len() && b[i + 1] == y;
let tok = if two('/', '/') {
i += 2;
Tok::Slashes
} else if two('+', '+') {
i += 2;
Tok::Concat
} else if two('|', '|') {
i += 2;
Tok::OrOr
} else if two('&', '&') {
i += 2;
Tok::AndAnd
} else if two('-', '>') {
i += 2;
Tok::Arrow
} else if two('=', '=') {
i += 2;
Tok::EqEq
} else if two('*', '*') {
i += 2;
Tok::StarStar
} else if two('\'', '\'') {
// `''...''` indented multiline string (common leading indent stripped)
i += 2;
let mut raw = String::new();
while i + 1 < b.len() && !(b[i] == '\'' && b[i + 1] == '\'') {
raw.push(b[i]);
i += 1;
}
if i + 1 >= b.len() {
return Err(Diagnostic::new(
"unterminated multiline string",
Span::new(start, b.len()),
));
}
i += 2; // closing ''
Tok::Str(dedent(&raw))
} else if c == '"' {
i += 1;
let mut s = String::new();
while i < b.len() && b[i] != '"' {
s.push(b[i]);
i += 1;
}
if i >= b.len() {
return Err(Diagnostic::new(
"unterminated string",
Span::new(start, b.len()),
));
}
i += 1; // closing quote
Tok::Str(s)
} else if c.is_ascii_digit() {
lex_int(&b, &mut i, start)?
} else if c.is_alphabetic() || c == '_' {
let mut s = String::new();
while i < b.len() && (b[i].is_alphanumeric() || b[i] == '_') {
s.push(b[i]);
i += 1;
}
keyword_or_ident(s)
} else {
i += 1;
match c {
'(' => Tok::LParen,
')' => Tok::RParen,
'[' => Tok::LBracket,
']' => Tok::RBracket,
'{' => Tok::LBrace,
'}' => Tok::RBrace,
';' => Tok::Semi,
',' => Tok::Comma,
':' => Tok::Colon,
'.' => Tok::Dot,
'\\' => Tok::Backslash,
'/' => Tok::Slash,
'=' => Tok::Assign,
'+' => Tok::Plus,
'-' => Tok::Minus,
'*' => Tok::Star,
'%' => Tok::Percent,
other => {
return Err(Diagnostic::new(
format!("unexpected character {other:?}"),
Span::new(start, i),
));
}
}
};
out.push((tok, Span::new(start, i)));
}
Ok(Lexed {
tokens: out,
comments,
})
}
/// Lex an integer literal (decimal, `0o<octal>`, `0x<hex>`).
fn lex_int(b: &[char], i: &mut usize, start: usize) -> Result<Tok, Diagnostic> {
let (radix, kind, alnum) =
if b[*i] == '0' && *i + 1 < b.len() && (b[*i + 1] == 'o' || b[*i + 1] == 'x') {
let oct = b[*i + 1] == 'o';
*i += 2;
if oct {
(8, Radix::Oct, true)
} else {
(16, Radix::Hex, true)
}
} else {
(10, Radix::Dec, false)
};
let mut n = String::new();
while *i < b.len() && (b[*i].is_ascii_digit() || (alnum && b[*i].is_ascii_alphanumeric())) {
n.push(b[*i]);
*i += 1;
}
i64::from_str_radix(&n, radix)
.map(|v| Tok::Int(v, kind))
.map_err(|_| Diagnostic::new("invalid integer literal", Span::new(start, *i)))
}
fn keyword_or_ident(s: String) -> Tok {
match s.as_str() {
"let" => Tok::Let,
"in" => Tok::In,
"if" => Tok::If,
"then" => Tok::Then,
"else" => Tok::Else,
"true" => Tok::True,
"false" => Tok::False,
"struct" => Tok::Struct,
"enum" => Tok::Enum,
"fn" => Tok::Fn,
"class" => Tok::Class,
"impl" => Tok::Impl,
"for" => Tok::For,
_ => Tok::Ident(s),
}
}
/// Strip the common leading indentation from a `''...''` string and drop the
/// blank first/last lines, so multiline literals can be indented in source.
fn dedent(s: &str) -> String {
let mut lines: Vec<&str> = s.split('\n').collect();
if lines.first().is_some_and(|l| l.trim().is_empty()) {
lines.remove(0);
}
if lines.last().is_some_and(|l| l.trim().is_empty()) {
lines.pop();
}
let indent = lines
.iter()
.filter(|l| !l.trim().is_empty())
.map(|l| l.len() - l.trim_start().len())
.min()
.unwrap_or(0);
lines
.iter()
.map(|l| if l.len() >= indent { &l[indent..] } else { *l })
.collect::<Vec<_>>()
.join("\n")
}

View file

@ -0,0 +1,20 @@
//! A lazy, pure, typed expression language whose evaluation produces
//! a dependency DAG of effects.
pub mod ast;
pub mod check;
pub mod diag;
pub mod engine;
pub mod eval;
pub mod fmt;
pub mod lexer;
pub mod parser;
pub mod plan;
pub use ast::{Program, Type};
pub use check::Checker;
pub use diag::{Diagnostic, Span};
pub use engine::{BuiltinScheme, Engine};
pub use eval::{Interp, Value};
pub use parser::parse;
pub use plan::{Node, Plan};

View file

@ -0,0 +1,542 @@
//! Recursive-descent parser.
//!
//! Struct declarations are parsed first so that `Name { ... }` can be
//! disambiguated from function application `f { ... }`: if `Name` is a declared
//! (or host-registered) struct it is construction, otherwise it is application
//! of `Name` to a record. The set of host-registered struct/enum names is passed
//! in so the language core stays free of any specific vocabulary.
use std::collections::HashSet;
use std::rc::Rc;
use super::ast::*;
use super::diag::{Diagnostic, Span};
use super::lexer::{Spanned, Tok, lex};
type PResult<T> = Result<T, Diagnostic>;
struct Parser {
t: Vec<Spanned>,
i: usize,
structs: HashSet<String>,
enums: HashSet<String>,
}
impl Parser {
fn peek(&self) -> Option<&Tok> {
self.t.get(self.i).map(|(t, _)| t)
}
/// The span of the current token, or a point at end-of-input.
fn cur_span(&self) -> Span {
match self.t.get(self.i) {
Some((_, s)) => *s,
None => self
.t
.last()
.map(|(_, s)| Span::point(s.end))
.unwrap_or(Span::point(0)),
}
}
fn err<T>(&self, msg: impl Into<String>) -> PResult<T> {
Err(Diagnostic::new(msg, self.cur_span()))
}
fn next(&mut self) -> PResult<Tok> {
match self.t.get(self.i) {
Some((tok, _)) => {
let tok = tok.clone();
self.i += 1;
Ok(tok)
}
None => self.err("unexpected end of input"),
}
}
fn eat(&mut self, t: &Tok) -> PResult<()> {
let sp = self.cur_span();
let got = self.next()?;
if &got == t {
Ok(())
} else {
Err(Diagnostic::new(format!("expected {t:?}, got {got:?}"), sp))
}
}
fn ident(&mut self) -> PResult<String> {
let sp = self.cur_span();
match self.next()? {
Tok::Ident(s) => Ok(s),
other => Err(Diagnostic::new(
format!("expected identifier, got {other:?}"),
sp,
)),
}
}
// types: `T -> T` is right-associative
fn ty(&mut self) -> PResult<Type> {
let base = self.ty_atom()?;
if matches!(self.peek(), Some(Tok::Arrow)) {
self.eat(&Tok::Arrow)?;
let ret = self.ty()?;
Ok(Type::Fun(Box::new(base), Box::new(ret)))
} else {
Ok(base)
}
}
fn ty_atom(&mut self) -> PResult<Type> {
let sp = self.cur_span();
match self.next()? {
Tok::LBracket => {
let inner = self.ty()?;
self.eat(&Tok::RBracket)?;
Ok(Type::List(Box::new(inner)))
}
Tok::LParen => {
let t = self.ty()?;
self.eat(&Tok::RParen)?;
Ok(t)
}
Tok::Ident(s) => Ok(match s.as_str() {
"Int" => Type::Int,
"Str" => Type::Str,
"Bool" => Type::Bool,
_ if self.enums.contains(&s) => Type::Enum(s),
_ => Type::Struct(s),
}),
other => Err(Diagnostic::new(format!("expected type, got {other:?}"), sp)),
}
}
fn enum_decl(&mut self) -> PResult<EnumDecl> {
let span = self.cur_span();
self.eat(&Tok::Enum)?;
let name = self.ident()?;
self.enums.insert(name.clone()); // visible inside its own methods
self.eat(&Tok::LBrace)?;
let mut variants = Vec::new();
let mut methods = Vec::new();
while !matches!(self.peek(), Some(Tok::RBrace)) {
if matches!(self.peek(), Some(Tok::Fn)) {
methods.push(self.method_decl()?);
} else {
variants.push(self.ident()?);
if matches!(self.peek(), Some(Tok::Comma)) {
self.eat(&Tok::Comma)?;
}
}
}
self.eat(&Tok::RBrace)?;
Ok(EnumDecl {
name,
variants,
methods,
span,
})
}
// `class Name a { method : Type; ... }`
fn class_decl(&mut self) -> PResult<ClassDecl> {
let span = self.cur_span();
self.eat(&Tok::Class)?;
let name = self.ident()?;
let param = self.ident()?;
self.eat(&Tok::LBrace)?;
let mut methods = Vec::new();
while !matches!(self.peek(), Some(Tok::RBrace)) {
let mname = self.ident()?;
self.eat(&Tok::Colon)?;
let sig = self.ty()?;
self.eat(&Tok::Semi)?;
methods.push((mname, sig));
}
self.eat(&Tok::RBrace)?;
Ok(ClassDecl {
name,
param,
methods,
span,
})
}
// `impl Class for Type { method = expr; ... }`
fn impl_decl(&mut self) -> PResult<ImplDecl> {
let span = self.cur_span();
self.eat(&Tok::Impl)?;
let class = self.ident()?;
self.eat(&Tok::For)?;
let type_name = self.ident()?;
self.eat(&Tok::LBrace)?;
let mut methods = Vec::new();
while !matches!(self.peek(), Some(Tok::RBrace)) {
let mname = self.ident()?;
self.eat(&Tok::Assign)?;
let body = self.expr()?;
self.eat(&Tok::Semi)?;
methods.push((mname, body));
}
self.eat(&Tok::RBrace)?;
Ok(ImplDecl {
class,
type_name,
methods,
span,
})
}
// `fn name self p1 ... = body;` (params[0] is self)
fn method_decl(&mut self) -> PResult<MethodDecl> {
self.eat(&Tok::Fn)?;
let name = self.ident()?;
let mut params = Vec::new();
while matches!(self.peek(), Some(Tok::Ident(_))) {
params.push(self.ident()?);
}
self.eat(&Tok::Assign)?;
let body = self.expr()?;
self.eat(&Tok::Semi)?;
Ok(MethodDecl { name, params, body })
}
// struct decls
fn struct_decl(&mut self) -> PResult<StructDecl> {
let span = self.cur_span();
self.eat(&Tok::Struct)?;
let name = self.ident()?;
self.structs.insert(name.clone()); // visible inside its own methods
self.eat(&Tok::LBrace)?;
let mut fields = Vec::new();
let mut methods = Vec::new();
while !matches!(self.peek(), Some(Tok::RBrace)) {
if matches!(self.peek(), Some(Tok::Fn)) {
methods.push(self.method_decl()?);
continue;
}
let fname = self.ident()?;
self.eat(&Tok::Colon)?;
let fty = self.ty()?;
let default = if matches!(self.peek(), Some(Tok::Assign)) {
self.eat(&Tok::Assign)?;
Some(self.expr()?)
} else {
None
};
self.eat(&Tok::Semi)?;
fields.push(FieldDecl {
name: fname,
ty: fty,
default,
});
}
self.eat(&Tok::RBrace)?;
Ok(StructDecl {
name,
fields,
methods,
span,
})
}
// expressions
// precedence, low -> high: || < && < == < { // / ++ } < application
fn expr(&mut self) -> PResult<Rc<Expr>> {
self.or_level()
}
fn or_level(&mut self) -> PResult<Rc<Expr>> {
let mut lhs = self.and_level()?;
while matches!(self.peek(), Some(Tok::OrOr)) {
self.i += 1;
let rhs = self.and_level()?;
lhs = Rc::new(Expr::Bin(BinOp::Or, lhs, rhs));
}
Ok(lhs)
}
fn and_level(&mut self) -> PResult<Rc<Expr>> {
let mut lhs = self.eq_level()?;
while matches!(self.peek(), Some(Tok::AndAnd)) {
self.i += 1;
let rhs = self.eq_level()?;
lhs = Rc::new(Expr::Bin(BinOp::And, lhs, rhs));
}
Ok(lhs)
}
fn eq_level(&mut self) -> PResult<Rc<Expr>> {
let mut lhs = self.concat_level()?;
while matches!(self.peek(), Some(Tok::EqEq)) {
self.i += 1;
let rhs = self.concat_level()?;
lhs = Rc::new(Expr::Bin(BinOp::Eq, lhs, rhs));
}
Ok(lhs)
}
/// `++` (concat) and `//` (merge), left-assoc.
fn concat_level(&mut self) -> PResult<Rc<Expr>> {
let mut lhs = self.additive()?;
loop {
let merge = matches!(self.peek(), Some(Tok::Slashes));
let concat = matches!(self.peek(), Some(Tok::Concat));
if !merge && !concat {
break;
}
self.i += 1;
let rhs = self.additive()?;
lhs = Rc::new(if merge {
Expr::Merge(lhs, rhs)
} else {
Expr::Bin(BinOp::Concat, lhs, rhs)
});
}
Ok(lhs)
}
fn additive(&mut self) -> PResult<Rc<Expr>> {
let mut lhs = self.multiplicative()?;
loop {
let op = match self.peek() {
Some(Tok::Plus) => BinOp::Add,
Some(Tok::Minus) => BinOp::Sub,
_ => break,
};
self.i += 1;
let rhs = self.multiplicative()?;
lhs = Rc::new(Expr::Bin(op, lhs, rhs));
}
Ok(lhs)
}
/// `*`, `/` (path join / division), `%`, left-assoc.
fn multiplicative(&mut self) -> PResult<Rc<Expr>> {
let mut lhs = self.power()?;
loop {
let op = match self.peek() {
Some(Tok::Star) => BinOp::Mul,
Some(Tok::Slash) => BinOp::Slash,
Some(Tok::Percent) => BinOp::Mod,
_ => break,
};
self.i += 1;
let rhs = self.power()?;
lhs = Rc::new(Expr::Bin(op, lhs, rhs));
}
Ok(lhs)
}
/// `**` power, right-assoc.
fn power(&mut self) -> PResult<Rc<Expr>> {
let lhs = self.app()?;
if matches!(self.peek(), Some(Tok::StarStar)) {
self.i += 1;
let rhs = self.power()?;
Ok(Rc::new(Expr::Bin(BinOp::Pow, lhs, rhs)))
} else {
Ok(lhs)
}
}
fn starts_atom(&self) -> bool {
matches!(
self.peek(),
Some(
Tok::Int(..)
| Tok::Str(_)
| Tok::Ident(_)
| Tok::True
| Tok::False
| Tok::LParen
| Tok::LBracket
| Tok::LBrace
| Tok::Backslash
)
)
}
fn app(&mut self) -> PResult<Rc<Expr>> {
let mut f = self.postfix()?;
while self.starts_atom() {
let arg = self.postfix()?;
f = Rc::new(Expr::App(f, arg));
}
Ok(f)
}
fn postfix(&mut self) -> PResult<Rc<Expr>> {
let mut e = self.atom()?;
while matches!(self.peek(), Some(Tok::Dot)) {
self.eat(&Tok::Dot)?;
let field = self.ident()?;
e = Rc::new(Expr::Select(e, field));
}
Ok(e)
}
fn record_block(&mut self) -> PResult<Vec<(String, Rc<Expr>)>> {
self.eat(&Tok::LBrace)?;
let mut fs = Vec::new();
while !matches!(self.peek(), Some(Tok::RBrace)) {
let k = self.ident()?;
self.eat(&Tok::Assign)?;
let v = self.expr()?;
self.eat(&Tok::Semi)?;
fs.push((k, v));
}
self.eat(&Tok::RBrace)?;
Ok(fs)
}
fn atom(&mut self) -> PResult<Rc<Expr>> {
let sp = self.cur_span();
Ok(match self.next()? {
Tok::Int(n, r) => Rc::new(Expr::Int(n, r)),
Tok::Str(s) => Rc::new(Expr::Str(s)),
Tok::True => Rc::new(Expr::Bool(true)),
Tok::False => Rc::new(Expr::Bool(false)),
Tok::Ident(s) => {
if self.enums.contains(&s) && matches!(self.peek(), Some(Tok::Dot)) {
// `Enum.Variant`
self.eat(&Tok::Dot)?;
Rc::new(Expr::EnumVariant(s, self.ident()?))
} else if self.structs.contains(&s) && matches!(self.peek(), Some(Tok::LBrace)) {
// `Name { ... }` construction (a struct); otherwise a plain `{ ... }`
// following a function is a separate argument handled by `app`.
Rc::new(Expr::Construct(s, self.record_block()?))
} else {
Rc::new(Expr::Var(s))
}
}
Tok::LParen => {
let e = self.expr()?;
self.eat(&Tok::RParen)?;
e
}
Tok::LBracket => {
// elements are postfix-atoms (Nix style): `[ f x ]` is two elements;
// write `[ (f x) ]` to apply. Optional commas allowed.
let mut items = Vec::new();
while !matches!(self.peek(), Some(Tok::RBracket)) {
items.push(self.postfix()?);
if matches!(self.peek(), Some(Tok::Comma)) {
self.eat(&Tok::Comma)?;
}
}
self.eat(&Tok::RBracket)?;
Rc::new(Expr::List(items))
}
Tok::LBrace => {
self.i -= 1; // hand the brace back to record_block
Rc::new(Expr::Record(self.record_block()?))
}
Tok::Backslash => {
// multi-param lambda `\a b c -> body` desugars to curried lambdas
let mut params = vec![self.ident()?];
while matches!(self.peek(), Some(Tok::Ident(_))) {
params.push(self.ident()?);
}
self.eat(&Tok::Arrow)?;
let mut body = self.expr()?;
for p in params.into_iter().rev() {
body = Rc::new(Expr::Lam(p, body));
}
body
}
Tok::Let => {
let mut binds = Vec::new();
while !matches!(self.peek(), Some(Tok::In)) {
let bspan = self.cur_span();
let name = self.ident()?;
// `let f a b = body;` is sugar for `f = \a b -> body`.
// Params (bare idents) and a `: Type` annotation are mutually exclusive.
let mut params = Vec::new();
while matches!(self.peek(), Some(Tok::Ident(_))) {
params.push(self.ident()?);
}
let ann = if params.is_empty() && matches!(self.peek(), Some(Tok::Colon)) {
self.eat(&Tok::Colon)?;
Some(self.ty()?)
} else {
None
};
self.eat(&Tok::Assign)?;
let mut value = self.expr()?;
for p in params.into_iter().rev() {
value = Rc::new(Expr::Lam(p, value));
}
self.eat(&Tok::Semi)?;
binds.push(Binding {
name,
ann,
value,
span: bspan,
});
}
self.eat(&Tok::In)?;
let body = self.expr()?;
Rc::new(Expr::Let(binds, body))
}
Tok::If => {
let c = self.expr()?;
self.eat(&Tok::Then)?;
let t = self.expr()?;
self.eat(&Tok::Else)?;
let e = self.expr()?;
Rc::new(Expr::If(c, t, e))
}
other => return Err(Diagnostic::new(format!("unexpected token {other:?}"), sp)),
})
}
}
/// Parse a full program. `structs`/`enums` are host-registered nominal names (in
/// addition to those declared in the source) used to disambiguate `Name { ... }`
/// construction and `Enum.Variant`.
pub fn parse(src: &str, structs: &[String], enums: &[String]) -> PResult<Program> {
let lexed = lex(src)?;
let mut p = Parser {
t: lexed.tokens,
i: 0,
structs: structs.iter().cloned().collect(),
enums: enums.iter().cloned().collect(),
};
let mut structs = Vec::new();
let mut enums = Vec::new();
let mut classes = Vec::new();
let mut impls = Vec::new();
// declarations may interleave
loop {
match p.peek() {
Some(Tok::Struct) => {
let d = p.struct_decl()?;
p.structs.insert(d.name.clone());
structs.push(Rc::new(d));
}
Some(Tok::Enum) => {
let d = p.enum_decl()?;
p.enums.insert(d.name.clone());
enums.push(Rc::new(d));
}
Some(Tok::Class) => classes.push(Rc::new(p.class_decl()?)),
Some(Tok::Impl) => impls.push(Rc::new(p.impl_decl()?)),
_ => break,
}
}
let body_span = p.cur_span();
let body = p.expr()?;
if p.peek().is_some() {
return p.err("unexpected trailing tokens");
}
Ok(Program {
structs,
enums,
classes,
impls,
body,
body_span,
comments: lexed.comments,
})
}

View file

@ -0,0 +1,61 @@
//! The realization plan: the dependency DAG produced by evaluating a program.
//!
//! The graph is domain-agnostic: each node carries an opaque `Rc<dyn Any>`
//! payload that a domain layer supplies and later downcasts. Edges are inferred
//! from value references, never written by hand.
use std::any::Any;
use std::collections::HashSet;
use std::rc::Rc;
/// A single effect node. `data` is an opaque payload (the dotfile layer stores a
/// `TaskData` and downcasts it back at the bridge).
#[derive(Clone)]
pub struct Node {
pub label: String,
pub data: Rc<dyn Any>,
}
/// The inferred dependency DAG.
#[derive(Default)]
pub struct Plan {
pub nodes: Vec<Node>,
/// `(from, to)` means `from` depends on `to` (so `to` is realized first).
pub edges: Vec<(usize, usize)>,
}
impl Plan {
/// Tasks that `id` directly depends on.
pub fn deps_of(&self, id: usize) -> Vec<usize> {
self.edges
.iter()
.filter(|(f, _)| *f == id)
.map(|(_, t)| *t)
.collect()
}
/// Topologically sort into layers; every node in a layer is independent of the
/// others and can be realized concurrently. Panics on a cycle.
pub fn parallel_layers(&self) -> Vec<Vec<usize>> {
let n = self.nodes.len();
let mut deps: Vec<HashSet<usize>> = vec![HashSet::new(); n];
for &(from, to) in &self.edges {
if from != to {
deps[from].insert(to);
}
}
let mut done: HashSet<usize> = HashSet::new();
let mut layers = Vec::new();
while done.len() < n {
let layer: Vec<usize> = (0..n)
.filter(|id| !done.contains(id) && deps[*id].iter().all(|d| done.contains(d)))
.collect();
assert!(!layer.is_empty(), "cycle in dependency graph");
for id in &layer {
done.insert(*id);
}
layers.push(layer);
}
layers
}
}

View file

@ -1,430 +0,0 @@
//! Lexer for the doot language.
use chumsky::prelude::*;
use ordered_float::OrderedFloat;
use std::fmt;
/// Token types produced by the lexer.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum Token {
// Literals
Int(i64),
Float(OrderedFloat<f64>),
Str(String),
Bool(bool),
// Identifiers and keywords
Ident(String),
// Keywords
Let,
Fn,
AsyncFn,
If,
Else,
Then,
For,
In,
Match,
Struct,
Enum,
Type,
Import,
As,
Dotfile,
Package,
Brew,
Secret,
Encrypted,
Hook,
BeforeDeploy,
AfterDeploy,
BeforePackage,
AfterPackage,
Macro,
Await,
Return,
When,
// Operators
Plus,
Minus,
Star,
Slash,
Percent,
Eq,
EqEq,
NotEq,
Lt,
Gt,
LtEq,
GtEq,
And,
Or,
Not,
Pipe,
DoublePipe,
DoubleColon,
Arrow,
FatArrow,
Dot,
DotDot,
QuestionQuestion,
// Delimiters
LParen,
RParen,
LBracket,
RBracket,
LBrace,
RBrace,
Comma,
Colon,
Semicolon,
Newline,
// Special
Tilde,
At,
Hash,
Bang,
Indent(usize),
Dedent,
}
impl fmt::Display for Token {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Token::Int(n) => write!(f, "{}", n),
Token::Float(n) => write!(f, "{}", n),
Token::Str(s) => write!(f, "\"{}\"", s),
Token::Bool(b) => write!(f, "{}", b),
Token::Ident(s) => write!(f, "{}", s),
Token::Let => write!(f, "let"),
Token::Fn => write!(f, "fn"),
Token::AsyncFn => write!(f, "async fn"),
Token::If => write!(f, "if"),
Token::Else => write!(f, "else"),
Token::Then => write!(f, "then"),
Token::For => write!(f, "for"),
Token::In => write!(f, "in"),
Token::Match => write!(f, "match"),
Token::Struct => write!(f, "struct"),
Token::Enum => write!(f, "enum"),
Token::Type => write!(f, "type"),
Token::Import => write!(f, "import"),
Token::As => write!(f, "as"),
Token::Dotfile => write!(f, "dotfile"),
Token::Package => write!(f, "package"),
Token::Brew => write!(f, "brew"),
Token::Secret => write!(f, "secret"),
Token::Encrypted => write!(f, "encrypted"),
Token::Hook => write!(f, "hook"),
Token::BeforeDeploy => write!(f, "before_deploy"),
Token::AfterDeploy => write!(f, "after_deploy"),
Token::BeforePackage => write!(f, "before_package"),
Token::AfterPackage => write!(f, "after_package"),
Token::Macro => write!(f, "macro"),
Token::Await => write!(f, "await"),
Token::Return => write!(f, "return"),
Token::When => write!(f, "when"),
Token::Plus => write!(f, "+"),
Token::Minus => write!(f, "-"),
Token::Star => write!(f, "*"),
Token::Slash => write!(f, "/"),
Token::Percent => write!(f, "%"),
Token::Eq => write!(f, "="),
Token::EqEq => write!(f, "=="),
Token::NotEq => write!(f, "!="),
Token::Lt => write!(f, "<"),
Token::Gt => write!(f, ">"),
Token::LtEq => write!(f, "<="),
Token::GtEq => write!(f, ">="),
Token::And => write!(f, "&&"),
Token::Or => write!(f, "||"),
Token::Not => write!(f, "!"),
Token::Pipe => write!(f, "|"),
Token::DoublePipe => write!(f, "||"),
Token::DoubleColon => write!(f, "::"),
Token::Arrow => write!(f, "->"),
Token::FatArrow => write!(f, "=>"),
Token::Dot => write!(f, "."),
Token::DotDot => write!(f, ".."),
Token::QuestionQuestion => write!(f, "??"),
Token::LParen => write!(f, "("),
Token::RParen => write!(f, ")"),
Token::LBracket => write!(f, "["),
Token::RBracket => write!(f, "]"),
Token::LBrace => write!(f, "{{"),
Token::RBrace => write!(f, "}}"),
Token::Comma => write!(f, ","),
Token::Colon => write!(f, ":"),
Token::Semicolon => write!(f, ";"),
Token::Newline => write!(f, "\\n"),
Token::Tilde => write!(f, "~"),
Token::At => write!(f, "@"),
Token::Hash => write!(f, "#"),
Token::Bang => write!(f, "!"),
Token::Indent(n) => write!(f, "<indent {}>", n),
Token::Dedent => write!(f, "<dedent>"),
}
}
}
/// Source location range.
pub type Span = std::ops::Range<usize>;
/// Token with source location.
#[derive(Clone, Debug)]
pub struct Spanned<T> {
pub node: T,
pub span: Span,
}
impl<T> Spanned<T> {
/// Creates a new spanned token.
pub fn new(node: T, span: Span) -> Self {
Self { node, span }
}
}
/// Tokenizes doot source code.
pub struct Lexer;
impl Lexer {
/// Returns the token parser combinator.
pub fn lexer() -> impl chumsky::Parser<char, Vec<Spanned<Token>>, Error = Simple<char>> {
let octal = just("0o")
.ignore_then(text::digits(8))
.map(|s: String| Token::Int(i64::from_str_radix(&s, 8).unwrap_or(0)));
let hex = just("0x")
.ignore_then(text::digits(16))
.map(|s: String| Token::Int(i64::from_str_radix(&s, 16).unwrap_or(0)));
let decimal = text::int(10).map(|s: String| Token::Int(s.parse().unwrap()));
let int = octal.or(hex).or(decimal);
let float = text::int(10).then(just('.').then(text::digits(10))).map(
|(a, (_, b)): (String, (char, String))| {
let f: f64 = format!("{}.{}", a, b).parse().unwrap();
Token::Float(OrderedFloat(f))
},
);
let escape = just('\\').ignore_then(
just('\\')
.or(just('/'))
.or(just('"'))
.or(just('n').to('\n'))
.or(just('r').to('\r'))
.or(just('t').to('\t')),
);
let string = just('"')
.ignore_then(filter(|c| *c != '\\' && *c != '"').or(escape).repeated())
.then_ignore(just('"'))
.collect::<String>()
.map(Token::Str);
// Heredoc: >>>...<<<
let heredoc =
just(">>>")
.ignore_then(take_until(just("<<<")))
.map(|(chars, _): (Vec<char>, _)| {
let s: String = chars.into_iter().collect();
// Trim leading newline if present
let s = s.strip_prefix('\n').unwrap_or(&s);
Token::Str(s.to_string())
});
let keyword_or_ident = text::ident().map(|s: String| match s.as_str() {
"let" => Token::Let,
"fn" => Token::Fn,
"async" => Token::Ident("async".to_string()),
"if" => Token::If,
"else" => Token::Else,
"then" => Token::Then,
"for" => Token::For,
"in" => Token::In,
"match" => Token::Match,
"struct" => Token::Struct,
"enum" => Token::Enum,
"type" => Token::Type,
"import" => Token::Import,
"as" => Token::As,
"dotfile" => Token::Dotfile,
"package" => Token::Package,
"brew" => Token::Brew,
"secret" => Token::Secret,
"encrypted" => Token::Encrypted,
"hook" => Token::Hook,
"before_deploy" => Token::BeforeDeploy,
"after_deploy" => Token::AfterDeploy,
"before_package" => Token::BeforePackage,
"after_package" => Token::AfterPackage,
"macro" => Token::Macro,
"await" => Token::Await,
"return" => Token::Return,
"when" => Token::When,
"true" => Token::Bool(true),
"false" => Token::Bool(false),
_ => Token::Ident(s),
});
let op = choice((
just("??").to(Token::QuestionQuestion),
just("=>").to(Token::FatArrow),
just("->").to(Token::Arrow),
just("::").to(Token::DoubleColon),
just("..").to(Token::DotDot),
just("==").to(Token::EqEq),
just("!=").to(Token::NotEq),
just("<=").to(Token::LtEq),
just(">=").to(Token::GtEq),
just("&&").to(Token::And),
just("||").to(Token::Or),
just('+').to(Token::Plus),
just('-').to(Token::Minus),
just('*').to(Token::Star),
just('/').to(Token::Slash),
just('%').to(Token::Percent),
just('=').to(Token::Eq),
just('<').to(Token::Lt),
just('>').to(Token::Gt),
just('!').to(Token::Bang),
just('|').to(Token::Pipe),
just('.').to(Token::Dot),
));
let delim = choice((
just('(').to(Token::LParen),
just(')').to(Token::RParen),
just('[').to(Token::LBracket),
just(']').to(Token::RBracket),
just('{').to(Token::LBrace),
just('}').to(Token::RBrace),
just(',').to(Token::Comma),
just(':').to(Token::Colon),
just(';').to(Token::Semicolon),
just('~').to(Token::Tilde),
just('@').to(Token::At),
just('#').to(Token::Hash),
));
let comment = just('#').then(none_of("\n").repeated()).ignored();
let whitespace = just(' ').or(just('\t')).repeated().at_least(1).ignored();
let newline = just('\n').to(Token::Newline);
let token = choice((
float,
int,
heredoc,
string,
keyword_or_ident,
op,
delim,
newline,
))
.map_with_span(Spanned::new);
token
.padded_by(comment.repeated())
.padded_by(whitespace.repeated())
.repeated()
.then_ignore(end())
}
/// Tokenizes the input string with indentation processing.
#[tracing::instrument(skip_all)]
pub fn lex(input: &str) -> Result<Vec<Spanned<Token>>, Vec<Simple<char>>> {
let tokens = Self::lexer().parse(input)?;
Ok(Self::process_indentation(tokens))
}
/// Converts whitespace into indent/dedent tokens.
#[tracing::instrument(level = "trace", skip_all)]
fn process_indentation(tokens: Vec<Spanned<Token>>) -> Vec<Spanned<Token>> {
let mut result = Vec::new();
let mut indent_stack = vec![0usize];
let mut at_line_start = true;
let mut line_start_pos = 0;
for token in tokens {
match &token.node {
Token::Newline => {
result.push(token.clone());
at_line_start = true;
line_start_pos = token.span.end;
}
_ if at_line_start => {
let span_start = token.span.start;
let current_indent = span_start.saturating_sub(line_start_pos);
let last_indent = *indent_stack.last().unwrap();
if current_indent > last_indent {
indent_stack.push(current_indent);
result.push(Spanned::new(
Token::Indent(current_indent),
span_start..span_start,
));
} else {
while indent_stack.len() > 1
&& current_indent < *indent_stack.last().unwrap()
{
indent_stack.pop();
result.push(Spanned::new(Token::Dedent, span_start..span_start));
}
}
at_line_start = false;
result.push(token);
}
_ => {
result.push(token);
}
}
}
let end = result.last().map(|t| t.span.end).unwrap_or(0);
while indent_stack.len() > 1 {
indent_stack.pop();
result.push(Spanned::new(Token::Dedent, end..end));
}
result
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_basic_tokens() {
let input = "let x = 42";
let tokens = Lexer::lex(input).unwrap();
assert!(matches!(tokens[0].node, Token::Let));
assert!(matches!(tokens[1].node, Token::Ident(ref s) if s == "x"));
assert!(matches!(tokens[2].node, Token::Eq));
assert!(matches!(tokens[3].node, Token::Int(42)));
}
#[test]
fn test_string_literal() {
let input = r#""hello world""#;
let tokens = Lexer::lex(input).unwrap();
assert!(matches!(tokens[0].node, Token::Str(ref s) if s == "hello world"));
}
#[test]
fn test_operators() {
let input = "a ?? b => c";
let tokens = Lexer::lex(input).unwrap();
assert!(matches!(tokens[1].node, Token::QuestionQuestion));
assert!(matches!(tokens[3].node, Token::FatArrow));
}
}

View file

@ -1,27 +1,6 @@
//! Doot language implementation.
//!
//! This crate provides the lexer, parser, type checker, and evaluator
//! for the doot configuration language.
//! The doot configuration language: a lazy, pure, typed expression language.
//! Lexer, parser, Hindley-Milner type checker, lazy CEK evaluator, and an
//! `Engine` registration API for embedding a standard library and domain
//! vocabulary. Domain-free: it knows nothing about dotfiles.
// chumsky 0.9's `Simple<Token>` error type is inherently large (~152 bytes) and
// is fixed by the parser-combinator API, so it cannot be boxed at the `select!`
// call sites. This lint is unactionable here.
#![allow(clippy::result_large_err)]
pub mod ast;
pub mod builtins;
pub mod evaluator;
pub mod lexer;
pub mod macros;
pub mod parser;
pub mod planner;
pub mod type_checker;
pub mod types;
pub use ast::*;
pub use evaluator::Evaluator;
pub use lexer::Lexer;
pub use parser::Parser;
pub use planner::{DotfileConflict, DotfileValidation, DotfileWarning, validate_dotfile_targets};
pub use type_checker::TypeChecker;
pub use types::Type;
pub mod lang;

View file

@ -1,227 +0,0 @@
//! Macro expansion for doot.
use crate::ast::*;
use std::collections::HashMap;
/// Expands macros in the AST.
pub struct MacroExpander {
macros: HashMap<String, MacroDecl>,
}
impl MacroExpander {
/// Creates a new macro expander.
#[tracing::instrument(level = "trace")]
pub fn new() -> Self {
Self {
macros: HashMap::new(),
}
}
/// Registers a macro definition.
#[tracing::instrument(level = "trace", skip(self), fields(name = %decl.name))]
pub fn register(&mut self, decl: MacroDecl) {
self.macros.insert(decl.name.clone(), decl);
}
/// Expands a macro call into statements.
#[tracing::instrument(level = "trace", skip(self), fields(name = %call.name))]
pub fn expand(&self, call: &MacroCall) -> Option<Vec<Spanned<Statement>>> {
let decl = self.macros.get(&call.name)?;
let mut substitutions: HashMap<String, &Expr> = HashMap::new();
for (param, arg) in decl.params.iter().zip(call.args.iter()) {
substitutions.insert(param.clone(), arg);
}
let expanded: Vec<Spanned<Statement>> = decl
.body
.iter()
.map(|stmt| {
Spanned::new(
self.substitute_statement(&stmt.node, &substitutions),
stmt.span.clone(),
)
})
.collect();
Some(expanded)
}
#[tracing::instrument(level = "trace", skip_all)]
fn substitute_statement(&self, stmt: &Statement, subs: &HashMap<String, &Expr>) -> Statement {
match stmt {
Statement::VarDecl(decl) => Statement::VarDecl(VarDecl {
name: decl.name.clone(),
ty: decl.ty.clone(),
value: self.substitute_expr(&decl.value, subs),
}),
Statement::Dotfile(dotfile) => Statement::Dotfile(Box::new(Dotfile {
source: self.substitute_expr(&dotfile.source, subs),
target: self.substitute_expr(&dotfile.target, subs),
when: dotfile.when.as_ref().map(|e| self.substitute_expr(e, subs)),
template: dotfile.template,
permissions: dotfile.permissions.clone(),
owner: dotfile.owner.clone(),
deploy: dotfile.deploy,
link_patterns: dotfile.link_patterns.clone(),
copy_patterns: dotfile.copy_patterns.clone(),
source_span: dotfile.source_span.clone(),
target_span: dotfile.target_span.clone(),
when_span: dotfile.when_span.clone(),
})),
Statement::Package(pkg) => Statement::Package(Box::new(Package {
default: pkg.default.as_ref().map(|e| self.substitute_expr(e, subs)),
brew: pkg.brew.as_ref().map(|s| PackageSpec {
name: self.substitute_expr(&s.name, subs),
}),
cask: pkg.cask.as_ref().map(|s| PackageSpec {
name: self.substitute_expr(&s.name, subs),
}),
apt: pkg.apt.as_ref().map(|s| PackageSpec {
name: self.substitute_expr(&s.name, subs),
}),
pacman: pkg.pacman.as_ref().map(|s| PackageSpec {
name: self.substitute_expr(&s.name, subs),
}),
yay: pkg.yay.as_ref().map(|s| PackageSpec {
name: self.substitute_expr(&s.name, subs),
}),
xbps: pkg.xbps.as_ref().map(|s| PackageSpec {
name: self.substitute_expr(&s.name, subs),
}),
when: pkg.when.as_ref().map(|e| self.substitute_expr(e, subs)),
})),
Statement::ForLoop(for_loop) => Statement::ForLoop(ForLoop {
var: for_loop.var.clone(),
iter: self.substitute_expr(&for_loop.iter, subs),
body: for_loop
.body
.iter()
.map(|s| Spanned::new(self.substitute_statement(&s.node, subs), s.span.clone()))
.collect(),
}),
Statement::If(if_stmt) => Statement::If(IfStatement {
condition: self.substitute_expr(&if_stmt.condition, subs),
then_body: if_stmt
.then_body
.iter()
.map(|s| Spanned::new(self.substitute_statement(&s.node, subs), s.span.clone()))
.collect(),
else_body: if_stmt.else_body.as_ref().map(|body| {
body.iter()
.map(|s| {
Spanned::new(self.substitute_statement(&s.node, subs), s.span.clone())
})
.collect()
}),
}),
Statement::Expr(expr) => Statement::Expr(self.substitute_expr(expr, subs)),
other => other.clone(),
}
}
#[tracing::instrument(level = "trace", skip_all)]
fn substitute_expr(&self, expr: &Expr, subs: &HashMap<String, &Expr>) -> Expr {
match expr {
Expr::Ident(name) => {
if let Some(&replacement) = subs.get(name) {
replacement.clone()
} else {
expr.clone()
}
}
Expr::Binary(left, op, right) => Expr::Binary(
Box::new(self.substitute_expr(left, subs)),
op.clone(),
Box::new(self.substitute_expr(right, subs)),
),
Expr::Unary(op, inner) => {
Expr::Unary(op.clone(), Box::new(self.substitute_expr(inner, subs)))
}
Expr::Call(callee, args) => Expr::Call(
Box::new(self.substitute_expr(callee, subs)),
args.iter().map(|a| self.substitute_expr(a, subs)).collect(),
),
Expr::MethodCall(obj, method, args) => Expr::MethodCall(
Box::new(self.substitute_expr(obj, subs)),
method.clone(),
args.iter().map(|a| self.substitute_expr(a, subs)).collect(),
),
Expr::Field(obj, field) => {
Expr::Field(Box::new(self.substitute_expr(obj, subs)), field.clone())
}
Expr::Index(obj, idx) => Expr::Index(
Box::new(self.substitute_expr(obj, subs)),
Box::new(self.substitute_expr(idx, subs)),
),
Expr::List(items) => Expr::List(
items
.iter()
.map(|i| self.substitute_expr(i, subs))
.collect(),
),
Expr::StructInit(name, fields) => Expr::StructInit(
name.clone(),
fields
.iter()
.map(|(k, v)| (k.clone(), self.substitute_expr(v, subs)))
.collect(),
),
Expr::If(cond, then_expr, else_expr) => Expr::If(
Box::new(self.substitute_expr(cond, subs)),
Box::new(self.substitute_expr(then_expr, subs)),
else_expr
.as_ref()
.map(|e| Box::new(self.substitute_expr(e, subs))),
),
Expr::Lambda(params, body) => {
Expr::Lambda(params.clone(), Box::new(self.substitute_expr(body, subs)))
}
Expr::Await(inner) => Expr::Await(Box::new(self.substitute_expr(inner, subs))),
Expr::Path(left, right) => Expr::Path(
Box::new(self.substitute_expr(left, subs)),
Box::new(self.substitute_expr(right, subs)),
),
Expr::HomePath(path) => Expr::HomePath(Box::new(self.substitute_expr(path, subs))),
Expr::Interpolated(parts) => Expr::Interpolated(
parts
.iter()
.map(|p| match p {
InterpolatedPart::Literal(s) => InterpolatedPart::Literal(s.clone()),
InterpolatedPart::Expr(e) => {
InterpolatedPart::Expr(self.substitute_expr(e, subs))
}
})
.collect(),
),
other => other.clone(),
}
}
}
impl Default for MacroExpander {
fn default() -> Self {
Self::new()
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,192 +0,0 @@
//! Dependency graph for task ordering.
use std::collections::{HashMap, HashSet};
/// Directed acyclic graph of task dependencies.
#[derive(Debug, Clone)]
pub struct DependencyGraph {
nodes: HashMap<String, Node>,
edges: HashMap<String, HashSet<String>>,
}
/// A node in the dependency graph.
#[derive(Debug, Clone)]
pub struct Node {
pub id: String,
pub task_type: TaskType,
pub data: TaskData,
}
/// Task category.
#[derive(Debug, Clone)]
pub enum TaskType {
Dotfile,
Package,
Secret,
Hook,
Custom,
}
/// Task-specific data.
#[derive(Debug, Clone)]
pub enum TaskData {
Dotfile {
source: std::path::PathBuf,
target: std::path::PathBuf,
template: bool,
},
Package {
name: String,
manager: String,
},
Secret {
source: std::path::PathBuf,
target: std::path::PathBuf,
},
Hook {
command: String,
},
Custom(String),
}
impl DependencyGraph {
/// Creates an empty dependency graph.
pub fn new() -> Self {
Self {
nodes: HashMap::new(),
edges: HashMap::new(),
}
}
/// Adds a task node.
pub fn add_node(&mut self, id: String, task_type: TaskType, data: TaskData) {
self.nodes.insert(
id.clone(),
Node {
id: id.clone(),
task_type,
data,
},
);
self.edges.entry(id).or_default();
}
/// Adds a dependency edge (from depends on to).
pub fn add_edge(&mut self, from: &str, to: &str) {
self.edges
.entry(from.to_string())
.or_default()
.insert(to.to_string());
}
/// Returns tasks in dependency order.
pub fn topological_sort(&self) -> Result<Vec<String>, String> {
let mut in_degree: HashMap<String, usize> = HashMap::new();
let mut reverse_edges: HashMap<String, HashSet<String>> = HashMap::new();
for id in self.nodes.keys() {
in_degree.insert(id.clone(), 0);
reverse_edges.insert(id.clone(), HashSet::new());
}
for (from, tos) in &self.edges {
for to in tos {
*in_degree.entry(to.clone()).or_default() += 1;
reverse_edges
.entry(from.clone())
.or_default()
.insert(to.clone());
}
}
let mut queue: Vec<String> = in_degree
.iter()
.filter(|(_, deg)| **deg == 0)
.map(|(id, _)| id.clone())
.collect();
let mut result = Vec::new();
while let Some(node) = queue.pop() {
result.push(node.clone());
if let Some(deps) = self.edges.get(&node) {
for dep in deps {
if let Some(deg) = in_degree.get_mut(dep) {
*deg -= 1;
if *deg == 0 {
queue.push(dep.clone());
}
}
}
}
}
if result.len() != self.nodes.len() {
return Err("cycle detected in dependency graph".to_string());
}
Ok(result)
}
/// Groups tasks into parallelizable batches.
pub fn get_parallel_batches(&self) -> Result<Vec<Vec<String>>, String> {
let mut in_degree: HashMap<String, usize> = HashMap::new();
let mut remaining = self.nodes.keys().cloned().collect::<HashSet<_>>();
for id in self.nodes.keys() {
in_degree.insert(id.clone(), 0);
}
for tos in self.edges.values() {
for to in tos {
*in_degree.entry(to.clone()).or_default() += 1;
}
}
let mut batches = Vec::new();
while !remaining.is_empty() {
let batch: Vec<String> = remaining
.iter()
.filter(|id| in_degree.get(*id).copied().unwrap_or(0) == 0)
.cloned()
.collect();
if batch.is_empty() {
return Err("cycle detected in dependency graph".to_string());
}
for node in &batch {
remaining.remove(node);
if let Some(deps) = self.edges.get(node) {
for dep in deps {
if let Some(deg) = in_degree.get_mut(dep) {
*deg -= 1;
}
}
}
}
batches.push(batch);
}
Ok(batches)
}
/// Gets a node by ID.
pub fn get_node(&self, id: &str) -> Option<&Node> {
self.nodes.get(id)
}
/// Iterates over all nodes.
pub fn nodes(&self) -> impl Iterator<Item = &Node> {
self.nodes.values()
}
}
impl Default for DependencyGraph {
fn default() -> Self {
Self::new()
}
}

View file

@ -1,9 +0,0 @@
//! Task planning and execution.
pub mod dag;
pub mod scheduler;
pub use dag::DependencyGraph;
pub use scheduler::{
DotfileConflict, DotfileValidation, DotfileWarning, Scheduler, validate_dotfile_targets,
};

View file

@ -1,423 +0,0 @@
//! Task scheduling from evaluation results.
use super::dag::{DependencyGraph, TaskData, TaskType};
use crate::evaluator::{DotfileConfig, EvalResult};
use std::path::Path;
/// Builds a dependency graph from evaluation results.
pub struct Scheduler {
graph: DependencyGraph,
}
impl Scheduler {
/// Creates an empty scheduler.
#[tracing::instrument(level = "trace")]
pub fn new() -> Self {
Self {
graph: DependencyGraph::new(),
}
}
/// Creates a scheduler from evaluation results.
#[tracing::instrument(skip_all)]
pub fn from_eval_result(result: &EvalResult) -> Self {
let mut scheduler = Self::new();
for (i, dotfile) in result.dotfiles.iter().enumerate() {
let id = format!("dotfile_{}", i);
scheduler.graph.add_node(
id,
TaskType::Dotfile,
TaskData::Dotfile {
source: dotfile.source.clone(),
target: dotfile.target.clone(),
template: dotfile.template,
},
);
}
for (i, package) in result.packages.iter().enumerate() {
let id = format!("package_{}", i);
let name = package.default.clone().unwrap_or_default();
scheduler.graph.add_node(
id,
TaskType::Package,
TaskData::Package {
name,
manager: "default".to_string(),
},
);
}
for (i, secret) in result.secrets.iter().enumerate() {
let id = format!("secret_{}", i);
scheduler.graph.add_node(
id,
TaskType::Secret,
TaskData::Secret {
source: secret.source.clone(),
target: secret.target.clone(),
},
);
}
for (i, hook) in result.hooks.iter().enumerate() {
let id = format!("hook_{}", i);
scheduler.graph.add_node(
id,
TaskType::Hook,
TaskData::Hook {
command: hook.run.clone(),
},
);
}
scheduler
}
/// Returns the built dependency graph.
pub fn build_graph(self) -> DependencyGraph {
self.graph
}
/// Returns task IDs in execution order.
#[tracing::instrument(level = "trace", skip(self))]
pub fn get_execution_order(&self) -> Result<Vec<String>, String> {
self.graph.topological_sort()
}
/// Returns tasks grouped into parallel batches.
#[tracing::instrument(level = "trace", skip(self))]
pub fn get_parallel_batches(&self) -> Result<Vec<Vec<String>>, String> {
self.graph.get_parallel_batches()
}
}
impl Default for Scheduler {
fn default() -> Self {
Self::new()
}
}
/// Conflict detected between dotfile entries.
#[derive(Debug, Clone)]
pub enum DotfileConflict {
/// Same source and target (duplicate entry).
Duplicate { index_a: usize, index_b: usize },
/// Overlapping directories with no distinguishing settings (likely redundant).
RedundantOverlap {
parent_index: usize,
child_index: usize,
},
}
/// Warning about dotfile configuration.
#[derive(Debug, Clone)]
pub struct DotfileWarning {
pub message: String,
pub index_a: usize,
pub index_b: usize,
}
/// Result of validating dotfile targets.
#[derive(Debug)]
pub struct DotfileValidation {
/// Indices in dependency order (respecting target relationships).
pub ordered_indices: Vec<usize>,
/// Batches of indices that can be deployed in parallel.
pub parallel_batches: Vec<Vec<usize>>,
/// Errors that prevent deployment.
pub errors: Vec<DotfileConflict>,
/// Warnings that should be shown to user.
pub warnings: Vec<DotfileWarning>,
}
/// Validates dotfile targets and returns proper execution order.
///
/// Detects:
/// - Duplicate entries (same source + same target) → Error
/// - Same target with different source → OK, add dependency (later depends on earlier)
/// - Overlapping directories (both dirs, one target is ancestor) with same settings → Warning
/// - Overlapping directories with different settings → OK, add dependency
/// - Directory + file inside → OK, add dependency
#[tracing::instrument(skip_all)]
pub fn validate_dotfile_targets(
dotfiles: &[DotfileConfig],
source_dir: &Path,
) -> DotfileValidation {
let mut errors = Vec::new();
let mut warnings = Vec::new();
let mut graph = DependencyGraph::new();
// Add all dotfiles as nodes
for (i, dotfile) in dotfiles.iter().enumerate() {
let id = format!("dotfile_{}", i);
graph.add_node(
id,
TaskType::Dotfile,
TaskData::Dotfile {
source: dotfile.source.clone(),
target: dotfile.target.clone(),
template: dotfile.template,
},
);
}
// Check all pairs for conflicts
for i in 0..dotfiles.len() {
for j in (i + 1)..dotfiles.len() {
let a = &dotfiles[i];
let b = &dotfiles[j];
let target_a = &a.target;
let target_b = &b.target;
// Check for same exact target
if target_a == target_b {
if a.source == b.source {
// Same source + same target = duplicate
errors.push(DotfileConflict::Duplicate {
index_a: i,
index_b: j,
});
} else {
// Different source + same target = override, j depends on i.
// Layering is supported, but two sources fighting over one file
// is usually a mistake, so surface it as a warning (last wins).
graph.add_edge(&format!("dotfile_{}", i), &format!("dotfile_{}", j));
warnings.push(DotfileWarning {
message: format!(
"'{}' and '{}' both deploy to '{}'; the later entry wins",
a.source.display(),
b.source.display(),
target_a.display()
),
index_a: i,
index_b: j,
});
}
continue;
}
// Check if one target is ancestor of the other
let a_is_ancestor = target_b.starts_with(target_a) && target_a != target_b;
let b_is_ancestor = target_a.starts_with(target_b) && target_a != target_b;
if a_is_ancestor {
// a's target is ancestor of b's target, so a must run first
let full_source_a = source_dir.join(&a.source);
let full_source_b = source_dir.join(&b.source);
let both_dirs = full_source_a.is_dir() && full_source_b.is_dir();
if both_dirs && is_redundant_overlap(a, b) {
warnings.push(DotfileWarning {
message: format!(
"overlapping directories with same settings: '{}' contains '{}'",
a.source.display(),
b.source.display()
),
index_a: i,
index_b: j,
});
}
// Add edge: a runs before b
graph.add_edge(&format!("dotfile_{}", i), &format!("dotfile_{}", j));
} else if b_is_ancestor {
// b's target is ancestor of a's target, so b must run first
let full_source_a = source_dir.join(&a.source);
let full_source_b = source_dir.join(&b.source);
let both_dirs = full_source_a.is_dir() && full_source_b.is_dir();
if both_dirs && is_redundant_overlap(b, a) {
warnings.push(DotfileWarning {
message: format!(
"overlapping directories with same settings: '{}' contains '{}'",
b.source.display(),
a.source.display()
),
index_a: j,
index_b: i,
});
}
// Add edge: b runs before a
graph.add_edge(&format!("dotfile_{}", j), &format!("dotfile_{}", i));
}
}
}
// Get execution order via topological sort
let ordered_indices: Vec<usize> = match graph.topological_sort() {
Ok(ids) => ids
.into_iter()
.filter_map(|id| id.strip_prefix("dotfile_").and_then(|s| s.parse().ok()))
.collect(),
Err(_) => {
// Cycle detected - shouldn't happen with our edge rules, but fallback to original order
(0..dotfiles.len()).collect()
}
};
// Get parallel batches from the DAG
let parallel_batches = match graph.get_parallel_batches() {
Ok(batches) => batches
.into_iter()
.map(|batch| {
batch
.into_iter()
.filter_map(|id| id.strip_prefix("dotfile_").and_then(|s| s.parse().ok()))
.collect()
})
.collect(),
Err(_) => ordered_indices.iter().map(|&i| vec![i]).collect(),
};
DotfileValidation {
ordered_indices,
parallel_batches,
errors,
warnings,
}
}
/// Checks if the child dotfile has no distinguishing settings from parent.
fn is_redundant_overlap(parent: &DotfileConfig, child: &DotfileConfig) -> bool {
child.permissions.is_empty()
&& child.owner.is_none()
&& !child.template
&& child.deploy == parent.deploy
&& child.link_patterns.is_empty()
&& child.copy_patterns.is_empty()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::evaluator::DeployMode;
use std::path::PathBuf;
use tempfile::TempDir;
fn make_dotfile(source: &str, target: &str) -> DotfileConfig {
DotfileConfig {
source: PathBuf::from(source),
target: PathBuf::from(target),
template: false,
permissions: Vec::new(),
owner: None,
deploy: DeployMode::Copy,
link_patterns: Vec::new(),
copy_patterns: Vec::new(),
exclude_paths: Vec::new(),
exclude_sources: Vec::new(),
}
}
#[test]
fn test_duplicate_entry_error() {
let temp = TempDir::new().unwrap();
let dotfiles = vec![
make_dotfile("config/app.conf", "/home/user/.config/app.conf"),
make_dotfile("config/app.conf", "/home/user/.config/app.conf"),
];
let result = validate_dotfile_targets(&dotfiles, temp.path());
assert_eq!(result.errors.len(), 1);
match &result.errors[0] {
DotfileConflict::Duplicate { index_a, index_b } => {
assert_eq!(*index_a, 0);
assert_eq!(*index_b, 1);
}
_ => panic!("expected Duplicate error"),
}
}
#[test]
fn test_same_target_different_source_ok() {
let temp = TempDir::new().unwrap();
let dotfiles = vec![
make_dotfile("config/app.conf", "/home/user/.config/app.conf"),
make_dotfile("templates/app.conf", "/home/user/.config/app.conf"),
];
let result = validate_dotfile_targets(&dotfiles, temp.path());
assert!(result.errors.is_empty());
// Second entry should come after first
assert_eq!(result.ordered_indices, vec![0, 1]);
// Two different sources hitting one target is allowed (last wins) but warns.
assert_eq!(result.warnings.len(), 1);
assert!(result.warnings[0].message.contains("both deploy to"));
}
#[test]
fn test_directory_file_override_ordering() {
let temp = TempDir::new().unwrap();
// Create a directory to simulate real filesystem
std::fs::create_dir_all(temp.path().join("config/nvim")).unwrap();
std::fs::write(temp.path().join("config/nvim/init.lua"), "").unwrap();
let mut file_dotfile =
make_dotfile("config/nvim/init.lua", "/home/user/.config/nvim/init.lua");
file_dotfile.template = true;
let dotfiles = vec![
// File with template (declared first)
file_dotfile,
// Directory (declared second)
make_dotfile("config/nvim", "/home/user/.config/nvim"),
];
let result = validate_dotfile_targets(&dotfiles, temp.path());
assert!(result.errors.is_empty());
// Directory should run first (index 1), then file (index 0)
assert_eq!(result.ordered_indices, vec![1, 0]);
}
#[test]
fn test_overlapping_dirs_with_different_settings_no_warning() {
let temp = TempDir::new().unwrap();
// Create directories
std::fs::create_dir_all(temp.path().join("config/nvim/lua")).unwrap();
let mut child_dotfile = make_dotfile("config/nvim/lua", "/home/user/.config/nvim/lua");
child_dotfile.owner = Some("root".to_string());
let dotfiles = vec![
make_dotfile("config/nvim", "/home/user/.config/nvim"),
child_dotfile,
];
let result = validate_dotfile_targets(&dotfiles, temp.path());
assert!(result.errors.is_empty());
assert!(result.warnings.is_empty()); // No warning because child has different settings
}
#[test]
fn test_overlapping_dirs_same_settings_warning() {
let temp = TempDir::new().unwrap();
// Create directories
std::fs::create_dir_all(temp.path().join("config/nvim/lua")).unwrap();
let dotfiles = vec![
make_dotfile("config/nvim", "/home/user/.config/nvim"),
make_dotfile("config/nvim/lua", "/home/user/.config/nvim/lua"),
];
let result = validate_dotfile_targets(&dotfiles, temp.path());
assert!(result.errors.is_empty());
assert_eq!(result.warnings.len(), 1);
assert!(
result.warnings[0]
.message
.contains("overlapping directories")
);
}
}

View file

@ -1,953 +0,0 @@
//! Static type checker for the doot language.
use crate::ast::*;
use crate::types::*;
use ariadne::{Color, Label, Report, ReportKind, Source};
use std::collections::HashMap;
use thiserror::Error;
/// Type checking errors.
#[derive(Error, Debug)]
pub enum TypeError {
#[error("undefined variable: {0}")]
UndefinedVariable(String, std::ops::Range<usize>),
#[error("undefined type: {0}")]
UndefinedType(String, std::ops::Range<usize>),
#[error("type mismatch: expected {expected}, got {got}")]
TypeMismatch {
expected: String,
got: String,
span: std::ops::Range<usize>,
},
#[error("cannot call non-function type: {0}")]
NotCallable(String, std::ops::Range<usize>),
#[error("field {field} not found on type {ty}")]
FieldNotFound {
ty: String,
field: String,
span: std::ops::Range<usize>,
},
#[error("wrong number of arguments: expected {expected}, got {got}")]
WrongArity {
expected: usize,
got: usize,
span: std::ops::Range<usize>,
},
#[error("await can only be used inside async functions")]
AwaitOutsideAsync(std::ops::Range<usize>),
}
impl TypeError {
/// Prints a formatted error report to stderr.
pub fn report(&self, source: &str, filename: &str) {
let (msg, span) = match self {
TypeError::UndefinedVariable(name, span) => {
(format!("undefined variable: {}", name), span.clone())
}
TypeError::UndefinedType(name, span) => {
(format!("undefined type: {}", name), span.clone())
}
TypeError::TypeMismatch {
expected,
got,
span,
} => (format!("expected {}, got {}", expected, got), span.clone()),
TypeError::NotCallable(ty, span) => (
format!("cannot call non-function type: {}", ty),
span.clone(),
),
TypeError::FieldNotFound { ty, field, span } => {
(format!("field {} not found on {}", field, ty), span.clone())
}
TypeError::WrongArity {
expected,
got,
span,
} => (
format!("expected {} arguments, got {}", expected, got),
span.clone(),
),
TypeError::AwaitOutsideAsync(span) => (
"await can only be used inside async functions".to_string(),
span.clone(),
),
};
Report::build(ReportKind::Error, filename, span.start)
.with_message(self.to_string())
.with_label(
Label::new((filename, span))
.with_message(msg)
.with_color(Color::Red),
)
.finish()
.print((filename, Source::from(source)))
.ok();
}
}
/// Static type checker.
pub struct TypeChecker {
env: TypeEnv,
errors: Vec<TypeError>,
in_async_context: bool,
}
impl TypeChecker {
/// Creates a new type checker with built-in types.
#[tracing::instrument(level = "trace")]
pub fn new() -> Self {
Self {
env: TypeEnv::new(),
errors: Vec::new(),
in_async_context: true, // top-level is implicitly async
}
}
/// Type checks a program, returning errors if any.
#[tracing::instrument(level = "trace", skip_all)]
pub fn check(&mut self, program: &Program) -> Result<(), Vec<TypeError>> {
for stmt in &program.statements {
self.check_statement(stmt);
}
if self.errors.is_empty() {
Ok(())
} else {
Err(std::mem::take(&mut self.errors))
}
}
#[tracing::instrument(level = "trace", skip_all)]
fn check_statement(&mut self, stmt: &Spanned<Statement>) {
match &stmt.node {
Statement::VarDecl(decl) => {
let inferred = self.infer_expr(&decl.value, &stmt.span);
if let Some(ref ty_ann) = decl.ty {
let expected = self.resolve_type(ty_ann);
if !expected.is_compatible(&inferred) {
self.errors.push(TypeError::TypeMismatch {
expected: expected.display(),
got: inferred.display(),
span: stmt.span.clone(),
});
}
self.env.define(decl.name.clone(), expected);
} else {
self.env.define(decl.name.clone(), inferred);
}
}
Statement::FnDecl(decl) => {
let params: Vec<(String, Type)> = decl
.params
.iter()
.map(|p| (p.name.clone(), self.resolve_type(&p.ty)))
.collect();
let return_type = decl
.return_type
.as_ref()
.map(|t| self.resolve_type(t))
.unwrap_or(Type::None);
self.env.define_function(
decl.name.clone(),
FunctionType {
params: params.clone(),
return_type: return_type.clone(),
is_async: decl.is_async,
},
);
self.env.push_scope();
for (name, ty) in params {
self.env.define(name, ty);
}
if decl.params.iter().any(|p| p.name == "self") {
// Method context
}
let old_async = self.in_async_context;
self.in_async_context = decl.is_async;
for body_stmt in &decl.body {
self.check_statement(body_stmt);
}
self.in_async_context = old_async;
self.env.pop_scope();
}
Statement::StructDecl(decl) => {
let mut fields = HashMap::new();
for field in &decl.fields {
let ty = self.resolve_type(&field.ty);
fields.insert(field.name.clone(), ty);
}
let mut methods = HashMap::new();
for method in &decl.methods {
let params: Vec<(String, Type)> = method
.params
.iter()
.map(|p| (p.name.clone(), self.resolve_type(&p.ty)))
.collect();
let return_type = method
.return_type
.as_ref()
.map(|t| self.resolve_type(t))
.unwrap_or(Type::None);
methods.insert(
method.name.clone(),
FunctionType {
params,
return_type,
is_async: method.is_async,
},
);
}
self.env.define_struct(
decl.name.clone(),
StructType {
name: decl.name.clone(),
fields,
methods,
},
);
}
Statement::EnumDecl(decl) => {
let mut variants = HashMap::new();
for variant in &decl.variants {
let fields = variant
.fields
.as_ref()
.map(|fs| fs.iter().map(|t| self.resolve_type(t)).collect());
variants.insert(variant.name.clone(), fields);
}
self.env.define_enum(
decl.name.clone(),
EnumType {
name: decl.name.clone(),
variants,
},
);
}
Statement::TypeAlias(alias) => {
let ty = self.resolve_type(&alias.ty);
self.env.define(alias.name.clone(), ty);
}
Statement::ForLoop(for_loop) => {
let iter_ty = self.infer_expr(&for_loop.iter, &stmt.span);
let elem_ty = match iter_ty {
Type::List(inner) => *inner,
Type::Str => Type::Str,
_ => Type::Any,
};
self.env.push_scope();
self.env.define(for_loop.var.clone(), elem_ty);
for body_stmt in &for_loop.body {
self.check_statement(body_stmt);
}
self.env.pop_scope();
}
Statement::If(if_stmt) => {
let cond_ty = self.infer_expr(&if_stmt.condition, &stmt.span);
if !cond_ty.is_compatible(&Type::Bool) {
self.errors.push(TypeError::TypeMismatch {
expected: "bool".to_string(),
got: cond_ty.display(),
span: stmt.span.clone(),
});
}
self.env.push_scope();
for body_stmt in &if_stmt.then_body {
self.check_statement(body_stmt);
}
self.env.pop_scope();
if let Some(ref else_body) = if_stmt.else_body {
self.env.push_scope();
for body_stmt in else_body {
self.check_statement(body_stmt);
}
self.env.pop_scope();
}
}
Statement::Dotfile(dotfile) => {
let source_span = dotfile.source_span.as_ref().unwrap_or(&stmt.span);
let source_ty = self.infer_expr(&dotfile.source, source_span);
// dotfile: source accepts path, str (pattern with wildcards), or list
if !matches!(
source_ty,
Type::Str | Type::Path | Type::List(_) | Type::Any | Type::Unknown
) {
self.errors.push(TypeError::TypeMismatch {
expected: "path, str, or [path]".to_string(),
got: source_ty.display(),
span: source_span.clone(),
});
}
let target_span = dotfile.target_span.as_ref().unwrap_or(&stmt.span);
let target_ty = self.infer_expr(&dotfile.target, target_span);
if matches!(target_ty, Type::List(_)) {
self.errors.push(TypeError::TypeMismatch {
expected: "path".to_string(),
got: target_ty.display(),
span: target_span.clone(),
});
}
if let Some(ref when) = dotfile.when {
let when_span = dotfile.when_span.as_ref().unwrap_or(&stmt.span);
let when_ty = self.infer_expr(when, when_span);
if !when_ty.is_compatible(&Type::Bool) {
self.errors.push(TypeError::TypeMismatch {
expected: "bool".to_string(),
got: when_ty.display(),
span: when_span.clone(),
});
}
}
}
Statement::Package(pkg) => {
// Package names are converted to strings at runtime, so skip type checking
// for the default value. Only check the 'when' condition if present.
if let Some(ref when) = pkg.when {
let when_ty = self.infer_expr(when, &stmt.span);
if !when_ty.is_compatible(&Type::Bool) {
self.errors.push(TypeError::TypeMismatch {
expected: "bool".to_string(),
got: when_ty.display(),
span: stmt.span.clone(),
});
}
}
}
Statement::Expr(expr) => {
self.infer_expr(expr, &stmt.span);
}
_ => {}
}
}
#[tracing::instrument(level = "trace", skip_all)]
fn infer_expr(&mut self, expr: &Expr, span: &std::ops::Range<usize>) -> Type {
match expr {
Expr::Literal(lit) => match lit {
Literal::Int(_) => Type::Int,
Literal::Float(_) => Type::Float,
Literal::Str(_) => Type::Str,
Literal::Bool(_) => Type::Bool,
Literal::None => Type::None,
},
Expr::Ident(name) => {
if let Some(ty) = self.env.lookup(name) {
ty.clone()
} else if let Some(ft) = self.env.functions.get(name) {
Type::Function(
ft.params.iter().map(|(_, t)| t.clone()).collect(),
Box::new(ft.return_type.clone()),
)
} else {
self.errors
.push(TypeError::UndefinedVariable(name.clone(), span.clone()));
Type::Unknown
}
}
Expr::Binary(left, op, right) => {
let left_ty = self.infer_expr(left, span);
let right_ty = self.infer_expr(right, span);
match op {
BinOp::Add | BinOp::Sub | BinOp::Mul | BinOp::Div | BinOp::Mod => {
if left_ty.is_numeric() && right_ty.is_numeric() {
if matches!(left_ty, Type::Float) || matches!(right_ty, Type::Float) {
Type::Float
} else {
Type::Int
}
} else if matches!(op, BinOp::Add)
&& (left_ty.is_compatible(&Type::Str)
|| right_ty.is_compatible(&Type::Str))
{
Type::Str
} else {
self.errors.push(TypeError::TypeMismatch {
expected: "numeric".to_string(),
got: format!("{} and {}", left_ty.display(), right_ty.display()),
span: span.clone(),
});
Type::Unknown
}
}
BinOp::Eq
| BinOp::NotEq
| BinOp::Lt
| BinOp::Gt
| BinOp::LtEq
| BinOp::GtEq => Type::Bool,
BinOp::And | BinOp::Or => {
if !left_ty.is_compatible(&Type::Bool) {
self.errors.push(TypeError::TypeMismatch {
expected: "bool".to_string(),
got: left_ty.display(),
span: span.clone(),
});
}
if !right_ty.is_compatible(&Type::Bool) {
self.errors.push(TypeError::TypeMismatch {
expected: "bool".to_string(),
got: right_ty.display(),
span: span.clone(),
});
}
Type::Bool
}
BinOp::PathJoin => Type::Path,
BinOp::NullCoalesce => {
if let Type::Optional(inner) = left_ty {
if inner.is_compatible(&right_ty) {
*inner
} else {
right_ty
}
} else {
left_ty
}
}
}
}
Expr::Unary(op, expr) => {
let ty = self.infer_expr(expr, span);
match op {
UnaryOp::Neg => {
if !ty.is_numeric() {
self.errors.push(TypeError::TypeMismatch {
expected: "numeric".to_string(),
got: ty.display(),
span: span.clone(),
});
}
ty
}
UnaryOp::Not => {
if !ty.is_compatible(&Type::Bool) {
self.errors.push(TypeError::TypeMismatch {
expected: "bool".to_string(),
got: ty.display(),
span: span.clone(),
});
}
Type::Bool
}
}
}
Expr::Call(callee, args) => {
// Check for built-in functions first (before inferring callee type)
if let Expr::Ident(name) = callee.as_ref() {
let builtin_ty = self.infer_builtin_call(name, args, span);
if builtin_ty != Type::Unknown {
return builtin_ty;
}
}
let callee_ty = self.infer_expr(callee, span);
match callee_ty {
Type::Function(params, ret) => {
if params.len() != args.len() {
self.errors.push(TypeError::WrongArity {
expected: params.len(),
got: args.len(),
span: span.clone(),
});
}
for (arg, param_ty) in args.iter().zip(params.iter()) {
let arg_ty = self.infer_expr(arg, span);
if !arg_ty.is_compatible(param_ty) {
self.errors.push(TypeError::TypeMismatch {
expected: param_ty.display(),
got: arg_ty.display(),
span: span.clone(),
});
}
}
*ret
}
Type::Unknown | Type::Any => Type::Any,
_ => {
self.errors
.push(TypeError::NotCallable(callee_ty.display(), span.clone()));
Type::Unknown
}
}
}
Expr::MethodCall(obj, method, args) => {
let obj_ty = self.infer_expr(obj, span);
match obj_ty {
Type::Struct(ref st) => {
if let Some(ft) = st.methods.get(method) {
for (arg, (_, param_ty)) in args.iter().zip(ft.params.iter().skip(1)) {
let arg_ty = self.infer_expr(arg, span);
if !arg_ty.is_compatible(param_ty) {
self.errors.push(TypeError::TypeMismatch {
expected: param_ty.display(),
got: arg_ty.display(),
span: span.clone(),
});
}
}
ft.return_type.clone()
} else {
self.errors.push(TypeError::FieldNotFound {
ty: st.name.clone(),
field: method.clone(),
span: span.clone(),
});
Type::Unknown
}
}
Type::List(_) => self.infer_list_method(method, args, span),
Type::Str => self.infer_str_method(method, args, span),
_ => Type::Any,
}
}
Expr::Field(obj, field) => {
let obj_ty = self.infer_expr(obj, span);
match obj_ty {
Type::Struct(st) => {
if let Some(field_ty) = st.fields.get(field) {
field_ty.clone()
} else {
self.errors.push(TypeError::FieldNotFound {
ty: st.name.clone(),
field: field.clone(),
span: span.clone(),
});
Type::Unknown
}
}
_ => Type::Any,
}
}
Expr::Index(obj, idx) => {
let obj_ty = self.infer_expr(obj, span);
let idx_ty = self.infer_expr(idx, span);
match obj_ty {
Type::List(inner) => {
if !idx_ty.is_compatible(&Type::Int) {
self.errors.push(TypeError::TypeMismatch {
expected: "int".to_string(),
got: idx_ty.display(),
span: span.clone(),
});
}
*inner
}
Type::Str => Type::Str,
_ => Type::Any,
}
}
Expr::List(items) => {
if items.is_empty() {
Type::List(Box::new(Type::Any))
} else {
let first_ty = self.infer_expr(&items[0], span);
for item in items.iter().skip(1) {
let item_ty = self.infer_expr(item, span);
if !item_ty.is_compatible(&first_ty) {
self.errors.push(TypeError::TypeMismatch {
expected: first_ty.display(),
got: item_ty.display(),
span: span.clone(),
});
}
}
Type::List(Box::new(first_ty))
}
}
Expr::EnumVariant(enum_name, _variant) => {
if let Some(et) = self.env.enums.get(enum_name) {
Type::Enum(et.clone())
} else {
self.errors
.push(TypeError::UndefinedType(enum_name.clone(), span.clone()));
Type::Unknown
}
}
Expr::StructInit(struct_name, fields) => {
if let Some(st) = self.env.structs.get(struct_name).cloned() {
for (field_name, field_expr) in fields {
if let Some(expected_ty) = st.fields.get(field_name) {
let actual_ty = self.infer_expr(field_expr, span);
if !actual_ty.is_compatible(expected_ty) {
self.errors.push(TypeError::TypeMismatch {
expected: expected_ty.display(),
got: actual_ty.display(),
span: span.clone(),
});
}
} else {
self.errors.push(TypeError::FieldNotFound {
ty: struct_name.clone(),
field: field_name.clone(),
span: span.clone(),
});
}
}
Type::Struct(st)
} else {
self.errors
.push(TypeError::UndefinedType(struct_name.clone(), span.clone()));
Type::Unknown
}
}
Expr::If(cond, then_expr, else_expr) => {
let cond_ty = self.infer_expr(cond, span);
if !cond_ty.is_compatible(&Type::Bool) {
self.errors.push(TypeError::TypeMismatch {
expected: "bool".to_string(),
got: cond_ty.display(),
span: span.clone(),
});
}
let then_ty = self.infer_expr(then_expr, span);
if let Some(else_expr) = else_expr {
let else_ty = self.infer_expr(else_expr, span);
if then_ty.is_compatible(&else_ty) {
then_ty
} else {
Type::Union(vec![then_ty, else_ty])
}
} else {
Type::Optional(Box::new(then_ty))
}
}
Expr::Lambda(params, body) => {
self.env.push_scope();
let param_types: Vec<Type> = params
.iter()
.map(|p| {
let ty = self.resolve_type(&p.ty);
self.env.define(p.name.clone(), ty.clone());
ty
})
.collect();
let return_ty = self.infer_expr(body, span);
self.env.pop_scope();
Type::Function(param_types, Box::new(return_ty))
}
Expr::Await(expr) => {
if !self.in_async_context {
self.errors.push(TypeError::AwaitOutsideAsync(span.clone()));
}
self.infer_expr(expr, span)
}
Expr::Path(left, right) => {
let left_ty = self.infer_expr(left, span);
self.infer_expr(right, span);
// If left is already a list (chained glob), result is a list
if matches!(left_ty, Type::List(_)) {
return Type::List(Box::new(Type::Path));
}
// Check if either operand has literal wildcards
if Self::expr_has_glob_wildcards(left) || Self::expr_has_glob_wildcards(right) {
Type::List(Box::new(Type::Path))
} else {
Type::Path
}
}
Expr::HomePath(_) => Type::Path,
Expr::Interpolated(parts) => {
for part in parts {
if let InterpolatedPart::Expr(expr) = part {
self.infer_expr(expr, span);
}
}
Type::Str
}
}
}
#[tracing::instrument(level = "trace", skip_all, fields(name))]
fn infer_builtin_call(
&mut self,
name: &str,
args: &[Expr],
span: &std::ops::Range<usize>,
) -> Type {
match name {
"map" | "filter" => {
if !args.is_empty() {
let list_ty = self.infer_expr(&args[0], span);
if let Type::List(inner) = list_ty {
if name == "filter" {
return Type::List(inner);
}
return Type::List(Box::new(Type::Any));
}
}
Type::List(Box::new(Type::Any))
}
"fold" => Type::Any,
"len" => Type::Int,
"first" | "last" => {
if !args.is_empty() {
let list_ty = self.infer_expr(&args[0], span);
if let Type::List(inner) = list_ty {
return Type::Optional(inner);
}
}
Type::Optional(Box::new(Type::Any))
}
"contains" => Type::Bool,
"join" | "upper" | "lower" | "trim" | "replace" | "format" => Type::Str,
"split" => Type::List(Box::new(Type::Str)),
"starts_with" | "ends_with" => Type::Bool,
"read_file" | "read_file_lines" => Type::Str,
"file_exists" | "dir_exists" | "is_symlink" => Type::Bool,
"list_dir" | "walk_dir" => Type::List(Box::new(Type::Path)),
"home_dir" | "config_dir" | "data_dir" | "cache_dir" | "temp_dir" | "temp_file" => {
Type::Path
}
"path_join" | "path_parent" | "path_filename" | "path_extension" | "read_link" => {
Type::Path
}
"fetch" | "fetch_json" | "fetch_bytes" | "post" | "post_json" => Type::Any,
"download" => Type::Bool,
"exec" | "shell" => Type::Str,
"exec_with_status" => Type::Int,
"which" => Type::Optional(Box::new(Type::Path)),
"to_json" | "to_toml" | "to_yaml" => Type::Str,
"from_json" | "from_toml" | "from_yaml" => Type::Any,
"hash_file" | "hash_str" => Type::Str,
"encrypt_age" | "decrypt_age" => Type::Str,
"env" => Type::Optional(Box::new(Type::Str)),
"unwrap" => {
if !args.is_empty() {
let opt_ty = self.infer_expr(&args[0], span);
if let Type::Optional(inner) = opt_ty {
return *inner;
}
}
Type::Any
}
"unwrap_or" => {
if args.len() >= 2 {
self.infer_expr(&args[1], span)
} else {
Type::Any
}
}
"is_some" | "is_none" => Type::Bool,
"all" | "race" => Type::Any,
"seq" | "batch" => {
if !args.is_empty() {
self.infer_expr(&args[0], span)
} else {
Type::Any
}
}
"flatten" | "concat" | "unique" | "sort" | "reverse" => {
if !args.is_empty() {
self.infer_expr(&args[0], span)
} else {
Type::List(Box::new(Type::Any))
}
}
"zip" | "enumerate" => Type::List(Box::new(Type::Any)),
"sort_by" => {
if !args.is_empty() {
self.infer_expr(&args[0], span)
} else {
Type::List(Box::new(Type::Any))
}
}
// Parallel builtins
"par_map" | "par_filter" | "par_flat_map" | "par_sort_by" | "par_batch" => {
if !args.is_empty() {
let list_ty = self.infer_expr(&args[0], span);
if let Type::List(inner) = list_ty {
if name == "par_filter" {
return Type::List(inner);
}
return Type::List(Box::new(Type::Any));
}
}
Type::List(Box::new(Type::Any))
}
"par_any" | "par_all" => Type::Bool,
"par_find" => {
if !args.is_empty() {
let list_ty = self.infer_expr(&args[0], span);
if let Type::List(inner) = list_ty {
return Type::Optional(inner);
}
}
Type::Optional(Box::new(Type::Any))
}
"par_partition" => {
if !args.is_empty() {
let list_ty = self.infer_expr(&args[0], span);
if let Type::List(_) = list_ty {
return Type::List(Box::new(list_ty));
}
}
Type::List(Box::new(Type::List(Box::new(Type::Any))))
}
"par_reduce" => Type::Any,
"par_min_by" | "par_max_by" => {
if !args.is_empty() {
let list_ty = self.infer_expr(&args[0], span);
if let Type::List(inner) = list_ty {
return Type::Optional(inner);
}
}
Type::Optional(Box::new(Type::Any))
}
"par_for_each" => Type::None,
// Debug/print functions return None
"print" | "println" => Type::None,
"dbg" => {
// dbg returns the last argument for chaining
if let Some(last) = args.last() {
self.infer_expr(last, span)
} else {
Type::None
}
}
// Not a builtin - return Unknown so normal lookup continues
_ => Type::Unknown,
}
}
#[tracing::instrument(level = "trace", skip_all, fields(method))]
fn infer_list_method(
&mut self,
method: &str,
_args: &[Expr],
_span: &std::ops::Range<usize>,
) -> Type {
match method {
"len" => Type::Int,
"first" | "last" => Type::Optional(Box::new(Type::Any)),
"contains" => Type::Bool,
"map" | "filter" | "sort" | "reverse" | "unique" => Type::List(Box::new(Type::Any)),
"par_map" | "par_filter" | "par_flat_map" | "par_sort_by" | "par_batch" => {
Type::List(Box::new(Type::Any))
}
"par_any" | "par_all" => Type::Bool,
"par_find" | "par_min_by" | "par_max_by" => Type::Optional(Box::new(Type::Any)),
"par_partition" => Type::List(Box::new(Type::List(Box::new(Type::Any)))),
"par_reduce" => Type::Any,
"par_for_each" => Type::None,
"fold" => Type::Any,
"join" => Type::Str,
_ => Type::Any,
}
}
#[tracing::instrument(level = "trace", skip_all, fields(method))]
fn infer_str_method(
&mut self,
method: &str,
_args: &[Expr],
_span: &std::ops::Range<usize>,
) -> Type {
match method {
"len" => Type::Int,
"upper" | "lower" | "trim" | "replace" => Type::Str,
"split" => Type::List(Box::new(Type::Str)),
"starts_with" | "ends_with" | "contains" => Type::Bool,
_ => Type::Any,
}
}
/// Checks if an expression is a string literal containing glob wildcards.
fn expr_has_glob_wildcards(expr: &Expr) -> bool {
match expr {
Expr::Literal(Literal::Str(s)) => s.contains('*') || s.contains('?') || s.contains('['),
_ => false,
}
}
#[tracing::instrument(level = "trace", skip_all)]
fn resolve_type(&self, ty: &TypeAnnotation) -> Type {
match ty {
TypeAnnotation::Simple(name) => match name.as_str() {
"int" => Type::Int,
"float" => Type::Float,
"str" => Type::Str,
"bool" => Type::Bool,
"path" => Type::Path,
"any" => Type::Any,
_ => {
if let Some(st) = self.env.structs.get(name) {
Type::Struct(st.clone())
} else if let Some(et) = self.env.enums.get(name) {
Type::Enum(et.clone())
} else {
Type::Unknown
}
}
},
TypeAnnotation::List(inner) => Type::List(Box::new(self.resolve_type(inner))),
TypeAnnotation::Optional(inner) => Type::Optional(Box::new(self.resolve_type(inner))),
TypeAnnotation::Function(params, ret) => Type::Function(
params.iter().map(|p| self.resolve_type(p)).collect(),
Box::new(self.resolve_type(ret)),
),
TypeAnnotation::Union(types) => {
Type::Union(types.iter().map(|t| self.resolve_type(t)).collect())
}
TypeAnnotation::Literal(lit) => match lit {
Literal::Str(_) => Type::Str,
Literal::Int(_) => Type::Int,
Literal::Float(_) => Type::Float,
Literal::Bool(_) => Type::Bool,
Literal::None => Type::None,
},
}
}
}
impl Default for TypeChecker {
fn default() -> Self {
Self::new()
}
}

View file

@ -1,203 +0,0 @@
//! Type system for the doot language.
use std::collections::HashMap;
/// Runtime and static types in doot.
#[derive(Clone, Debug, PartialEq)]
pub enum Type {
Int,
Float,
Str,
Bool,
Path,
None,
List(Box<Type>),
Optional(Box<Type>),
Function(Vec<Type>, Box<Type>),
Struct(StructType),
Enum(EnumType),
Union(Vec<Type>),
Any,
Unknown,
}
impl Type {
/// Returns true if this is an int or float type.
pub fn is_numeric(&self) -> bool {
matches!(self, Type::Int | Type::Float)
}
/// Checks if this type can be used where `other` is expected.
pub fn is_compatible(&self, other: &Type) -> bool {
match (self, other) {
(Type::Any, _) | (_, Type::Any) => true,
(Type::Unknown, _) | (_, Type::Unknown) => true,
(Type::Int, Type::Int) => true,
(Type::Float, Type::Float) => true,
(Type::Int, Type::Float) | (Type::Float, Type::Int) => true,
(Type::Str, Type::Str) => true,
(Type::Str, Type::Path) | (Type::Path, Type::Str) => true,
(Type::Path, Type::Path) => true,
(Type::Bool, Type::Bool) => true,
(Type::None, Type::None) => true,
(Type::None, Type::Optional(_)) | (Type::Optional(_), Type::None) => true,
(Type::List(a), Type::List(b)) => a.is_compatible(b),
(Type::Optional(a), Type::Optional(b)) => a.is_compatible(b),
(Type::Optional(a), b) => a.is_compatible(b),
(a, Type::Optional(b)) => a.is_compatible(b),
(Type::Function(a_params, a_ret), Type::Function(b_params, b_ret)) => {
a_params.len() == b_params.len()
&& a_params
.iter()
.zip(b_params.iter())
.all(|(a, b)| a.is_compatible(b))
&& a_ret.is_compatible(b_ret)
}
(Type::Struct(a), Type::Struct(b)) => a.name == b.name,
(Type::Enum(a), Type::Enum(b)) => a.name == b.name,
(Type::Union(types), other) | (other, Type::Union(types)) => {
types.iter().any(|t| t.is_compatible(other))
}
_ => false,
}
}
/// Returns a human-readable representation of this type.
pub fn display(&self) -> String {
match self {
Type::Int => "int".to_string(),
Type::Float => "float".to_string(),
Type::Str => "str".to_string(),
Type::Bool => "bool".to_string(),
Type::Path => "path".to_string(),
Type::None => "none".to_string(),
Type::List(inner) => format!("[{}]", inner.display()),
Type::Optional(inner) => format!("{}?", inner.display()),
Type::Function(params, ret) => {
let params_str = params
.iter()
.map(|p| p.display())
.collect::<Vec<_>>()
.join(", ");
format!("fn({}) -> {}", params_str, ret.display())
}
Type::Struct(s) => s.name.clone(),
Type::Enum(e) => e.name.clone(),
Type::Union(types) => types
.iter()
.map(|t| t.display())
.collect::<Vec<_>>()
.join(" | "),
Type::Any => "any".to_string(),
Type::Unknown => "unknown".to_string(),
}
}
}
/// Struct type with fields and methods.
#[derive(Clone, Debug, PartialEq)]
pub struct StructType {
pub name: String,
pub fields: HashMap<String, Type>,
pub methods: HashMap<String, FunctionType>,
}
/// Enum type with named variants.
#[derive(Clone, Debug, PartialEq)]
pub struct EnumType {
pub name: String,
pub variants: HashMap<String, Option<Vec<Type>>>,
}
/// Function signature type.
#[derive(Clone, Debug, PartialEq)]
pub struct FunctionType {
pub params: Vec<(String, Type)>,
pub return_type: Type,
pub is_async: bool,
}
/// Type environment with scoped bindings.
#[derive(Clone, Debug, Default)]
pub struct TypeEnv {
scopes: Vec<HashMap<String, Type>>,
pub structs: HashMap<String, StructType>,
pub enums: HashMap<String, EnumType>,
pub functions: HashMap<String, FunctionType>,
}
impl TypeEnv {
/// Creates a new type environment with built-in types.
pub fn new() -> Self {
let mut env = Self {
scopes: vec![HashMap::new()],
structs: HashMap::new(),
enums: HashMap::new(),
functions: HashMap::new(),
};
env.register_builtins();
env
}
fn register_builtins(&mut self) {
let mut os_variants = HashMap::new();
os_variants.insert("Linux".to_string(), None);
os_variants.insert("MacOS".to_string(), None);
os_variants.insert("Windows".to_string(), None);
self.enums.insert(
"Os".to_string(),
EnumType {
name: "Os".to_string(),
variants: os_variants,
},
);
self.define("os".to_string(), Type::Enum(self.enums["Os"].clone()));
self.define("distro".to_string(), Type::Str);
self.define("pkg_manager".to_string(), Type::Str);
self.define("hostname".to_string(), Type::Str);
self.define("arch".to_string(), Type::Str);
}
/// Enters a new scope.
pub fn push_scope(&mut self) {
self.scopes.push(HashMap::new());
}
/// Exits the current scope.
pub fn pop_scope(&mut self) {
self.scopes.pop();
}
/// Defines a variable in the current scope.
pub fn define(&mut self, name: String, ty: Type) {
if let Some(scope) = self.scopes.last_mut() {
scope.insert(name, ty);
}
}
/// Looks up a variable by name through all scopes.
pub fn lookup(&self, name: &str) -> Option<&Type> {
for scope in self.scopes.iter().rev() {
if let Some(ty) = scope.get(name) {
return Some(ty);
}
}
None
}
/// Registers a struct type.
pub fn define_struct(&mut self, name: String, st: StructType) {
self.structs.insert(name, st);
}
/// Registers an enum type.
pub fn define_enum(&mut self, name: String, et: EnumType) {
self.enums.insert(name, et);
}
/// Registers a function type.
pub fn define_function(&mut self, name: String, ft: FunctionType) {
self.functions.insert(name, ft);
}
}

View file

@ -0,0 +1,7 @@
[package]
name = "doot-std"
version.workspace = true
edition.workspace = true
[dependencies]
doot-lang.workspace = true

153
crates/doot-std/src/lib.rs Normal file
View file

@ -0,0 +1,153 @@
//! The doot standard library: general-purpose builtins registered into an
//! [`Engine`]. Lazy lists, folds, and the arithmetic operator classes with
//! their built-in Int/Str instances. Knows nothing about dotfiles.
use doot_lang::lang::ast::{ClassDecl, Type};
use doot_lang::lang::engine::{BuiltinScheme, Engine};
use doot_lang::lang::eval::{Value, as_bool, as_int, empty_list, value_eq};
/// Register the standard library into `engine`.
pub fn register(e: &mut Engine) {
let var = Type::Var;
let fun = |a: Type, b: Type| Type::Fun(Box::new(a), Box::new(b));
let list = |a: Type| Type::List(Box::new(a));
// map : (a -> b) -> [a] -> [b]
e.register_builtin(
"map",
BuiltinScheme::poly(2, fun(fun(var(0), var(1)), fun(list(var(0)), list(var(1))))),
2,
|i, a| {
let f = i.force(&a[0]);
let xs = i.force(&a[1]);
i.map_list(f, xs)
},
);
// body (a[1]) is only forced when the condition holds, so
// `optionals false (...)` creates no nodes
e.register_builtin(
"optionals",
BuiltinScheme::poly(1, fun(Type::Bool, fun(list(var(0)), list(var(0))))),
2,
|i, a| {
if as_bool(i.force(&a[0])) {
i.force(&a[1])
} else {
empty_list()
}
},
);
// both head and tail stay unforced -> supports infinite lists
e.register_builtin(
"cons",
BuiltinScheme::poly(1, fun(var(0), fun(list(var(0)), list(var(0))))),
2,
|_, a| Value::Cons(a[0].clone(), a[1].clone()),
);
e.register_builtin(
"head",
BuiltinScheme::poly(1, fun(list(var(0)), var(0))),
1,
|i, a| match i.force(&a[0]) {
Value::Cons(h, _) => i.force(&h),
_ => panic!("head of empty/non-list"),
},
);
e.register_builtin(
"tail",
BuiltinScheme::poly(1, fun(list(var(0)), list(var(0)))),
1,
|i, a| match i.force(&a[0]) {
Value::Cons(_, t) => i.force(&t),
_ => panic!("tail of empty/non-list"),
},
);
e.register_builtin(
"empty",
BuiltinScheme::poly(1, fun(list(var(0)), Type::Bool)),
1,
|i, a| Value::Bool(matches!(i.force(&a[0]), Value::Nil)),
);
e.register_builtin(
"take",
BuiltinScheme::poly(1, fun(Type::Int, fun(list(var(0)), list(var(0))))),
2,
|i, a| {
let n = as_int(i.force(&a[0]));
let xs = i.force(&a[1]);
i.take_list(n, xs)
},
);
e.register_builtin(
"elem",
BuiltinScheme::poly(1, fun(var(0), fun(list(var(0)), Type::Bool))),
2,
|i, a| {
let x = i.force(&a[0]);
let mut cur = i.force(&a[1]);
while let Value::Cons(h, t) = cur {
if value_eq(&x, &i.force(&h)) {
return Value::Bool(true);
}
cur = i.force(&t);
}
Value::Bool(false)
},
);
// seq a b: force a to WHNF, then return b
e.register_builtin(
"seq",
BuiltinScheme::poly(2, fun(var(0), fun(var(1), var(1)))),
2,
|i, a| {
i.force(&a[0]);
i.force(&a[1])
},
);
// strict left fold: forces the accumulator each step (constant space)
e.register_builtin(
"foldl",
BuiltinScheme::poly(
2,
fun(
fun(var(1), fun(var(0), var(1))),
fun(var(1), fun(list(var(0)), var(1))),
),
),
3,
|i, a| {
let ff = i.force(&a[0]);
let mut acc = i.force(&a[1]);
let mut cur = i.force(&a[2]);
while let Value::Cons(h, t) = cur {
let partial = i.apply(ff.clone(), doot_lang::lang::eval::forced(acc));
acc = i.apply(partial, h); // a WHNF value -> no thunk chain
cur = i.force(&t);
}
acc
},
);
e.register_value("nil", BuiltinScheme::poly(1, list(var(0))), Value::Nil);
// arithmetic operator classes (`class C a { m : a -> a -> a; }`) with built-in
// Int instances; `/` (Div) also has a Str instance for path join.
let a = || Type::Struct("a".to_string());
let binop = || fun(a(), fun(a(), a()));
for (name, method) in [
("Add", "add"),
("Sub", "sub"),
("Mul", "mul"),
("Div", "div"),
("Mod", "mod"),
("Pow", "pow"),
] {
e.register_class(ClassDecl {
name: name.to_string(),
param: "a".to_string(),
methods: vec![(method.to_string(), binop())],
span: doot_lang::lang::diag::Span::point(0),
});
e.register_instance(name, "Int");
}
e.register_instance("Div", "Str");
}