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,15 @@
[package]
name = "el-manifest"
description = "manifest.el project manifest parser for the Engram language package system"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
semver = { version = "1", features = ["serde"] }
el-lexer = { workspace = true }
[dev-dependencies]
@@ -0,0 +1,39 @@
//! Manifest error types.
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ManifestError {
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("manifest parse error at line {line}: {reason}")]
Parse { line: u32, reason: String },
#[error("semver parse error for '{field}': {source}")]
Semver {
field: String,
#[source]
source: semver::Error,
},
#[error("missing required field: {0}")]
MissingField(String),
#[error("invalid value for '{field}': {reason}")]
InvalidValue { field: String, reason: String },
#[error("manifest.el not found (searched from {0})")]
NotFound(String),
#[error("invalid cross target '{0}': use x86_64-linux, aarch64-linux, x86_64-macos, aarch64-macos, wasm32")]
InvalidCrossTarget(String),
#[error("invalid build target '{0}': use debug, release, prod")]
InvalidBuildTarget(String),
#[error("invalid seal key source '{0}': use env:VAR, file:path, or literal")]
InvalidSealKeySource(String),
}
pub type ManifestResult<T> = Result<T, ManifestError>;
@@ -0,0 +1,28 @@
//! el-manifest — `manifest.el` project manifest parser.
//!
//! Every Engram project has a `manifest.el` at its root. This crate defines the
//! manifest data model and parses it from El block syntax.
//!
//! # Quick start
//! ```rust
//! use el_manifest::Manifest;
//!
//! let src = r#"
//! package "my-service" {
//! version "0.1.0"
//! edition "2026"
//! }
//! "#;
//! let manifest = Manifest::parse(src).unwrap();
//! assert_eq!(manifest.package.name, "my-service");
//! ```
mod error;
mod manifest;
mod parse;
pub use error::{ManifestError, ManifestResult};
pub use manifest::{
AppConfig, BuildConfig, BuildTarget, CrossConfig, CrossTarget, Dependency, Manifest,
NativeTarget, PackageInfo, SealKeySource,
};
@@ -0,0 +1,392 @@
//! Core data model for the `manifest.el` manifest.
use std::collections::HashMap;
use std::path::PathBuf;
use semver::{Version, VersionReq};
use serde::{Deserialize, Serialize};
// ── Package info ──────────────────────────────────────────────────────────────
/// Metadata about the package itself (`[package]` section).
#[derive(Debug, Clone, PartialEq)]
pub struct PackageInfo {
pub name: String,
pub version: Version,
pub description: Option<String>,
pub authors: Vec<String>,
pub license: Option<String>,
/// Language edition, e.g. "2026".
pub edition: String,
}
// ── Dependencies ──────────────────────────────────────────────────────────────
/// A single dependency specifier.
#[derive(Debug, Clone, PartialEq)]
pub enum Dependency {
/// A bare semver version requirement string (`"1.2"`, `"^0.8.1"`).
VersionReq(VersionReq),
/// A path-local dependency (`{ path = "../some-local" }`).
Path(PathBuf),
/// A registry package with an explicit registry URL.
Registry {
version: VersionReq,
registry: String,
},
}
impl Dependency {
/// Returns the version requirement if this is a registry / version dep.
pub fn version_req(&self) -> Option<&VersionReq> {
match self {
Dependency::VersionReq(req) => Some(req),
Dependency::Registry { version, .. } => Some(version),
Dependency::Path(_) => None,
}
}
/// Returns the local path if this is a path dependency.
pub fn local_path(&self) -> Option<&PathBuf> {
match self {
Dependency::Path(p) => Some(p),
_ => None,
}
}
}
// ── Build config ──────────────────────────────────────────────────────────────
/// The three compilation targets supported by the Engram toolchain.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum BuildTarget {
Debug,
Release,
Prod,
}
impl BuildTarget {
pub fn as_str(&self) -> &'static str {
match self {
BuildTarget::Debug => "debug",
BuildTarget::Release => "release",
BuildTarget::Prod => "prod",
}
}
}
impl std::fmt::Display for BuildTarget {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl std::str::FromStr for BuildTarget {
type Err = crate::ManifestError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"debug" => Ok(BuildTarget::Debug),
"release" => Ok(BuildTarget::Release),
"prod" => Ok(BuildTarget::Prod),
other => Err(crate::ManifestError::InvalidBuildTarget(other.to_string())),
}
}
}
/// Where to read the sealing key from.
#[derive(Debug, Clone, PartialEq)]
pub enum SealKeySource {
/// `env:VAR_NAME` — read from an environment variable at build time.
EnvVar(String),
/// `file:path/to/key` — read raw bytes from a file.
File(PathBuf),
/// A literal key value — for development/testing only.
Literal(String),
}
impl SealKeySource {
/// Parse from the `el.toml` string representation.
pub fn parse(s: &str) -> Result<Self, crate::ManifestError> {
if let Some(var) = s.strip_prefix("env:") {
Ok(SealKeySource::EnvVar(var.to_string()))
} else if let Some(path) = s.strip_prefix("file:") {
Ok(SealKeySource::File(PathBuf::from(path)))
} else if s.is_empty() {
Err(crate::ManifestError::InvalidSealKeySource(s.to_string()))
} else {
Ok(SealKeySource::Literal(s.to_string()))
}
}
/// Resolve the key bytes at runtime.
pub fn resolve(&self) -> Result<Vec<u8>, crate::ManifestError> {
match self {
SealKeySource::EnvVar(var) => {
std::env::var(var)
.map(|v| v.into_bytes())
.map_err(|_| crate::ManifestError::InvalidValue {
field: format!("env:{var}"),
reason: "environment variable not set".to_string(),
})
}
SealKeySource::File(path) => {
std::fs::read(path).map_err(crate::ManifestError::Io)
}
SealKeySource::Literal(s) => Ok(s.as_bytes().to_vec()),
}
}
}
/// The `[build]` section.
#[derive(Debug, Clone, PartialEq)]
pub struct BuildConfig {
pub target: BuildTarget,
pub entry: PathBuf,
pub output: PathBuf,
pub seal_key: Option<SealKeySource>,
}
impl Default for BuildConfig {
fn default() -> Self {
Self {
target: BuildTarget::Debug,
entry: PathBuf::from("src/main.el"),
output: PathBuf::from("dist/"),
seal_key: None,
}
}
}
// ── Cross-compilation ─────────────────────────────────────────────────────────
/// A native target triple for cross-compilation.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum CrossTarget {
#[serde(rename = "x86_64-linux")]
X86_64Linux,
#[serde(rename = "aarch64-linux")]
Aarch64Linux,
#[serde(rename = "x86_64-macos")]
X86_64Macos,
#[serde(rename = "aarch64-macos")]
Aarch64Macos,
#[serde(rename = "wasm32")]
Wasm32,
}
impl CrossTarget {
/// Parse from the string used in `el.toml`.
pub fn parse(s: &str) -> Result<Self, crate::ManifestError> {
match s {
"x86_64-linux" => Ok(CrossTarget::X86_64Linux),
"aarch64-linux" => Ok(CrossTarget::Aarch64Linux),
"x86_64-macos" => Ok(CrossTarget::X86_64Macos),
"aarch64-macos" => Ok(CrossTarget::Aarch64Macos),
"wasm32" => Ok(CrossTarget::Wasm32),
other => Err(crate::ManifestError::InvalidCrossTarget(other.to_string())),
}
}
/// The canonical Rust/LLVM target triple for this target.
pub fn triple(&self) -> &'static str {
match self {
CrossTarget::X86_64Linux => "x86_64-unknown-linux-gnu",
CrossTarget::Aarch64Linux => "aarch64-unknown-linux-gnu",
CrossTarget::X86_64Macos => "x86_64-apple-darwin",
CrossTarget::Aarch64Macos => "aarch64-apple-darwin",
CrossTarget::Wasm32 => "wasm32-unknown-unknown",
}
}
/// File extension for compiled artifacts on this target.
pub fn artifact_extension(&self) -> &'static str {
match self {
CrossTarget::Wasm32 => ".wasm",
_ => "",
}
}
/// The string as it appears in `el.toml`.
pub fn as_str(&self) -> &'static str {
match self {
CrossTarget::X86_64Linux => "x86_64-linux",
CrossTarget::Aarch64Linux => "aarch64-linux",
CrossTarget::X86_64Macos => "x86_64-macos",
CrossTarget::Aarch64Macos => "aarch64-macos",
CrossTarget::Wasm32 => "wasm32",
}
}
}
impl std::fmt::Display for CrossTarget {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
/// The `[cross]` section.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct CrossConfig {
pub targets: Vec<CrossTarget>,
}
// ── Native target (compiler-level) ────────────────────────────────────────────
/// Compiler-level native target — includes `Host` for the current machine.
///
/// This is stored in the sealed artifact header so the runtime knows which
/// native code generation backend to use.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum NativeTarget {
#[serde(rename = "x86_64-linux")]
X86_64Linux,
#[serde(rename = "aarch64-linux")]
Aarch64Linux,
#[serde(rename = "x86_64-macos")]
X86_64Macos,
#[serde(rename = "aarch64-macos")]
Aarch64Macos,
#[serde(rename = "wasm32")]
Wasm32,
/// The host machine — resolved at runtime.
#[serde(rename = "host")]
Host,
}
impl NativeTarget {
/// The canonical LLVM triple for this target.
///
/// For `Host`, returns the compile-time host triple using
/// `std::env::consts`.
pub fn triple(&self) -> &str {
match self {
NativeTarget::X86_64Linux => "x86_64-unknown-linux-gnu",
NativeTarget::Aarch64Linux => "aarch64-unknown-linux-gnu",
NativeTarget::X86_64Macos => "x86_64-apple-darwin",
NativeTarget::Aarch64Macos => "aarch64-apple-darwin",
NativeTarget::Wasm32 => "wasm32-unknown-unknown",
NativeTarget::Host => {
// This is a static string that depends on the compile-time target.
// We detect at compile time via cfg! macros.
host_triple()
}
}
}
/// Output file extension for artifacts on this target.
pub fn artifact_extension(&self) -> &'static str {
match self {
NativeTarget::Wasm32 => ".wasm",
_ => {
if cfg!(target_os = "windows") {
".exe"
} else {
""
}
}
}
}
/// Convert a [`CrossTarget`] to the equivalent [`NativeTarget`].
pub fn from_cross(c: &CrossTarget) -> Self {
match c {
CrossTarget::X86_64Linux => NativeTarget::X86_64Linux,
CrossTarget::Aarch64Linux => NativeTarget::Aarch64Linux,
CrossTarget::X86_64Macos => NativeTarget::X86_64Macos,
CrossTarget::Aarch64Macos => NativeTarget::Aarch64Macos,
CrossTarget::Wasm32 => NativeTarget::Wasm32,
}
}
}
fn host_triple() -> &'static str {
// Determine at compile time — these cfg values are set by rustc.
if cfg!(all(target_arch = "x86_64", target_os = "linux")) {
"x86_64-unknown-linux-gnu"
} else if cfg!(all(target_arch = "aarch64", target_os = "linux")) {
"aarch64-unknown-linux-gnu"
} else if cfg!(all(target_arch = "x86_64", target_os = "macos")) {
"x86_64-apple-darwin"
} else if cfg!(all(target_arch = "aarch64", target_os = "macos")) {
"aarch64-apple-darwin"
} else if cfg!(target_arch = "wasm32") {
"wasm32-unknown-unknown"
} else {
"unknown-unknown-unknown"
}
}
impl std::fmt::Display for NativeTarget {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.triple())
}
}
// ── App config (native desktop) ───────────────────────────────────────────────
/// The `app { }` section — present only in native desktop apps using el-ui.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct AppConfig {
pub window_title: String,
pub window_width: u32,
pub window_height: u32,
pub min_width: u32,
pub min_height: u32,
}
// ── Top-level manifest ────────────────────────────────────────────────────────
/// The parsed contents of a `manifest.el` project manifest.
#[derive(Debug, Clone)]
pub struct Manifest {
pub package: PackageInfo,
pub dependencies: HashMap<String, Dependency>,
pub dev_dependencies: HashMap<String, Dependency>,
pub build: BuildConfig,
pub cross: CrossConfig,
/// Plugin name → version requirement string.
pub plugins: HashMap<String, String>,
/// Native desktop app config (present only when `app { }` section exists).
pub app: Option<AppConfig>,
}
impl Manifest {
/// Parse a manifest from an El source string.
pub fn parse(s: &str) -> crate::ManifestResult<Self> {
crate::parse::parse_manifest(s)
}
/// Parse a manifest from a file on disk.
pub fn from_file(path: &std::path::Path) -> crate::ManifestResult<Self> {
let text = std::fs::read_to_string(path).map_err(crate::ManifestError::Io)?;
Self::parse(&text)
}
/// Walk up the directory tree from `from` until a `manifest.el` is found.
///
/// Returns the path to the manifest file (not its directory).
pub fn find_manifest(from: &std::path::Path) -> crate::ManifestResult<PathBuf> {
let mut dir = if from.is_file() {
from.parent().unwrap_or(from).to_path_buf()
} else {
from.to_path_buf()
};
loop {
let candidate = dir.join("manifest.el");
if candidate.exists() {
return Ok(candidate);
}
match dir.parent() {
Some(parent) => dir = parent.to_path_buf(),
None => {
return Err(crate::ManifestError::NotFound(from.display().to_string()))
}
}
}
}
}
@@ -0,0 +1,748 @@
//! El-syntax parser for `manifest.el` project manifests.
//!
//! The manifest format is a subset of El block syntax:
//!
//! ```el
//! package "name" {
//! version "0.1.0"
//! description "What this does"
//! authors ["Author One"]
//! edition "2026"
//! }
//!
//! build {
//! entry "src/main.el"
//! target "release"
//! output "dist/"
//! }
//!
//! dependencies {
//! el-ui { path "../../../../foundation/el-ui" }
//! }
//!
//! app {
//! window_title "My App"
//! window_width 1200
//! window_height 800
//! }
//! ```
//!
//! Rules:
//! - No equals signs — space-separated declarations
//! - String values use `"..."`, integer values are bare numbers
//! - Arrays use `[...]`, block sections use `{ }`
//! - `//` line comments are stripped by the lexer
use std::collections::HashMap;
use std::path::PathBuf;
use el_lexer::{tokenize, Token, Spanned};
use crate::error::{ManifestError, ManifestResult};
use crate::manifest::{
AppConfig, BuildConfig, BuildTarget, CrossConfig, CrossTarget, Dependency, Manifest,
PackageInfo, SealKeySource,
};
// ── Token stream wrapper ──────────────────────────────────────────────────────
struct TokenStream {
tokens: Vec<Spanned<Token>>,
pos: usize,
}
impl TokenStream {
fn new(tokens: Vec<Spanned<Token>>) -> Self {
Self { tokens, pos: 0 }
}
fn peek(&self) -> &Token {
self.tokens.get(self.pos).map(|s| &s.node).unwrap_or(&Token::Eof)
}
fn current_line(&self) -> u32 {
self.tokens.get(self.pos).map(|s| s.span.line).unwrap_or(0)
}
fn advance(&mut self) -> &Spanned<Token> {
let idx = self.pos;
self.pos += 1;
&self.tokens[idx]
}
fn expect_lbrace(&mut self) -> ManifestResult<()> {
match self.peek() {
Token::LBrace => { self.advance(); Ok(()) }
other => Err(ManifestError::Parse {
line: self.current_line(),
reason: format!("expected '{{', found '{other}'"),
}),
}
}
fn expect_rbrace(&mut self) -> ManifestResult<()> {
match self.peek() {
Token::RBrace => { self.advance(); Ok(()) }
other => Err(ManifestError::Parse {
line: self.current_line(),
reason: format!("expected '}}', found '{other}'"),
}),
}
}
/// Consume an identifier or return error.
fn expect_ident(&mut self) -> ManifestResult<String> {
match self.peek().clone() {
Token::Ident(s) => { self.advance(); Ok(s) }
// Some fields like "target" are keywords in El — allow them as idents here
Token::Target => { self.advance(); Ok("target".to_string()) }
other => Err(ManifestError::Parse {
line: self.current_line(),
reason: format!("expected identifier, found '{other}'"),
}),
}
}
/// Consume a string literal or return error.
fn expect_string(&mut self) -> ManifestResult<String> {
match self.peek().clone() {
Token::StringLiteral(s) => { self.advance(); Ok(s) }
other => Err(ManifestError::Parse {
line: self.current_line(),
reason: format!("expected string literal, found '{other}'"),
}),
}
}
/// Consume an integer literal or return error.
fn expect_int(&mut self) -> ManifestResult<i64> {
match self.peek().clone() {
Token::IntLiteral(n) => { self.advance(); Ok(n) }
other => Err(ManifestError::Parse {
line: self.current_line(),
reason: format!("expected integer literal, found '{other}'"),
}),
}
}
fn is_at_end(&self) -> bool {
matches!(self.peek(), Token::Eof)
}
}
// ── Entry point ───────────────────────────────────────────────────────────────
/// Parse a `manifest.el` source string into a [`Manifest`].
pub(crate) fn parse_manifest(s: &str) -> ManifestResult<Manifest> {
let spanned = tokenize(s).map_err(|e| ManifestError::Parse {
line: e.span.line,
reason: format!("lex error: {}", e.kind),
})?;
// Filter out comments and whitespace-only tokens (the lexer doesn't produce
// whitespace tokens, but Unknown chars from `//` comments might appear).
let tokens: Vec<Spanned<Token>> = spanned
.into_iter()
.filter(|t| !matches!(t.node, Token::Eof))
.collect();
let mut stream = TokenStream::new(tokens);
let mut raw_package: Option<RawPackage> = None;
let mut raw_build: Option<RawBuild> = None;
let mut raw_deps: HashMap<String, RawDep> = HashMap::new();
let mut raw_cross: Option<RawCross> = None;
let mut raw_app: Option<RawApp> = None;
while !stream.is_at_end() {
let section_name = match stream.peek().clone() {
Token::Ident(s) => { stream.advance(); s }
Token::Eof => break,
other => {
return Err(ManifestError::Parse {
line: stream.current_line(),
reason: format!("expected section name, found '{other}'"),
});
}
};
match section_name.as_str() {
"package" => {
// package "name" { ... }
let name = stream.expect_string()?;
stream.expect_lbrace()?;
raw_package = Some(parse_package_block(&mut stream, name)?);
stream.expect_rbrace()?;
}
"build" => {
stream.expect_lbrace()?;
raw_build = Some(parse_build_block(&mut stream)?);
stream.expect_rbrace()?;
}
"dependencies" => {
stream.expect_lbrace()?;
raw_deps = parse_deps_block(&mut stream)?;
stream.expect_rbrace()?;
}
"cross" => {
stream.expect_lbrace()?;
raw_cross = Some(parse_cross_block(&mut stream)?);
stream.expect_rbrace()?;
}
"app" => {
stream.expect_lbrace()?;
raw_app = Some(parse_app_block(&mut stream)?);
stream.expect_rbrace()?;
}
other => {
// Skip unknown sections gracefully by consuming until matching }
if matches!(stream.peek(), Token::LBrace) {
stream.advance();
skip_block(&mut stream)?;
} else {
return Err(ManifestError::Parse {
line: stream.current_line(),
reason: format!("unknown section '{other}'"),
});
}
}
}
}
convert(raw_package, raw_build, raw_deps, raw_cross, raw_app)
}
// ── Block parsers ─────────────────────────────────────────────────────────────
#[derive(Debug, Default)]
struct RawPackage {
name: String,
version: String,
description: Option<String>,
authors: Vec<String>,
license: Option<String>,
edition: String,
entry: Option<String>, // neuron-code puts entry in [package]
}
fn parse_package_block(stream: &mut TokenStream, name: String) -> ManifestResult<RawPackage> {
let mut pkg = RawPackage {
name,
version: "0.1.0".to_string(),
edition: "2026".to_string(),
..Default::default()
};
while !matches!(stream.peek(), Token::RBrace | Token::Eof) {
let field = stream.expect_ident()?;
match field.as_str() {
"version" => pkg.version = stream.expect_string()?,
"description" => pkg.description = Some(stream.expect_string()?),
"authors" => pkg.authors = parse_string_array(stream)?,
"license" => pkg.license = Some(stream.expect_string()?),
"edition" => pkg.edition = stream.expect_string()?,
"entry" => pkg.entry = Some(stream.expect_string()?),
other => {
return Err(ManifestError::Parse {
line: stream.current_line(),
reason: format!("unknown package field '{other}'"),
});
}
}
}
Ok(pkg)
}
#[derive(Debug, Default)]
struct RawBuild {
target: Option<String>,
entry: Option<String>,
output: Option<String>,
seal_key: Option<String>,
}
fn parse_build_block(stream: &mut TokenStream) -> ManifestResult<RawBuild> {
let mut build = RawBuild::default();
while !matches!(stream.peek(), Token::RBrace | Token::Eof) {
let field = match stream.peek().clone() {
Token::Ident(s) => { stream.advance(); s }
Token::Target => { stream.advance(); "target".to_string() }
Token::RBrace | Token::Eof => break,
other => {
return Err(ManifestError::Parse {
line: stream.current_line(),
reason: format!("expected build field name, found '{other}'"),
});
}
};
match field.as_str() {
"target" => build.target = Some(stream.expect_string()?),
"entry" => build.entry = Some(stream.expect_string()?),
"output" => build.output = Some(stream.expect_string()?),
"seal_key" => build.seal_key = Some(stream.expect_string()?),
other => {
return Err(ManifestError::Parse {
line: stream.current_line(),
reason: format!("unknown build field '{other}'"),
});
}
}
}
Ok(build)
}
#[derive(Debug)]
enum RawDep {
Path(String),
Version(String),
}
fn parse_deps_block(stream: &mut TokenStream) -> ManifestResult<HashMap<String, RawDep>> {
let mut deps = HashMap::new();
while !matches!(stream.peek(), Token::RBrace | Token::Eof) {
// Dependency name can contain hyphens — the lexer will produce Ident tokens
// separated by Minus for names like `el-ui`. We need to reconstruct the full name.
let dep_name = parse_dep_name(stream)?;
// Value is either a string version or a block `{ path "..." }`
match stream.peek().clone() {
Token::StringLiteral(ver) => {
stream.advance();
deps.insert(dep_name, RawDep::Version(ver));
}
Token::LBrace => {
stream.advance();
// Parse inner fields until }
let mut path: Option<String> = None;
while !matches!(stream.peek(), Token::RBrace | Token::Eof) {
let field = stream.expect_ident()?;
match field.as_str() {
"path" => path = Some(stream.expect_string()?),
other => {
// Skip unknown fields (version, registry, etc.)
match stream.peek().clone() {
Token::StringLiteral(_) => { stream.advance(); }
_ => {
return Err(ManifestError::Parse {
line: stream.current_line(),
reason: format!("unknown dep field '{other}'"),
});
}
}
}
}
}
stream.expect_rbrace()?;
match path {
Some(p) => { deps.insert(dep_name, RawDep::Path(p)); }
None => {
return Err(ManifestError::Parse {
line: stream.current_line(),
reason: format!("dependency '{dep_name}' block has no 'path' or 'version'"),
});
}
}
}
other => {
return Err(ManifestError::Parse {
line: stream.current_line(),
reason: format!("expected string or block for dependency '{dep_name}', found '{other}'"),
});
}
}
}
Ok(deps)
}
/// Parse a possibly-hyphenated dependency name like `el-ui` or `engram-http`.
/// The lexer produces Ident("-") — actually Minus tokens — so we need to stitch
/// Ident + (Minus + Ident)* together.
fn parse_dep_name(stream: &mut TokenStream) -> ManifestResult<String> {
let mut name = match stream.peek().clone() {
Token::Ident(s) => { stream.advance(); s }
other => {
return Err(ManifestError::Parse {
line: stream.current_line(),
reason: format!("expected dependency name, found '{other}'"),
});
}
};
// Greedily consume `-ident` segments
loop {
match stream.peek() {
Token::Minus => {
// Peek ahead to see if next is Ident
let next_pos = stream.pos + 1;
if let Some(next) = stream.tokens.get(next_pos) {
if let Token::Ident(_) = &next.node {
stream.advance(); // consume Minus
if let Token::Ident(seg) = stream.peek().clone() {
stream.advance();
name.push('-');
name.push_str(&seg);
continue;
}
}
}
break;
}
_ => break,
}
}
Ok(name)
}
#[derive(Debug, Default)]
struct RawCross {
targets: Vec<String>,
}
fn parse_cross_block(stream: &mut TokenStream) -> ManifestResult<RawCross> {
let mut cross = RawCross::default();
while !matches!(stream.peek(), Token::RBrace | Token::Eof) {
let field = match stream.peek().clone() {
Token::Ident(s) => { stream.advance(); s }
Token::Target => { stream.advance(); "targets".to_string() }
Token::RBrace | Token::Eof => break,
other => {
return Err(ManifestError::Parse {
line: stream.current_line(),
reason: format!("expected cross field, found '{other}'"),
});
}
};
match field.as_str() {
"targets" => cross.targets = parse_string_array(stream)?,
other => {
return Err(ManifestError::Parse {
line: stream.current_line(),
reason: format!("unknown cross field '{other}'"),
});
}
}
}
Ok(cross)
}
#[derive(Debug, Default)]
struct RawApp {
window_title: Option<String>,
window_width: Option<i64>,
window_height: Option<i64>,
min_width: Option<i64>,
min_height: Option<i64>,
}
fn parse_app_block(stream: &mut TokenStream) -> ManifestResult<RawApp> {
let mut app = RawApp::default();
while !matches!(stream.peek(), Token::RBrace | Token::Eof) {
let field = stream.expect_ident()?;
match field.as_str() {
"window_title" => app.window_title = Some(stream.expect_string()?),
"window_width" => app.window_width = Some(stream.expect_int()?),
"window_height" => app.window_height = Some(stream.expect_int()?),
"min_width" => app.min_width = Some(stream.expect_int()?),
"min_height" => app.min_height = Some(stream.expect_int()?),
other => {
return Err(ManifestError::Parse {
line: stream.current_line(),
reason: format!("unknown app field '{other}'"),
});
}
}
}
Ok(app)
}
fn parse_string_array(stream: &mut TokenStream) -> ManifestResult<Vec<String>> {
match stream.peek() {
Token::LBracket => { stream.advance(); }
other => {
return Err(ManifestError::Parse {
line: stream.current_line(),
reason: format!("expected '[', found '{other}'"),
});
}
}
let mut items = Vec::new();
while !matches!(stream.peek(), Token::RBracket | Token::Eof) {
// Items in the array are strings
match stream.peek().clone() {
Token::StringLiteral(s) => {
stream.advance();
items.push(s);
// Optional comma
if matches!(stream.peek(), Token::Comma) {
stream.advance();
}
}
Token::Comma => { stream.advance(); } // trailing comma
other => {
return Err(ManifestError::Parse {
line: stream.current_line(),
reason: format!("expected string in array, found '{other}'"),
});
}
}
}
match stream.peek() {
Token::RBracket => { stream.advance(); }
_ => {} // Eof handled gracefully
}
Ok(items)
}
/// Skip over a `{ ... }` block (already consumed the opening brace).
fn skip_block(stream: &mut TokenStream) -> ManifestResult<()> {
let mut depth = 1;
while depth > 0 && !stream.is_at_end() {
match stream.peek() {
Token::LBrace => { depth += 1; stream.advance(); }
Token::RBrace => { depth -= 1; stream.advance(); }
_ => { stream.advance(); }
}
}
Ok(())
}
// ── Conversion to typed manifest ──────────────────────────────────────────────
fn convert(
raw_package: Option<RawPackage>,
raw_build: Option<RawBuild>,
raw_deps: HashMap<String, RawDep>,
raw_cross: Option<RawCross>,
raw_app: Option<RawApp>,
) -> ManifestResult<Manifest> {
let pkg_raw = raw_package.ok_or_else(|| ManifestError::MissingField("package".to_string()))?;
let package = convert_package(pkg_raw)?;
let build = convert_build(raw_build.unwrap_or_default(), &package)?;
let dependencies = convert_deps(raw_deps)?;
let cross = convert_cross(raw_cross.unwrap_or_default())?;
let app = raw_app.map(convert_app);
Ok(Manifest {
package,
dependencies,
dev_dependencies: HashMap::new(),
build,
cross,
plugins: HashMap::new(),
app,
})
}
fn convert_package(raw: RawPackage) -> ManifestResult<PackageInfo> {
let version = semver::Version::parse(&raw.version).map_err(|e| ManifestError::Semver {
field: "package.version".to_string(),
source: e,
})?;
Ok(PackageInfo {
name: raw.name,
version,
description: raw.description,
authors: raw.authors,
license: raw.license,
edition: raw.edition,
})
}
fn convert_build(raw: RawBuild, _package: &PackageInfo) -> ManifestResult<BuildConfig> {
let target = raw
.target
.unwrap_or_else(|| "debug".to_string())
.parse::<BuildTarget>()?;
// Entry can come from build block or package block (legacy neuron-code style)
let entry_str = raw.entry.unwrap_or_else(|| "src/main.el".to_string());
let output_str = raw.output.unwrap_or_else(|| "dist/".to_string());
let seal_key = raw
.seal_key
.map(|s| SealKeySource::parse(&s))
.transpose()?;
Ok(BuildConfig {
target,
entry: PathBuf::from(entry_str),
output: PathBuf::from(output_str),
seal_key,
})
}
fn convert_deps(raw: HashMap<String, RawDep>) -> ManifestResult<HashMap<String, Dependency>> {
let mut out = HashMap::new();
for (name, dep) in raw {
let converted = match dep {
RawDep::Path(p) => Dependency::Path(PathBuf::from(p)),
RawDep::Version(v) => {
let req = semver::VersionReq::parse(&v).map_err(|e| ManifestError::Semver {
field: format!("dependencies.{name}"),
source: e,
})?;
Dependency::VersionReq(req)
}
};
out.insert(name, converted);
}
Ok(out)
}
fn convert_cross(raw: RawCross) -> ManifestResult<CrossConfig> {
let targets = raw
.targets
.iter()
.map(|s| CrossTarget::parse(s))
.collect::<ManifestResult<Vec<_>>>()?;
Ok(CrossConfig { targets })
}
fn convert_app(raw: RawApp) -> AppConfig {
AppConfig {
window_title: raw.window_title.unwrap_or_default(),
window_width: raw.window_width.unwrap_or(1024) as u32,
window_height: raw.window_height.unwrap_or(768) as u32,
min_width: raw.min_width.unwrap_or(400) as u32,
min_height: raw.min_height.unwrap_or(300) as u32,
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use crate::manifest::{BuildTarget, CrossTarget, Dependency};
fn full_manifest() -> &'static str {
r#"
// manifest.el — full example
package "my-service" {
version "0.1.0"
description "What this does"
authors ["Will Anderson <will@neurontechnologies.ai>"]
license "MIT"
edition "2026"
}
build {
target "prod"
entry "src/main.el"
output "dist/"
seal_key "env:ENGRAM_SEAL_KEY"
}
dependencies {
el-ui { path "../el-ui" }
}
cross {
targets ["x86_64-linux", "aarch64-linux", "x86_64-macos", "aarch64-macos", "wasm32"]
}
app {
window_title "My App"
window_width 1200
window_height 800
min_width 800
min_height 600
}
"#
}
#[test]
fn test_parse_full_manifest() {
let m = parse_manifest(full_manifest()).unwrap();
assert_eq!(m.package.name, "my-service");
assert_eq!(m.package.version.to_string(), "0.1.0");
assert_eq!(m.package.edition, "2026");
assert_eq!(m.package.license.as_deref(), Some("MIT"));
assert_eq!(m.package.authors, vec!["Will Anderson <will@neurontechnologies.ai>"]);
}
#[test]
fn test_parse_build_config() {
let m = parse_manifest(full_manifest()).unwrap();
assert_eq!(m.build.target, BuildTarget::Prod);
assert_eq!(m.build.entry.to_str().unwrap(), "src/main.el");
assert_eq!(m.build.output.to_str().unwrap(), "dist/");
match &m.build.seal_key {
Some(SealKeySource::EnvVar(v)) => assert_eq!(v, "ENGRAM_SEAL_KEY"),
other => panic!("expected EnvVar seal_key, got {other:?}"),
}
}
#[test]
fn test_parse_path_dep() {
let m = parse_manifest(full_manifest()).unwrap();
assert!(m.dependencies.contains_key("el-ui"));
match &m.dependencies["el-ui"] {
Dependency::Path(p) => assert_eq!(p.to_str().unwrap(), "../el-ui"),
other => panic!("expected Path dep, got {other:?}"),
}
}
#[test]
fn test_parse_cross_targets() {
let m = parse_manifest(full_manifest()).unwrap();
assert_eq!(m.cross.targets.len(), 5);
assert!(m.cross.targets.contains(&CrossTarget::X86_64Linux));
assert!(m.cross.targets.contains(&CrossTarget::Wasm32));
}
#[test]
fn test_parse_app_config() {
let m = parse_manifest(full_manifest()).unwrap();
let app = m.app.as_ref().expect("app config missing");
assert_eq!(app.window_title, "My App");
assert_eq!(app.window_width, 1200);
assert_eq!(app.window_height, 800);
assert_eq!(app.min_width, 800);
assert_eq!(app.min_height, 600);
}
#[test]
fn test_minimal_manifest() {
let src = r#"
package "hello" {
version "0.1.0"
}
"#;
let m = parse_manifest(src).unwrap();
assert_eq!(m.package.name, "hello");
assert_eq!(m.package.edition, "2026");
assert_eq!(m.build.target, BuildTarget::Debug);
assert!(m.dependencies.is_empty());
assert!(m.cross.targets.is_empty());
}
#[test]
fn test_missing_package_error() {
let src = r#"
build {
entry "src/main.el"
}
"#;
let err = parse_manifest(src).unwrap_err();
assert!(err.to_string().contains("package"), "expected package error, got: {err}");
}
}