Archive Rust bootstrap — El compiler is now self-hosting
This commit is contained in:
@@ -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}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user