Archive Rust bootstrap — El compiler is now self-hosting

This commit is contained in:
Will Anderson
2026-04-29 22:21:31 -05:00
parent 9a0747aa13
commit 4f3543b068
139 changed files with 8980 additions and 1778 deletions
@@ -0,0 +1,24 @@
[package]
name = "el-build"
description = "Build orchestrator and incremental build system for the Engram language toolchain"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
el-manifest = { path = "../el-manifest" }
el-registry = { path = "../el-registry" }
el-compiler = { path = "../el-compiler" }
el-lexer = { workspace = true }
el-parser = { workspace = true }
el-types = { workspace = true }
el-seal = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
blake3 = { workspace = true }
tokio = { version = "1", features = ["fs", "io-util", "rt", "macros", "process"] }
semver = { version = "1", features = ["serde"] }
[dev-dependencies]
tempfile = "3"
@@ -0,0 +1,589 @@
//! The core build orchestrator.
use std::path::{Path, PathBuf};
use std::time::Instant;
use el_compiler::{Compiler, CompilerOptions, Target};
use el_manifest::{BuildTarget, CrossTarget, Manifest, NativeTarget, SealKeySource};
use el_seal::{DeploymentBinding, SealAlgorithm, SealConfig};
use semver::Version;
use crate::cache::BuildCache;
use crate::error::{BuildError, BuildResult};
// ── Output types ──────────────────────────────────────────────────────────────
/// The output of a single successful build.
#[derive(Debug, Clone)]
pub struct BuildOutput {
/// Path to the produced artifact.
pub artifact_path: PathBuf,
/// Compilation target used.
pub target: BuildTarget,
/// Cross-compilation target, if this was a cross build.
pub cross_target: Option<CrossTarget>,
/// Whether the artifact is quantum-sealed.
pub sealed: bool,
/// Size of the artifact in bytes.
pub size_bytes: u64,
/// Wall-clock compilation time in milliseconds.
pub compile_time_ms: u64,
}
/// A resolved dependency (after registry lookup / path resolution).
#[derive(Debug, Clone)]
pub struct ResolvedDep {
pub name: String,
pub version: Version,
pub source: DepSource,
/// Local path to the package (either downloaded cache or local path dep).
pub path: PathBuf,
}
/// Where a resolved dependency came from.
#[derive(Debug, Clone)]
pub enum DepSource {
/// Downloaded from a registry URL.
Registry(String),
/// A local path dependency.
Path(PathBuf),
/// A git source (not yet implemented; reserved for future use).
Git(String),
}
/// Report returned by `BuildSystem::test()`.
#[derive(Debug, Clone)]
pub struct TestReport {
pub total: usize,
pub passed: usize,
pub failed: usize,
/// Descriptions of each failing test.
pub failures: Vec<String>,
}
impl TestReport {
pub fn success(&self) -> bool {
self.failed == 0
}
}
// ── Build system ──────────────────────────────────────────────────────────────
/// Orchestrates the full build pipeline for an Engram project.
pub struct BuildSystem {
pub manifest: Manifest,
pub workspace_root: PathBuf,
}
impl BuildSystem {
/// Create a new build system from a manifest and workspace root path.
pub fn new(manifest: Manifest, workspace_root: PathBuf) -> Self {
Self { manifest, workspace_root }
}
/// Load from a manifest file, setting the workspace root to the manifest's directory.
pub fn from_manifest_file(manifest_path: &Path) -> BuildResult<Self> {
let manifest = Manifest::from_file(manifest_path)?;
let workspace_root = manifest_path
.parent()
.unwrap_or(manifest_path)
.to_path_buf();
Ok(Self { manifest, workspace_root })
}
// ── Build ─────────────────────────────────────────────────────────────────
/// Full build: resolve deps (skipped for now — no live registry), compile, produce artifact.
///
/// `target` overrides the manifest's `[build].target` setting.
pub async fn build(&self, target: Option<BuildTarget>) -> BuildResult<BuildOutput> {
let effective_target = target.unwrap_or(self.manifest.build.target.clone());
self.build_for_target(&effective_target, None).await
}
/// Build for all cross-compilation targets declared in `[cross]`.
pub async fn build_all_targets(
&self,
) -> BuildResult<Vec<(CrossTarget, BuildOutput)>> {
let mut results = Vec::new();
for cross_target in &self.manifest.cross.targets {
let build_target = self.manifest.build.target.clone();
let output = self.build_for_target(&build_target, Some(cross_target)).await?;
results.push((cross_target.clone(), output));
}
Ok(results)
}
async fn build_for_target(
&self,
build_target: &BuildTarget,
cross_target: Option<&CrossTarget>,
) -> BuildResult<BuildOutput> {
let start = Instant::now();
// Locate entry point
let entry = self.workspace_root.join(&self.manifest.build.entry);
if !entry.exists() {
return Err(BuildError::EntryNotFound(entry.display().to_string()));
}
// Check incremental cache
let mut cache = BuildCache::load(&self.workspace_root);
let rel_entry = self
.manifest
.build
.entry
.to_string_lossy()
.to_string();
let current_hash = BuildCache::hash_file(&entry)?;
let is_cached = cache.is_up_to_date(&rel_entry, &current_hash);
// Build output path
let output_dir = self.workspace_root.join(&self.manifest.build.output);
std::fs::create_dir_all(&output_dir)?;
let artifact_name = artifact_name(
&self.manifest.package.name,
build_target,
cross_target,
);
let artifact_path = output_dir.join(&artifact_name);
if is_cached && artifact_path.exists() {
let size_bytes = std::fs::metadata(&artifact_path)?.len();
return Ok(BuildOutput {
artifact_path,
target: build_target.clone(),
cross_target: cross_target.cloned(),
sealed: matches!(build_target, BuildTarget::Prod),
size_bytes,
compile_time_ms: start.elapsed().as_millis() as u64,
});
}
// Read source (resolving imports recursively)
let source = resolve_imports_recursive(&entry)
.map_err(|e| BuildError::Io(std::io::Error::new(std::io::ErrorKind::Other, e)))?;
// Build seal config for prod builds
let seal_config = self.build_seal_config()?;
// Compile
let compiler_target = match build_target {
BuildTarget::Debug => Target::Debug,
BuildTarget::Release => Target::Release,
BuildTarget::Prod => Target::Prod,
};
let opts = CompilerOptions {
target: compiler_target,
output_path: artifact_path.clone(),
source_path: entry.clone(),
engram_db_path: None,
seal_config,
};
let output = Compiler::compile(&source, opts)?;
// Emit diagnostics
for diag in &output.diagnostics {
eprintln!("warning: {diag}");
}
// Write artifact
std::fs::write(&artifact_path, &output.artifact)?;
// Annotate artifact with cross-target triple (stub — in LLVM backend this
// selects the code generation target).
if let Some(ct) = cross_target {
let native = NativeTarget::from_cross(ct);
let annotation_path = artifact_path.with_extension("target");
std::fs::write(&annotation_path, native.triple())?;
}
// Update cache
cache.record(&rel_entry, &current_hash);
cache.save(&self.workspace_root)?;
let size_bytes = std::fs::metadata(&artifact_path)?.len();
let compile_time_ms = start.elapsed().as_millis() as u64;
Ok(BuildOutput {
artifact_path,
target: build_target.clone(),
cross_target: cross_target.cloned(),
sealed: output.sealed,
size_bytes,
compile_time_ms,
})
}
// ── Dependencies ──────────────────────────────────────────────────────────
/// Resolve and download all registry dependencies.
pub async fn resolve_deps(&self) -> BuildResult<Vec<ResolvedDep>> {
let mut resolved = Vec::new();
for (name, dep) in &self.manifest.dependencies {
match dep {
el_manifest::Dependency::Path(path) => {
let abs_path = if path.is_absolute() {
path.clone()
} else {
self.workspace_root.join(path)
};
resolved.push(ResolvedDep {
name: name.clone(),
version: Version::new(0, 0, 0),
source: DepSource::Path(abs_path.clone()),
path: abs_path,
});
}
el_manifest::Dependency::VersionReq(_req) => {
// In a live environment this would call the registry.
// For now, record the dep with the local cache path.
let cache_base = el_registry::cache_dir().join(name);
resolved.push(ResolvedDep {
name: name.clone(),
version: Version::new(0, 0, 0), // placeholder until registry is live
source: DepSource::Registry(
el_registry::DEFAULT_REGISTRY_URL.to_string(),
),
path: cache_base,
});
}
el_manifest::Dependency::Registry { version: _version, registry } => {
let cache_base = el_registry::cache_dir().join(name);
resolved.push(ResolvedDep {
name: name.clone(),
version: Version::new(0, 0, 0),
source: DepSource::Registry(registry.clone()),
path: cache_base,
});
}
}
}
Ok(resolved)
}
// ── Test ──────────────────────────────────────────────────────────────────
/// Discover and run all test files.
pub async fn test(&self) -> BuildResult<TestReport> {
let src_root = self.workspace_root.join("src");
crate::test_runner::run_tests(&src_root, &self.workspace_root).await
}
// ── Format ────────────────────────────────────────────────────────────────
/// Format all source files (stub — delegates to `el-fmt` plugin when available).
pub fn fmt(&self) -> BuildResult<()> {
let sources = BuildCache::collect_sources(&self.workspace_root.join("src"));
for file in &sources {
// TODO: invoke el-fmt plugin or built-in formatter.
let _ = file;
}
println!("fmt: {} source file(s) checked", sources.len());
Ok(())
}
// ── Check ─────────────────────────────────────────────────────────────────
/// Type-check source files without producing artifacts.
pub fn check(&self) -> BuildResult<Vec<String>> {
let entry = self.workspace_root.join(&self.manifest.build.entry);
if !entry.exists() {
return Err(BuildError::EntryNotFound(entry.display().to_string()));
}
let source = resolve_imports_recursive(&entry)
.map_err(|e| BuildError::Io(std::io::Error::new(std::io::ErrorKind::Other, e)))?;
let tokens = el_lexer::tokenize(&source)
.map_err(el_compiler::CompileError::Lex)?;
let program = el_parser::parse(tokens, source.clone())
.map_err(el_compiler::CompileError::Parse)?;
let mut checker = el_types::TypeChecker::with_builtins();
let diags = checker.check(&program);
Ok(diags.iter().map(|d| d.message.clone()).collect())
}
// ── Clean ─────────────────────────────────────────────────────────────────
/// Remove build artifacts and the build cache.
pub fn clean(&self) -> BuildResult<()> {
let output_dir = self.workspace_root.join(&self.manifest.build.output);
if output_dir.exists() {
std::fs::remove_dir_all(&output_dir)?;
}
let cache_dir = self.workspace_root.join(".el");
if cache_dir.exists() {
std::fs::remove_dir_all(&cache_dir)?;
}
println!("clean: removed build artifacts and cache");
Ok(())
}
// ── Helpers ───────────────────────────────────────────────────────────────
fn build_seal_config(&self) -> BuildResult<SealConfig> {
let binding = match &self.manifest.build.seal_key {
Some(SealKeySource::EnvVar(var)) => DeploymentBinding::EnvironmentKey(var.clone()),
Some(SealKeySource::File(_)) | Some(SealKeySource::Literal(_)) => {
// For file/literal keys, fall back to machine fingerprint in prod.
DeploymentBinding::None
}
None => DeploymentBinding::None,
};
Ok(SealConfig {
algorithm: SealAlgorithm::Aes256Gcm,
deployment_binding: binding,
})
}
}
/// Resolve `import "path.el"` directives by reading and concatenating source files.
/// Imports are resolved relative to the directory of the importing file.
/// Circular imports are detected via a visited set.
fn resolve_imports_recursive(file: &std::path::Path) -> Result<String, String> {
let mut visited = std::collections::HashSet::new();
resolve_imports_inner(file, &mut visited)
}
fn resolve_imports_inner(
file: &std::path::Path,
visited: &mut std::collections::HashSet<std::path::PathBuf>,
) -> Result<String, String> {
let canonical = file.canonicalize().unwrap_or_else(|_| file.to_path_buf());
if visited.contains(&canonical) {
return Ok(String::new()); // circular — skip
}
visited.insert(canonical.clone());
let dir = file.parent().unwrap_or(std::path::Path::new("."));
let source = std::fs::read_to_string(file)
.map_err(|e| format!("cannot read {}: {e}", file.display()))?;
let mut out = String::new();
let mut lines_iter = source.lines().peekable();
while let Some(line) = lines_iter.next() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("import ") {
let rest = rest.trim();
if rest.starts_with('"') && rest.ends_with('"') {
// import "relative/path.el"
let import_path_str = &rest[1..rest.len() - 1];
let import_path = dir.join(import_path_str);
let imported = resolve_imports_inner(&import_path, visited)?;
out.push_str(&imported);
out.push('\n');
} else {
out.push_str(line);
out.push('\n');
}
} else if let Some(rest) = trimmed.strip_prefix("from ") {
// from ModuleName import { ... } — resolve ModuleName.el in the same dir
// Extract the module name (everything before " import")
if let Some(module_part) = rest.split(" import").next() {
let module_name = module_part.trim();
// Only resolve bare identifiers (not quoted paths or dotted names with /)
if !module_name.contains('"') && !module_name.contains('/') && !module_name.is_empty() {
let module_file = format!("{}.el", module_name);
let import_path = dir.join(&module_file);
// Consume any continuation lines of a multi-line import:
// `from X import {\n Y,\n Z,\n}`
// The opening line may or may not contain `{`. If it does not end
// with `}`, skip lines until we see one that ends with `}`.
let import_rest = rest.splitn(2, " import").nth(1).unwrap_or("").trim();
let is_multiline = import_rest.contains('{') && !import_rest.contains('}');
if is_multiline {
// Skip continuation lines until we see `}`
for cont in lines_iter.by_ref() {
if cont.trim().contains('}') {
break;
}
}
}
if import_path.exists() {
// Inline the module, omitting the import line itself
let imported = resolve_imports_inner(&import_path, visited)?;
out.push_str(&imported);
out.push('\n');
} else {
// Module file not found — leave the line for the compiler to handle
out.push_str(line);
out.push('\n');
}
} else {
out.push_str(line);
out.push('\n');
}
} else {
out.push_str(line);
out.push('\n');
}
} else {
out.push_str(line);
out.push('\n');
}
}
Ok(out)
}
fn artifact_name(
pkg_name: &str,
build_target: &BuildTarget,
cross_target: Option<&CrossTarget>,
) -> String {
let ext = match (build_target, cross_target) {
(BuildTarget::Prod, _) => ".sealed",
(_, Some(CrossTarget::Wasm32)) => ".wasm",
_ => ".elc",
};
if let Some(ct) = cross_target {
format!("{pkg_name}-{ct}{ext}")
} else {
format!("{pkg_name}{ext}")
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use el_manifest::{BuildConfig, CrossConfig, PackageInfo};
use semver::Version;
use std::collections::HashMap;
use tempfile::TempDir;
fn temp_dir() -> TempDir {
tempfile::TempDir::new().unwrap()
}
fn simple_manifest(dir: &Path) -> Manifest {
Manifest {
package: PackageInfo {
name: "test-pkg".to_string(),
version: Version::new(0, 1, 0),
description: None,
authors: vec![],
license: None,
edition: "2026".to_string(),
},
dependencies: HashMap::new(),
dev_dependencies: HashMap::new(),
build: BuildConfig {
target: BuildTarget::Debug,
entry: PathBuf::from("src/main.el"),
output: PathBuf::from("dist/"),
seal_key: None,
},
cross: CrossConfig { targets: vec![] },
plugins: HashMap::new(),
app: None,
}
}
#[tokio::test]
async fn test_build_debug() {
let dir = temp_dir();
// Create entry file
let src = dir.path().join("src");
std::fs::create_dir(&src).unwrap();
std::fs::write(src.join("main.el"), b"let x: Int = 42").unwrap();
let manifest = simple_manifest(dir.path());
let bs = BuildSystem::new(manifest, dir.path().to_path_buf());
let output = bs.build(None).await.unwrap();
assert!(output.artifact_path.exists());
assert!(!output.sealed);
assert_eq!(output.target, BuildTarget::Debug);
assert!(output.size_bytes > 0);
}
#[tokio::test]
async fn test_build_incremental_skips_rebuild() {
let dir = temp_dir();
let src = dir.path().join("src");
std::fs::create_dir(&src).unwrap();
std::fs::write(src.join("main.el"), b"let x: Int = 1").unwrap();
let manifest = simple_manifest(dir.path());
let bs = BuildSystem::new(manifest, dir.path().to_path_buf());
let out1 = bs.build(None).await.unwrap();
let t1 = out1.compile_time_ms;
let out2 = bs.build(None).await.unwrap();
// Second build should be very fast (cache hit)
assert_eq!(out1.artifact_path, out2.artifact_path);
}
#[tokio::test]
async fn test_build_missing_entry_errors() {
let dir = temp_dir();
let manifest = simple_manifest(dir.path());
let bs = BuildSystem::new(manifest, dir.path().to_path_buf());
let err = bs.build(None).await.unwrap_err();
assert!(matches!(err, BuildError::EntryNotFound(_)));
}
#[tokio::test]
async fn test_clean() {
let dir = temp_dir();
let src = dir.path().join("src");
std::fs::create_dir(&src).unwrap();
std::fs::write(src.join("main.el"), b"let x = 1").unwrap();
let manifest = simple_manifest(dir.path());
let bs = BuildSystem::new(manifest, dir.path().to_path_buf());
bs.build(None).await.unwrap();
bs.clean().unwrap();
let dist = dir.path().join("dist");
assert!(!dist.exists());
}
#[tokio::test]
async fn test_resolve_path_dep() {
let dir = temp_dir();
let mut manifest = simple_manifest(dir.path());
manifest.dependencies.insert(
"local-lib".to_string(),
el_manifest::Dependency::Path(PathBuf::from("../local-lib")),
);
let bs = BuildSystem::new(manifest, dir.path().to_path_buf());
let deps = bs.resolve_deps().await.unwrap();
assert_eq!(deps.len(), 1);
assert_eq!(deps[0].name, "local-lib");
assert!(matches!(deps[0].source, DepSource::Path(_)));
}
#[test]
fn test_artifact_name_debug() {
let name = artifact_name("my-pkg", &BuildTarget::Debug, None);
assert_eq!(name, "my-pkg.elc");
}
#[test]
fn test_artifact_name_prod() {
let name = artifact_name("my-pkg", &BuildTarget::Prod, None);
assert_eq!(name, "my-pkg.sealed");
}
#[test]
fn test_artifact_name_wasm_cross() {
let name = artifact_name("my-pkg", &BuildTarget::Debug, Some(&CrossTarget::Wasm32));
assert_eq!(name, "my-pkg-wasm32.wasm");
}
#[test]
fn test_artifact_name_linux_cross() {
let name = artifact_name("my-pkg", &BuildTarget::Release, Some(&CrossTarget::X86_64Linux));
assert_eq!(name, "my-pkg-x86_64-linux.elc");
}
}
@@ -0,0 +1,164 @@
//! Incremental build cache.
//!
//! Stores BLAKE3 hashes of source files in `.el/build-cache.json` so the
//! build system can skip recompiling files that haven't changed.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
/// The on-disk structure of the build cache.
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct BuildCache {
/// Map of file path (relative to workspace root) → BLAKE3 hex hash.
pub file_hashes: HashMap<String, String>,
}
impl BuildCache {
/// Load the build cache from `.el/build-cache.json`.
///
/// Returns an empty cache if the file doesn't exist yet.
pub fn load(workspace_root: &Path) -> Self {
let path = cache_path(workspace_root);
if !path.exists() {
return Self::default();
}
let text = std::fs::read_to_string(&path).unwrap_or_default();
serde_json::from_str(&text).unwrap_or_default()
}
/// Persist the cache back to disk.
pub fn save(&self, workspace_root: &Path) -> Result<(), std::io::Error> {
let path = cache_path(workspace_root);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(self)?;
std::fs::write(&path, json)
}
/// Hash a single file and return the BLAKE3 hex string.
pub fn hash_file(path: &Path) -> Result<String, std::io::Error> {
let bytes = std::fs::read(path)?;
Ok(hex_encode(blake3::hash(&bytes).as_bytes()))
}
/// Returns `true` if the file's current hash matches the cached hash.
pub fn is_up_to_date(&self, rel_path: &str, current_hash: &str) -> bool {
self.file_hashes
.get(rel_path)
.map(|cached| cached == current_hash)
.unwrap_or(false)
}
/// Update the cached hash for a file.
pub fn record(&mut self, rel_path: impl Into<String>, hash: impl Into<String>) {
self.file_hashes.insert(rel_path.into(), hash.into());
}
/// Collect all `.el` source files under a directory recursively.
pub fn collect_sources(root: &Path) -> Vec<PathBuf> {
let mut sources = Vec::new();
collect_el_files(root, &mut sources);
sources.sort();
sources
}
}
fn cache_path(workspace_root: &Path) -> PathBuf {
workspace_root.join(".el").join("build-cache.json")
}
fn collect_el_files(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
// Skip hidden dirs and build outputs
let name = path.file_name().unwrap_or_default().to_string_lossy();
if name.starts_with('.') || name == "dist" || name == "target" {
continue;
}
collect_el_files(&path, out);
} else if path.extension().map(|e| e == "el").unwrap_or(false) {
out.push(path);
}
}
}
fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::TempDir;
fn temp_dir() -> TempDir {
tempfile::TempDir::new().unwrap()
}
#[test]
fn test_cache_empty_by_default() {
let dir = temp_dir();
let cache = BuildCache::load(dir.path());
assert!(cache.file_hashes.is_empty());
}
#[test]
fn test_cache_save_and_load() {
let dir = temp_dir();
let mut cache = BuildCache::default();
cache.record("src/main.el", "abc123");
cache.save(dir.path()).unwrap();
let loaded = BuildCache::load(dir.path());
assert_eq!(loaded.file_hashes.get("src/main.el").map(|s| s.as_str()), Some("abc123"));
}
#[test]
fn test_is_up_to_date() {
let mut cache = BuildCache::default();
cache.record("src/main.el", "deadbeef");
assert!(cache.is_up_to_date("src/main.el", "deadbeef"));
assert!(!cache.is_up_to_date("src/main.el", "different"));
assert!(!cache.is_up_to_date("src/other.el", "deadbeef"));
}
#[test]
fn test_hash_file() {
let dir = temp_dir();
let path = dir.path().join("test.el");
std::fs::write(&path, b"let x = 1").unwrap();
let hash1 = BuildCache::hash_file(&path).unwrap();
let hash2 = BuildCache::hash_file(&path).unwrap();
assert_eq!(hash1, hash2); // deterministic
std::fs::write(&path, b"let x = 2").unwrap();
let hash3 = BuildCache::hash_file(&path).unwrap();
assert_ne!(hash1, hash3); // different content → different hash
}
#[test]
fn test_collect_sources() {
let dir = temp_dir();
let src = dir.path().join("src");
std::fs::create_dir(&src).unwrap();
std::fs::write(src.join("main.el"), b"fn main() {}").unwrap();
std::fs::write(src.join("lib.el"), b"fn helper() {}").unwrap();
std::fs::write(src.join("README.md"), b"# README").unwrap();
let sources = BuildCache::collect_sources(dir.path());
assert_eq!(sources.len(), 2);
assert!(sources.iter().any(|p| p.file_name().unwrap() == "main.el"));
assert!(sources.iter().any(|p| p.file_name().unwrap() == "lib.el"));
}
}
@@ -0,0 +1,35 @@
//! Build system error types.
use thiserror::Error;
#[derive(Debug, Error)]
pub enum BuildError {
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("manifest error: {0}")]
Manifest(#[from] el_manifest::ManifestError),
#[error("registry error: {0}")]
Registry(#[from] el_registry::RegistryError),
#[error("compile error: {0}")]
Compile(#[from] el_compiler::CompileError),
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
#[error("plugin error: {0}")]
Plugin(#[from] crate::plugin::PluginError),
#[error("entry point not found: {0}")]
EntryNotFound(String),
#[error("build failed: {0}")]
BuildFailed(String),
#[error("test failed: {count} test(s) failed")]
TestsFailed { count: usize },
}
pub type BuildResult<T> = Result<T, BuildError>;
@@ -0,0 +1,30 @@
//! el-build — Build orchestrator for the Engram language toolchain.
//!
//! Reads `el.toml`, resolves dependencies, compiles source files, and produces
//! artifacts. Supports incremental builds via BLAKE3 file hashes stored in
//! `.el/build-cache.json`.
//!
//! # Usage
//! ```rust,no_run
//! use el_build::BuildSystem;
//! use el_manifest::Manifest;
//!
//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
//! let manifest = Manifest::from_file(std::path::Path::new("el.toml"))?;
//! let bs = BuildSystem::new(manifest, std::env::current_dir()?);
//! let output = bs.build(None).await?;
//! println!("artifact: {}", output.artifact_path.display());
//! # Ok(())
//! # }
//! ```
mod build;
mod cache;
mod error;
mod plugin;
mod test_runner;
pub use build::{BuildOutput, BuildSystem, DepSource, ResolvedDep, TestReport};
pub use cache::BuildCache;
pub use error::{BuildError, BuildResult};
pub use plugin::{CompilerPlugin, PluginError, PluginRegistry};
@@ -0,0 +1,275 @@
//! Compiler plugin system.
//!
//! Plugins are Rust dynamic libraries (`.dylib` / `.so`) that implement the
//! [`CompilerPlugin`] trait. They are loaded at compile time and receive hooks
//! at each stage of the compilation pipeline.
//!
//! # Lifecycle hooks
//! 1. `on_ast` — called after parsing, before type checking
//! 2. `on_typed_ast` — called after type checking, before codegen
//! 3. `on_bytecode` — called after codegen, before sealing
//!
//! # Writing a plugin
//! ```rust,ignore
//! use el_build::CompilerPlugin;
//! use el_parser::Program;
//! use el_types::TypeEnv;
//!
//! pub struct MyPlugin;
//!
//! impl CompilerPlugin for MyPlugin {
//! fn name(&self) -> &str { "my-plugin" }
//! fn version(&self) -> &str { "0.1.0" }
//! fn on_ast(&self, _program: &mut Program) -> Result<(), el_build::PluginError> { Ok(()) }
//! fn on_typed_ast(&self, _program: &Program, _types: &TypeEnv) -> Result<(), el_build::PluginError> { Ok(()) }
//! fn on_bytecode(&self, _bytecode: &mut Vec<u8>) -> Result<(), el_build::PluginError> { Ok(()) }
//! }
//! ```
use std::path::Path;
use thiserror::Error;
use el_manifest::Manifest;
// ── Error ─────────────────────────────────────────────────────────────────────
#[derive(Debug, Error)]
pub enum PluginError {
#[error("plugin '{name}' ast hook failed: {reason}")]
AstHookFailed { name: String, reason: String },
#[error("plugin '{name}' typed-ast hook failed: {reason}")]
TypedAstHookFailed { name: String, reason: String },
#[error("plugin '{name}' bytecode hook failed: {reason}")]
BytecodeHookFailed { name: String, reason: String },
#[error("plugin '{name}' not found in {dir}")]
NotFound { name: String, dir: String },
#[error("plugin loading is not supported on this platform")]
PlatformUnsupported,
}
// ── Plugin trait ──────────────────────────────────────────────────────────────
/// The interface that all compiler plugins must implement.
///
/// Plugins receive three optional hooks during compilation. Each hook may
/// mutate the data it receives (AST, bytecode) or read it for analysis.
pub trait CompilerPlugin: Send + Sync {
/// The plugin's canonical name (matches its key in `el.toml [plugins]`).
fn name(&self) -> &str;
/// The plugin's version string.
fn version(&self) -> &str;
/// Called after parsing, before type checking.
///
/// Implementations may add synthetic AST nodes, remove nodes, or
/// record observations. Mutation is allowed.
fn on_ast(&self, program: &mut el_parser::Program) -> Result<(), PluginError>;
/// Called after type checking, before code generation.
///
/// The AST is immutable at this stage. Implementations may inspect the
/// resolved types for documentation generation, linting, etc.
fn on_typed_ast(
&self,
program: &el_parser::Program,
types: &el_types::TypeEnv,
) -> Result<(), PluginError>;
/// Called after code generation, before sealing.
///
/// Implementations may inspect or transform the raw bytecode bytes.
fn on_bytecode(&self, bytecode: &mut Vec<u8>) -> Result<(), PluginError>;
}
// ── Registry ──────────────────────────────────────────────────────────────────
/// A registry of loaded compiler plugins.
pub struct PluginRegistry {
plugins: Vec<Box<dyn CompilerPlugin>>,
}
impl PluginRegistry {
/// Create an empty registry.
pub fn new() -> Self {
Self {
plugins: Vec::new(),
}
}
/// Register a plugin directly (used in tests and for built-in plugins).
pub fn register(&mut self, plugin: Box<dyn CompilerPlugin>) {
self.plugins.push(plugin);
}
/// Load all plugins listed in the manifest's `[plugins]` section.
///
/// Plugins are expected to be `.dylib` (macOS) / `.so` (Linux) files
/// in `plugin_dir`. Dynamic loading is marked as a TODO — for now, this
/// is a no-op stub that validates the plugin manifest entries.
pub fn load_from_manifest(
&mut self,
manifest: &Manifest,
plugin_dir: &Path,
) -> Result<(), PluginError> {
for name in manifest.plugins.keys() {
// TODO(LLVM backend): use `libloading` crate to dlopen the .dylib/.so,
// look up the `engram_plugin_init` symbol, call it, and register the
// returned Box<dyn CompilerPlugin>.
//
// Extension point:
// let lib = unsafe { libloading::Library::new(dylib_path) }?;
// let init: Symbol<fn() -> Box<dyn CompilerPlugin>> =
// unsafe { lib.get(b"engram_plugin_init") }?;
// self.plugins.push(init());
let dylib_name = if cfg!(target_os = "macos") {
format!("lib{name}.dylib")
} else if cfg!(target_os = "windows") {
format!("{name}.dll")
} else {
format!("lib{name}.so")
};
let dylib_path = plugin_dir.join(&dylib_name);
if !dylib_path.exists() {
// Not treating missing plugins as fatal during the stub phase.
// In production, this would be an error.
eprintln!(
"warning: plugin '{name}' not found at {} (dynamic loading is a TODO)",
dylib_path.display()
);
}
}
Ok(())
}
/// Run the `on_ast` hook for all registered plugins.
pub fn run_ast_hooks(&self, program: &mut el_parser::Program) -> Result<(), PluginError> {
for plugin in &self.plugins {
plugin.on_ast(program)?;
}
Ok(())
}
/// Run the `on_typed_ast` hook for all registered plugins.
pub fn run_typed_hooks(
&self,
program: &el_parser::Program,
types: &el_types::TypeEnv,
) -> Result<(), PluginError> {
for plugin in &self.plugins {
plugin.on_typed_ast(program, types)?;
}
Ok(())
}
/// Run the `on_bytecode` hook for all registered plugins.
pub fn run_bytecode_hooks(&self, bytecode: &mut Vec<u8>) -> Result<(), PluginError> {
for plugin in &self.plugins {
plugin.on_bytecode(bytecode)?;
}
Ok(())
}
/// Number of plugins currently registered.
pub fn len(&self) -> usize {
self.plugins.len()
}
/// Whether no plugins are registered.
pub fn is_empty(&self) -> bool {
self.plugins.is_empty()
}
}
impl Default for PluginRegistry {
fn default() -> Self {
Self::new()
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
/// A no-op test plugin.
struct NopPlugin;
impl CompilerPlugin for NopPlugin {
fn name(&self) -> &str { "nop-plugin" }
fn version(&self) -> &str { "0.1.0" }
fn on_ast(&self, _program: &mut el_parser::Program) -> Result<(), PluginError> { Ok(()) }
fn on_typed_ast(&self, _p: &el_parser::Program, _t: &el_types::TypeEnv) -> Result<(), PluginError> { Ok(()) }
fn on_bytecode(&self, _b: &mut Vec<u8>) -> Result<(), PluginError> { Ok(()) }
}
/// A plugin that appends a byte to the bytecode (to verify mutation).
struct MutatingPlugin;
impl CompilerPlugin for MutatingPlugin {
fn name(&self) -> &str { "mutating-plugin" }
fn version(&self) -> &str { "1.0.0" }
fn on_ast(&self, _program: &mut el_parser::Program) -> Result<(), PluginError> { Ok(()) }
fn on_typed_ast(&self, _p: &el_parser::Program, _t: &el_types::TypeEnv) -> Result<(), PluginError> { Ok(()) }
fn on_bytecode(&self, bytecode: &mut Vec<u8>) -> Result<(), PluginError> {
bytecode.push(0xFF); // marker byte
Ok(())
}
}
#[test]
fn test_empty_registry() {
let reg = PluginRegistry::new();
assert!(reg.is_empty());
assert_eq!(reg.len(), 0);
}
#[test]
fn test_register_plugin() {
let mut reg = PluginRegistry::new();
reg.register(Box::new(NopPlugin));
assert_eq!(reg.len(), 1);
assert!(!reg.is_empty());
}
#[test]
fn test_bytecode_hook_mutates() {
let mut reg = PluginRegistry::new();
reg.register(Box::new(MutatingPlugin));
let mut bytecode = vec![0x01, 0x02, 0x03];
reg.run_bytecode_hooks(&mut bytecode).unwrap();
assert_eq!(bytecode.last(), Some(&0xFF));
assert_eq!(bytecode.len(), 4);
}
#[test]
fn test_multiple_plugins_run_in_order() {
let mut reg = PluginRegistry::new();
reg.register(Box::new(MutatingPlugin));
reg.register(Box::new(MutatingPlugin));
let mut bytecode = vec![0x01];
reg.run_bytecode_hooks(&mut bytecode).unwrap();
// Two MutatingPlugins → two 0xFF bytes appended
assert_eq!(bytecode, vec![0x01, 0xFF, 0xFF]);
}
#[test]
fn test_nop_plugin_hooks_succeed() {
let mut reg = PluginRegistry::new();
reg.register(Box::new(NopPlugin));
let mut bytecode = vec![0x00];
assert!(reg.run_bytecode_hooks(&mut bytecode).is_ok());
assert_eq!(bytecode.len(), 1); // NopPlugin does not mutate
}
}
@@ -0,0 +1,95 @@
//! Test runner — compiles and runs `*.test.el` / `*_test.el` files.
use std::path::Path;
use crate::build::TestReport;
use crate::error::BuildResult;
/// Discover and run all test files under `src_root`.
///
/// Test files must end in `.test.el` or `_test.el`.
/// This is a stub implementation — in the full toolchain the test files
/// are compiled to debug bytecode and run against the interpreter with
/// assertions captured.
pub async fn run_tests(src_root: &Path, workspace_root: &Path) -> BuildResult<TestReport> {
let test_files = discover_test_files(src_root);
let total = test_files.len();
let mut passed = 0usize;
let mut failed = 0usize;
let mut failures = Vec::new();
for file in &test_files {
let rel = file
.strip_prefix(workspace_root)
.unwrap_or(file)
.display()
.to_string();
let source = match std::fs::read_to_string(file) {
Ok(s) => s,
Err(e) => {
failures.push(format!("{rel}: io error: {e}"));
failed += 1;
continue;
}
};
// Compile to debug bytecode — if compilation fails, test fails.
let opts = el_compiler::CompilerOptions {
target: el_compiler::Target::Debug,
source_path: file.clone(),
..Default::default()
};
match el_compiler::Compiler::compile(&source, opts) {
Ok(output) => {
if output.diagnostics.iter().any(|d| d.contains("error")) {
failures.push(format!("{rel}: compile error"));
failed += 1;
} else {
passed += 1;
}
}
Err(e) => {
failures.push(format!("{rel}: {e}"));
failed += 1;
}
}
}
Ok(TestReport {
total,
passed,
failed,
failures,
})
}
fn discover_test_files(root: &Path) -> Vec<std::path::PathBuf> {
let mut files = Vec::new();
collect_test_files(root, &mut files);
files.sort();
files
}
fn collect_test_files(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let name = path.file_name().unwrap_or_default().to_string_lossy();
if !name.starts_with('.') && name != "dist" && name != "target" {
collect_test_files(&path, out);
}
} else {
let name = path.file_name().unwrap_or_default().to_string_lossy();
if (name.ends_with(".test.el") || name.ends_with("_test.el"))
&& path.extension().map(|e| e == "el").unwrap_or(false)
{
out.push(path);
}
}
}
}