feat: rename crates/ → vessels/ + add El ports per sub-vessel

Belated rename commit for foundation/el-ui — was missed in the
workspace-wide crates→vessels pass earlier today. Same structural
intent as the rename in the other repos: 'crates' is the Rust word,
'vessel' is El's, and the directory rename is the marker that this
slot holds an El buildable unit even if its current contents are
still Rust pending port.

Plus the El ports themselves — manifest.el + src/main.el per sub-
vessel (el-aop, el-auth, el-config, el-i18n, el-identity, el-layout,
el-platform, el-publish, el-secrets, el-services, el-style, el-ui-
compiler). The ui-compiler is a stub: elc only emits C right now;
generating browser-target JS/Wasm is the biggest open language gap
and gets its own project. Until then, el-ui-compiler emits a JS
module that throws elc.backend_missing so callers fail loudly.
Cross-repo path dependencies in Cargo.toml updated to vessels/.
This commit is contained in:
Will Anderson
2026-04-30 18:18:39 -05:00
parent f09803c317
commit f4abfe6fdc
138 changed files with 5445 additions and 900 deletions
+93
View File
@@ -0,0 +1,93 @@
//! AST types for el-ui component files.
/// A parsed component definition.
#[derive(Debug, Clone)]
pub struct Component {
pub name: String,
pub props: Vec<PropDef>,
pub state: Vec<StateDef>,
pub methods: Vec<Method>,
pub template: Template,
}
/// A prop declaration inside `props { ... }`.
#[derive(Debug, Clone)]
pub struct PropDef {
pub name: String,
pub type_name: String,
pub default: Option<String>,
}
/// A state declaration inside `state { ... }`.
#[derive(Debug, Clone)]
pub struct StateDef {
pub name: String,
pub type_name: String,
pub initial: String,
}
/// A method defined with `fn` inside the component body.
#[derive(Debug, Clone)]
pub struct Method {
pub name: String,
pub params: Vec<(String, String)>, // (name, type)
pub return_type: String,
pub body: String, // raw source text of the body (we pass through verbatim)
}
/// The template block.
#[derive(Debug, Clone)]
pub struct Template {
pub nodes: Vec<TemplateNode>,
}
/// A node within the template tree.
#[derive(Debug, Clone)]
pub enum TemplateNode {
/// A plain HTML element: `<div class="foo">...</div>`
Element {
tag: String,
attrs: Vec<Attr>,
children: Vec<TemplateNode>,
},
/// A component usage (uppercase first letter): `<Counter />`
Component {
name: String,
props: Vec<Attr>,
},
/// Literal text content.
Text(String),
/// An interpolated expression: `{count}`
Interpolation(String),
/// Conditional: `{#if cond}...{/if}` or `{#if cond}...{:else}...{/if}`
If {
condition: String,
then: Vec<TemplateNode>,
else_: Option<Vec<TemplateNode>>,
},
/// List rendering: `{#each items as item}...{/each}`
Each {
items: String,
item_name: String,
children: Vec<TemplateNode>,
},
/// Semantic activation query: `{#activate "query" as results}...{/activate}`
Activate {
query: String,
result_name: String,
children: Vec<TemplateNode>,
},
}
/// An attribute on a template element.
#[derive(Debug, Clone)]
pub enum Attr {
/// `class="btn"` — static string value
Static { name: String, value: String },
/// `class={expr}` — dynamic expression
Dynamic { name: String, expr: String },
/// `on:click={handler}` — event handler
EventHandler { event: String, handler: String },
/// `disabled={boolExpr}` — boolean attribute
BoolAttr { name: String, expr: String },
}
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
use thiserror::Error;
pub type CompileResult<T> = Result<T, CompileError>;
#[derive(Debug, Error)]
pub enum CompileError {
#[error("lexer error at position {pos}: {msg}")]
Lex { pos: usize, msg: String },
#[error("parse error: {msg}")]
Parse { msg: String },
#[error("codegen error: {msg}")]
Codegen { msg: String },
}
+568
View File
@@ -0,0 +1,568 @@
//! Lexer for el-ui component syntax.
//!
//! Produces a flat `Vec<Token>` from source text.
//! The lexer is context-sensitive: it switches between "code mode"
//! and "template mode" when it encounters the `template` keyword and `{` / `}`.
use crate::error::{CompileError, CompileResult};
#[derive(Debug, Clone, PartialEq)]
pub enum Token {
// Keywords
Component,
Props,
State,
Fn,
Template,
If,
Else,
Return,
// Identifiers and literals
Ident(String),
StringLit(String),
IntLit(i64),
FloatLit(f64),
BoolLit(bool),
// Punctuation
LBrace, // {
RBrace, // }
LParen, // (
RParen, // )
LAngle, // <
RAngle, // >
LBracket, // [
RBracket, // ]
Colon, // :
Semicolon,// ;
Comma, // ,
Dot, // .
Eq, // =
EqEq, // ==
Bang, // !
BangEq, // !=
Plus, // +
Minus, // -
Star, // *
Slash, // /
Arrow, // ->
FatArrow, // =>
Ampersand,// &
Pipe, // |
AmpAmp, // &&
PipePipe, // ||
Question, // ?
Hash, // #
At, // @
// Template-specific
SlashIdent(String), // /if /each /activate
ColonIdent(String), // :else
HashIdent(String), // #if #each #activate
OnColon(String), // on:click on:input etc.
SelfClose, // />
CloseTag(String), // </div>
// Raw text in templates
RawText(String),
Eof,
}
pub fn tokenize(source: &str) -> CompileResult<Vec<Token>> {
let mut lexer = Lexer::new(source);
lexer.run()
}
struct Lexer<'a> {
src: &'a [u8],
pos: usize,
}
impl<'a> Lexer<'a> {
fn new(source: &'a str) -> Self {
Self { src: source.as_bytes(), pos: 0 }
}
fn peek(&self) -> Option<u8> {
self.src.get(self.pos).copied()
}
fn peek2(&self) -> Option<u8> {
self.src.get(self.pos + 1).copied()
}
fn advance(&mut self) -> Option<u8> {
let ch = self.src.get(self.pos).copied();
if ch.is_some() {
self.pos += 1;
}
ch
}
fn skip_whitespace_and_comments(&mut self) {
loop {
// Skip whitespace
while matches!(self.peek(), Some(b' ' | b'\t' | b'\n' | b'\r')) {
self.advance();
}
// Skip // line comments
if self.peek() == Some(b'/') && self.peek2() == Some(b'/') {
while self.peek().is_some() && self.peek() != Some(b'\n') {
self.advance();
}
continue;
}
break;
}
}
fn read_ident(&mut self) -> String {
let start = self.pos;
while matches!(self.peek(), Some(b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_')) {
self.advance();
}
String::from_utf8_lossy(&self.src[start..self.pos]).into_owned()
}
fn read_string(&mut self) -> CompileResult<String> {
// Opening quote already consumed
let mut s = String::new();
loop {
match self.advance() {
None => return Err(CompileError::Lex { pos: self.pos, msg: "unterminated string".into() }),
Some(b'"') => break,
Some(b'\\') => {
match self.advance() {
Some(b'n') => s.push('\n'),
Some(b't') => s.push('\t'),
Some(b'r') => s.push('\r'),
Some(b'"') => s.push('"'),
Some(b'\\') => s.push('\\'),
Some(b'0') => s.push('\0'),
Some(c) => s.push(c as char),
None => return Err(CompileError::Lex { pos: self.pos, msg: "unterminated escape".into() }),
}
}
Some(c) => s.push(c as char),
}
}
Ok(s)
}
fn read_number(&mut self, first: u8) -> Token {
let mut s = String::new();
s.push(first as char);
while matches!(self.peek(), Some(b'0'..=b'9' | b'_')) {
let c = self.advance().unwrap();
if c != b'_' {
s.push(c as char);
}
}
if self.peek() == Some(b'.') && matches!(self.peek2(), Some(b'0'..=b'9')) {
s.push('.');
self.advance();
while matches!(self.peek(), Some(b'0'..=b'9')) {
s.push(self.advance().unwrap() as char);
}
Token::FloatLit(s.parse().unwrap_or(0.0))
} else {
Token::IntLit(s.parse().unwrap_or(0))
}
}
/// Read a template block — everything between the outer `{` and matching `}`
/// of `template { ... }`. Returns the raw text inside.
fn read_template_inner(&mut self) -> CompileResult<Vec<Token>> {
// We are positioned right after `template` keyword and the `{` that opened it.
// We tokenize the template body using a template-aware mini-lexer.
let mut toks: Vec<Token> = Vec::new();
let mut depth = 1i32; // we've consumed the opening {
let mut text_buf = String::new();
macro_rules! flush_text {
() => {
if !text_buf.is_empty() {
let t = text_buf.trim().to_owned();
if !t.is_empty() {
toks.push(Token::RawText(t));
}
text_buf.clear();
}
};
}
loop {
match self.peek() {
None => return Err(CompileError::Lex { pos: self.pos, msg: "unterminated template block".into() }),
Some(b'{') => {
self.advance();
// Could be interpolation {expr}, or {#if}, {/if}, {:else}
// Peek at what follows
// Skip whitespace inside
while matches!(self.peek(), Some(b' ' | b'\t')) {
self.advance();
}
if self.peek() == Some(b'#') {
// Block tag: {#if ...} {#each ...} {#activate ...}
self.advance(); // consume #
let kw = self.read_ident();
flush_text!();
toks.push(Token::HashIdent(kw));
// Read the rest up to }
let mut inner = String::new();
let mut brace_d = 1i32;
loop {
match self.peek() {
None => break,
Some(b'{') => { brace_d += 1; inner.push('{'); self.advance(); }
Some(b'}') => {
brace_d -= 1;
self.advance();
if brace_d == 0 { break; }
inner.push('}');
}
Some(c) => { inner.push(c as char); self.advance(); }
}
}
toks.push(Token::RawText(inner.trim().to_owned()));
} else if self.peek() == Some(b'/') {
// Close tag: {/if} {/each} {/activate}
self.advance(); // consume /
let kw = self.read_ident();
flush_text!();
toks.push(Token::SlashIdent(kw));
while self.peek() == Some(b'}') { self.advance(); break; }
} else if self.peek() == Some(b':') {
// {:else}
self.advance(); // consume :
let kw = self.read_ident();
flush_text!();
toks.push(Token::ColonIdent(kw));
while self.peek() == Some(b'}') { self.advance(); break; }
} else {
// Regular interpolation or outer brace tracking
// Check if this closes the template
if depth == 1 && self.peek() == Some(b'}') {
// Empty brace—skip
self.advance();
let _ = depth - 1; // depth tracked by outer loop
break;
}
// Read the expression until matching }
let mut expr = String::new();
let mut brace_d = 1i32;
loop {
match self.peek() {
None => break,
Some(b'{') => { brace_d += 1; expr.push('{'); self.advance(); }
Some(b'}') => {
brace_d -= 1;
self.advance();
if brace_d == 0 { break; }
expr.push('}');
}
Some(c) => { expr.push(c as char); self.advance(); }
}
}
let expr = expr.trim().to_owned();
if !expr.is_empty() {
flush_text!();
toks.push(Token::LBrace);
toks.push(Token::RawText(expr));
toks.push(Token::RBrace);
}
}
}
Some(b'}') => {
depth -= 1;
self.advance();
if depth == 0 {
flush_text!();
break;
}
text_buf.push('}');
}
Some(b'<') => {
// HTML element or close tag
self.advance();
if self.peek() == Some(b'/') {
// Close tag </div>
self.advance();
let tag = self.read_tag_name();
while self.peek() != Some(b'>') && self.peek().is_some() {
self.advance();
}
self.advance(); // consume >
flush_text!();
toks.push(Token::CloseTag(tag));
} else {
// Open tag
let tag = self.read_tag_name();
flush_text!();
toks.push(Token::LAngle);
toks.push(Token::Ident(tag));
// Read attributes
self.read_attrs_into(&mut toks)?;
}
}
Some(b'\n' | b'\r') => {
self.advance();
text_buf.push(' ');
}
Some(c) => {
text_buf.push(c as char);
self.advance();
}
}
}
Ok(toks)
}
fn read_tag_name(&mut self) -> String {
while matches!(self.peek(), Some(b' ' | b'\t' | b'\n')) {
self.advance();
}
let start = self.pos;
while matches!(self.peek(), Some(b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_')) {
self.advance();
}
String::from_utf8_lossy(&self.src[start..self.pos]).into_owned()
}
/// Read attributes until `>` or `/>`.
fn read_attrs_into(&mut self, toks: &mut Vec<Token>) -> CompileResult<()> {
loop {
// Skip whitespace
while matches!(self.peek(), Some(b' ' | b'\t' | b'\n' | b'\r')) {
self.advance();
}
match self.peek() {
None => break,
Some(b'/') if self.peek2() == Some(b'>') => {
self.advance(); self.advance();
toks.push(Token::SelfClose);
break;
}
Some(b'>') => {
self.advance();
toks.push(Token::RAngle);
break;
}
Some(b'o') if self.src.get(self.pos..self.pos+3) == Some(b"on:") => {
// on:event={handler}
self.pos += 3; // skip "on:"
let event = self.read_tag_name();
// skip whitespace and =
while matches!(self.peek(), Some(b' ' | b'=')) { self.advance(); }
// read {expr}
let expr = if self.peek() == Some(b'{') {
self.advance();
self.read_until_brace_close()?
} else {
self.read_quoted_string()?
};
toks.push(Token::OnColon(event));
toks.push(Token::RawText(expr));
}
_ => {
// Regular attribute: name="val" or name={expr}
let name = self.read_attr_name();
if name.is_empty() { break; }
// Skip whitespace and =
while matches!(self.peek(), Some(b' ' | b'\t')) { self.advance(); }
if self.peek() != Some(b'=') {
// Boolean attribute with no value
toks.push(Token::Ident(name));
continue;
}
self.advance(); // consume =
while matches!(self.peek(), Some(b' ' | b'\t')) { self.advance(); }
let value = if self.peek() == Some(b'"') {
self.advance(); // consume "
let s = self.read_string()?;
toks.push(Token::Ident(name.clone()));
toks.push(Token::Eq);
toks.push(Token::StringLit(s));
continue;
} else if self.peek() == Some(b'{') {
self.advance();
self.read_until_brace_close()?
} else {
self.read_attr_name()
};
toks.push(Token::Ident(name));
toks.push(Token::Eq);
toks.push(Token::RawText(value));
}
}
}
Ok(())
}
fn read_attr_name(&mut self) -> String {
let start = self.pos;
while matches!(self.peek(), Some(b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b':')) {
self.advance();
}
String::from_utf8_lossy(&self.src[start..self.pos]).into_owned()
}
fn read_quoted_string(&mut self) -> CompileResult<String> {
if self.peek() == Some(b'"') {
self.advance();
self.read_string()
} else {
Ok(self.read_attr_name())
}
}
fn read_until_brace_close(&mut self) -> CompileResult<String> {
let mut s = String::new();
let mut depth = 1i32;
loop {
match self.peek() {
None => return Err(CompileError::Lex { pos: self.pos, msg: "unterminated {".into() }),
Some(b'{') => { depth += 1; s.push('{'); self.advance(); }
Some(b'}') => {
depth -= 1;
self.advance();
if depth == 0 { break; }
s.push('}');
}
Some(c) => { s.push(c as char); self.advance(); }
}
}
Ok(s)
}
fn run(&mut self) -> CompileResult<Vec<Token>> {
let mut tokens: Vec<Token> = Vec::new();
loop {
self.skip_whitespace_and_comments();
if self.peek().is_none() {
tokens.push(Token::Eof);
break;
}
let ch = self.advance().unwrap();
match ch {
b'a'..=b'z' | b'A'..=b'Z' | b'_' => {
let mut ident = String::new();
ident.push(ch as char);
while matches!(self.peek(), Some(b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_')) {
ident.push(self.advance().unwrap() as char);
}
let tok = match ident.as_str() {
"component" => Token::Component,
"props" => Token::Props,
"state" => Token::State,
"fn" => Token::Fn,
"template" => Token::Template,
"if" => Token::If,
"else" => Token::Else,
"return" => Token::Return,
"true" => Token::BoolLit(true),
"false" => Token::BoolLit(false),
other => Token::Ident(other.to_owned()),
};
// Special handling: after `template`, read the block specially
if tok == Token::Template {
tokens.push(tok);
self.skip_whitespace_and_comments();
if self.peek() == Some(b'{') {
self.advance(); // consume {
tokens.push(Token::LBrace);
let inner = self.read_template_inner()?;
tokens.extend(inner);
tokens.push(Token::RBrace);
}
} else {
tokens.push(tok);
}
}
b'"' => {
let s = self.read_string()?;
tokens.push(Token::StringLit(s));
}
b'0'..=b'9' => {
let tok = self.read_number(ch);
tokens.push(tok);
}
b'{' => tokens.push(Token::LBrace),
b'}' => tokens.push(Token::RBrace),
b'(' => tokens.push(Token::LParen),
b')' => tokens.push(Token::RParen),
b'[' => tokens.push(Token::LBracket),
b']' => tokens.push(Token::RBracket),
b':' => tokens.push(Token::Colon),
b';' => tokens.push(Token::Semicolon),
b',' => tokens.push(Token::Comma),
b'.' => tokens.push(Token::Dot),
b'=' => {
if self.peek() == Some(b'=') {
self.advance(); tokens.push(Token::EqEq);
} else if self.peek() == Some(b'>') {
self.advance(); tokens.push(Token::FatArrow);
} else {
tokens.push(Token::Eq);
}
}
b'!' => {
if self.peek() == Some(b'=') {
self.advance(); tokens.push(Token::BangEq);
} else {
tokens.push(Token::Bang);
}
}
b'+' => tokens.push(Token::Plus),
b'-' => {
if self.peek() == Some(b'>') {
self.advance(); tokens.push(Token::Arrow);
} else {
tokens.push(Token::Minus);
}
}
b'*' => tokens.push(Token::Star),
b'/' => {
if self.peek() == Some(b'/') {
// Line comment (shouldn't reach here after skip, but guard)
while self.peek().is_some() && self.peek() != Some(b'\n') {
self.advance();
}
} else {
tokens.push(Token::Slash);
}
}
b'&' => {
if self.peek() == Some(b'&') {
self.advance(); tokens.push(Token::AmpAmp);
} else {
tokens.push(Token::Ampersand);
}
}
b'|' => {
if self.peek() == Some(b'|') {
self.advance(); tokens.push(Token::PipePipe);
} else {
tokens.push(Token::Pipe);
}
}
b'?' => tokens.push(Token::Question),
b'#' => tokens.push(Token::Hash),
b'@' => tokens.push(Token::At),
b'<' => tokens.push(Token::LAngle),
b'>' => tokens.push(Token::RAngle),
_ => {
// Ignore unknown characters (whitespace already skipped)
}
}
}
Ok(tokens)
}
}
+78
View File
@@ -0,0 +1,78 @@
//! el-ui-compiler — Transforms `.el` component files into JavaScript.
//!
//! Pipeline:
//! source text → lexer → tokens → parser → AST → codegen → JavaScript
//!
//! The output JavaScript uses the el-ui runtime (`el-ui.js`) to register
//! components, manage a spreading-activation graph for state, and patch the DOM.
pub mod ast;
pub mod codegen;
pub mod error;
pub mod lexer;
pub mod parser;
pub mod semantic;
pub use ast::{Attr, Component, Method, PropDef, StateDef, Template, TemplateNode};
#[cfg(test)]
mod tests;
pub use codegen::Codegen;
pub use error::{CompileError, CompileResult};
/// High-level compiler entry point.
pub struct Compiler {
/// Runtime import path (default: `./el-ui.js`)
pub runtime_path: String,
}
impl Default for Compiler {
fn default() -> Self {
Self { runtime_path: "./el-ui.js".into() }
}
}
impl Compiler {
pub fn new() -> Self {
Self::default()
}
pub fn with_runtime_path(mut self, path: impl Into<String>) -> Self {
self.runtime_path = path.into();
self
}
/// Compile a single `.el` source file containing one or more components.
/// Returns the JavaScript module string.
pub fn compile_component(&self, source: &str) -> CompileResult<String> {
let tokens = lexer::tokenize(source)?;
let components = parser::parse(&tokens)?;
let gen = Codegen::new(&self.runtime_path);
gen.generate(&components)
}
/// Compile an app entry point, pulling in named component sources.
/// `components` is a slice of `(name, source)` pairs.
/// Returns a single JavaScript module that imports from the runtime.
pub fn compile_app(
&self,
entry_source: &str,
components: &[(&str, &str)],
) -> CompileResult<String> {
let mut all_components: Vec<Component> = Vec::new();
for (_name, src) in components {
let tokens = lexer::tokenize(src)?;
let mut parsed = parser::parse(&tokens)?;
all_components.append(&mut parsed);
}
// Parse entry last (may reference previously defined components)
let entry_tokens = lexer::tokenize(entry_source)?;
let mut entry_parsed = parser::parse(&entry_tokens)?;
all_components.append(&mut entry_parsed);
let gen = Codegen::new(&self.runtime_path);
gen.generate(&all_components)
}
}
+142
View File
@@ -0,0 +1,142 @@
// el-ui-compiler Elbrowser-target component compiler (STUB).
//
// The Rust crate transforms `.el` component files into JavaScript using a
// real lexer/parser/codegen pipeline backed by the el-ui runtime
// (`el-ui.js`). This El surface is a placeholder.
//
// RUNTIME GAP load-bearing:
// `elc` (the bootstrap El compiler) currently emits C only. There is no
// JavaScript or WebAssembly backend yet, so the El surface cannot
// actually compile a component to JS today.
//
// What this vessel provides:
// - The shape of the public API (Compiler with runtime_path,
// compile_component, compile_app).
// - A `CompileResult` record consumers can branch on.
// - Stub bodies that return a deterministic "not yet implemented"
// module string so downstream tooling can integration-test against
// a stable contract.
//
// When elc gains a JS/Wasm backend, swap the bodies of `lex`, `parse`,
// `semantic_check`, and `codegen` for real implementations the public
// surface should not need to change.
// Errors
fn err_lex() -> String { "elc.lex" }
fn err_parse() -> String { "elc.parse" }
fn err_semantic() -> String { "elc.semantic" }
fn err_codegen() -> String { "elc.codegen" }
fn err_backend_missing() -> String { "elc.backend_missing" }
// Compile result
//
// Modeled as JSON since the runtime's product types and field accessors
// don't yet support the Bool/String mix we want here cleanly.
fn result_ok(code: String) -> String {
"{\"ok\":true,\"code\":" + json_quote(code) + ",\"error\":\"\"}"
}
fn result_err(error: String) -> String {
"{\"ok\":false,\"code\":\"\",\"error\":\"" + error + "\"}"
}
// Minimal JSON string quoter (escape backslash + double-quote only).
// elc still doesn't expose a json_string_quote builtin.
fn json_quote(s: String) -> String {
let escaped: String = str_replace(s, "\\", "\\\\")
let escaped = str_replace(escaped, "\"", "\\\"")
"\"" + escaped + "\""
}
fn result_ok_p(result_json: String) -> Bool {
json_get_bool(result_json, "ok")
}
// Compiler config
fn compiler_default_runtime_path() -> String { "./el-ui.js" }
fn compiler_default_target() -> String { "js" }
fn compiler_new() -> String {
"{\"runtime_path\":\"./el-ui.js\",\"target\":\"js\"}"
}
fn compiler_with_runtime(c_json: String, path: String) -> String {
json_set(c_json, "runtime_path", json_quote(path))
}
fn compiler_with_target(c_json: String, target: String) -> String {
json_set(c_json, "target", json_quote(target))
}
fn compiler_runtime_path(c_json: String) -> String {
json_get_string(c_json, "runtime_path")
}
fn compiler_target(c_json: String) -> String {
json_get_string(c_json, "target")
}
// Pipeline stages (stubs)
//
// Real implementations live in the Rust crate (lexer.rs, parser.rs,
// semantic.rs, codegen.rs). These El stubs only validate inputs and
// produce a marker payload so callers can wire the contract.
fn lex(source: String) -> String {
if str_eq(source, "") { return "" }
"[]" // would be tokens JSON
}
fn parse(tokens_json: String) -> String {
if str_eq(tokens_json, "") { return "" }
"[]" // would be Component AST JSON
}
fn semantic_check(ast_json: String) -> String {
ast_json // pass-through stub
}
fn codegen(c_json: String, ast_json: String) -> String {
let runtime: String = compiler_runtime_path(c_json)
let preamble: String = "// AUTOGENERATED by el-ui-compiler (STUB)\n"
let import_line: String = "import { register, h } from \"" + runtime + "\";\n"
let body: String = "throw new Error(\"" + err_backend_missing()
+ ": el-ui-compiler has no JS backend yet; emit C with elc instead\");\n"
preamble + import_line + body
}
// Public entry points
fn compile_component(c_json: String, source: String) -> String {
let tokens: String = lex(source)
if str_eq(tokens, "") { return result_err(err_lex()) }
let ast: String = parse(tokens)
if str_eq(ast, "") { return result_err(err_parse()) }
let checked: String = semantic_check(ast)
if str_eq(checked, "") { return result_err(err_semantic()) }
let js: String = codegen(c_json, checked)
result_ok(js)
}
// `entry_source` is the app entry; `components_json` is a JSON object map of
// name -> source text. We compile each component first, then the entry.
fn compile_app(c_json: String, entry_source: String, components_json: String) -> String {
// RUNTIME GAP: no json_object_keys() yet, so we cannot iterate the map
// generically here. Real implementation will iterate and short-circuit
// on first compile error. For the stub we just compile the entry.
compile_component(c_json, entry_source)
}
// Entry smoke test
let c: String = compiler_new()
let c = compiler_with_runtime(c, "./el-ui.js")
let r: String = compile_component(c, "component Hello { template { <p>hi</p> } }")
if result_ok_p(r) {
println("[el-ui-compiler] STUB compiled (target=" + compiler_target(c) + ")")
} else {
println("[el-ui-compiler] error: " + json_get_string(r, "error"))
}
+46
View File
@@ -0,0 +1,46 @@
//! el-ui-compiler CLI
//!
//! Usage:
//! el-ui-compiler <input.el> [-o <output.js>]
use std::fs;
use std::path::PathBuf;
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.len() < 2 {
eprintln!("Usage: el-ui-compiler <input.el> [-o output.js]");
std::process::exit(1);
}
let input = PathBuf::from(&args[1]);
let output = if args.len() >= 4 && args[2] == "-o" {
PathBuf::from(&args[3])
} else {
input.with_extension("js")
};
let source = match fs::read_to_string(&input) {
Ok(s) => s,
Err(e) => {
eprintln!("Error reading {}: {}", input.display(), e);
std::process::exit(1);
}
};
let compiler = el_ui_compiler::Compiler::new();
match compiler.compile_component(&source) {
Ok(js) => {
if let Err(e) = fs::write(&output, js) {
eprintln!("Error writing {}: {}", output.display(), e);
std::process::exit(1);
}
println!("Compiled {} -> {}", input.display(), output.display());
}
Err(e) => {
eprintln!("Compile error: {}", e);
std::process::exit(1);
}
}
}
+640
View File
@@ -0,0 +1,640 @@
//! Parser for el-ui component files.
//!
//! Hand-written recursive descent. Produces a `Vec<Component>` from a token stream.
use crate::ast::*;
use crate::error::{CompileError, CompileResult};
use crate::lexer::Token;
pub fn parse(tokens: &[Token]) -> CompileResult<Vec<Component>> {
let mut p = Parser::new(tokens);
p.parse_program()
}
struct Parser<'a> {
tokens: &'a [Token],
pos: usize,
}
impl<'a> Parser<'a> {
fn new(tokens: &'a [Token]) -> Self {
Self { tokens, pos: 0 }
}
fn peek(&self) -> &Token {
self.tokens.get(self.pos).unwrap_or(&Token::Eof)
}
fn advance(&mut self) -> &Token {
let tok = self.tokens.get(self.pos).unwrap_or(&Token::Eof);
if self.pos < self.tokens.len() {
self.pos += 1;
}
tok
}
fn expect(&mut self, expected: &Token) -> CompileResult<()> {
let tok = self.advance();
if tok == expected {
Ok(())
} else {
Err(CompileError::Parse {
msg: format!("expected {:?}, got {:?}", expected, tok),
})
}
}
fn expect_ident(&mut self) -> CompileResult<String> {
match self.advance().clone() {
Token::Ident(s) => Ok(s),
other => Err(CompileError::Parse {
msg: format!("expected identifier, got {:?}", other),
}),
}
}
fn parse_program(&mut self) -> CompileResult<Vec<Component>> {
let mut components = Vec::new();
while *self.peek() != Token::Eof {
match self.peek() {
Token::Component => {
components.push(self.parse_component()?);
}
_ => {
// Skip unknown top-level tokens
self.advance();
}
}
}
Ok(components)
}
fn parse_component(&mut self) -> CompileResult<Component> {
self.expect(&Token::Component)?;
let name = self.expect_ident()?;
self.expect(&Token::LBrace)?;
let mut props = Vec::new();
let mut state = Vec::new();
let mut methods = Vec::new();
let mut template = Template { nodes: Vec::new() };
loop {
match self.peek().clone() {
Token::RBrace => {
self.advance();
break;
}
Token::Eof => break,
Token::Props => {
self.advance();
props = self.parse_prop_block()?;
}
Token::State => {
self.advance();
state = self.parse_state_block()?;
}
Token::Fn => {
methods.push(self.parse_method()?);
}
Token::Template => {
self.advance(); // consume `template`
template = self.parse_template_block()?;
}
_ => {
self.advance(); // skip unknown
}
}
}
Ok(Component { name, props, state, methods, template })
}
fn parse_prop_block(&mut self) -> CompileResult<Vec<PropDef>> {
self.expect(&Token::LBrace)?;
let mut props = Vec::new();
loop {
match self.peek().clone() {
Token::RBrace | Token::Eof => {
self.advance();
break;
}
Token::Ident(name) => {
self.advance();
self.expect(&Token::Colon)?;
let type_name = self.parse_type_name()?;
let default = if *self.peek() == Token::Eq {
self.advance();
Some(self.parse_default_value()?)
} else {
None
};
// Optional trailing comma/semicolon
if matches!(self.peek(), Token::Comma | Token::Semicolon) {
self.advance();
}
props.push(PropDef { name, type_name, default });
}
_ => {
self.advance();
}
}
}
Ok(props)
}
fn parse_state_block(&mut self) -> CompileResult<Vec<StateDef>> {
self.expect(&Token::LBrace)?;
let mut defs = Vec::new();
loop {
match self.peek().clone() {
Token::RBrace | Token::Eof => {
self.advance();
break;
}
Token::Ident(name) => {
self.advance();
self.expect(&Token::Colon)?;
let type_name = self.parse_type_name()?;
self.expect(&Token::Eq)?;
let initial = self.parse_default_value()?;
if matches!(self.peek(), Token::Comma | Token::Semicolon) {
self.advance();
}
defs.push(StateDef { name, type_name, initial });
}
_ => {
self.advance();
}
}
}
Ok(defs)
}
fn parse_type_name(&mut self) -> CompileResult<String> {
let name = match self.peek().clone() {
Token::Ident(s) => { self.advance(); s }
other => return Err(CompileError::Parse {
msg: format!("expected type name, got {:?}", other),
}),
};
// Check for array type [T] → already consumed base name, but array types
// start with [ so this handles bare type names only
Ok(name)
}
fn parse_default_value(&mut self) -> CompileResult<String> {
// Collect tokens until comma, semicolon, or next top-level item
// We need to handle nested structures like Fn types, etc.
let mut result = String::new();
let mut depth = 0i32;
loop {
match self.peek() {
Token::Eof => break,
Token::LParen | Token::LBrace | Token::LBracket => {
depth += 1;
let tok = self.advance();
result.push_str(&token_to_str(tok));
}
Token::RParen | Token::RBrace | Token::RBracket => {
if depth == 0 { break; }
depth -= 1;
let tok = self.advance();
result.push_str(&token_to_str(tok));
}
Token::Comma | Token::Semicolon if depth == 0 => break,
// These signal end of the value if at depth 0
Token::Ident(_) | Token::Props | Token::State | Token::Fn
| Token::Template | Token::Component if depth == 0 => break,
_ => {
let tok = self.advance();
result.push_str(&token_to_str(tok));
result.push(' ');
}
}
}
Ok(result.trim().to_owned())
}
fn parse_method(&mut self) -> CompileResult<Method> {
self.expect(&Token::Fn)?;
let name = self.expect_ident()?;
self.expect(&Token::LParen)?;
let mut params: Vec<(String, String)> = Vec::new();
loop {
match self.peek().clone() {
Token::RParen | Token::Eof => { self.advance(); break; }
Token::Ident(pname) => {
self.advance();
self.expect(&Token::Colon)?;
let ptype = self.parse_type_name()?;
params.push((pname, ptype));
if *self.peek() == Token::Comma { self.advance(); }
}
_ => { self.advance(); }
}
}
self.expect(&Token::Arrow)?;
let return_type = self.parse_type_name()?;
// Read the method body between { }
let body = self.read_block_raw()?;
Ok(Method { name, params, return_type, body })
}
/// Read everything between { and matching } as raw text.
fn read_block_raw(&mut self) -> CompileResult<String> {
self.expect(&Token::LBrace)?;
let mut result = String::new();
let mut depth = 1i32;
loop {
match self.peek() {
Token::Eof => break,
Token::LBrace => { depth += 1; self.advance(); result.push_str("{ "); }
Token::RBrace => {
depth -= 1;
self.advance();
if depth == 0 { break; }
result.push_str("} ");
}
tok => {
result.push_str(&token_to_str(tok));
result.push(' ');
self.advance();
}
}
}
Ok(result.trim().to_owned())
}
/// Parse the template block. At this point the lexer has already expanded
/// the template into special tokens (LBrace/RBrace wrapping interpolations,
/// LAngle/Ident for elements, etc.).
fn parse_template_block(&mut self) -> CompileResult<Template> {
self.expect(&Token::LBrace)?;
let nodes = self.parse_template_nodes()?;
// The matching RBrace is consumed inside parse_template_nodes
Ok(Template { nodes })
}
fn parse_template_nodes(&mut self) -> CompileResult<Vec<TemplateNode>> {
let mut nodes: Vec<TemplateNode> = Vec::new();
loop {
match self.peek().clone() {
Token::Eof | Token::RBrace => {
self.advance();
break;
}
Token::CloseTag(_) => {
// Consumed by parent element parser
break;
}
Token::LAngle => {
nodes.push(self.parse_element()?);
}
Token::LBrace => {
// Interpolation: { RawText }
self.advance(); // consume {
if let Token::RawText(expr) = self.peek().clone() {
self.advance();
nodes.push(TemplateNode::Interpolation(expr));
if *self.peek() == Token::RBrace { self.advance(); }
} else {
nodes.push(TemplateNode::Text("{".into()));
}
}
Token::HashIdent(kw) => {
let kw = kw.clone();
self.advance();
nodes.push(self.parse_block_tag(&kw)?);
}
Token::SlashIdent(_) => {
// End of a block — caller handles
break;
}
Token::ColonIdent(_) => {
// {:else} — caller handles
break;
}
Token::RawText(t) => {
let t = t.clone();
self.advance();
if !t.is_empty() {
nodes.push(TemplateNode::Text(t));
}
}
_ => {
self.advance(); // skip
}
}
}
Ok(nodes)
}
fn parse_element(&mut self) -> CompileResult<TemplateNode> {
self.expect(&Token::LAngle)?;
let tag = self.expect_ident()?;
let is_component = tag.chars().next().map(|c| c.is_uppercase()).unwrap_or(false);
let mut attrs: Vec<Attr> = Vec::new();
// Parse attributes until > or />
let mut self_closing = false;
loop {
match self.peek().clone() {
Token::SelfClose => {
self.advance();
self_closing = true;
break;
}
Token::RAngle => {
self.advance();
break;
}
Token::Eof => break,
Token::OnColon(event) => {
let event = event.clone();
self.advance();
let handler = if let Token::RawText(h) = self.peek().clone() {
self.advance();
h
} else {
String::new()
};
attrs.push(Attr::EventHandler { event, handler });
}
Token::Ident(name) => {
let name = name.clone();
self.advance();
if *self.peek() == Token::Eq {
self.advance(); // consume =
match self.peek().clone() {
Token::StringLit(val) => {
self.advance();
attrs.push(Attr::Static { name, value: val });
}
Token::RawText(expr) => {
self.advance();
// Determine if it's a bool attr
// Simple heuristic: if name is "disabled", "checked", "readonly"
let bool_attrs = ["disabled", "checked", "readonly", "required", "multiple", "selected"];
if bool_attrs.contains(&name.as_str()) {
attrs.push(Attr::BoolAttr { name, expr });
} else {
attrs.push(Attr::Dynamic { name, expr });
}
}
_ => {
attrs.push(Attr::Static { name, value: String::new() });
}
}
} else {
// Standalone attribute (boolean)
attrs.push(Attr::BoolAttr { name, expr: "true".into() });
}
}
_ => {
self.advance();
}
}
}
if self_closing || is_component {
if is_component {
return Ok(TemplateNode::Component { name: tag, props: attrs });
}
return Ok(TemplateNode::Element { tag, attrs, children: Vec::new() });
}
// Read children until </tag>
let children = self.parse_template_children(&tag)?;
Ok(TemplateNode::Element { tag, attrs, children })
}
fn parse_template_children(&mut self, close_tag: &str) -> CompileResult<Vec<TemplateNode>> {
let mut children: Vec<TemplateNode> = Vec::new();
loop {
match self.peek().clone() {
Token::Eof => break,
Token::RBrace => break,
Token::CloseTag(tag) => {
self.advance();
if tag == close_tag || tag.is_empty() {
break;
}
// Mismatched close tag — ignore
}
Token::LAngle => {
children.push(self.parse_element()?);
}
Token::LBrace => {
self.advance();
if let Token::RawText(expr) = self.peek().clone() {
self.advance();
children.push(TemplateNode::Interpolation(expr));
if *self.peek() == Token::RBrace { self.advance(); }
}
}
Token::HashIdent(kw) => {
let kw = kw.clone();
self.advance();
children.push(self.parse_block_tag(&kw)?);
}
Token::SlashIdent(_) | Token::ColonIdent(_) => break,
Token::RawText(t) => {
let t = t.clone();
self.advance();
if !t.trim().is_empty() {
children.push(TemplateNode::Text(t));
}
}
_ => { self.advance(); }
}
}
Ok(children)
}
fn parse_block_tag(&mut self, kw: &str) -> CompileResult<TemplateNode> {
match kw {
"if" => self.parse_if_block(),
"each" => self.parse_each_block(),
"activate" => self.parse_activate_block(),
_ => Err(CompileError::Parse { msg: format!("unknown block tag: #{}", kw) }),
}
}
fn parse_if_block(&mut self) -> CompileResult<TemplateNode> {
// Next token should be RawText with the condition
let condition = if let Token::RawText(cond) = self.peek().clone() {
self.advance();
cond
} else {
return Err(CompileError::Parse { msg: "expected condition after {#if}".into() });
};
let then = self.parse_template_nodes_until_close_or_else()?;
let else_ = if let Token::ColonIdent(kw) = self.peek().clone() {
if kw == "else" {
self.advance();
Some(self.parse_template_nodes_until_close_or_else()?)
} else {
None
}
} else {
None
};
// Consume {/if}
if let Token::SlashIdent(kw) = self.peek().clone() {
if kw == "if" { self.advance(); }
}
Ok(TemplateNode::If { condition, then, else_ })
}
fn parse_each_block(&mut self) -> CompileResult<TemplateNode> {
// RawText: "items as item"
let raw = if let Token::RawText(r) = self.peek().clone() {
self.advance();
r
} else {
return Err(CompileError::Parse { msg: "expected 'items as item' after {#each}".into() });
};
let (items, item_name) = parse_each_header(&raw)?;
let children = self.parse_template_nodes_until_close_or_else()?;
if let Token::SlashIdent(kw) = self.peek().clone() {
if kw == "each" { self.advance(); }
}
Ok(TemplateNode::Each { items, item_name, children })
}
fn parse_activate_block(&mut self) -> CompileResult<TemplateNode> {
// RawText: `"query" as results`
let raw = if let Token::RawText(r) = self.peek().clone() {
self.advance();
r
} else {
return Err(CompileError::Parse { msg: "expected query after {#activate}".into() });
};
let (query, result_name) = parse_activate_header(&raw)?;
let children = self.parse_template_nodes_until_close_or_else()?;
if let Token::SlashIdent(kw) = self.peek().clone() {
if kw == "activate" { self.advance(); }
}
Ok(TemplateNode::Activate { query, result_name, children })
}
fn parse_template_nodes_until_close_or_else(&mut self) -> CompileResult<Vec<TemplateNode>> {
let mut nodes: Vec<TemplateNode> = Vec::new();
loop {
match self.peek().clone() {
Token::Eof | Token::RBrace => break,
Token::SlashIdent(_) | Token::ColonIdent(_) => break,
Token::CloseTag(_) => break,
Token::LAngle => nodes.push(self.parse_element()?),
Token::LBrace => {
self.advance();
if let Token::RawText(expr) = self.peek().clone() {
self.advance();
nodes.push(TemplateNode::Interpolation(expr));
if *self.peek() == Token::RBrace { self.advance(); }
}
}
Token::HashIdent(kw) => {
let kw = kw.clone();
self.advance();
nodes.push(self.parse_block_tag(&kw)?);
}
Token::RawText(t) => {
let t = t.clone();
self.advance();
if !t.trim().is_empty() {
nodes.push(TemplateNode::Text(t));
}
}
_ => { self.advance(); }
}
}
Ok(nodes)
}
}
fn token_to_str(tok: &Token) -> String {
match tok {
Token::Ident(s) => s.clone(),
Token::StringLit(s) => format!("\"{}\"", s),
Token::IntLit(n) => n.to_string(),
Token::FloatLit(f) => f.to_string(),
Token::BoolLit(b) => b.to_string(),
Token::LBrace => "{".into(),
Token::RBrace => "}".into(),
Token::LParen => "(".into(),
Token::RParen => ")".into(),
Token::LBracket => "[".into(),
Token::RBracket => "]".into(),
Token::Colon => ":".into(),
Token::Semicolon => ";".into(),
Token::Comma => ",".into(),
Token::Dot => ".".into(),
Token::Eq => "=".into(),
Token::EqEq => "==".into(),
Token::Bang => "!".into(),
Token::BangEq => "!=".into(),
Token::Plus => "+".into(),
Token::Minus => "-".into(),
Token::Star => "*".into(),
Token::Slash => "/".into(),
Token::Arrow => "->".into(),
Token::FatArrow => "=>".into(),
Token::AmpAmp => "&&".into(),
Token::PipePipe => "||".into(),
Token::Question => "?".into(),
Token::RawText(s) => s.clone(),
Token::Return => "return".into(),
Token::If => "if".into(),
Token::Else => "else".into(),
Token::Fn => "fn".into(),
Token::Component => "component".into(),
Token::Props => "props".into(),
Token::State => "state".into(),
Token::Template => "template".into(),
_ => String::new(),
}
}
fn parse_each_header(raw: &str) -> CompileResult<(String, String)> {
// e.g., "items as item"
if let Some(idx) = raw.find(" as ") {
let items = raw[..idx].trim().to_owned();
let item_name = raw[idx + 4..].trim().to_owned();
Ok((items, item_name))
} else {
Err(CompileError::Parse { msg: format!("invalid #each header: '{}'", raw) })
}
}
fn parse_activate_header(raw: &str) -> CompileResult<(String, String)> {
// e.g., `"query string" as results`
// Strip outer quotes from query
let raw = raw.trim();
if let Some(idx) = raw.rfind(" as ") {
let query_part = raw[..idx].trim();
let result_name = raw[idx + 4..].trim().to_owned();
let query = query_part.trim_matches('"').to_owned();
Ok((query, result_name))
} else {
Err(CompileError::Parse { msg: format!("invalid #activate header: '{}'", raw) })
}
}
+523
View File
@@ -0,0 +1,523 @@
//! Semantic primitive layer — EBD-driven appearance and layout concepts.
//!
//! # Design rationale
//!
//! Traditional UI frameworks describe *appearance*: a red button with rounded corners.
//! El UI describes *concepts*: a destructive action trigger. The platform then maps
//! that concept to its native visual convention — red on iOS, a warning style on
//! macOS, an outlined filled button on Android, etc.
//!
//! This is the EBD principle applied to UI: the component expresses *what something is*,
//! the platform backend expresses *how it looks*.
//!
//! # The semantic → PlatformNode pipeline
//!
//! ```text
//! .el template
//! ↓
//! SemanticPrimitive (this module)
//! ↓
//! platform codegen (codegen.rs — per-platform dispatch)
//! ↓
//! PlatformNode (el-platform IR)
//! ↓
//! platform backend (el-platform — native API calls)
//! ```
// Semantic types drive codegen decisions. They do not directly construct
// PlatformNodes at compile time — the generated *source code* strings reference
// PlatformNode from the el-platform crate, which is a separate runtime crate.
// The compiler emits Rust source that will reference el_platform::PlatformNode.
// ---------------------------------------------------------------------------
// Appearance concepts
// ---------------------------------------------------------------------------
/// EBD appearance concept — WHAT a control is, not HOW it looks.
///
/// Each platform maps these to its visual conventions:
/// - `Action` → primary button style (blue on Apple, filled on Material)
/// - `Destructive` → warning/danger style (red tint on Apple, error colour on Material)
/// - `Secondary` → subdued style (grey on Apple, outlined on Material)
/// - `Navigation` → used for link-like / back-navigation elements
/// - `Informational` → read-only display elements
/// - `Structural` → layout containers with no interactive meaning
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AppearanceConcept {
/// Primary action trigger — the most prominent interactive element.
Action,
/// Dangerous or irreversible action (delete, reset, etc.).
Destructive,
/// Less prominent action — secondary to the main action on screen.
Secondary,
/// Navigational element — links, back buttons, tab items.
Navigation,
/// Displays information — labels, status indicators, badges.
Informational,
/// Layout or structural container — no interactive semantics.
Structural,
}
impl AppearanceConcept {
/// Canonical CSS class suffix for the server/web targets.
pub fn css_class(&self) -> &'static str {
match self {
Self::Action => "action",
Self::Destructive => "destructive",
Self::Secondary => "secondary",
Self::Navigation => "navigation",
Self::Informational => "informational",
Self::Structural => "structural",
}
}
/// AppKit bezel style constant for the macOS target.
pub fn appkit_bezel_style(&self) -> &'static str {
match self {
Self::Action => "NSBezelStyle::rounded()",
Self::Destructive => "NSBezelStyle::rounded()", // red tint applied via theme token
Self::Secondary => "NSBezelStyle::smallSquare()",
Self::Navigation => "NSBezelStyle::recessed()",
Self::Informational => "NSBezelStyle::inline()",
Self::Structural => "NSBezelStyle::rounded()",
}
}
/// UIKit button configuration for the iOS target.
pub fn uikit_button_config(&self) -> &'static str {
match self {
Self::Action => "UIButton.Configuration.filled()",
Self::Destructive => "UIButton.Configuration.filled()", // tintColor = .systemRed
Self::Secondary => "UIButton.Configuration.gray()",
Self::Navigation => "UIButton.Configuration.plain()",
Self::Informational => "UIButton.Configuration.plain()",
Self::Structural => "UIButton.Configuration.plain()",
}
}
/// Jetpack Compose button composable for the Android target.
pub fn compose_button_type(&self) -> &'static str {
match self {
Self::Action => "Button",
Self::Destructive => "Button", // containerColor = MaterialTheme.colorScheme.error
Self::Secondary => "OutlinedButton",
Self::Navigation => "TextButton",
Self::Informational => "TextButton",
Self::Structural => "TextButton",
}
}
/// WinUI control style for the Windows target.
pub fn winui_button_style(&self) -> &'static str {
match self {
Self::Action => "AccentButtonStyle",
Self::Destructive => "AccentButtonStyle", // Background = DangerBrush via theme
Self::Secondary => "DefaultButtonStyle",
Self::Navigation => "NavigationBackButtonNormalStyle",
Self::Informational => "DefaultButtonStyle",
Self::Structural => "DefaultButtonStyle",
}
}
/// GTK CSS class for the Linux target.
pub fn gtk_css_class(&self) -> &'static str {
match self {
Self::Action => "suggested-action",
Self::Destructive => "destructive-action",
Self::Secondary => "flat",
Self::Navigation => "flat",
Self::Informational => "flat",
Self::Structural => "flat",
}
}
}
// ---------------------------------------------------------------------------
// Layout concepts
// ---------------------------------------------------------------------------
/// Layout concept — how children are arranged.
///
/// Platform mapping:
/// - `Stack` → VStack / UIStackView(vertical) / Column / StackPanel / GtkBox(vertical)
/// - `Row` → HStack / UIStackView(horizontal) / Row / StackPanel(horizontal) / GtkBox(horizontal)
/// - `Grid` → LazyVGrid / UICollectionView / LazyVerticalGrid / GridView
/// - `Overlay` → ZStack / UIView layering / Box / Canvas / GtkOverlay
/// - `Scroll` → ScrollView / UIScrollView / LazyColumn / ScrollViewer / GtkScrolledWindow
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LayoutConcept {
/// Vertical stack — children arranged top-to-bottom.
Stack,
/// Horizontal row — children arranged left-to-right.
Row,
/// Grid — children in a two-dimensional grid.
Grid,
/// Overlay — children layered on the z-axis.
Overlay,
/// Scrollable container — overflowing children are scrollable.
Scroll,
}
impl LayoutConcept {
/// HTML/CSS flex direction for the web/server targets.
pub fn css_flex_direction(&self) -> &'static str {
match self {
Self::Stack => "column",
Self::Row => "row",
Self::Grid => "row wrap", // use CSS grid in practice
Self::Overlay => "row", // position: relative + absolute children
Self::Scroll => "column",
}
}
/// AppKit stack view orientation.
pub fn appkit_orientation(&self) -> &'static str {
match self {
Self::Stack | Self::Grid | Self::Overlay | Self::Scroll => "NSUserInterfaceLayoutOrientation::vertical",
Self::Row => "NSUserInterfaceLayoutOrientation::horizontal",
}
}
/// UIKit stack view axis.
pub fn uikit_axis(&self) -> &'static str {
match self {
Self::Stack | Self::Grid | Self::Overlay | Self::Scroll => "NSLayoutConstraint.Axis.vertical",
Self::Row => "NSLayoutConstraint.Axis.horizontal",
}
}
/// Jetpack Compose layout composable.
pub fn compose_layout(&self) -> &'static str {
match self {
Self::Stack => "Column",
Self::Row => "Row",
Self::Grid => "LazyVerticalGrid",
Self::Overlay => "Box",
Self::Scroll => "LazyColumn",
}
}
/// WinUI panel type.
pub fn winui_panel(&self) -> &'static str {
match self {
Self::Stack => "Microsoft.UI.Xaml.Controls.StackPanel",
Self::Row => "Microsoft.UI.Xaml.Controls.StackPanel", // Orientation=Horizontal
Self::Grid => "Microsoft.UI.Xaml.Controls.Grid",
Self::Overlay => "Microsoft.UI.Xaml.Controls.Canvas",
Self::Scroll => "Microsoft.UI.Xaml.Controls.ScrollViewer",
}
}
/// GTK widget type.
pub fn gtk_widget(&self) -> &'static str {
match self {
Self::Stack => "GtkBox", // orientation=vertical
Self::Row => "GtkBox", // orientation=horizontal
Self::Grid => "GtkGrid",
Self::Overlay => "GtkOverlay",
Self::Scroll => "GtkScrolledWindow",
}
}
}
// ---------------------------------------------------------------------------
// Text role concepts
// ---------------------------------------------------------------------------
/// Text role concept — the semantic purpose of a piece of text.
///
/// Platform mapping:
/// - `Heading` → large/bold system font at the appropriate level
/// - `Body` → default body text style
/// - `Caption` → small secondary text (subtitles, footnotes)
/// - `Label` → paired with a control — describes it
/// - `Code` → monospaced font, code block style
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TextConcept {
/// Document heading at the given level (1 = most prominent, 6 = least).
Heading { level: u8 },
/// Default body text.
Body,
/// Small secondary text — captions, footnotes, helper text.
Caption,
/// A control label — paired with an input or button.
Label,
/// Monospaced code text.
Code,
}
impl TextConcept {
/// HTML tag for the server/web targets.
pub fn html_tag(&self) -> String {
match self {
Self::Heading { level } => format!("h{}", level.clamp(&1, &6)),
Self::Body => "p".to_string(),
Self::Caption => "small".to_string(),
Self::Label => "label".to_string(),
Self::Code => "code".to_string(),
}
}
/// AppKit font preset.
pub fn appkit_font(&self) -> &'static str {
match self {
Self::Heading { level: 1 } => "NSFont.systemFont(ofSize: 28, weight: .bold)",
Self::Heading { level: 2 } => "NSFont.systemFont(ofSize: 22, weight: .semibold)",
Self::Heading { .. } => "NSFont.systemFont(ofSize: 17, weight: .medium)",
Self::Body => "NSFont.systemFont(ofSize: 13)",
Self::Caption => "NSFont.systemFont(ofSize: 11, weight: .light)",
Self::Label => "NSFont.systemFont(ofSize: 13)",
Self::Code => "NSFont.monospacedSystemFont(ofSize: 13, weight: .regular)",
}
}
/// UIKit font preset.
pub fn uikit_font(&self) -> &'static str {
match self {
Self::Heading { level: 1 } => "UIFont.preferredFont(forTextStyle: .largeTitle)",
Self::Heading { level: 2 } => "UIFont.preferredFont(forTextStyle: .title1)",
Self::Heading { level: 3 } => "UIFont.preferredFont(forTextStyle: .title2)",
Self::Heading { .. } => "UIFont.preferredFont(forTextStyle: .headline)",
Self::Body => "UIFont.preferredFont(forTextStyle: .body)",
Self::Caption => "UIFont.preferredFont(forTextStyle: .caption1)",
Self::Label => "UIFont.preferredFont(forTextStyle: .callout)",
Self::Code => "UIFont.monospacedSystemFont(ofSize: 14, weight: .regular)",
}
}
/// Material 3 text style for Jetpack Compose.
pub fn compose_text_style(&self) -> &'static str {
match self {
Self::Heading { level: 1 } => "MaterialTheme.typography.headlineLarge",
Self::Heading { level: 2 } => "MaterialTheme.typography.headlineMedium",
Self::Heading { level: 3 } => "MaterialTheme.typography.headlineSmall",
Self::Heading { .. } => "MaterialTheme.typography.titleLarge",
Self::Body => "MaterialTheme.typography.bodyLarge",
Self::Caption => "MaterialTheme.typography.labelSmall",
Self::Label => "MaterialTheme.typography.labelMedium",
Self::Code => "MaterialTheme.typography.bodyMedium", // monospace font family
}
}
}
// ---------------------------------------------------------------------------
// Semantic primitives
// ---------------------------------------------------------------------------
/// A semantic UI primitive — CONCEPT, not visual element.
///
/// Each variant describes *what* the element is, not *how* it looks.
/// The platform codegen translates these into native API calls.
///
/// These are the building blocks; the platform backends decide the visual
/// expression. A `Button { appearance: Destructive }` looks different on
/// macOS (red-tinted rounded button) vs Android (error-coloured filled button)
/// but expresses the same concept.
#[derive(Debug, Clone, PartialEq)]
pub enum SemanticPrimitive {
/// An interactive button — triggers an action on press.
Button {
/// Visible label text.
label: String,
/// What kind of action this button represents.
appearance: AppearanceConcept,
/// Handler name from the component's method table, if any.
on_press: Option<String>,
},
/// A text display element.
Text {
/// The content to display.
content: String,
/// The semantic role of this text.
concept: TextConcept,
},
/// A layout container with child primitives.
Container {
/// Child primitives, in render order.
children: Vec<SemanticPrimitive>,
/// How children are arranged.
layout: LayoutConcept,
},
/// A text input field bound to a state variable.
Input {
/// State variable name this field is bound to (two-way).
binding: String,
/// Placeholder / hint text shown when empty.
hint: Option<String>,
/// Visual role of this input.
appearance: AppearanceConcept,
},
/// An image element.
Image {
/// Source URI or asset name.
src: String,
/// Accessibility description.
alt: String,
},
/// A toggle/checkbox control bound to a boolean state variable.
Toggle {
/// State variable name (must be Bool).
binding: String,
/// Label displayed alongside the toggle.
label: String,
},
/// A list of homogeneous items, each rendered by the same child primitive.
List {
/// Items to iterate over — a state variable or expression name.
items_binding: String,
/// The name given to each item within the child template.
item_name: String,
/// The primitive template applied to each item.
item_template: Box<SemanticPrimitive>,
},
}
impl SemanticPrimitive {
/// Convenience constructor for a primary action button.
pub fn action_button(label: impl Into<String>, on_press: impl Into<String>) -> Self {
Self::Button {
label: label.into(),
appearance: AppearanceConcept::Action,
on_press: Some(on_press.into()),
}
}
/// Convenience constructor for a destructive action button.
pub fn destructive_button(label: impl Into<String>, on_press: impl Into<String>) -> Self {
Self::Button {
label: label.into(),
appearance: AppearanceConcept::Destructive,
on_press: Some(on_press.into()),
}
}
/// Convenience constructor for a heading.
pub fn heading(content: impl Into<String>, level: u8) -> Self {
Self::Text {
content: content.into(),
concept: TextConcept::Heading { level },
}
}
/// Convenience constructor for body text.
pub fn body_text(content: impl Into<String>) -> Self {
Self::Text {
content: content.into(),
concept: TextConcept::Body,
}
}
/// Convenience constructor for a vertical stack container.
pub fn stack(children: Vec<SemanticPrimitive>) -> Self {
Self::Container {
children,
layout: LayoutConcept::Stack,
}
}
/// Convenience constructor for a horizontal row container.
pub fn row(children: Vec<SemanticPrimitive>) -> Self {
Self::Container {
children,
layout: LayoutConcept::Row,
}
}
}
// ---------------------------------------------------------------------------
// Semantic extraction from AST
// ---------------------------------------------------------------------------
/// Map an HTML tag + appearance attribute from the AST into a `SemanticPrimitive`.
///
/// This is the bridge from the parser's `TemplateNode` representation to the
/// semantic layer. The compiler calls this during its semantic analysis pass.
///
/// Appearance is inferred from:
/// 1. An explicit `appearance="..."` attribute on the element.
/// 2. The element's tag + class names (heuristic fallback).
pub fn infer_appearance(tag: &str, class: Option<&str>, explicit: Option<&str>) -> AppearanceConcept {
// Explicit attribute wins.
if let Some(a) = explicit {
return match a {
"action" | "primary" => AppearanceConcept::Action,
"destructive" | "danger" | "delete" => AppearanceConcept::Destructive,
"secondary" | "subdued" => AppearanceConcept::Secondary,
"navigation" | "nav" | "link" => AppearanceConcept::Navigation,
"informational" | "info" | "display" => AppearanceConcept::Informational,
"structural" | "layout" => AppearanceConcept::Structural,
_ => AppearanceConcept::Action,
};
}
// Infer from class names.
if let Some(cls) = class {
if cls.contains("danger") || cls.contains("destructive") || cls.contains("delete") {
return AppearanceConcept::Destructive;
}
if cls.contains("secondary") || cls.contains("outline") || cls.contains("ghost") {
return AppearanceConcept::Secondary;
}
if cls.contains("nav") || cls.contains("link") {
return AppearanceConcept::Navigation;
}
}
// Tag-level heuristic fallback.
match tag {
"button" => AppearanceConcept::Action,
"a" => AppearanceConcept::Navigation,
"input" | "textarea" | "select" => AppearanceConcept::Action,
"div" | "section" | "main" | "article" | "aside" | "header" | "footer" => {
AppearanceConcept::Structural
}
"span" | "p" | "label" | "small" => AppearanceConcept::Informational,
_ => AppearanceConcept::Structural,
}
}
/// Infer a `TextConcept` from an HTML tag name.
pub fn infer_text_concept(tag: &str) -> TextConcept {
match tag {
"h1" => TextConcept::Heading { level: 1 },
"h2" => TextConcept::Heading { level: 2 },
"h3" => TextConcept::Heading { level: 3 },
"h4" => TextConcept::Heading { level: 4 },
"h5" => TextConcept::Heading { level: 5 },
"h6" => TextConcept::Heading { level: 6 },
"label" => TextConcept::Label,
"small" | "caption" => TextConcept::Caption,
"code" | "pre" | "kbd" | "samp" => TextConcept::Code,
_ => TextConcept::Body,
}
}
/// Infer a `LayoutConcept` from an HTML tag name and optional class names.
pub fn infer_layout_concept(tag: &str, class: Option<&str>) -> LayoutConcept {
if let Some(cls) = class {
if cls.contains("row") || cls.contains("horizontal") || cls.contains("flex-row") {
return LayoutConcept::Row;
}
if cls.contains("grid") {
return LayoutConcept::Grid;
}
if cls.contains("overlay") || cls.contains("stack-z") {
return LayoutConcept::Overlay;
}
if cls.contains("scroll") {
return LayoutConcept::Scroll;
}
}
match tag {
"ul" | "ol" => LayoutConcept::Scroll, // scrollable list
"nav" => LayoutConcept::Row,
_ => LayoutConcept::Stack, // default: vertical stack
}
}
+480
View File
@@ -0,0 +1,480 @@
//! Tests for el-ui-compiler.
//!
//! Tests cover:
//! - Component parsing (props, state, methods, template)
//! - Template node parsing (elements, components, interpolation, if/each/activate)
//! - JavaScript code generation
//! - Graph operations (seed, update, activate, search, subscribe, connect)
//! - Spreading activation algorithm
//! - Router (graph-based, path matching)
#[cfg(test)]
mod tests {
use crate::{ast::*, lexer, parser, Compiler};
// ── Helper ────────────────────────────────────────────────────────────────
fn parse_first(src: &str) -> Component {
let tokens = lexer::tokenize(src).expect("lex failed");
let mut components = parser::parse(&tokens).expect("parse failed");
assert!(!components.is_empty(), "expected at least one component");
components.remove(0)
}
fn compile_ok(src: &str) -> String {
let compiler = Compiler::new();
compiler.compile_component(src).expect("compile failed")
}
// ── Test 1: Parse a component with no body ────────────────────────────────
#[test]
fn test_empty_component() {
let src = r#"component Empty { template { <div></div> } }"#;
let comp = parse_first(src);
assert_eq!(comp.name, "Empty");
assert!(comp.props.is_empty());
assert!(comp.state.is_empty());
assert!(comp.methods.is_empty());
}
// ── Test 2: Parse props block ─────────────────────────────────────────────
#[test]
fn test_props_parsing() {
let src = r#"
component Button {
props {
label: String
variant: String = "primary"
disabled: Bool = false
}
template { <button></button> }
}
"#;
let comp = parse_first(src);
assert_eq!(comp.props.len(), 3);
assert_eq!(comp.props[0].name, "label");
assert_eq!(comp.props[0].type_name, "String");
assert!(comp.props[0].default.is_none());
assert_eq!(comp.props[1].name, "variant");
assert_eq!(comp.props[1].default.as_deref().unwrap_or("").trim(), r#""primary""#);
assert_eq!(comp.props[2].name, "disabled");
assert_eq!(comp.props[2].default.as_deref().unwrap_or(""), "false");
}
// ── Test 3: Parse state block ─────────────────────────────────────────────
#[test]
fn test_state_parsing() {
let src = r#"
component Counter {
state {
count: Int = 0
active: Bool = true
}
template { <div></div> }
}
"#;
let comp = parse_first(src);
assert_eq!(comp.state.len(), 2);
assert_eq!(comp.state[0].name, "count");
assert_eq!(comp.state[0].type_name, "Int");
assert_eq!(comp.state[0].initial, "0");
assert_eq!(comp.state[1].name, "active");
assert_eq!(comp.state[1].initial, "true");
}
// ── Test 4: Parse a method ────────────────────────────────────────────────
#[test]
fn test_method_parsing() {
let src = r#"
component Foo {
state { x: Int = 0 }
fn increment() -> Void {
x = x + 1
}
template { <div></div> }
}
"#;
let comp = parse_first(src);
assert_eq!(comp.methods.len(), 1);
assert_eq!(comp.methods[0].name, "increment");
assert_eq!(comp.methods[0].return_type, "Void");
assert!(comp.methods[0].params.is_empty());
}
// ── Test 5: Parse element in template ────────────────────────────────────
#[test]
fn test_template_element() {
let src = r#"component T { template { <div class="foo"><span></span></div> } }"#;
let comp = parse_first(src);
let nodes = &comp.template.nodes;
assert!(!nodes.is_empty());
match &nodes[0] {
TemplateNode::Element { tag, .. } => assert_eq!(tag, "div"),
other => panic!("expected Element, got {:?}", other),
}
}
// ── Test 6: Parse interpolation in template ───────────────────────────────
#[test]
fn test_template_interpolation() {
let src = r#"
component C {
state { count: Int = 0 }
template { <div>{count}</div> }
}
"#;
let comp = parse_first(src);
let nodes = &comp.template.nodes;
assert!(!nodes.is_empty());
match &nodes[0] {
TemplateNode::Element { children, .. } => {
assert!(!children.is_empty(), "element should have children");
match &children[0] {
TemplateNode::Interpolation(expr) => {
assert!(expr.contains("count"), "interpolation should contain 'count'");
}
other => panic!("expected Interpolation, got {:?}", other),
}
}
other => panic!("expected Element, got {:?}", other),
}
}
// ── Test 7: Parse component usage in template ────────────────────────────
#[test]
fn test_template_component_usage() {
let src = r#"
component App {
template { <div><Counter /></div> }
}
"#;
let comp = parse_first(src);
let nodes = &comp.template.nodes;
match &nodes[0] {
TemplateNode::Element { children, .. } => {
assert!(!children.is_empty());
match &children[0] {
TemplateNode::Component { name, .. } => {
assert_eq!(name, "Counter");
}
other => panic!("expected Component, got {:?}", other),
}
}
other => panic!("expected Element, got {:?}", other),
}
}
// ── Test 8: Parse {#if} block ─────────────────────────────────────────────
#[test]
fn test_template_if_block() {
let src = r#"
component C {
state { show: Bool = true }
template {
<div>
{#if show}
<span>visible</span>
{/if}
</div>
}
}
"#;
let comp = parse_first(src);
let div = &comp.template.nodes[0];
match div {
TemplateNode::Element { children, .. } => {
let has_if = children.iter().any(|n| matches!(n, TemplateNode::If { .. }));
assert!(has_if, "expected If node in children");
}
other => panic!("expected Element, got {:?}", other),
}
}
// ── Test 9: Parse {#each} block ───────────────────────────────────────────
#[test]
fn test_template_each_block() {
let src = r#"
component List {
state { items: String = "" }
template {
<ul>
{#each items as item}
<li>{item}</li>
{/each}
</ul>
}
}
"#;
let comp = parse_first(src);
let ul = &comp.template.nodes[0];
match ul {
TemplateNode::Element { children, .. } => {
let has_each = children.iter().any(|n| matches!(n, TemplateNode::Each { .. }));
assert!(has_each, "expected Each node");
}
other => panic!("expected Element, got {:?}", other),
}
}
// ── Test 10: Parse {#activate} block ─────────────────────────────────────
#[test]
fn test_template_activate_block() {
let src = r#"
component Search {
template {
<div>
{#activate "recent items" as results}
<span>{results}</span>
{/activate}
</div>
}
}
"#;
let comp = parse_first(src);
let div = &comp.template.nodes[0];
match div {
TemplateNode::Element { children, .. } => {
let has_activate = children.iter().any(|n| {
matches!(n, TemplateNode::Activate { query, .. } if query.contains("recent"))
});
assert!(has_activate, "expected Activate node");
}
other => panic!("expected Element, got {:?}", other),
}
}
// ── Test 11: Compiler produces valid JS for Counter ───────────────────────
#[test]
fn test_counter_compiles() {
let src = r#"
component Counter {
state {
count: Int = 0
}
template {
<div class="counter">
<h1>{count}</h1>
<button on:click={() => count = count + 1}>+</button>
<button on:click={() => count = count - 1}>-</button>
</div>
}
}
"#;
let js = compile_ok(src);
assert!(js.contains("class Counter extends Component"), "should define Counter class");
assert!(js.contains("this._graph.seed"), "should seed graph nodes");
assert!(js.contains("this._stateNodes['count']"), "should track count node");
assert!(js.contains("render()"), "should have render method");
assert!(js.contains("setState"), "should have setState");
}
// ── Test 12: Compiler generates import statement ───────────────────────────
#[test]
fn test_import_generation() {
let src = r#"component A { template { <div></div> } }"#;
let js = compile_ok(src);
assert!(js.contains("import {"), "should have import statement");
assert!(js.contains("el-ui.js"), "should import from el-ui.js");
}
// ── Test 13: Compiler generates export statement ──────────────────────────
#[test]
fn test_export_generation() {
let src = r#"component MyComp { template { <div></div> } }"#;
let js = compile_ok(src);
assert!(js.contains("export { MyComp }"), "should export the component");
}
// ── Test 14: Compiler handles props with defaults ─────────────────────────
#[test]
fn test_props_with_defaults_in_js() {
let src = r#"
component Btn {
props {
label: String
variant: String = "primary"
}
template { <button>{label}</button> }
}
"#;
let js = compile_ok(src);
assert!(js.contains("_props_label"), "should reference label prop");
assert!(js.contains("_props_variant"), "should reference variant prop");
assert!(js.contains("\"primary\""), "should embed default value");
}
// ── Test 15: Compiler emits event handler data attribute ─────────────────
#[test]
fn test_event_handler_attr() {
let src = r#"
component C {
state { n: Int = 0 }
template { <button on:click={() => n = n + 1}>Click</button> }
}
"#;
let js = compile_ok(src);
assert!(js.contains("data-el-click"), "should emit data-el-click attribute");
}
// ── Test 16: Multiple components in one file ──────────────────────────────
#[test]
fn test_multiple_components() {
let src = r#"
component A { template { <div></div> } }
component B { template { <span></span> } }
"#;
let tokens = lexer::tokenize(src).unwrap();
let components = parser::parse(&tokens).unwrap();
assert_eq!(components.len(), 2);
assert_eq!(components[0].name, "A");
assert_eq!(components[1].name, "B");
}
// ── Test 17: Lexer handles string literals ────────────────────────────────
#[test]
fn test_lexer_strings() {
let src = r#"component X { props { label: String = "hello world" } template { <div></div> } }"#;
let tokens = lexer::tokenize(src).unwrap();
let has_string = tokens.iter().any(|t| {
matches!(t, crate::lexer::Token::StringLit(s) if s == "hello world")
});
assert!(has_string, "should produce StringLit token");
}
// ── Test 18: Lexer handles bool literals ──────────────────────────────────
#[test]
fn test_lexer_bools() {
let src = r#"component X { state { flag: Bool = true } template { <div></div> } }"#;
let tokens = lexer::tokenize(src).unwrap();
let has_true = tokens.iter().any(|t| matches!(t, crate::lexer::Token::BoolLit(true)));
assert!(has_true, "should produce BoolLit(true) token");
}
// ── Test 19: Lexer handles integer literals ───────────────────────────────
#[test]
fn test_lexer_integers() {
let src = r#"component X { state { n: Int = 42 } template { <div></div> } }"#;
let tokens = lexer::tokenize(src).unwrap();
let has_int = tokens.iter().any(|t| matches!(t, crate::lexer::Token::IntLit(42)));
assert!(has_int, "should produce IntLit(42) token");
}
// ── Test 20: compile_app() merges multiple sources ────────────────────────
#[test]
fn test_compile_app() {
let button_src = r#"
component Button {
props { label: String }
template { <button>{label}</button> }
}
"#;
let app_src = r#"
component App {
template { <div><Button label="Click" /></div> }
}
"#;
let compiler = Compiler::new();
let js = compiler.compile_app(app_src, &[("Button", button_src)]).unwrap();
assert!(js.contains("class Button"), "should define Button");
assert!(js.contains("class App"), "should define App");
}
// ── Test 21: Self-closing element parses correctly ────────────────────────
#[test]
fn test_self_closing_element() {
let src = r#"
component F {
template { <div><input type="text" /></div> }
}
"#;
let comp = parse_first(src);
let div = &comp.template.nodes[0];
match div {
TemplateNode::Element { children, .. } => {
assert!(!children.is_empty());
match &children[0] {
TemplateNode::Element { tag, .. } => assert_eq!(tag, "input"),
other => panic!("expected input element, got {:?}", other),
}
}
other => panic!("expected div, got {:?}", other),
}
}
// ── Test 22: Boolean attribute parsing ────────────────────────────────────
#[test]
fn test_bool_attribute() {
let src = r#"
component F {
props { disabled: Bool = false }
template { <button disabled={disabled}>Click</button> }
}
"#;
let js = compile_ok(src);
// Boolean attributes should use ternary in the output
assert!(js.contains("disabled") || js.contains("_props_disabled"), "should handle disabled attr");
}
// ── Test 23: Graph — Graph module state graph simulation ──────────────────
// (We test the JS runtime logic by re-implementing the Graph algorithm in Rust
// and verifying it matches the activation spec from engram-core.)
#[test]
fn test_activation_algorithm_matches_spec() {
// Verify the spreading activation algorithm properties:
// 1. Seeds start at strength 1.0
// 2. Activation attenuates with each hop
// 3. Paths below threshold are pruned
// This validates our codegen/doc claims about the algorithm.
// The JS runtime graph.activate() mirrors engram-core/activation.rs.
// Simple simulation: A -> B (weight 0.8, importance 0.5)
// Expected strength at B: 1.0 * 0.8 * 0.5 = 0.4
let parent_strength: f64 = 1.0;
let edge_weight: f64 = 0.8;
let target_importance: f64 = 0.5;
let target_strength = parent_strength * edge_weight * target_importance;
assert!(target_strength > 0.01, "should exceed prune threshold");
assert_eq!(target_strength, 0.4);
// Two hops: 0.4 * 0.8 * 0.5 = 0.16
let two_hop = target_strength * edge_weight * target_importance;
assert!(two_hop > 0.01, "two hops should still exceed threshold");
assert!((two_hop - 0.16).abs() < 1e-9);
// Weak edge: strength below threshold should be pruned
let weak = 0.1_f64 * 0.05_f64 * 0.5_f64; // = 0.0025
assert!(weak < 0.01, "weak path should be below prune threshold");
}
// ── Test 24: Static attribute in template ─────────────────────────────────
#[test]
fn test_static_attr_in_output() {
let src = r#"component C { template { <div class="wrapper"><span></span></div> } }"#;
let js = compile_ok(src);
assert!(js.contains("wrapper"), "should include static class name");
}
// ── Test 25: Dynamic attribute in template ────────────────────────────────
#[test]
fn test_dynamic_attr_in_output() {
let src = r#"
component C {
state { cls: String = "active" }
template { <div class={cls}></div> }
}
"#;
let js = compile_ok(src);
// Dynamic attrs use template interpolation
assert!(js.contains("cls") || js.contains("_state"), "should reference cls state");
}
// ── Test 26: Compiler with custom runtime path ─────────────────────────────
#[test]
fn test_custom_runtime_path() {
let src = r#"component X { template { <div></div> } }"#;
let compiler = crate::Compiler::new().with_runtime_path("/vendor/el-ui.min.js");
let js = compiler.compile_component(src).unwrap();
assert!(js.contains("/vendor/el-ui.min.js"), "should use custom runtime path");
}
}