feat(lang): implement v2 language
This commit is contained in:
parent
cc4684072d
commit
59eae012de
75 changed files with 6569 additions and 11152 deletions
1460
Cargo.lock
generated
1460
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -11,6 +11,8 @@ repository = "https://github.com/rayandrew/doot"
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
doot-utils = { path = "crates/doot-utils" }
|
doot-utils = { path = "crates/doot-utils" }
|
||||||
doot-lang = { path = "crates/doot-lang" }
|
doot-lang = { path = "crates/doot-lang" }
|
||||||
|
doot-std = { path = "crates/doot-std" }
|
||||||
|
doot-dotfile = { path = "crates/doot-dotfile" }
|
||||||
doot-core = { path = "crates/doot-core" }
|
doot-core = { path = "crates/doot-core" }
|
||||||
|
|
||||||
chumsky = "0.9"
|
chumsky = "0.9"
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
doot-utils.workspace = true
|
doot-utils.workspace = true
|
||||||
doot-lang.workspace = true
|
doot-dotfile.workspace = true
|
||||||
doot-core.workspace = true
|
doot-core.workspace = true
|
||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,22 +1,25 @@
|
||||||
use super::{find_config_file, parse_config, type_check};
|
use super::{find_config_file, load};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
/// Validates a config file (parse + type check).
|
/// Validates a config file (parse + type check + evaluate).
|
||||||
#[tracing::instrument(skip_all)]
|
#[tracing::instrument(skip_all)]
|
||||||
pub fn run(config_path: Option<PathBuf>) -> anyhow::Result<()> {
|
pub fn run(config_path: Option<PathBuf>) -> anyhow::Result<()> {
|
||||||
let path = find_config_file(config_path)?;
|
let path = find_config_file(config_path)?;
|
||||||
let source = std::fs::read_to_string(&path)?;
|
|
||||||
|
|
||||||
tracing::debug!(path = %path.display(), "checking config");
|
tracing::debug!(path = %path.display(), "checking config");
|
||||||
|
|
||||||
let program = parse_config(&path)?;
|
let (result, _vars) = load(&path)?;
|
||||||
println!("syntax: ok");
|
println!("config is valid");
|
||||||
|
println!(
|
||||||
type_check(&program, &source, &path.display().to_string())?;
|
" dotfiles: {}",
|
||||||
println!("types: ok");
|
result.dotfiles.len() + result.dotfile_patterns.len()
|
||||||
|
);
|
||||||
println!("\nconfig is valid");
|
println!(" packages: {}", result.packages.len());
|
||||||
println!(" statements: {}", program.statements.len());
|
println!(" hooks: {}", result.hooks.len());
|
||||||
|
println!(
|
||||||
|
" secrets: {}",
|
||||||
|
result.secrets.len() + result.encrypted_files.len()
|
||||||
|
);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use doot_core::{Config, encryption::AgeEncryption};
|
use doot_core::{Settings, encryption::AgeEncryption};
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
|
@ -17,7 +17,7 @@ pub fn run(
|
||||||
identity: Option<PathBuf>,
|
identity: Option<PathBuf>,
|
||||||
output: Option<PathBuf>,
|
output: Option<PathBuf>,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let config = Config::default();
|
let config = Settings::default();
|
||||||
let identity_raw = if let Some(path) = identity {
|
let identity_raw = if let Some(path) = identity {
|
||||||
std::fs::read_to_string(&path)?
|
std::fs::read_to_string(&path)?
|
||||||
} else if let Ok(key) = std::env::var("DOOT_AGE_IDENTITY") {
|
} else if let Ok(key) = std::env::var("DOOT_AGE_IDENTITY") {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
use super::{find_config_file, parse_config};
|
use super::{find_config_file, load};
|
||||||
use doot_core::Config;
|
use doot_core::Settings;
|
||||||
use doot_lang::Evaluator;
|
use doot_core::builtins::crypto::base64_decode;
|
||||||
use doot_lang::builtins::crypto::base64_decode;
|
|
||||||
use std::io::Read;
|
use std::io::Read;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
|
@ -34,11 +33,8 @@ pub fn run(
|
||||||
identity: Option<PathBuf>,
|
identity: Option<PathBuf>,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let path = find_config_file(config_path)?;
|
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 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)?;
|
|
||||||
|
|
||||||
if result.encrypted_vars.is_empty() && result.encrypted_files.is_empty() {
|
if result.encrypted_vars.is_empty() && result.encrypted_files.is_empty() {
|
||||||
println!("no encrypted entries found");
|
println!("no encrypted entries found");
|
||||||
|
|
@ -46,7 +42,7 @@ pub fn run(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve identity
|
// 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 {
|
let identity_raw: String = if let Some(ref path) = identity {
|
||||||
std::fs::read_to_string(path)?
|
std::fs::read_to_string(path)?
|
||||||
} else if let Ok(key) = std::env::var("DOOT_AGE_IDENTITY") {
|
} else if let Ok(key) = std::env::var("DOOT_AGE_IDENTITY") {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
use doot_core::Config;
|
use doot_core::Settings;
|
||||||
use doot_lang::builtins::crypto::base64_decode;
|
use doot_core::builtins::crypto::base64_decode;
|
||||||
use std::io::{self, Read};
|
use std::io::{self, Read};
|
||||||
use std::path::PathBuf;
|
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") {
|
} else if let Ok(key) = std::env::var("DOOT_AGE_IDENTITY") {
|
||||||
key
|
key
|
||||||
} else {
|
} 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() {
|
if id_file.exists() {
|
||||||
std::fs::read_to_string(&id_file)?
|
std::fs::read_to_string(&id_file)?
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
265
crates/doot-cli/src/commands/deploy_util.rs
Normal file
265
crates/doot-cli/src/commands/deploy_util.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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::deploy::DiffDisplay;
|
||||||
|
use doot_core::evaluator::PermissionRule;
|
||||||
use doot_core::state::{expected_mode_for_file, get_file_mode};
|
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};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
/// Shows diffs between source and deployed dotfiles.
|
/// Shows diffs between source and deployed dotfiles.
|
||||||
#[tracing::instrument(skip_all, fields(all))]
|
#[tracing::instrument(skip_all, fields(all))]
|
||||||
pub fn run(config_path: Option<PathBuf>, all: bool) -> anyhow::Result<()> {
|
pub fn run(config_path: Option<PathBuf>, all: bool) -> anyhow::Result<()> {
|
||||||
let path = find_config_file(config_path)?;
|
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 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 mut has_changes = false;
|
let mut has_changes = false;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
use super::{find_config_file, parse_config, type_check};
|
use super::{find_config_file, load};
|
||||||
use doot_core::{
|
use doot_core::{
|
||||||
Config,
|
Settings,
|
||||||
deploy::{Linker, TemplateEngine},
|
deploy::{Linker, TemplateEngine},
|
||||||
state::{DeployMode, StateStore},
|
state::{DeployMode, StateStore},
|
||||||
};
|
};
|
||||||
use doot_lang::Evaluator;
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::io::{self, Write};
|
use std::io::{self, Write};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
@ -19,16 +18,9 @@ pub fn run(
|
||||||
skip_prompt: bool,
|
skip_prompt: bool,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let path = find_config_file(config_path)?;
|
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 source_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf();
|
||||||
|
let (result, mut template_vars) = load(&path)?;
|
||||||
let mut evaluator = Evaluator::new().with_source_dir(source_dir.clone());
|
let config = Settings::new(source_dir.clone());
|
||||||
let result = evaluator.eval_sync(&program)?;
|
|
||||||
let mut template_vars = evaluator.get_template_variables();
|
|
||||||
let config = Config::new(source_dir.clone());
|
|
||||||
|
|
||||||
super::decrypt_encrypted_vars_with_source_dir(
|
super::decrypt_encrypted_vars_with_source_dir(
|
||||||
&result,
|
&result,
|
||||||
|
|
@ -107,13 +99,13 @@ fn hash_file(path: &PathBuf) -> String {
|
||||||
fn apply_single(
|
fn apply_single(
|
||||||
source: &PathBuf,
|
source: &PathBuf,
|
||||||
target: &PathBuf,
|
target: &PathBuf,
|
||||||
dotfile: &doot_lang::evaluator::DotfileConfig,
|
dotfile: &doot_core::evaluator::DotfileConfig,
|
||||||
config: &Config,
|
config: &Settings,
|
||||||
template_vars: &HashMap<String, doot_lang::evaluator::Value>,
|
template_vars: &HashMap<String, doot_core::evaluator::Value>,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let deploy_mode = match dotfile.deploy {
|
let deploy_mode = match dotfile.deploy {
|
||||||
doot_lang::evaluator::DeployMode::Copy => DeployMode::Copy,
|
doot_core::evaluator::DeployMode::Copy => DeployMode::Copy,
|
||||||
doot_lang::evaluator::DeployMode::Link => DeployMode::Link,
|
doot_core::evaluator::DeployMode::Link => DeployMode::Link,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut state = StateStore::new(&config.state_file);
|
let mut state = StateStore::new(&config.state_file);
|
||||||
|
|
@ -204,10 +196,10 @@ fn expand_tilde(path: &str) -> PathBuf {
|
||||||
#[tracing::instrument(skip_all)]
|
#[tracing::instrument(skip_all)]
|
||||||
fn find_source_and_dotfile<'a>(
|
fn find_source_and_dotfile<'a>(
|
||||||
target: &PathBuf,
|
target: &PathBuf,
|
||||||
dotfiles: &'a [doot_lang::evaluator::DotfileConfig],
|
dotfiles: &'a [doot_core::evaluator::DotfileConfig],
|
||||||
source_dir: &Path,
|
source_dir: &Path,
|
||||||
state: &StateStore,
|
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
|
// Exact match with dotfile targets
|
||||||
for df in dotfiles {
|
for df in dotfiles {
|
||||||
if &df.target == target {
|
if &df.target == target {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use doot_core::{Config, encryption::AgeEncryption};
|
use doot_core::{Settings, encryption::AgeEncryption};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
/// Encrypts a file using age encryption with multi-recipient support.
|
/// 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())
|
.filter(|l| !l.is_empty())
|
||||||
.collect()
|
.collect()
|
||||||
} else {
|
} 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() {
|
if key_file.exists() {
|
||||||
std::fs::read_to_string(&key_file)?
|
std::fs::read_to_string(&key_file)?
|
||||||
.lines()
|
.lines()
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
use doot_core::Config;
|
use doot_core::Settings;
|
||||||
use doot_lang::builtins::crypto::base64_encode;
|
use doot_core::builtins::crypto::base64_encode;
|
||||||
use std::io::{self, Read, Write};
|
use std::io::{self, Read, Write};
|
||||||
|
|
||||||
/// Resolves recipient keys from CLI flags, env var, or recipient.txt (supports multiple keys).
|
/// 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())
|
.filter(|l| !l.is_empty())
|
||||||
.collect()
|
.collect()
|
||||||
} else {
|
} 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() {
|
if key_file.exists() {
|
||||||
std::fs::read_to_string(&key_file)?
|
std::fs::read_to_string(&key_file)?
|
||||||
.lines()
|
.lines()
|
||||||
|
|
|
||||||
|
|
@ -2,22 +2,31 @@ use super::find_config_file;
|
||||||
use doot_core::deploy::diff::DiffDisplay;
|
use doot_core::deploy::diff::DiffDisplay;
|
||||||
use std::path::PathBuf;
|
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))]
|
#[tracing::instrument(skip_all, fields(check))]
|
||||||
pub fn run(config_path: Option<PathBuf>, check: bool) -> anyhow::Result<()> {
|
pub fn run(config_path: Option<PathBuf>, check: bool) -> anyhow::Result<()> {
|
||||||
let path = find_config_file(config_path)?;
|
let path = find_config_file(config_path)?;
|
||||||
let source = std::fs::read_to_string(&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 check {
|
||||||
if formatted != source {
|
if formatted != source {
|
||||||
let diff = DiffDisplay::diff_strings(&source, &formatted);
|
let diff = DiffDisplay::diff_strings(&source, &formatted);
|
||||||
eprintln!("{}\n{}", path.display(), diff);
|
eprintln!("{}\n{}", path.display(), diff);
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
} else {
|
|
||||||
println!("{} is formatted correctly", path.display());
|
|
||||||
}
|
}
|
||||||
|
println!("{} is formatted correctly", path.display());
|
||||||
} else if formatted != source {
|
} else if formatted != source {
|
||||||
std::fs::write(&path, &formatted)?;
|
std::fs::write(&path, &formatted)?;
|
||||||
println!("formatted {}", path.display());
|
println!("formatted {}", path.display());
|
||||||
|
|
@ -27,208 +36,3 @@ pub fn run(config_path: Option<PathBuf>, check: bool) -> anyhow::Result<()> {
|
||||||
|
|
||||||
Ok(())
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
use doot_core::Config;
|
use doot_core::Settings;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
/// Initializes a new doot project directory structure.
|
/// Initializes a new doot project directory structure.
|
||||||
#[tracing::instrument(skip_all)]
|
#[tracing::instrument(skip_all)]
|
||||||
pub fn run(path: Option<PathBuf>) -> anyhow::Result<()> {
|
pub fn run(path: Option<PathBuf>) -> anyhow::Result<()> {
|
||||||
let source_dir = path.unwrap_or_else(Config::default_source_dir);
|
let source_dir = path.unwrap_or_else(Settings::default_source_dir);
|
||||||
let config = Config::new(source_dir.clone());
|
let config = Settings::new(source_dir.clone());
|
||||||
let is_default = source_dir == Config::default_config_dir();
|
let is_default = source_dir == Settings::default_config_dir();
|
||||||
|
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
config_dir = %config.config_dir.display(),
|
config_dir = %config.config_dir.display(),
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
use doot_core::{Config, encryption::AgeEncryption};
|
use doot_core::{Settings, encryption::AgeEncryption};
|
||||||
use std::io::{self, Write};
|
use std::io::{self, Write};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
/// Generates an age keypair, writing identity to identity.txt and appending public key to recipient.txt.
|
/// Generates an age keypair, writing identity to identity.txt and appending public key to recipient.txt.
|
||||||
#[tracing::instrument(skip_all)]
|
#[tracing::instrument(skip_all)]
|
||||||
pub fn run(output: Option<PathBuf>, force: bool) -> anyhow::Result<()> {
|
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 identity_file = output.unwrap_or_else(|| config_dir.join("identity.txt"));
|
||||||
let recipient_file = identity_file
|
let recipient_file = identity_file
|
||||||
.parent()
|
.parent()
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ pub mod check;
|
||||||
pub mod decrypt;
|
pub mod decrypt;
|
||||||
pub mod decrypt_entries;
|
pub mod decrypt_entries;
|
||||||
pub mod decrypt_var;
|
pub mod decrypt_var;
|
||||||
|
pub mod deploy_util;
|
||||||
pub mod diff;
|
pub mod diff;
|
||||||
pub mod edit;
|
pub mod edit;
|
||||||
pub mod encrypt;
|
pub mod encrypt;
|
||||||
|
|
@ -14,19 +15,59 @@ pub mod init;
|
||||||
pub mod keygen;
|
pub mod keygen;
|
||||||
pub mod lsp;
|
pub mod lsp;
|
||||||
pub mod package;
|
pub mod package;
|
||||||
|
pub mod plan;
|
||||||
pub mod reencrypt;
|
pub mod reencrypt;
|
||||||
pub mod rollback;
|
pub mod rollback;
|
||||||
pub mod snapshot;
|
pub mod snapshot;
|
||||||
pub mod status;
|
pub mod status;
|
||||||
pub mod tui;
|
pub mod tui;
|
||||||
|
|
||||||
use doot_core::Config;
|
use doot_core::Settings;
|
||||||
use doot_lang::evaluator::{EvalResult, Value};
|
use doot_core::evaluator::{EvalResult, Value};
|
||||||
use doot_lang::{Lexer, Parser, TypeChecker};
|
|
||||||
use indexmap::IndexMap;
|
use indexmap::IndexMap;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::PathBuf;
|
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.
|
/// 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.
|
/// Always returns an absolute path so source_dir is correct regardless of CWD.
|
||||||
#[tracing::instrument(skip_all)]
|
#[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());
|
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 {
|
for candidate in candidates {
|
||||||
if candidate.exists() {
|
if candidate.exists() {
|
||||||
|
|
@ -48,70 +89,14 @@ pub fn find_config_file(base: Option<PathBuf>) -> anyhow::Result<PathBuf> {
|
||||||
|
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"no config file found. searched:\n - ./doot.doot\n - {}",
|
"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`.
|
/// Decrypts encrypted vars and files, resolving file paths relative to `source_dir`.
|
||||||
pub fn decrypt_encrypted_vars_with_source_dir(
|
pub fn decrypt_encrypted_vars_with_source_dir(
|
||||||
result: &EvalResult,
|
result: &EvalResult,
|
||||||
config: &Config,
|
config: &Settings,
|
||||||
template_vars: &mut HashMap<String, Value>,
|
template_vars: &mut HashMap<String, Value>,
|
||||||
source_dir: Option<&std::path::Path>,
|
source_dir: Option<&std::path::Path>,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
|
|
@ -145,7 +130,7 @@ pub fn decrypt_encrypted_vars_with_source_dir(
|
||||||
|
|
||||||
// Decrypt inline vars
|
// Decrypt inline vars
|
||||||
for (name, ciphertext_b64) in &result.encrypted_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))?;
|
.map_err(|e| anyhow::anyhow!("invalid base64 for encrypted var '{}': {}", name, e))?;
|
||||||
|
|
||||||
let decryptor = match age::Decryptor::new(&encrypted_bytes[..])
|
let decryptor = match age::Decryptor::new(&encrypted_bytes[..])
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,11 @@
|
||||||
use super::{find_config_file, parse_config, type_check};
|
use super::{find_config_file, load};
|
||||||
use doot_lang::Evaluator;
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
/// Installs packages defined in the config.
|
/// Installs packages defined in the config.
|
||||||
#[tracing::instrument(skip_all)]
|
#[tracing::instrument(skip_all)]
|
||||||
pub fn install(config_path: Option<PathBuf>) -> anyhow::Result<()> {
|
pub fn install(config_path: Option<PathBuf>) -> anyhow::Result<()> {
|
||||||
let path = find_config_file(config_path)?;
|
let path = find_config_file(config_path)?;
|
||||||
let source = std::fs::read_to_string(&path)?;
|
let (result, _vars) = load(&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)?;
|
|
||||||
|
|
||||||
if result.packages.is_empty() {
|
if result.packages.is_empty() {
|
||||||
println!("no packages configured");
|
println!("no packages configured");
|
||||||
|
|
@ -72,14 +64,7 @@ pub fn update() -> anyhow::Result<()> {
|
||||||
#[tracing::instrument(skip_all)]
|
#[tracing::instrument(skip_all)]
|
||||||
pub fn list(config_path: Option<PathBuf>) -> anyhow::Result<()> {
|
pub fn list(config_path: Option<PathBuf>) -> anyhow::Result<()> {
|
||||||
let path = find_config_file(config_path)?;
|
let path = find_config_file(config_path)?;
|
||||||
let source = std::fs::read_to_string(&path)?;
|
let (result, _vars) = load(&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)?;
|
|
||||||
|
|
||||||
if result.packages.is_empty() {
|
if result.packages.is_empty() {
|
||||||
println!("no packages configured");
|
println!("no packages configured");
|
||||||
|
|
|
||||||
68
crates/doot-cli/src/commands/plan.rs
Normal file
68
crates/doot-cli/src/commands/plan.rs
Normal 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}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use doot_core::{Config, encryption::AgeEncryption};
|
use doot_core::{Settings, encryption::AgeEncryption};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
/// Re-encrypts all .age files in the secrets directory with current recipients.
|
/// 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<()> {
|
pub fn run(config_path: Option<PathBuf>, recipients: Vec<String>) -> anyhow::Result<()> {
|
||||||
let path = super::find_config_file(config_path)?;
|
let path = super::find_config_file(config_path)?;
|
||||||
let source_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf();
|
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
|
// Resolve identity for decryption
|
||||||
let identity_raw = if let Ok(key) = std::env::var("DOOT_AGE_IDENTITY") {
|
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())
|
.filter(|l| !l.is_empty())
|
||||||
.collect()
|
.collect()
|
||||||
} else {
|
} 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() {
|
if key_file.exists() {
|
||||||
std::fs::read_to_string(&key_file)?
|
std::fs::read_to_string(&key_file)?
|
||||||
.lines()
|
.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
|
// Also re-encrypt encrypted vars from the doot config
|
||||||
let program = super::parse_config(&path)?;
|
let (result, _vars) = super::load(&path)?;
|
||||||
let mut evaluator = doot_lang::Evaluator::new().with_source_dir(source_dir.clone());
|
|
||||||
let result = evaluator.eval_sync(&program)?;
|
|
||||||
|
|
||||||
let mut count = 0;
|
let mut count = 0;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
use doot_core::{
|
use doot_core::{
|
||||||
Config,
|
Settings,
|
||||||
state::{DeployMode, Snapshot},
|
state::{DeployMode, Snapshot},
|
||||||
};
|
};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
@ -7,7 +7,7 @@ use std::path::PathBuf;
|
||||||
/// Rolls back to a previous snapshot.
|
/// Rolls back to a previous snapshot.
|
||||||
#[tracing::instrument(skip_all)]
|
#[tracing::instrument(skip_all)]
|
||||||
pub fn run(_config_path: Option<PathBuf>, snapshot_name: Option<String>) -> anyhow::Result<()> {
|
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 {
|
let name = if let Some(n) = snapshot_name {
|
||||||
if n == "last" || n == "latest" {
|
if n == "last" || n == "latest" {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
use doot_core::{
|
use doot_core::{
|
||||||
Config,
|
Settings,
|
||||||
state::{Snapshot, StateStore},
|
state::{Snapshot, StateStore},
|
||||||
};
|
};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
@ -7,7 +7,7 @@ use std::path::PathBuf;
|
||||||
/// Creates a named snapshot of the current deployment state.
|
/// Creates a named snapshot of the current deployment state.
|
||||||
#[tracing::instrument(skip_all, fields(name = %name))]
|
#[tracing::instrument(skip_all, fields(name = %name))]
|
||||||
pub fn run(_config_path: Option<PathBuf>, name: String) -> anyhow::Result<()> {
|
pub fn run(_config_path: Option<PathBuf>, name: String) -> anyhow::Result<()> {
|
||||||
let config = Config::default();
|
let config = Settings::default();
|
||||||
config.ensure_dirs()?;
|
config.ensure_dirs()?;
|
||||||
|
|
||||||
let mut state = StateStore::new(&config.state_file);
|
let mut state = StateStore::new(&config.state_file);
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,23 @@
|
||||||
use super::{
|
use super::{
|
||||||
apply::template_outdated, decrypt_encrypted_vars_with_source_dir, find_config_file,
|
decrypt_encrypted_vars_with_source_dir, deploy_util::template_outdated, find_config_file, load,
|
||||||
parse_config, type_check,
|
|
||||||
};
|
};
|
||||||
use doot_core::Config;
|
use doot_core::Settings;
|
||||||
use doot_core::deploy::TemplateEngine;
|
use doot_core::deploy::TemplateEngine;
|
||||||
use doot_core::state::{StateStore, SyncStatus};
|
use doot_core::state::{StateStore, SyncStatus};
|
||||||
use doot_lang::Evaluator;
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
/// Shows the deployment status of managed dotfiles.
|
/// Shows the deployment status of managed dotfiles.
|
||||||
#[tracing::instrument(skip_all)]
|
#[tracing::instrument(skip_all)]
|
||||||
pub fn run(config_path: Option<PathBuf>) -> anyhow::Result<()> {
|
pub fn run(config_path: Option<PathBuf>) -> anyhow::Result<()> {
|
||||||
let path = find_config_file(config_path)?;
|
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 source_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf();
|
||||||
|
let (result, mut template_vars) = load(&path)?;
|
||||||
let mut evaluator = Evaluator::new().with_source_dir(source_dir.clone());
|
|
||||||
let result = evaluator.eval_sync(&program)?;
|
|
||||||
|
|
||||||
// Prepare template variables early for preview rendering
|
// 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_file = config.state_file.clone();
|
||||||
let state = StateStore::new(&state_file);
|
let state = StateStore::new(&state_file);
|
||||||
|
|
||||||
let mut template_vars = evaluator.get_template_variables();
|
|
||||||
decrypt_encrypted_vars_with_source_dir(
|
decrypt_encrypted_vars_with_source_dir(
|
||||||
&result,
|
&result,
|
||||||
&config,
|
&config,
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
use super::{find_config_file, parse_config, type_check};
|
use super::{find_config_file, load};
|
||||||
use crossterm::{
|
use crossterm::{
|
||||||
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind},
|
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind},
|
||||||
execute,
|
execute,
|
||||||
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
|
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::deploy::Linker;
|
||||||
use doot_core::state::{DeployMode, StateStore};
|
use doot_core::state::{DeployMode, StateStore};
|
||||||
use doot_lang::Evaluator;
|
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame, Terminal,
|
Frame, Terminal,
|
||||||
backend::CrosstermBackend,
|
backend::CrosstermBackend,
|
||||||
|
|
@ -113,16 +112,10 @@ impl App {
|
||||||
#[tracing::instrument(skip_all)]
|
#[tracing::instrument(skip_all)]
|
||||||
fn new(config_path: Option<PathBuf>) -> anyhow::Result<Self> {
|
fn new(config_path: Option<PathBuf>) -> anyhow::Result<Self> {
|
||||||
let path = find_config_file(config_path)?;
|
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 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 config = Settings::new(source_dir.clone());
|
||||||
let result = evaluator.eval_sync(&program)?;
|
|
||||||
|
|
||||||
let config = Config::new(source_dir.clone());
|
|
||||||
let state = StateStore::new(&config.state_file);
|
let state = StateStore::new(&config.state_file);
|
||||||
|
|
||||||
let dotfiles: Vec<DotfileItem> = result
|
let dotfiles: Vec<DotfileItem> = result
|
||||||
|
|
@ -131,8 +124,8 @@ impl App {
|
||||||
.map(|d| {
|
.map(|d| {
|
||||||
let full_source = source_dir.join(&d.source);
|
let full_source = source_dir.join(&d.source);
|
||||||
let deploy_mode = match d.deploy {
|
let deploy_mode = match d.deploy {
|
||||||
doot_lang::evaluator::DeployMode::Copy => DeployMode::Copy,
|
doot_core::evaluator::DeployMode::Copy => DeployMode::Copy,
|
||||||
doot_lang::evaluator::DeployMode::Link => DeployMode::Link,
|
doot_core::evaluator::DeployMode::Link => DeployMode::Link,
|
||||||
};
|
};
|
||||||
|
|
||||||
let status = if !full_source.exists() {
|
let status = if !full_source.exists() {
|
||||||
|
|
@ -440,7 +433,7 @@ impl App {
|
||||||
self.apply_progress = 0;
|
self.apply_progress = 0;
|
||||||
|
|
||||||
// Apply dotfiles
|
// 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 linker = Linker::new(config.clone());
|
||||||
let mut state = StateStore::new(&config.state_file);
|
let mut state = StateStore::new(&config.state_file);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,9 @@ enum Commands {
|
||||||
/// Validate config (parse + type check): `doot check`
|
/// Validate config (parse + type check): `doot check`
|
||||||
Check,
|
Check,
|
||||||
|
|
||||||
|
/// Show the inferred dependency DAG and execution order: `doot plan`
|
||||||
|
Plan,
|
||||||
|
|
||||||
/// Format config file: `doot fmt [--check]`
|
/// Format config file: `doot fmt [--check]`
|
||||||
Fmt {
|
Fmt {
|
||||||
/// Check formatting without modifying (exits 1 if unformatted)
|
/// 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::Diff { all } => commands::diff::run(cli.config, all),
|
||||||
Commands::Status => commands::status::run(cli.config),
|
Commands::Status => commands::status::run(cli.config),
|
||||||
Commands::Check => commands::check::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::Fmt { check } => commands::fmt::run(cli.config, check),
|
||||||
Commands::Rollback { snapshot } => commands::rollback::run(cli.config, snapshot),
|
Commands::Rollback { snapshot } => commands::rollback::run(cli.config, snapshot),
|
||||||
Commands::Snapshot { name } => commands::snapshot::run(cli.config, name),
|
Commands::Snapshot { name } => commands::snapshot::run(cli.config, name),
|
||||||
|
|
|
||||||
|
|
@ -101,12 +101,7 @@ fn test_init_creates_structure() {
|
||||||
#[test]
|
#[test]
|
||||||
fn test_check_valid_config() {
|
fn test_check_valid_config() {
|
||||||
let sandbox = Sandbox::new("check-valid");
|
let sandbox = Sandbox::new("check-valid");
|
||||||
sandbox.write_config(
|
sandbox.write_config(r#"Config { packages = [ (package "ripgrep") (package "fd") ]; }"#);
|
||||||
r#"
|
|
||||||
package: "ripgrep"
|
|
||||||
package: "fd"
|
|
||||||
"#,
|
|
||||||
);
|
|
||||||
|
|
||||||
let output = sandbox.run(&["check"]);
|
let output = sandbox.run(&["check"]);
|
||||||
assert!(output.status.success(), "check failed: {:?}", output);
|
assert!(output.status.success(), "check failed: {:?}", output);
|
||||||
|
|
@ -116,11 +111,7 @@ package: "fd"
|
||||||
fn test_apply_dry_run() {
|
fn test_apply_dry_run() {
|
||||||
let sandbox = Sandbox::new("apply-dry");
|
let sandbox = Sandbox::new("apply-dry");
|
||||||
sandbox.write_config(
|
sandbox.write_config(
|
||||||
r#"
|
r#"Config { dotfiles = [ (dotfile { source = "config/test.conf"; target = "~/.config/test/test.conf"; }) ]; }"#,
|
||||||
dotfile:
|
|
||||||
source = "config/test.conf"
|
|
||||||
target = "~/.config/test/test.conf"
|
|
||||||
"#,
|
|
||||||
);
|
);
|
||||||
sandbox.write_source("config/test.conf", "test content");
|
sandbox.write_source("config/test.conf", "test content");
|
||||||
|
|
||||||
|
|
@ -135,12 +126,7 @@ dotfile:
|
||||||
fn test_apply_creates_symlink() {
|
fn test_apply_creates_symlink() {
|
||||||
let sandbox = Sandbox::new("apply-symlink");
|
let sandbox = Sandbox::new("apply-symlink");
|
||||||
sandbox.write_config(
|
sandbox.write_config(
|
||||||
r#"
|
r#"Config { dotfiles = [ (dotfile { source = "config/app.conf"; target = "~/.config/app/app.conf"; deploy = "link"; }) ]; }"#,
|
||||||
dotfile:
|
|
||||||
source = "config/app.conf"
|
|
||||||
target = "~/.config/app/app.conf"
|
|
||||||
deploy = "link"
|
|
||||||
"#,
|
|
||||||
);
|
);
|
||||||
sandbox.write_source("config/app.conf", "app config content");
|
sandbox.write_source("config/app.conf", "app config content");
|
||||||
|
|
||||||
|
|
@ -162,7 +148,7 @@ dotfile:
|
||||||
fn test_apply_unchanged_on_rerun() {
|
fn test_apply_unchanged_on_rerun() {
|
||||||
let sandbox = Sandbox::new("apply-unchanged");
|
let sandbox = Sandbox::new("apply-unchanged");
|
||||||
sandbox.write_config(
|
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");
|
sandbox.write_source("config/app.conf", "content");
|
||||||
|
|
||||||
|
|
@ -184,11 +170,7 @@ fn test_apply_unchanged_on_rerun() {
|
||||||
fn test_apply_creates_copy() {
|
fn test_apply_creates_copy() {
|
||||||
let sandbox = Sandbox::new("apply-copy");
|
let sandbox = Sandbox::new("apply-copy");
|
||||||
sandbox.write_config(
|
sandbox.write_config(
|
||||||
r#"
|
r#"Config { dotfiles = [ (dotfile { source = "config/app.conf"; target = "~/.config/app/app.conf"; }) ]; }"#,
|
||||||
dotfile:
|
|
||||||
source = "config/app.conf"
|
|
||||||
target = "~/.config/app/app.conf"
|
|
||||||
"#,
|
|
||||||
);
|
);
|
||||||
sandbox.write_source("config/app.conf", "app config content");
|
sandbox.write_source("config/app.conf", "app config content");
|
||||||
|
|
||||||
|
|
@ -210,7 +192,7 @@ dotfile:
|
||||||
fn test_apply_copy_unchanged_on_rerun() {
|
fn test_apply_copy_unchanged_on_rerun() {
|
||||||
let sandbox = Sandbox::new("apply-copy-unchanged");
|
let sandbox = Sandbox::new("apply-copy-unchanged");
|
||||||
sandbox.write_config(
|
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");
|
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() {
|
fn test_template_redeploys_when_env_changes() {
|
||||||
let sandbox = Sandbox::new("template-env-change");
|
let sandbox = Sandbox::new("template-env-change");
|
||||||
sandbox.write_config(
|
sandbox.write_config(
|
||||||
r#"
|
r#"Config { dotfiles = [ (dotfile { source = "templates/app.conf"; target = "~/.config/app/app.conf"; template = true; }) ]; }"#,
|
||||||
dotfile:
|
|
||||||
source = "templates/app.conf"
|
|
||||||
target = "~/.config/app/app.conf"
|
|
||||||
template = true
|
|
||||||
"#,
|
|
||||||
);
|
);
|
||||||
sandbox.write_source("templates/app.conf", "value = {{ env.TEMPLATE_VAL }}\n");
|
sandbox.write_source("templates/app.conf", "value = {{ env.TEMPLATE_VAL }}\n");
|
||||||
|
|
||||||
|
|
@ -274,11 +251,7 @@ dotfile:
|
||||||
fn test_status_shows_state() {
|
fn test_status_shows_state() {
|
||||||
let sandbox = Sandbox::new("status");
|
let sandbox = Sandbox::new("status");
|
||||||
sandbox.write_config(
|
sandbox.write_config(
|
||||||
r#"
|
r#"Config { dotfiles = [ (dotfile { source = "config/app.conf"; target = "~/.config/app/app.conf"; }) ]; }"#,
|
||||||
dotfile:
|
|
||||||
source = "config/app.conf"
|
|
||||||
target = "~/.config/app/app.conf"
|
|
||||||
"#,
|
|
||||||
);
|
);
|
||||||
sandbox.write_source("config/app.conf", "content");
|
sandbox.write_source("config/app.conf", "content");
|
||||||
sandbox.run(&["apply"]);
|
sandbox.run(&["apply"]);
|
||||||
|
|
@ -291,11 +264,7 @@ dotfile:
|
||||||
fn test_snapshot_and_rollback() {
|
fn test_snapshot_and_rollback() {
|
||||||
let sandbox = Sandbox::new("snapshot");
|
let sandbox = Sandbox::new("snapshot");
|
||||||
sandbox.write_config(
|
sandbox.write_config(
|
||||||
r#"
|
r#"Config { dotfiles = [ (dotfile { source = "config/app.conf"; target = "~/.config/app/app.conf"; }) ]; }"#,
|
||||||
dotfile:
|
|
||||||
source = "config/app.conf"
|
|
||||||
target = "~/.config/app/app.conf"
|
|
||||||
"#,
|
|
||||||
);
|
);
|
||||||
sandbox.write_source("config/app.conf", "v1");
|
sandbox.write_source("config/app.conf", "v1");
|
||||||
sandbox.run(&["apply"]);
|
sandbox.run(&["apply"]);
|
||||||
|
|
@ -316,11 +285,7 @@ fn test_dotfile_with_when_condition() {
|
||||||
let sandbox = Sandbox::new("conditional");
|
let sandbox = Sandbox::new("conditional");
|
||||||
|
|
||||||
// Test that 'when' condition works - only deploy if condition is true
|
// Test that 'when' condition works - only deploy if condition is true
|
||||||
let config = r#"dotfile:
|
let config = r#"Config { dotfiles = optionals true [ (dotfile { source = "config/test.conf"; target = "~/.config/test.conf"; }) ]; }"#;
|
||||||
source = "config/test.conf"
|
|
||||||
target = "~/.config/test.conf"
|
|
||||||
when = true
|
|
||||||
"#;
|
|
||||||
sandbox.write_config(config);
|
sandbox.write_config(config);
|
||||||
sandbox.write_source("config/test.conf", "test content");
|
sandbox.write_source("config/test.conf", "test content");
|
||||||
|
|
||||||
|
|
@ -338,11 +303,7 @@ fn test_dotfile_with_when_condition() {
|
||||||
fn test_dotfile_when_false_skips() {
|
fn test_dotfile_when_false_skips() {
|
||||||
let sandbox = Sandbox::new("when-false");
|
let sandbox = Sandbox::new("when-false");
|
||||||
|
|
||||||
let config = r#"dotfile:
|
let config = r#"Config { dotfiles = optionals false [ (dotfile { source = "config/skip.conf"; target = "~/.config/skip.conf"; }) ]; }"#;
|
||||||
source = "config/skip.conf"
|
|
||||||
target = "~/.config/skip.conf"
|
|
||||||
when = false
|
|
||||||
"#;
|
|
||||||
sandbox.write_config(config);
|
sandbox.write_config(config);
|
||||||
sandbox.write_source("config/skip.conf", "should not deploy");
|
sandbox.write_source("config/skip.conf", "should not deploy");
|
||||||
|
|
||||||
|
|
@ -360,11 +321,7 @@ fn test_dotfile_when_false_skips() {
|
||||||
fn test_diff_shows_changes() {
|
fn test_diff_shows_changes() {
|
||||||
let sandbox = Sandbox::new("diff");
|
let sandbox = Sandbox::new("diff");
|
||||||
sandbox.write_config(
|
sandbox.write_config(
|
||||||
r#"
|
r#"Config { dotfiles = [ (dotfile { source = "config/app.conf"; target = "~/.config/app/app.conf"; }) ]; }"#,
|
||||||
dotfile:
|
|
||||||
source = "config/app.conf"
|
|
||||||
target = "~/.config/app/app.conf"
|
|
||||||
"#,
|
|
||||||
);
|
);
|
||||||
sandbox.write_source("config/app.conf", "new content");
|
sandbox.write_source("config/app.conf", "new content");
|
||||||
|
|
||||||
|
|
@ -696,11 +653,7 @@ fn test_reencrypt_age_files() {
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
sandbox.write_config(
|
sandbox.write_config(r#"Config { packages = [ (package "git") ]; }"#);
|
||||||
r#"
|
|
||||||
package: "git"
|
|
||||||
"#,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Write the first identity back (reencrypt needs it to decrypt)
|
// Write the first identity back (reencrypt needs it to decrypt)
|
||||||
std::fs::write(sandbox.config_dir().join("identity.txt"), &identity_content).unwrap();
|
std::fs::write(sandbox.config_dir().join("identity.txt"), &identity_content).unwrap();
|
||||||
|
|
@ -746,7 +699,7 @@ package: "git"
|
||||||
#[test]
|
#[test]
|
||||||
fn test_reencrypt_no_identity_fails() {
|
fn test_reencrypt_no_identity_fails() {
|
||||||
let sandbox = Sandbox::new("reencrypt-no-id");
|
let sandbox = Sandbox::new("reencrypt-no-id");
|
||||||
sandbox.write_config("package: \"git\"\n");
|
sandbox.write_config("Config { packages = [ (package \"git\") ]; }");
|
||||||
|
|
||||||
let output = sandbox.run(&[
|
let output = sandbox.run(&[
|
||||||
"reencrypt",
|
"reencrypt",
|
||||||
|
|
@ -769,7 +722,7 @@ fn test_reencrypt_no_age_files_reports_zero() {
|
||||||
.trim()
|
.trim()
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
sandbox.write_config("package: \"git\"\n");
|
sandbox.write_config("Config { packages = [ (package \"git\") ]; }");
|
||||||
|
|
||||||
let output = sandbox.run(&["reencrypt", "--recipient", &pubkey]);
|
let output = sandbox.run(&["reencrypt", "--recipient", &pubkey]);
|
||||||
assert!(output.status.success(), "reencrypt failed: {:?}", output);
|
assert!(output.status.success(), "reencrypt failed: {:?}", output);
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ edition.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
doot-utils.workspace = true
|
doot-utils.workspace = true
|
||||||
doot-lang.workspace = true
|
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
toml.workspace = true
|
toml.workspace = true
|
||||||
|
|
@ -20,7 +19,11 @@ anyhow.workspace = true
|
||||||
hostname = "0.4"
|
hostname = "0.4"
|
||||||
regex-lite = "0.1"
|
regex-lite = "0.1"
|
||||||
glob = "0.3"
|
glob = "0.3"
|
||||||
|
indexmap = "2"
|
||||||
rayon.workspace = true
|
rayon.workspace = true
|
||||||
minijinja = { version = "2", features = ["builtins"] }
|
minijinja = { version = "2", features = ["builtins"] }
|
||||||
which = "7"
|
which = "7"
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
|
|
|
||||||
67
crates/doot-core/src/builtins/crypto.rs
Normal file
67
crates/doot-core/src/builtins/crypto.rs
Normal 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)
|
||||||
|
}
|
||||||
3
crates/doot-core/src/builtins/mod.rs
Normal file
3
crates/doot-core/src/builtins/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
//! Pure utility helpers retained from the language runtime.
|
||||||
|
|
||||||
|
pub mod crypto;
|
||||||
|
|
@ -5,7 +5,7 @@ use std::path::PathBuf;
|
||||||
|
|
||||||
/// Doot runtime configuration.
|
/// Doot runtime configuration.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Config {
|
pub struct Settings {
|
||||||
/// Directory containing dotfile sources.
|
/// Directory containing dotfile sources.
|
||||||
pub source_dir: PathBuf,
|
pub source_dir: PathBuf,
|
||||||
/// Doot configuration directory.
|
/// Doot configuration directory.
|
||||||
|
|
@ -26,7 +26,7 @@ pub struct Config {
|
||||||
pub verbose: bool,
|
pub verbose: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Settings {
|
||||||
/// Creates a new config with the given source directory.
|
/// Creates a new config with the given source directory.
|
||||||
#[tracing::instrument(skip_all, fields(source_dir = %source_dir.display()))]
|
#[tracing::instrument(skip_all, fields(source_dir = %source_dir.display()))]
|
||||||
pub fn new(source_dir: PathBuf) -> Self {
|
pub fn new(source_dir: PathBuf) -> Self {
|
||||||
|
|
@ -101,7 +101,7 @@ impl Config {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Config {
|
impl Default for Settings {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::new(Self::default_source_dir())
|
Self::new(Self::default_source_dir())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,18 @@
|
||||||
//! Symlink management.
|
//! Symlink management.
|
||||||
|
|
||||||
use super::{DeployAction, DeployError};
|
use super::{DeployAction, DeployError};
|
||||||
use crate::config::Config;
|
use crate::config::Settings;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
/// Creates and manages symlinks.
|
/// Creates and manages symlinks.
|
||||||
pub struct Linker {
|
pub struct Linker {
|
||||||
config: Config,
|
config: Settings,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Linker {
|
impl Linker {
|
||||||
/// Creates a new linker.
|
/// Creates a new linker.
|
||||||
#[tracing::instrument(skip_all)]
|
#[tracing::instrument(skip_all)]
|
||||||
pub fn new(config: Config) -> Self {
|
pub fn new(config: Settings) -> Self {
|
||||||
Self { config }
|
Self { config }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,10 @@ pub mod diff;
|
||||||
pub mod linker;
|
pub mod linker;
|
||||||
pub mod template;
|
pub mod template;
|
||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Settings;
|
||||||
|
use crate::evaluator::DotfileConfig;
|
||||||
use crate::state::StateStore;
|
use crate::state::StateStore;
|
||||||
use crate::state::store::DeployMode;
|
use crate::state::store::DeployMode;
|
||||||
use doot_lang::evaluator::DotfileConfig;
|
|
||||||
use glob::Pattern;
|
use glob::Pattern;
|
||||||
use indicatif::ProgressBar;
|
use indicatif::ProgressBar;
|
||||||
use rayon::prelude::*;
|
use rayon::prelude::*;
|
||||||
|
|
@ -96,7 +96,7 @@ pub struct DeployErrorInfo {
|
||||||
|
|
||||||
/// Handles dotfile deployment.
|
/// Handles dotfile deployment.
|
||||||
pub struct Deployer {
|
pub struct Deployer {
|
||||||
config: Arc<Config>,
|
config: Arc<Settings>,
|
||||||
linker: Arc<Linker>,
|
linker: Arc<Linker>,
|
||||||
template_engine: Arc<TemplateEngine>,
|
template_engine: Arc<TemplateEngine>,
|
||||||
state: Arc<Mutex<StateStore>>,
|
state: Arc<Mutex<StateStore>>,
|
||||||
|
|
@ -107,9 +107,9 @@ impl Deployer {
|
||||||
/// Creates a new deployer.
|
/// Creates a new deployer.
|
||||||
#[tracing::instrument(skip_all)]
|
#[tracing::instrument(skip_all)]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
config: Config,
|
config: Settings,
|
||||||
sandbox: bool,
|
sandbox: bool,
|
||||||
template_vars: Option<&std::collections::HashMap<String, doot_lang::evaluator::Value>>,
|
template_vars: Option<&std::collections::HashMap<String, crate::evaluator::Value>>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let state = StateStore::new(&config.state_file);
|
let state = StateStore::new(&config.state_file);
|
||||||
let linker = Linker::new(config.clone());
|
let linker = Linker::new(config.clone());
|
||||||
|
|
@ -132,7 +132,7 @@ impl Deployer {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let home = crate::config::Config::home_dir();
|
let home = crate::config::Settings::home_dir();
|
||||||
let target_canonical = target
|
let target_canonical = target
|
||||||
.canonicalize()
|
.canonicalize()
|
||||||
.unwrap_or_else(|_| target.to_path_buf());
|
.unwrap_or_else(|_| target.to_path_buf());
|
||||||
|
|
@ -497,8 +497,8 @@ impl Deployer {
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
let base_mode = match dotfile.deploy {
|
let base_mode = match dotfile.deploy {
|
||||||
doot_lang::evaluator::DeployMode::Copy => DeployMode::Copy,
|
crate::evaluator::DeployMode::Copy => DeployMode::Copy,
|
||||||
doot_lang::evaluator::DeployMode::Link => DeployMode::Link,
|
crate::evaluator::DeployMode::Link => DeployMode::Link,
|
||||||
};
|
};
|
||||||
tracing::trace!(mode = ?base_mode, "resolved deploy mode");
|
tracing::trace!(mode = ?base_mode, "resolved deploy mode");
|
||||||
|
|
||||||
|
|
@ -637,7 +637,7 @@ fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
use doot_lang::evaluator::PermissionRule;
|
use crate::evaluator::PermissionRule;
|
||||||
|
|
||||||
#[tracing::instrument(level = "trace", skip_all)]
|
#[tracing::instrument(level = "trace", skip_all)]
|
||||||
fn apply_permissions(target: &Path, rules: &[PermissionRule]) -> Result<(), DeployError> {
|
fn apply_permissions(target: &Path, rules: &[PermissionRule]) -> Result<(), DeployError> {
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ impl TemplateEngine {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sets multiple variables from doot evaluator values.
|
/// 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 {
|
for (key, value) in vars {
|
||||||
self.variables
|
self.variables
|
||||||
.insert(key.clone(), doot_value_to_minijinja(value));
|
.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.
|
/// Converts a doot evaluator Value to a minijinja Value.
|
||||||
fn doot_value_to_minijinja(val: &doot_lang::evaluator::Value) -> Value {
|
fn doot_value_to_minijinja(val: &crate::evaluator::Value) -> Value {
|
||||||
use doot_lang::evaluator::Value as DootValue;
|
use crate::evaluator::Value as DootValue;
|
||||||
match val {
|
match val {
|
||||||
DootValue::Int(n) => Value::from(*n),
|
DootValue::Int(n) => Value::from(*n),
|
||||||
DootValue::Float(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::Enum(_, variant) => Value::from(variant.as_str()),
|
||||||
DootValue::None => Value::UNDEFINED,
|
DootValue::None => Value::UNDEFINED,
|
||||||
_ => Value::UNDEFINED, // Function, Lambda, Future
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
216
crates/doot-core/src/evaluator.rs
Normal file
216
crates/doot-core/src/evaluator.rs
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
//! Lifecycle hook execution.
|
//! Lifecycle hook execution.
|
||||||
|
|
||||||
use doot_lang::HookStage;
|
use crate::evaluator::HookStage;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,20 @@
|
||||||
//! Provides configuration, deployment, encryption, package management,
|
//! Provides configuration, deployment, encryption, package management,
|
||||||
//! and state tracking.
|
//! and state tracking.
|
||||||
|
|
||||||
|
pub mod builtins;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod deploy;
|
pub mod deploy;
|
||||||
pub mod encryption;
|
pub mod encryption;
|
||||||
|
pub mod evaluator;
|
||||||
pub mod hooks;
|
pub mod hooks;
|
||||||
pub mod os;
|
pub mod os;
|
||||||
pub mod package;
|
pub mod package;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
|
||||||
pub use config::Config;
|
pub use config::Settings;
|
||||||
pub use deploy::{DeployAction, DeployResult, Deployer};
|
pub use deploy::{DeployAction, DeployResult, Deployer};
|
||||||
pub use encryption::AgeEncryption;
|
pub use encryption::AgeEncryption;
|
||||||
|
pub use evaluator::HookStage;
|
||||||
pub use hooks::HookRunner;
|
pub use hooks::HookRunner;
|
||||||
pub use os::OsInfo;
|
pub use os::OsInfo;
|
||||||
pub use package::PackageManager;
|
pub use package::PackageManager;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
//! State persistence for doot.
|
//! State persistence for doot.
|
||||||
|
|
||||||
use doot_lang::evaluator::PermissionRule;
|
use crate::evaluator::PermissionRule;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
|
||||||
15
crates/doot-dotfile/Cargo.toml
Normal file
15
crates/doot-dotfile/Cargo.toml
Normal 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"
|
||||||
194
crates/doot-dotfile/src/bridge.rs
Normal file
194
crates/doot-dotfile/src/bridge.rs
Normal 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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
393
crates/doot-dotfile/src/builtins.rs
Normal file
393
crates/doot-dotfile/src/builtins.rs
Normal 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()
|
||||||
|
}
|
||||||
36
crates/doot-dotfile/src/exec.rs
Normal file
36
crates/doot-dotfile/src/exec.rs
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
851
crates/doot-dotfile/src/lib.rs
Normal file
851
crates/doot-dotfile/src/lib.rs
Normal 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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
109
crates/doot-dotfile/src/payload.rs
Normal file
109
crates/doot-dotfile/src/payload.rs
Normal 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
219
crates/doot-dotfile/src/reflect.rs
Normal file
219
crates/doot-dotfile/src/reflect.rs
Normal 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()
|
||||||
|
}
|
||||||
|
|
@ -4,28 +4,3 @@ version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[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"
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
|
|
@ -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()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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))
|
|
||||||
}
|
|
||||||
|
|
@ -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(),
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
190
crates/doot-lang/src/lang/ast.rs
Normal file
190
crates/doot-lang/src/lang/ast.rs
Normal 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)>,
|
||||||
|
}
|
||||||
822
crates/doot-lang/src/lang/check.rs
Normal file
822
crates/doot-lang/src/lang/check.rs
Normal 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(<, &rt);
|
||||||
|
let t = self.prune(<);
|
||||||
|
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(<, &rt);
|
||||||
|
Type::Bool
|
||||||
|
}
|
||||||
|
BinOp::And | BinOp::Or => {
|
||||||
|
let lt = self.infer(l);
|
||||||
|
let rt = self.infer(r);
|
||||||
|
self.want(<, &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(<), Type::Str) {
|
||||||
|
self.want(&rt, &Type::Str);
|
||||||
|
Type::Str
|
||||||
|
} else {
|
||||||
|
let ev = self.fresh();
|
||||||
|
self.want(<, &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(<),
|
||||||
|
other => {
|
||||||
|
self.errors.push(format!(
|
||||||
|
"right of `//` must be a record, got {}",
|
||||||
|
other.show()
|
||||||
|
));
|
||||||
|
return self.prune(<);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match self.prune(<) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
69
crates/doot-lang/src/lang/diag.rs
Normal file
69
crates/doot-lang/src/lang/diag.rs
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
118
crates/doot-lang/src/lang/engine.rs
Normal file
118
crates/doot-lang/src/lang/engine.rs
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
787
crates/doot-lang/src/lang/eval.rs
Normal file
787
crates/doot-lang/src/lang/eval.rs
Normal 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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
498
crates/doot-lang/src/lang/fmt.rs
Normal file
498
crates/doot-lang/src/lang/fmt.rs
Normal 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
249
crates/doot-lang/src/lang/lexer.rs
Normal file
249
crates/doot-lang/src/lang/lexer.rs
Normal 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")
|
||||||
|
}
|
||||||
20
crates/doot-lang/src/lang/mod.rs
Normal file
20
crates/doot-lang/src/lang/mod.rs
Normal 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};
|
||||||
542
crates/doot-lang/src/lang/parser.rs
Normal file
542
crates/doot-lang/src/lang/parser.rs
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
61
crates/doot-lang/src/lang/plan.rs
Normal file
61
crates/doot-lang/src/lang/plan.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +1,6 @@
|
||||||
//! Doot language implementation.
|
//! The doot configuration language: a lazy, pure, typed expression language.
|
||||||
//!
|
//! Lexer, parser, Hindley-Milner type checker, lazy CEK evaluator, and an
|
||||||
//! This crate provides the lexer, parser, type checker, and evaluator
|
//! `Engine` registration API for embedding a standard library and domain
|
||||||
//! for the doot configuration language.
|
//! vocabulary. Domain-free: it knows nothing about dotfiles.
|
||||||
|
|
||||||
// chumsky 0.9's `Simple<Token>` error type is inherently large (~152 bytes) and
|
pub mod lang;
|
||||||
// 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;
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
||||||
|
|
@ -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")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
7
crates/doot-std/Cargo.toml
Normal file
7
crates/doot-std/Cargo.toml
Normal 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
153
crates/doot-std/src/lib.rs
Normal 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");
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue