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]
|
||||
doot-utils = { path = "crates/doot-utils" }
|
||||
doot-lang = { path = "crates/doot-lang" }
|
||||
doot-std = { path = "crates/doot-std" }
|
||||
doot-dotfile = { path = "crates/doot-dotfile" }
|
||||
doot-core = { path = "crates/doot-core" }
|
||||
|
||||
chumsky = "0.9"
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ path = "src/main.rs"
|
|||
|
||||
[dependencies]
|
||||
doot-utils.workspace = true
|
||||
doot-lang.workspace = true
|
||||
doot-dotfile.workspace = true
|
||||
doot-core.workspace = true
|
||||
clap.workspace = true
|
||||
serde.workspace = true
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,22 +1,25 @@
|
|||
use super::{find_config_file, parse_config, type_check};
|
||||
use super::{find_config_file, load};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Validates a config file (parse + type check).
|
||||
/// Validates a config file (parse + type check + evaluate).
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub fn run(config_path: Option<PathBuf>) -> anyhow::Result<()> {
|
||||
let path = find_config_file(config_path)?;
|
||||
let source = std::fs::read_to_string(&path)?;
|
||||
|
||||
tracing::debug!(path = %path.display(), "checking config");
|
||||
|
||||
let program = parse_config(&path)?;
|
||||
println!("syntax: ok");
|
||||
|
||||
type_check(&program, &source, &path.display().to_string())?;
|
||||
println!("types: ok");
|
||||
|
||||
println!("\nconfig is valid");
|
||||
println!(" statements: {}", program.statements.len());
|
||||
let (result, _vars) = load(&path)?;
|
||||
println!("config is valid");
|
||||
println!(
|
||||
" dotfiles: {}",
|
||||
result.dotfiles.len() + result.dotfile_patterns.len()
|
||||
);
|
||||
println!(" packages: {}", result.packages.len());
|
||||
println!(" hooks: {}", result.hooks.len());
|
||||
println!(
|
||||
" secrets: {}",
|
||||
result.secrets.len() + result.encrypted_files.len()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use doot_core::{Config, encryption::AgeEncryption};
|
||||
use doot_core::{Settings, encryption::AgeEncryption};
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
|
|
@ -17,7 +17,7 @@ pub fn run(
|
|||
identity: Option<PathBuf>,
|
||||
output: Option<PathBuf>,
|
||||
) -> anyhow::Result<()> {
|
||||
let config = Config::default();
|
||||
let config = Settings::default();
|
||||
let identity_raw = if let Some(path) = identity {
|
||||
std::fs::read_to_string(&path)?
|
||||
} else if let Ok(key) = std::env::var("DOOT_AGE_IDENTITY") {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
use super::{find_config_file, parse_config};
|
||||
use doot_core::Config;
|
||||
use doot_lang::Evaluator;
|
||||
use doot_lang::builtins::crypto::base64_decode;
|
||||
use super::{find_config_file, load};
|
||||
use doot_core::Settings;
|
||||
use doot_core::builtins::crypto::base64_decode;
|
||||
use std::io::Read;
|
||||
use std::path::PathBuf;
|
||||
|
||||
|
|
@ -34,11 +33,8 @@ pub fn run(
|
|||
identity: Option<PathBuf>,
|
||||
) -> anyhow::Result<()> {
|
||||
let path = find_config_file(config_path)?;
|
||||
let program = parse_config(&path)?;
|
||||
let source_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf();
|
||||
|
||||
let mut evaluator = Evaluator::new().with_source_dir(source_dir.clone());
|
||||
let result = evaluator.eval_sync(&program)?;
|
||||
let (result, _vars) = load(&path)?;
|
||||
|
||||
if result.encrypted_vars.is_empty() && result.encrypted_files.is_empty() {
|
||||
println!("no encrypted entries found");
|
||||
|
|
@ -46,7 +42,7 @@ pub fn run(
|
|||
}
|
||||
|
||||
// Resolve identity
|
||||
let config = Config::new(source_dir.clone());
|
||||
let config = Settings::new(source_dir.clone());
|
||||
let identity_raw: String = if let Some(ref path) = identity {
|
||||
std::fs::read_to_string(path)?
|
||||
} else if let Ok(key) = std::env::var("DOOT_AGE_IDENTITY") {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use doot_core::Config;
|
||||
use doot_lang::builtins::crypto::base64_decode;
|
||||
use doot_core::Settings;
|
||||
use doot_core::builtins::crypto::base64_decode;
|
||||
use std::io::{self, Read};
|
||||
use std::path::PathBuf;
|
||||
|
||||
|
|
@ -10,7 +10,7 @@ fn resolve_identity(identity: Option<PathBuf>) -> anyhow::Result<age::x25519::Id
|
|||
} else if let Ok(key) = std::env::var("DOOT_AGE_IDENTITY") {
|
||||
key
|
||||
} else {
|
||||
let id_file = Config::default_config_dir().join("identity.txt");
|
||||
let id_file = Settings::default_config_dir().join("identity.txt");
|
||||
if id_file.exists() {
|
||||
std::fs::read_to_string(&id_file)?
|
||||
} else {
|
||||
|
|
|
|||
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::evaluator::PermissionRule;
|
||||
use doot_core::state::{expected_mode_for_file, get_file_mode};
|
||||
use doot_lang::Evaluator;
|
||||
use doot_lang::evaluator::PermissionRule;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Shows diffs between source and deployed dotfiles.
|
||||
#[tracing::instrument(skip_all, fields(all))]
|
||||
pub fn run(config_path: Option<PathBuf>, all: bool) -> anyhow::Result<()> {
|
||||
let path = find_config_file(config_path)?;
|
||||
let source = std::fs::read_to_string(&path)?;
|
||||
|
||||
let program = parse_config(&path)?;
|
||||
type_check(&program, &source, &path.display().to_string())?;
|
||||
|
||||
let source_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf();
|
||||
|
||||
let mut evaluator = Evaluator::new().with_source_dir(source_dir.clone());
|
||||
let result = evaluator.eval_sync(&program)?;
|
||||
let (result, _vars) = load(&path)?;
|
||||
|
||||
let mut has_changes = false;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
use super::{find_config_file, parse_config, type_check};
|
||||
use super::{find_config_file, load};
|
||||
use doot_core::{
|
||||
Config,
|
||||
Settings,
|
||||
deploy::{Linker, TemplateEngine},
|
||||
state::{DeployMode, StateStore},
|
||||
};
|
||||
use doot_lang::Evaluator;
|
||||
use std::collections::HashMap;
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
|
@ -19,16 +18,9 @@ pub fn run(
|
|||
skip_prompt: bool,
|
||||
) -> anyhow::Result<()> {
|
||||
let path = find_config_file(config_path)?;
|
||||
let source = std::fs::read_to_string(&path)?;
|
||||
let program = parse_config(&path)?;
|
||||
type_check(&program, &source, &path.display().to_string())?;
|
||||
|
||||
let source_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf();
|
||||
|
||||
let mut evaluator = Evaluator::new().with_source_dir(source_dir.clone());
|
||||
let result = evaluator.eval_sync(&program)?;
|
||||
let mut template_vars = evaluator.get_template_variables();
|
||||
let config = Config::new(source_dir.clone());
|
||||
let (result, mut template_vars) = load(&path)?;
|
||||
let config = Settings::new(source_dir.clone());
|
||||
|
||||
super::decrypt_encrypted_vars_with_source_dir(
|
||||
&result,
|
||||
|
|
@ -107,13 +99,13 @@ fn hash_file(path: &PathBuf) -> String {
|
|||
fn apply_single(
|
||||
source: &PathBuf,
|
||||
target: &PathBuf,
|
||||
dotfile: &doot_lang::evaluator::DotfileConfig,
|
||||
config: &Config,
|
||||
template_vars: &HashMap<String, doot_lang::evaluator::Value>,
|
||||
dotfile: &doot_core::evaluator::DotfileConfig,
|
||||
config: &Settings,
|
||||
template_vars: &HashMap<String, doot_core::evaluator::Value>,
|
||||
) -> anyhow::Result<()> {
|
||||
let deploy_mode = match dotfile.deploy {
|
||||
doot_lang::evaluator::DeployMode::Copy => DeployMode::Copy,
|
||||
doot_lang::evaluator::DeployMode::Link => DeployMode::Link,
|
||||
doot_core::evaluator::DeployMode::Copy => DeployMode::Copy,
|
||||
doot_core::evaluator::DeployMode::Link => DeployMode::Link,
|
||||
};
|
||||
|
||||
let mut state = StateStore::new(&config.state_file);
|
||||
|
|
@ -204,10 +196,10 @@ fn expand_tilde(path: &str) -> PathBuf {
|
|||
#[tracing::instrument(skip_all)]
|
||||
fn find_source_and_dotfile<'a>(
|
||||
target: &PathBuf,
|
||||
dotfiles: &'a [doot_lang::evaluator::DotfileConfig],
|
||||
dotfiles: &'a [doot_core::evaluator::DotfileConfig],
|
||||
source_dir: &Path,
|
||||
state: &StateStore,
|
||||
) -> anyhow::Result<(PathBuf, Option<&'a doot_lang::evaluator::DotfileConfig>)> {
|
||||
) -> anyhow::Result<(PathBuf, Option<&'a doot_core::evaluator::DotfileConfig>)> {
|
||||
// Exact match with dotfile targets
|
||||
for df in dotfiles {
|
||||
if &df.target == target {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use doot_core::{Config, encryption::AgeEncryption};
|
||||
use doot_core::{Settings, encryption::AgeEncryption};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Encrypts a file using age encryption with multi-recipient support.
|
||||
|
|
@ -12,7 +12,7 @@ pub fn run(file: PathBuf, recipients: Vec<String>) -> anyhow::Result<()> {
|
|||
.filter(|l| !l.is_empty())
|
||||
.collect()
|
||||
} else {
|
||||
let key_file = Config::default_config_dir().join("recipient.txt");
|
||||
let key_file = Settings::default_config_dir().join("recipient.txt");
|
||||
if key_file.exists() {
|
||||
std::fs::read_to_string(&key_file)?
|
||||
.lines()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use doot_core::Config;
|
||||
use doot_lang::builtins::crypto::base64_encode;
|
||||
use doot_core::Settings;
|
||||
use doot_core::builtins::crypto::base64_encode;
|
||||
use std::io::{self, Read, Write};
|
||||
|
||||
/// Resolves recipient keys from CLI flags, env var, or recipient.txt (supports multiple keys).
|
||||
|
|
@ -12,7 +12,7 @@ fn resolve_recipients(recipients: Vec<String>) -> anyhow::Result<Vec<age::x25519
|
|||
.filter(|l| !l.is_empty())
|
||||
.collect()
|
||||
} else {
|
||||
let key_file = Config::default_config_dir().join("recipient.txt");
|
||||
let key_file = Settings::default_config_dir().join("recipient.txt");
|
||||
if key_file.exists() {
|
||||
std::fs::read_to_string(&key_file)?
|
||||
.lines()
|
||||
|
|
|
|||
|
|
@ -2,22 +2,31 @@ use super::find_config_file;
|
|||
use doot_core::deploy::diff::DiffDisplay;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Formats or checks formatting of a `.doot` config file.
|
||||
/// Formats or checks formatting of a `.doot` config file by parsing it and
|
||||
/// reprinting the AST in canonical layout (comments, literal forms, and
|
||||
/// multiline strings preserved).
|
||||
#[tracing::instrument(skip_all, fields(check))]
|
||||
pub fn run(config_path: Option<PathBuf>, check: bool) -> anyhow::Result<()> {
|
||||
let path = find_config_file(config_path)?;
|
||||
let source = std::fs::read_to_string(&path)?;
|
||||
|
||||
let formatted = format_source(&source);
|
||||
let formatted = match doot_dotfile::format(&source) {
|
||||
Ok(s) => s,
|
||||
Err(diags) => {
|
||||
for d in &diags {
|
||||
eprintln!("{}:{}", path.display(), d.render(&source));
|
||||
}
|
||||
anyhow::bail!("cannot format: {} parse error(s)", diags.len());
|
||||
}
|
||||
};
|
||||
|
||||
if check {
|
||||
if formatted != source {
|
||||
let diff = DiffDisplay::diff_strings(&source, &formatted);
|
||||
eprintln!("{}\n{}", path.display(), diff);
|
||||
std::process::exit(1);
|
||||
} else {
|
||||
println!("{} is formatted correctly", path.display());
|
||||
}
|
||||
println!("{} is formatted correctly", path.display());
|
||||
} else if formatted != source {
|
||||
std::fs::write(&path, &formatted)?;
|
||||
println!("formatted {}", path.display());
|
||||
|
|
@ -27,208 +36,3 @@ pub fn run(config_path: Option<PathBuf>, check: bool) -> anyhow::Result<()> {
|
|||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "trace", skip_all)]
|
||||
fn format_source(source: &str) -> String {
|
||||
// Use an indent stack to track nesting levels from raw whitespace.
|
||||
// When indentation increases, push a new level; when it decreases,
|
||||
// pop back. This handles files with inconsistent indent widths.
|
||||
let mut result = String::new();
|
||||
let mut prev_was_blank = false;
|
||||
let mut indent_stack: Vec<usize> = vec![0]; // raw whitespace widths
|
||||
|
||||
for line in source.lines() {
|
||||
let trimmed = line.trim();
|
||||
|
||||
if trimmed.is_empty() {
|
||||
if !prev_was_blank {
|
||||
result.push('\n');
|
||||
prev_was_blank = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
prev_was_blank = false;
|
||||
|
||||
let leading = line.len() - line.trim_start().len();
|
||||
|
||||
if leading > *indent_stack.last().unwrap() {
|
||||
// Deeper nesting
|
||||
indent_stack.push(leading);
|
||||
} else if leading < *indent_stack.last().unwrap() {
|
||||
// Dedent: pop until we find a level <= current
|
||||
while indent_stack.len() > 1 && *indent_stack.last().unwrap() > leading {
|
||||
indent_stack.pop();
|
||||
}
|
||||
}
|
||||
|
||||
let level = indent_stack.len() - 1;
|
||||
result.push_str(&" ".repeat(level));
|
||||
result.push_str(trimmed);
|
||||
result.push('\n');
|
||||
}
|
||||
|
||||
// Trim trailing blank lines
|
||||
while result.ends_with("\n\n") {
|
||||
result.pop();
|
||||
}
|
||||
|
||||
if !result.ends_with('\n') {
|
||||
result.push('\n');
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_preserves_top_level_blocks() {
|
||||
let input = "\
|
||||
dotfile:
|
||||
src: fish
|
||||
dst: ~/.config/fish
|
||||
|
||||
dotfile:
|
||||
src: nvim
|
||||
dst: ~/.config/nvim
|
||||
|
||||
dotfile:
|
||||
src: git
|
||||
dst: ~/.config/git
|
||||
";
|
||||
let result = format_source(input);
|
||||
assert_eq!(result, input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalizes_4space_to_2space() {
|
||||
let input = "\
|
||||
dotfile:
|
||||
src: fish
|
||||
dst: ~/.config/fish
|
||||
";
|
||||
let expected = "\
|
||||
dotfile:
|
||||
src: fish
|
||||
dst: ~/.config/fish
|
||||
";
|
||||
assert_eq!(format_source(input), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nested_blocks() {
|
||||
let input = "\
|
||||
dotfile:
|
||||
src: fish
|
||||
if os == \"linux\":
|
||||
package: apt
|
||||
else:
|
||||
package: brew
|
||||
";
|
||||
let result = format_source(input);
|
||||
assert_eq!(result, input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collapses_consecutive_blank_lines() {
|
||||
let input = "\
|
||||
dotfile:
|
||||
src: fish
|
||||
|
||||
|
||||
dst: ~/.config/fish
|
||||
";
|
||||
let expected = "\
|
||||
dotfile:
|
||||
src: fish
|
||||
|
||||
dst: ~/.config/fish
|
||||
";
|
||||
assert_eq!(format_source(input), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trailing_blank_lines_trimmed() {
|
||||
let input = "\
|
||||
dotfile:
|
||||
src: fish
|
||||
|
||||
|
||||
";
|
||||
let expected = "\
|
||||
dotfile:
|
||||
src: fish
|
||||
";
|
||||
assert_eq!(format_source(input), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_comments_preserve_indent() {
|
||||
let input = "\
|
||||
# top-level comment
|
||||
dotfile:
|
||||
# nested comment
|
||||
src: fish
|
||||
";
|
||||
let result = format_source(input);
|
||||
assert_eq!(result, input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_indented_lines() {
|
||||
let input = "\
|
||||
one
|
||||
two
|
||||
three
|
||||
";
|
||||
let result = format_source(input);
|
||||
assert_eq!(result, input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mixed_indent_normalizes() {
|
||||
let input = "\
|
||||
dotfile:
|
||||
src: fish
|
||||
if cond:
|
||||
package: apt
|
||||
";
|
||||
let expected = "\
|
||||
dotfile:
|
||||
src: fish
|
||||
if cond:
|
||||
package: apt
|
||||
";
|
||||
assert_eq!(format_source(input), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inconsistent_indent_widths() {
|
||||
// Mix of 6-space, 2-space, and 4-space indentation (GCD = 2)
|
||||
let input = "\
|
||||
dotfile:
|
||||
source = \"config/*\"
|
||||
target = config_dir()
|
||||
|
||||
if cond:
|
||||
package: \"fish\"
|
||||
|
||||
if other:
|
||||
package: \"bat\"
|
||||
";
|
||||
let expected = "\
|
||||
dotfile:
|
||||
source = \"config/*\"
|
||||
target = config_dir()
|
||||
|
||||
if cond:
|
||||
package: \"fish\"
|
||||
|
||||
if other:
|
||||
package: \"bat\"
|
||||
";
|
||||
assert_eq!(format_source(input), expected);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
use doot_core::Config;
|
||||
use doot_core::Settings;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Initializes a new doot project directory structure.
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub fn run(path: Option<PathBuf>) -> anyhow::Result<()> {
|
||||
let source_dir = path.unwrap_or_else(Config::default_source_dir);
|
||||
let config = Config::new(source_dir.clone());
|
||||
let is_default = source_dir == Config::default_config_dir();
|
||||
let source_dir = path.unwrap_or_else(Settings::default_source_dir);
|
||||
let config = Settings::new(source_dir.clone());
|
||||
let is_default = source_dir == Settings::default_config_dir();
|
||||
|
||||
tracing::debug!(
|
||||
config_dir = %config.config_dir.display(),
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
use doot_core::{Config, encryption::AgeEncryption};
|
||||
use doot_core::{Settings, encryption::AgeEncryption};
|
||||
use std::io::{self, Write};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Generates an age keypair, writing identity to identity.txt and appending public key to recipient.txt.
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub fn run(output: Option<PathBuf>, force: bool) -> anyhow::Result<()> {
|
||||
let config_dir = Config::default_config_dir();
|
||||
let config_dir = Settings::default_config_dir();
|
||||
let identity_file = output.unwrap_or_else(|| config_dir.join("identity.txt"));
|
||||
let recipient_file = identity_file
|
||||
.parent()
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ pub mod check;
|
|||
pub mod decrypt;
|
||||
pub mod decrypt_entries;
|
||||
pub mod decrypt_var;
|
||||
pub mod deploy_util;
|
||||
pub mod diff;
|
||||
pub mod edit;
|
||||
pub mod encrypt;
|
||||
|
|
@ -14,19 +15,59 @@ pub mod init;
|
|||
pub mod keygen;
|
||||
pub mod lsp;
|
||||
pub mod package;
|
||||
pub mod plan;
|
||||
pub mod reencrypt;
|
||||
pub mod rollback;
|
||||
pub mod snapshot;
|
||||
pub mod status;
|
||||
pub mod tui;
|
||||
|
||||
use doot_core::Config;
|
||||
use doot_lang::evaluator::{EvalResult, Value};
|
||||
use doot_lang::{Lexer, Parser, TypeChecker};
|
||||
use doot_core::Settings;
|
||||
use doot_core::evaluator::{EvalResult, Value};
|
||||
use indexmap::IndexMap;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Load a config file: parse, type-check, and evaluate to an
|
||||
/// `EvalResult` plus template variables. Type errors abort.
|
||||
#[tracing::instrument(skip_all, fields(path = %path.display()))]
|
||||
pub fn load(path: &PathBuf) -> anyhow::Result<(EvalResult, HashMap<String, Value>)> {
|
||||
let source = std::fs::read_to_string(path)?;
|
||||
let (result, vars, errors) = doot_dotfile::compile_eval_result(&source);
|
||||
if !errors.is_empty() {
|
||||
for e in &errors {
|
||||
eprintln!("{}:{}", path.display(), e.render(&source));
|
||||
}
|
||||
anyhow::bail!("{} error(s) found", errors.len());
|
||||
}
|
||||
Ok((result, vars))
|
||||
}
|
||||
|
||||
/// Build the environment exposed to hook scripts: string-valued template vars
|
||||
/// plus the DOOT_* globals.
|
||||
pub fn hook_env(
|
||||
vars: &HashMap<String, Value>,
|
||||
source_dir: &std::path::Path,
|
||||
) -> HashMap<String, String> {
|
||||
let mut env = HashMap::new();
|
||||
for (k, v) in vars {
|
||||
if let Value::Str(s) = v {
|
||||
env.insert(k.clone(), s.clone());
|
||||
}
|
||||
}
|
||||
env.insert(
|
||||
"DOOT_HOME".to_string(),
|
||||
std::env::var("HOME").unwrap_or_default(),
|
||||
);
|
||||
env.insert(
|
||||
"DOOT_CONFIG_DIR".to_string(),
|
||||
source_dir.display().to_string(),
|
||||
);
|
||||
env.insert("DOOT_OS".to_string(), std::env::consts::OS.to_string());
|
||||
env.insert("DOOT_ARCH".to_string(), std::env::consts::ARCH.to_string());
|
||||
env
|
||||
}
|
||||
|
||||
/// Resolves the config file path, checking the given path or default locations.
|
||||
/// Always returns an absolute path so source_dir is correct regardless of CWD.
|
||||
#[tracing::instrument(skip_all)]
|
||||
|
|
@ -38,7 +79,7 @@ pub fn find_config_file(base: Option<PathBuf>) -> anyhow::Result<PathBuf> {
|
|||
anyhow::bail!("config file not found: {}", path.display());
|
||||
}
|
||||
|
||||
let candidates = vec![PathBuf::from("doot.doot"), Config::default_config_file()];
|
||||
let candidates = vec![PathBuf::from("doot.doot"), Settings::default_config_file()];
|
||||
|
||||
for candidate in candidates {
|
||||
if candidate.exists() {
|
||||
|
|
@ -48,70 +89,14 @@ pub fn find_config_file(base: Option<PathBuf>) -> anyhow::Result<PathBuf> {
|
|||
|
||||
anyhow::bail!(
|
||||
"no config file found. searched:\n - ./doot.doot\n - {}",
|
||||
Config::default_config_file().display()
|
||||
Settings::default_config_file().display()
|
||||
)
|
||||
}
|
||||
|
||||
fn byte_offset_to_line(source: &str, offset: usize) -> usize {
|
||||
source[..offset.min(source.len())]
|
||||
.chars()
|
||||
.filter(|&c| c == '\n')
|
||||
.count()
|
||||
+ 1
|
||||
}
|
||||
|
||||
/// Parses a `.doot` config file into a program AST.
|
||||
#[tracing::instrument(skip_all, fields(path = %path.display()))]
|
||||
pub fn parse_config(path: &PathBuf) -> anyhow::Result<doot_lang::Program> {
|
||||
let source = std::fs::read_to_string(path)?;
|
||||
let tokens = Lexer::lex(&source).map_err(|errs| {
|
||||
let msg = errs
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let line = byte_offset_to_line(&source, e.span().start);
|
||||
format!("{}:{}: {}", path.display(), line, e)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
anyhow::anyhow!("lexer errors:\n{}", msg)
|
||||
})?;
|
||||
|
||||
let program = Parser::parse(tokens).map_err(|errs| {
|
||||
let msg = errs
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let line = byte_offset_to_line(&source, e.span().start);
|
||||
format!("{}:{}: {}", path.display(), line, e)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
anyhow::anyhow!("parser errors:\n{}", msg)
|
||||
})?;
|
||||
|
||||
Ok(program)
|
||||
}
|
||||
|
||||
/// Runs the type checker on a parsed program.
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub fn type_check(
|
||||
program: &doot_lang::Program,
|
||||
source: &str,
|
||||
filename: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut checker = TypeChecker::new();
|
||||
if let Err(errors) = checker.check(program) {
|
||||
for error in &errors {
|
||||
error.report(source, filename);
|
||||
}
|
||||
anyhow::bail!("{} type error(s) found", errors.len());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Decrypts encrypted vars and files, resolving file paths relative to `source_dir`.
|
||||
pub fn decrypt_encrypted_vars_with_source_dir(
|
||||
result: &EvalResult,
|
||||
config: &Config,
|
||||
config: &Settings,
|
||||
template_vars: &mut HashMap<String, Value>,
|
||||
source_dir: Option<&std::path::Path>,
|
||||
) -> anyhow::Result<()> {
|
||||
|
|
@ -145,7 +130,7 @@ pub fn decrypt_encrypted_vars_with_source_dir(
|
|||
|
||||
// Decrypt inline vars
|
||||
for (name, ciphertext_b64) in &result.encrypted_vars {
|
||||
let encrypted_bytes = doot_lang::builtins::crypto::base64_decode(ciphertext_b64)
|
||||
let encrypted_bytes = doot_core::builtins::crypto::base64_decode(ciphertext_b64)
|
||||
.map_err(|e| anyhow::anyhow!("invalid base64 for encrypted var '{}': {}", name, e))?;
|
||||
|
||||
let decryptor = match age::Decryptor::new(&encrypted_bytes[..])
|
||||
|
|
|
|||
|
|
@ -1,19 +1,11 @@
|
|||
use super::{find_config_file, parse_config, type_check};
|
||||
use doot_lang::Evaluator;
|
||||
use super::{find_config_file, load};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Installs packages defined in the config.
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub fn install(config_path: Option<PathBuf>) -> anyhow::Result<()> {
|
||||
let path = find_config_file(config_path)?;
|
||||
let source = std::fs::read_to_string(&path)?;
|
||||
let source_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf();
|
||||
|
||||
let program = parse_config(&path)?;
|
||||
type_check(&program, &source, &path.display().to_string())?;
|
||||
|
||||
let mut evaluator = Evaluator::new().with_source_dir(source_dir);
|
||||
let result = evaluator.eval_sync(&program)?;
|
||||
let (result, _vars) = load(&path)?;
|
||||
|
||||
if result.packages.is_empty() {
|
||||
println!("no packages configured");
|
||||
|
|
@ -72,14 +64,7 @@ pub fn update() -> anyhow::Result<()> {
|
|||
#[tracing::instrument(skip_all)]
|
||||
pub fn list(config_path: Option<PathBuf>) -> anyhow::Result<()> {
|
||||
let path = find_config_file(config_path)?;
|
||||
let source = std::fs::read_to_string(&path)?;
|
||||
let source_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf();
|
||||
|
||||
let program = parse_config(&path)?;
|
||||
type_check(&program, &source, &path.display().to_string())?;
|
||||
|
||||
let mut evaluator = Evaluator::new().with_source_dir(source_dir);
|
||||
let result = evaluator.eval_sync(&program)?;
|
||||
let (result, _vars) = load(&path)?;
|
||||
|
||||
if result.packages.is_empty() {
|
||||
println!("no packages configured");
|
||||
|
|
|
|||
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;
|
||||
|
||||
/// Re-encrypts all .age files in the secrets directory with current recipients.
|
||||
|
|
@ -7,7 +7,7 @@ use std::path::PathBuf;
|
|||
pub fn run(config_path: Option<PathBuf>, recipients: Vec<String>) -> anyhow::Result<()> {
|
||||
let path = super::find_config_file(config_path)?;
|
||||
let source_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf();
|
||||
let config = Config::new(source_dir.clone());
|
||||
let config = Settings::new(source_dir.clone());
|
||||
|
||||
// Resolve identity for decryption
|
||||
let identity_raw = if let Ok(key) = std::env::var("DOOT_AGE_IDENTITY") {
|
||||
|
|
@ -36,7 +36,7 @@ pub fn run(config_path: Option<PathBuf>, recipients: Vec<String>) -> anyhow::Res
|
|||
.filter(|l| !l.is_empty())
|
||||
.collect()
|
||||
} else {
|
||||
let key_file = Config::default_config_dir().join("recipient.txt");
|
||||
let key_file = Settings::default_config_dir().join("recipient.txt");
|
||||
if key_file.exists() {
|
||||
std::fs::read_to_string(&key_file)?
|
||||
.lines()
|
||||
|
|
@ -56,9 +56,7 @@ pub fn run(config_path: Option<PathBuf>, recipients: Vec<String>) -> anyhow::Res
|
|||
}
|
||||
|
||||
// Also re-encrypt encrypted vars from the doot config
|
||||
let program = super::parse_config(&path)?;
|
||||
let mut evaluator = doot_lang::Evaluator::new().with_source_dir(source_dir.clone());
|
||||
let result = evaluator.eval_sync(&program)?;
|
||||
let (result, _vars) = super::load(&path)?;
|
||||
|
||||
let mut count = 0;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use doot_core::{
|
||||
Config,
|
||||
Settings,
|
||||
state::{DeployMode, Snapshot},
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
|
|
@ -7,7 +7,7 @@ use std::path::PathBuf;
|
|||
/// Rolls back to a previous snapshot.
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub fn run(_config_path: Option<PathBuf>, snapshot_name: Option<String>) -> anyhow::Result<()> {
|
||||
let config = Config::default();
|
||||
let config = Settings::default();
|
||||
|
||||
let name = if let Some(n) = snapshot_name {
|
||||
if n == "last" || n == "latest" {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use doot_core::{
|
||||
Config,
|
||||
Settings,
|
||||
state::{Snapshot, StateStore},
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
|
|
@ -7,7 +7,7 @@ use std::path::PathBuf;
|
|||
/// Creates a named snapshot of the current deployment state.
|
||||
#[tracing::instrument(skip_all, fields(name = %name))]
|
||||
pub fn run(_config_path: Option<PathBuf>, name: String) -> anyhow::Result<()> {
|
||||
let config = Config::default();
|
||||
let config = Settings::default();
|
||||
config.ensure_dirs()?;
|
||||
|
||||
let mut state = StateStore::new(&config.state_file);
|
||||
|
|
|
|||
|
|
@ -1,33 +1,23 @@
|
|||
use super::{
|
||||
apply::template_outdated, decrypt_encrypted_vars_with_source_dir, find_config_file,
|
||||
parse_config, type_check,
|
||||
decrypt_encrypted_vars_with_source_dir, deploy_util::template_outdated, find_config_file, load,
|
||||
};
|
||||
use doot_core::Config;
|
||||
use doot_core::Settings;
|
||||
use doot_core::deploy::TemplateEngine;
|
||||
use doot_core::state::{StateStore, SyncStatus};
|
||||
use doot_lang::Evaluator;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Shows the deployment status of managed dotfiles.
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub fn run(config_path: Option<PathBuf>) -> anyhow::Result<()> {
|
||||
let path = find_config_file(config_path)?;
|
||||
let source = std::fs::read_to_string(&path)?;
|
||||
|
||||
let program = parse_config(&path)?;
|
||||
type_check(&program, &source, &path.display().to_string())?;
|
||||
|
||||
let source_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf();
|
||||
|
||||
let mut evaluator = Evaluator::new().with_source_dir(source_dir.clone());
|
||||
let result = evaluator.eval_sync(&program)?;
|
||||
let (result, mut template_vars) = load(&path)?;
|
||||
|
||||
// Prepare template variables early for preview rendering
|
||||
let config = Config::new(source_dir.clone());
|
||||
let config = Settings::new(source_dir.clone());
|
||||
let state_file = config.state_file.clone();
|
||||
let state = StateStore::new(&state_file);
|
||||
|
||||
let mut template_vars = evaluator.get_template_variables();
|
||||
decrypt_encrypted_vars_with_source_dir(
|
||||
&result,
|
||||
&config,
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
use super::{find_config_file, parse_config, type_check};
|
||||
use super::{find_config_file, load};
|
||||
use crossterm::{
|
||||
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind},
|
||||
execute,
|
||||
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
|
||||
};
|
||||
use doot_core::config::Config;
|
||||
use doot_core::config::Settings;
|
||||
use doot_core::deploy::Linker;
|
||||
use doot_core::state::{DeployMode, StateStore};
|
||||
use doot_lang::Evaluator;
|
||||
use ratatui::{
|
||||
Frame, Terminal,
|
||||
backend::CrosstermBackend,
|
||||
|
|
@ -113,16 +112,10 @@ impl App {
|
|||
#[tracing::instrument(skip_all)]
|
||||
fn new(config_path: Option<PathBuf>) -> anyhow::Result<Self> {
|
||||
let path = find_config_file(config_path)?;
|
||||
let source = std::fs::read_to_string(&path)?;
|
||||
let program = parse_config(&path)?;
|
||||
type_check(&program, &source, &path.display().to_string())?;
|
||||
|
||||
let source_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf();
|
||||
let (result, _vars) = load(&path)?;
|
||||
|
||||
let mut evaluator = Evaluator::new().with_source_dir(source_dir.clone());
|
||||
let result = evaluator.eval_sync(&program)?;
|
||||
|
||||
let config = Config::new(source_dir.clone());
|
||||
let config = Settings::new(source_dir.clone());
|
||||
let state = StateStore::new(&config.state_file);
|
||||
|
||||
let dotfiles: Vec<DotfileItem> = result
|
||||
|
|
@ -131,8 +124,8 @@ impl App {
|
|||
.map(|d| {
|
||||
let full_source = source_dir.join(&d.source);
|
||||
let deploy_mode = match d.deploy {
|
||||
doot_lang::evaluator::DeployMode::Copy => DeployMode::Copy,
|
||||
doot_lang::evaluator::DeployMode::Link => DeployMode::Link,
|
||||
doot_core::evaluator::DeployMode::Copy => DeployMode::Copy,
|
||||
doot_core::evaluator::DeployMode::Link => DeployMode::Link,
|
||||
};
|
||||
|
||||
let status = if !full_source.exists() {
|
||||
|
|
@ -440,7 +433,7 @@ impl App {
|
|||
self.apply_progress = 0;
|
||||
|
||||
// Apply dotfiles
|
||||
let config = Config::new(self.source_dir.clone());
|
||||
let config = Settings::new(self.source_dir.clone());
|
||||
let linker = Linker::new(config.clone());
|
||||
let mut state = StateStore::new(&config.state_file);
|
||||
|
||||
|
|
|
|||
|
|
@ -96,6 +96,9 @@ enum Commands {
|
|||
/// Validate config (parse + type check): `doot check`
|
||||
Check,
|
||||
|
||||
/// Show the inferred dependency DAG and execution order: `doot plan`
|
||||
Plan,
|
||||
|
||||
/// Format config file: `doot fmt [--check]`
|
||||
Fmt {
|
||||
/// Check formatting without modifying (exits 1 if unformatted)
|
||||
|
|
@ -290,6 +293,7 @@ fn main() -> anyhow::Result<()> {
|
|||
Commands::Diff { all } => commands::diff::run(cli.config, all),
|
||||
Commands::Status => commands::status::run(cli.config),
|
||||
Commands::Check => commands::check::run(cli.config),
|
||||
Commands::Plan => commands::plan::run(cli.config),
|
||||
Commands::Fmt { check } => commands::fmt::run(cli.config, check),
|
||||
Commands::Rollback { snapshot } => commands::rollback::run(cli.config, snapshot),
|
||||
Commands::Snapshot { name } => commands::snapshot::run(cli.config, name),
|
||||
|
|
|
|||
|
|
@ -101,12 +101,7 @@ fn test_init_creates_structure() {
|
|||
#[test]
|
||||
fn test_check_valid_config() {
|
||||
let sandbox = Sandbox::new("check-valid");
|
||||
sandbox.write_config(
|
||||
r#"
|
||||
package: "ripgrep"
|
||||
package: "fd"
|
||||
"#,
|
||||
);
|
||||
sandbox.write_config(r#"Config { packages = [ (package "ripgrep") (package "fd") ]; }"#);
|
||||
|
||||
let output = sandbox.run(&["check"]);
|
||||
assert!(output.status.success(), "check failed: {:?}", output);
|
||||
|
|
@ -116,11 +111,7 @@ package: "fd"
|
|||
fn test_apply_dry_run() {
|
||||
let sandbox = Sandbox::new("apply-dry");
|
||||
sandbox.write_config(
|
||||
r#"
|
||||
dotfile:
|
||||
source = "config/test.conf"
|
||||
target = "~/.config/test/test.conf"
|
||||
"#,
|
||||
r#"Config { dotfiles = [ (dotfile { source = "config/test.conf"; target = "~/.config/test/test.conf"; }) ]; }"#,
|
||||
);
|
||||
sandbox.write_source("config/test.conf", "test content");
|
||||
|
||||
|
|
@ -135,12 +126,7 @@ dotfile:
|
|||
fn test_apply_creates_symlink() {
|
||||
let sandbox = Sandbox::new("apply-symlink");
|
||||
sandbox.write_config(
|
||||
r#"
|
||||
dotfile:
|
||||
source = "config/app.conf"
|
||||
target = "~/.config/app/app.conf"
|
||||
deploy = "link"
|
||||
"#,
|
||||
r#"Config { dotfiles = [ (dotfile { source = "config/app.conf"; target = "~/.config/app/app.conf"; deploy = "link"; }) ]; }"#,
|
||||
);
|
||||
sandbox.write_source("config/app.conf", "app config content");
|
||||
|
||||
|
|
@ -162,7 +148,7 @@ dotfile:
|
|||
fn test_apply_unchanged_on_rerun() {
|
||||
let sandbox = Sandbox::new("apply-unchanged");
|
||||
sandbox.write_config(
|
||||
"dotfile:\n source = \"config/app.conf\"\n target = \"~/.config/app/app.conf\"\n deploy = \"link\"\n",
|
||||
"Config { dotfiles = [ (dotfile { source = \"config/app.conf\"; target = \"~/.config/app/app.conf\"; deploy = \"link\"; }) ]; }",
|
||||
);
|
||||
sandbox.write_source("config/app.conf", "content");
|
||||
|
||||
|
|
@ -184,11 +170,7 @@ fn test_apply_unchanged_on_rerun() {
|
|||
fn test_apply_creates_copy() {
|
||||
let sandbox = Sandbox::new("apply-copy");
|
||||
sandbox.write_config(
|
||||
r#"
|
||||
dotfile:
|
||||
source = "config/app.conf"
|
||||
target = "~/.config/app/app.conf"
|
||||
"#,
|
||||
r#"Config { dotfiles = [ (dotfile { source = "config/app.conf"; target = "~/.config/app/app.conf"; }) ]; }"#,
|
||||
);
|
||||
sandbox.write_source("config/app.conf", "app config content");
|
||||
|
||||
|
|
@ -210,7 +192,7 @@ dotfile:
|
|||
fn test_apply_copy_unchanged_on_rerun() {
|
||||
let sandbox = Sandbox::new("apply-copy-unchanged");
|
||||
sandbox.write_config(
|
||||
"dotfile:\n source = \"config/app.conf\"\n target = \"~/.config/app/app.conf\"\n",
|
||||
"Config { dotfiles = [ (dotfile { source = \"config/app.conf\"; target = \"~/.config/app/app.conf\"; }) ]; }",
|
||||
);
|
||||
sandbox.write_source("config/app.conf", "content");
|
||||
|
||||
|
|
@ -229,12 +211,7 @@ fn test_apply_copy_unchanged_on_rerun() {
|
|||
fn test_template_redeploys_when_env_changes() {
|
||||
let sandbox = Sandbox::new("template-env-change");
|
||||
sandbox.write_config(
|
||||
r#"
|
||||
dotfile:
|
||||
source = "templates/app.conf"
|
||||
target = "~/.config/app/app.conf"
|
||||
template = true
|
||||
"#,
|
||||
r#"Config { dotfiles = [ (dotfile { source = "templates/app.conf"; target = "~/.config/app/app.conf"; template = true; }) ]; }"#,
|
||||
);
|
||||
sandbox.write_source("templates/app.conf", "value = {{ env.TEMPLATE_VAL }}\n");
|
||||
|
||||
|
|
@ -274,11 +251,7 @@ dotfile:
|
|||
fn test_status_shows_state() {
|
||||
let sandbox = Sandbox::new("status");
|
||||
sandbox.write_config(
|
||||
r#"
|
||||
dotfile:
|
||||
source = "config/app.conf"
|
||||
target = "~/.config/app/app.conf"
|
||||
"#,
|
||||
r#"Config { dotfiles = [ (dotfile { source = "config/app.conf"; target = "~/.config/app/app.conf"; }) ]; }"#,
|
||||
);
|
||||
sandbox.write_source("config/app.conf", "content");
|
||||
sandbox.run(&["apply"]);
|
||||
|
|
@ -291,11 +264,7 @@ dotfile:
|
|||
fn test_snapshot_and_rollback() {
|
||||
let sandbox = Sandbox::new("snapshot");
|
||||
sandbox.write_config(
|
||||
r#"
|
||||
dotfile:
|
||||
source = "config/app.conf"
|
||||
target = "~/.config/app/app.conf"
|
||||
"#,
|
||||
r#"Config { dotfiles = [ (dotfile { source = "config/app.conf"; target = "~/.config/app/app.conf"; }) ]; }"#,
|
||||
);
|
||||
sandbox.write_source("config/app.conf", "v1");
|
||||
sandbox.run(&["apply"]);
|
||||
|
|
@ -316,11 +285,7 @@ fn test_dotfile_with_when_condition() {
|
|||
let sandbox = Sandbox::new("conditional");
|
||||
|
||||
// Test that 'when' condition works - only deploy if condition is true
|
||||
let config = r#"dotfile:
|
||||
source = "config/test.conf"
|
||||
target = "~/.config/test.conf"
|
||||
when = true
|
||||
"#;
|
||||
let config = r#"Config { dotfiles = optionals true [ (dotfile { source = "config/test.conf"; target = "~/.config/test.conf"; }) ]; }"#;
|
||||
sandbox.write_config(config);
|
||||
sandbox.write_source("config/test.conf", "test content");
|
||||
|
||||
|
|
@ -338,11 +303,7 @@ fn test_dotfile_with_when_condition() {
|
|||
fn test_dotfile_when_false_skips() {
|
||||
let sandbox = Sandbox::new("when-false");
|
||||
|
||||
let config = r#"dotfile:
|
||||
source = "config/skip.conf"
|
||||
target = "~/.config/skip.conf"
|
||||
when = false
|
||||
"#;
|
||||
let config = r#"Config { dotfiles = optionals false [ (dotfile { source = "config/skip.conf"; target = "~/.config/skip.conf"; }) ]; }"#;
|
||||
sandbox.write_config(config);
|
||||
sandbox.write_source("config/skip.conf", "should not deploy");
|
||||
|
||||
|
|
@ -360,11 +321,7 @@ fn test_dotfile_when_false_skips() {
|
|||
fn test_diff_shows_changes() {
|
||||
let sandbox = Sandbox::new("diff");
|
||||
sandbox.write_config(
|
||||
r#"
|
||||
dotfile:
|
||||
source = "config/app.conf"
|
||||
target = "~/.config/app/app.conf"
|
||||
"#,
|
||||
r#"Config { dotfiles = [ (dotfile { source = "config/app.conf"; target = "~/.config/app/app.conf"; }) ]; }"#,
|
||||
);
|
||||
sandbox.write_source("config/app.conf", "new content");
|
||||
|
||||
|
|
@ -696,11 +653,7 @@ fn test_reencrypt_age_files() {
|
|||
)
|
||||
.unwrap();
|
||||
|
||||
sandbox.write_config(
|
||||
r#"
|
||||
package: "git"
|
||||
"#,
|
||||
);
|
||||
sandbox.write_config(r#"Config { packages = [ (package "git") ]; }"#);
|
||||
|
||||
// Write the first identity back (reencrypt needs it to decrypt)
|
||||
std::fs::write(sandbox.config_dir().join("identity.txt"), &identity_content).unwrap();
|
||||
|
|
@ -746,7 +699,7 @@ package: "git"
|
|||
#[test]
|
||||
fn test_reencrypt_no_identity_fails() {
|
||||
let sandbox = Sandbox::new("reencrypt-no-id");
|
||||
sandbox.write_config("package: \"git\"\n");
|
||||
sandbox.write_config("Config { packages = [ (package \"git\") ]; }");
|
||||
|
||||
let output = sandbox.run(&[
|
||||
"reencrypt",
|
||||
|
|
@ -769,7 +722,7 @@ fn test_reencrypt_no_age_files_reports_zero() {
|
|||
.trim()
|
||||
.to_string();
|
||||
|
||||
sandbox.write_config("package: \"git\"\n");
|
||||
sandbox.write_config("Config { packages = [ (package \"git\") ]; }");
|
||||
|
||||
let output = sandbox.run(&["reencrypt", "--recipient", &pubkey]);
|
||||
assert!(output.status.success(), "reencrypt failed: {:?}", output);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ edition.workspace = true
|
|||
|
||||
[dependencies]
|
||||
doot-utils.workspace = true
|
||||
doot-lang.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
toml.workspace = true
|
||||
|
|
@ -20,7 +19,11 @@ anyhow.workspace = true
|
|||
hostname = "0.4"
|
||||
regex-lite = "0.1"
|
||||
glob = "0.3"
|
||||
indexmap = "2"
|
||||
rayon.workspace = true
|
||||
minijinja = { version = "2", features = ["builtins"] }
|
||||
which = "7"
|
||||
tracing.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
|
|
|||
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.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
pub struct Settings {
|
||||
/// Directory containing dotfile sources.
|
||||
pub source_dir: PathBuf,
|
||||
/// Doot configuration directory.
|
||||
|
|
@ -26,7 +26,7 @@ pub struct Config {
|
|||
pub verbose: bool,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
impl Settings {
|
||||
/// Creates a new config with the given source directory.
|
||||
#[tracing::instrument(skip_all, fields(source_dir = %source_dir.display()))]
|
||||
pub fn new(source_dir: PathBuf) -> Self {
|
||||
|
|
@ -101,7 +101,7 @@ impl Config {
|
|||
}
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self::new(Self::default_source_dir())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
//! Symlink management.
|
||||
|
||||
use super::{DeployAction, DeployError};
|
||||
use crate::config::Config;
|
||||
use crate::config::Settings;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Creates and manages symlinks.
|
||||
pub struct Linker {
|
||||
config: Config,
|
||||
config: Settings,
|
||||
}
|
||||
|
||||
impl Linker {
|
||||
/// Creates a new linker.
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub fn new(config: Config) -> Self {
|
||||
pub fn new(config: Settings) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@ pub mod diff;
|
|||
pub mod linker;
|
||||
pub mod template;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::config::Settings;
|
||||
use crate::evaluator::DotfileConfig;
|
||||
use crate::state::StateStore;
|
||||
use crate::state::store::DeployMode;
|
||||
use doot_lang::evaluator::DotfileConfig;
|
||||
use glob::Pattern;
|
||||
use indicatif::ProgressBar;
|
||||
use rayon::prelude::*;
|
||||
|
|
@ -96,7 +96,7 @@ pub struct DeployErrorInfo {
|
|||
|
||||
/// Handles dotfile deployment.
|
||||
pub struct Deployer {
|
||||
config: Arc<Config>,
|
||||
config: Arc<Settings>,
|
||||
linker: Arc<Linker>,
|
||||
template_engine: Arc<TemplateEngine>,
|
||||
state: Arc<Mutex<StateStore>>,
|
||||
|
|
@ -107,9 +107,9 @@ impl Deployer {
|
|||
/// Creates a new deployer.
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub fn new(
|
||||
config: Config,
|
||||
config: Settings,
|
||||
sandbox: bool,
|
||||
template_vars: Option<&std::collections::HashMap<String, doot_lang::evaluator::Value>>,
|
||||
template_vars: Option<&std::collections::HashMap<String, crate::evaluator::Value>>,
|
||||
) -> Self {
|
||||
let state = StateStore::new(&config.state_file);
|
||||
let linker = Linker::new(config.clone());
|
||||
|
|
@ -132,7 +132,7 @@ impl Deployer {
|
|||
return Ok(());
|
||||
}
|
||||
|
||||
let home = crate::config::Config::home_dir();
|
||||
let home = crate::config::Settings::home_dir();
|
||||
let target_canonical = target
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| target.to_path_buf());
|
||||
|
|
@ -497,8 +497,8 @@ impl Deployer {
|
|||
.to_string();
|
||||
|
||||
let base_mode = match dotfile.deploy {
|
||||
doot_lang::evaluator::DeployMode::Copy => DeployMode::Copy,
|
||||
doot_lang::evaluator::DeployMode::Link => DeployMode::Link,
|
||||
crate::evaluator::DeployMode::Copy => DeployMode::Copy,
|
||||
crate::evaluator::DeployMode::Link => DeployMode::Link,
|
||||
};
|
||||
tracing::trace!(mode = ?base_mode, "resolved deploy mode");
|
||||
|
||||
|
|
@ -637,7 +637,7 @@ fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
use doot_lang::evaluator::PermissionRule;
|
||||
use crate::evaluator::PermissionRule;
|
||||
|
||||
#[tracing::instrument(level = "trace", skip_all)]
|
||||
fn apply_permissions(target: &Path, rules: &[PermissionRule]) -> Result<(), DeployError> {
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ impl TemplateEngine {
|
|||
}
|
||||
|
||||
/// Sets multiple variables from doot evaluator values.
|
||||
pub fn set_doot_variables(&mut self, vars: &HashMap<String, doot_lang::evaluator::Value>) {
|
||||
pub fn set_doot_variables(&mut self, vars: &HashMap<String, crate::evaluator::Value>) {
|
||||
for (key, value) in vars {
|
||||
self.variables
|
||||
.insert(key.clone(), doot_value_to_minijinja(value));
|
||||
|
|
@ -444,8 +444,8 @@ fn json_to_minijinja(json: &serde_json::Value) -> Value {
|
|||
}
|
||||
|
||||
/// Converts a doot evaluator Value to a minijinja Value.
|
||||
fn doot_value_to_minijinja(val: &doot_lang::evaluator::Value) -> Value {
|
||||
use doot_lang::evaluator::Value as DootValue;
|
||||
fn doot_value_to_minijinja(val: &crate::evaluator::Value) -> Value {
|
||||
use crate::evaluator::Value as DootValue;
|
||||
match val {
|
||||
DootValue::Int(n) => Value::from(*n),
|
||||
DootValue::Float(n) => Value::from(*n),
|
||||
|
|
@ -465,7 +465,6 @@ fn doot_value_to_minijinja(val: &doot_lang::evaluator::Value) -> Value {
|
|||
}
|
||||
DootValue::Enum(_, variant) => Value::from(variant.as_str()),
|
||||
DootValue::None => Value::UNDEFINED,
|
||||
_ => Value::UNDEFINED, // Function, Lambda, Future
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
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.
|
||||
|
||||
use doot_lang::HookStage;
|
||||
use crate::evaluator::HookStage;
|
||||
use std::process::Command;
|
||||
use thiserror::Error;
|
||||
|
||||
|
|
|
|||
|
|
@ -3,17 +3,20 @@
|
|||
//! Provides configuration, deployment, encryption, package management,
|
||||
//! and state tracking.
|
||||
|
||||
pub mod builtins;
|
||||
pub mod config;
|
||||
pub mod deploy;
|
||||
pub mod encryption;
|
||||
pub mod evaluator;
|
||||
pub mod hooks;
|
||||
pub mod os;
|
||||
pub mod package;
|
||||
pub mod state;
|
||||
|
||||
pub use config::Config;
|
||||
pub use config::Settings;
|
||||
pub use deploy::{DeployAction, DeployResult, Deployer};
|
||||
pub use encryption::AgeEncryption;
|
||||
pub use evaluator::HookStage;
|
||||
pub use hooks::HookRunner;
|
||||
pub use os::OsInfo;
|
||||
pub use package::PackageManager;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
//! State persistence for doot.
|
||||
|
||||
use doot_lang::evaluator::PermissionRule;
|
||||
use crate::evaluator::PermissionRule;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
|
|
|||
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
|
||||
|
||||
[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.
|
||||
//!
|
||||
//! This crate provides the lexer, parser, type checker, and evaluator
|
||||
//! for the doot configuration language.
|
||||
//! The doot configuration language: a lazy, pure, typed expression language.
|
||||
//! Lexer, parser, Hindley-Milner type checker, lazy CEK evaluator, and an
|
||||
//! `Engine` registration API for embedding a standard library and domain
|
||||
//! vocabulary. Domain-free: it knows nothing about dotfiles.
|
||||
|
||||
// chumsky 0.9's `Simple<Token>` error type is inherently large (~152 bytes) and
|
||||
// is fixed by the parser-combinator API, so it cannot be boxed at the `select!`
|
||||
// call sites. This lint is unactionable here.
|
||||
#![allow(clippy::result_large_err)]
|
||||
|
||||
pub mod ast;
|
||||
pub mod builtins;
|
||||
pub mod evaluator;
|
||||
pub mod lexer;
|
||||
pub mod macros;
|
||||
pub mod parser;
|
||||
pub mod planner;
|
||||
pub mod type_checker;
|
||||
pub mod types;
|
||||
|
||||
pub use ast::*;
|
||||
pub use evaluator::Evaluator;
|
||||
pub use lexer::Lexer;
|
||||
pub use parser::Parser;
|
||||
pub use planner::{DotfileConflict, DotfileValidation, DotfileWarning, validate_dotfile_targets};
|
||||
pub use type_checker::TypeChecker;
|
||||
pub use types::Type;
|
||||
pub mod lang;
|
||||
|
|
|
|||
|
|
@ -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