rename crates/ to engrams/; add el-compiler el package with bootstrap artifact
- crates/ → engrams/ (Rust engrams live here)
- el-compiler/ added: el self-hosting compiler as an el package
- src/{compiler,lexer,parser,codegen}.el
- bootstrap/el-compiler.elc (114KB, Rust-compiled seed)
- el.toml Cargo.toml workspace paths updated
- neuron-rs cross-repo path deps fixed (were pointing to products/ instead of foundation/)
This commit is contained in:
@@ -0,0 +1,543 @@
|
||||
//! 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, ¤t_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, ¤t_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();
|
||||
for line in source.lines() {
|
||||
let trimmed = line.trim();
|
||||
if let Some(rest) = trimmed.strip_prefix("import ") {
|
||||
let rest = rest.trim();
|
||||
if rest.starts_with('"') && rest.ends_with('"') {
|
||||
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 {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user