feat: el-ui — activation-based frontend framework, spreading activation reactivity, graph state

This commit is contained in:
Will Anderson
2026-04-27 19:15:53 -05:00
commit ea56ad6e09
25 changed files with 4642 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "el-ui-compiler"
version = "0.1.0"
edition = "2021"
description = "el-ui component compiler — transforms .el component files into JavaScript"
license = "MIT"
[[bin]]
name = "el-ui-compiler"
path = "src/main.rs"
[lib]
name = "el_ui_compiler"
path = "src/lib.rs"
[dependencies]
thiserror = "1"
[dev-dependencies]
+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 },
}
+403
View File
@@ -0,0 +1,403 @@
//! Code generator — transforms el-ui AST into JavaScript module source.
//!
//! Each component becomes a class that:
//! 1. Extends `Component` from the el-ui runtime.
//! 2. Stores each `state` field as a node in an in-instance `Graph`.
//! 3. Implements `render()` returning an HTML string.
//! 4. Uses `setState()` to trigger spreading activation and DOM patching.
use crate::ast::*;
use crate::error::CompileResult;
pub struct Codegen {
runtime_path: String,
}
impl Codegen {
pub fn new(runtime_path: &str) -> Self {
Self { runtime_path: runtime_path.to_owned() }
}
pub fn generate(&self, components: &[Component]) -> CompileResult<String> {
let mut out = String::new();
// Runtime import
out.push_str(&format!(
"import {{ Component, Graph, Renderer, Router, mount }} from '{}';\n\n",
self.runtime_path
));
for component in components {
out.push_str(&self.gen_component(component)?);
out.push('\n');
}
// Export all component names
let names: Vec<&str> = components.iter().map(|c| c.name.as_str()).collect();
if !names.is_empty() {
out.push_str(&format!("export {{ {} }};\n", names.join(", ")));
}
Ok(out)
}
fn gen_component(&self, comp: &Component) -> CompileResult<String> {
let mut out = String::new();
out.push_str(&format!("class {} extends Component {{\n", comp.name));
// constructor
out.push_str(" constructor(props = {}) {\n");
out.push_str(" super();\n");
out.push_str(" this.props = props;\n");
out.push_str(" this._graph = new Graph();\n");
out.push_str(" this._stateNodes = {};\n");
out.push_str(" this._state = {};\n");
// Validate and set props
if !comp.props.is_empty() {
out.push_str(" // Props\n");
for prop in &comp.props {
let default_js = prop.default.as_deref()
.map(|d| translate_el_to_js(d))
.unwrap_or_else(|| "undefined".to_owned());
out.push_str(&format!(
" this._props_{name} = props.{name} !== undefined ? props.{name} : {default};\n",
name = prop.name,
default = default_js,
));
}
}
// Seed state nodes
if !comp.state.is_empty() {
out.push_str(" // State nodes (Engram graph seeds)\n");
for s in &comp.state {
let initial_js = translate_el_to_js(&s.initial);
out.push_str(&format!(
" this._stateNodes['{name}'] = this._graph.seed({{ type: 'state', name: '{name}', content: {initial} }});\n",
name = s.name,
initial = initial_js,
));
out.push_str(&format!(
" this._state['{name}'] = {initial};\n",
name = s.name,
initial = initial_js,
));
}
}
// Subscribe to state node changes for reactive re-render
if !comp.state.is_empty() {
out.push_str(" // Subscribe to graph activation events\n");
out.push_str(" for (const [key, nodeId] of Object.entries(this._stateNodes)) {\n");
out.push_str(" this._graph.subscribe(nodeId, (node) => {\n");
out.push_str(" this._state[key] = node.content;\n");
out.push_str(" if (this._renderer) this._renderer.patch();\n");
out.push_str(" });\n");
out.push_str(" }\n");
}
out.push_str(" }\n\n");
// setState method
out.push_str(" setState(name, value) {\n");
out.push_str(" if (this._stateNodes[name] !== undefined) {\n");
out.push_str(" this._graph.update(this._stateNodes[name], value);\n");
out.push_str(" }\n");
out.push_str(" }\n\n");
// User-defined methods
for method in &comp.methods {
out.push_str(&self.gen_method(method, comp)?);
out.push('\n');
}
// render()
out.push_str(" render() {\n");
out.push_str(" const __self = this;\n");
// Expose state variables
for s in &comp.state {
out.push_str(&format!(
" const {name} = this._state['{name}'];\n",
name = s.name,
));
}
// Expose props
for p in &comp.props {
out.push_str(&format!(
" const {name} = this._props_{name};\n",
name = p.name,
));
}
out.push_str(" return `");
let template_js = self.gen_template_nodes(&comp.template.nodes, comp)?;
out.push_str(&template_js);
out.push_str("`;\n");
out.push_str(" }\n\n");
out.push_str("}\n");
Ok(out)
}
fn gen_method(&self, method: &Method, comp: &Component) -> CompileResult<String> {
let mut out = String::new();
let params: Vec<String> = method.params.iter()
.map(|(n, _)| n.clone())
.collect();
out.push_str(&format!(
" {}({}) {{\n",
method.name,
params.join(", ")
));
// Expose state in method body
for s in &comp.state {
out.push_str(&format!(
" const {name} = this._state['{name}'];\n",
name = s.name
));
}
// Translate body — simple pass-through with setState substitution
let body = translate_method_body(&method.body, comp);
for line in body.lines() {
out.push_str(&format!(" {}\n", line));
}
out.push_str(" }\n");
Ok(out)
}
fn gen_template_nodes(&self, nodes: &[TemplateNode], comp: &Component) -> CompileResult<String> {
let mut out = String::new();
for node in nodes {
out.push_str(&self.gen_template_node(node, comp)?);
}
Ok(out)
}
fn gen_template_node(&self, node: &TemplateNode, comp: &Component) -> CompileResult<String> {
match node {
TemplateNode::Text(t) => Ok(t.clone()),
TemplateNode::Interpolation(expr) => {
let js_expr = translate_interpolation(expr, comp);
Ok(format!("${{{} }}", js_expr))
}
TemplateNode::Element { tag, attrs, children } => {
let mut out = format!("<{}", tag);
for attr in attrs {
out.push_str(&self.gen_attr(attr, comp)?);
}
if children.is_empty() {
out.push_str(&format!(" data-el-tag=\"{}\">", tag));
out.push_str(&format!("</{}>", tag));
} else {
out.push_str(&format!(" data-el-tag=\"{}\">", tag));
out.push_str(&self.gen_template_nodes(children, comp)?);
out.push_str(&format!("</{}>", tag));
}
Ok(out)
}
TemplateNode::Component { name, props } => {
// Render as inline component call
let mut prop_entries: Vec<String> = Vec::new();
for prop in props {
match prop {
Attr::Static { name: pn, value } => {
prop_entries.push(format!("{}: \"{}\"", pn, value));
}
Attr::Dynamic { name: pn, expr } => {
let js = translate_interpolation(expr, comp);
prop_entries.push(format!("{}: {}", pn, js));
}
Attr::EventHandler { event, handler } => {
let js = translate_handler(handler, comp);
prop_entries.push(format!("on{}: {}", capitalize(event), js));
}
Attr::BoolAttr { name: pn, expr } => {
prop_entries.push(format!("{}: {}", pn, expr));
}
}
}
let props_js = format!("{{ {} }}", prop_entries.join(", "));
Ok(format!("${{__self._child({}, {})}}", name, props_js))
}
TemplateNode::If { condition, then, else_ } => {
let cond_js = translate_interpolation(condition, comp);
let then_html = self.gen_template_nodes(then, comp)?;
let else_html = if let Some(els) = else_ {
self.gen_template_nodes(els, comp)?
} else {
String::new()
};
Ok(format!(
"${{({}) ? `{}` : `{}`}}",
cond_js, then_html, else_html
))
}
TemplateNode::Each { items, item_name, children } => {
let items_js = translate_interpolation(items, comp);
let child_html = self.gen_template_nodes(children, comp)?;
// Generate a map over the array
Ok(format!(
"${{({}).map(({}) => `{}`).join('')}}",
items_js, item_name, child_html
))
}
TemplateNode::Activate { query, result_name, children } => {
let child_html = self.gen_template_nodes(children, comp)?;
Ok(format!(
"${{((__self._graph.search(\"{}\")) || []).map(({}) => `{}`).join('')}}",
query, result_name, child_html
))
}
}
}
fn gen_attr(&self, attr: &Attr, comp: &Component) -> CompileResult<String> {
match attr {
Attr::Static { name, value } => {
Ok(format!(" {}=\"{}\"", name, value))
}
Attr::Dynamic { name, expr } => {
let js = translate_interpolation(expr, comp);
Ok(format!(" {}=\"${{{} }}\"", name, js))
}
Attr::BoolAttr { name, expr } => {
let js = translate_interpolation(expr, comp);
Ok(format!(" ${{({}) ? '{}' : '' }}", js, name))
}
Attr::EventHandler { event, handler } => {
// We use data attributes to defer event binding
let js = translate_handler(handler, comp);
// Inline handler via data attribute — the renderer will bind these
Ok(format!(" data-el-{}=\"{}\"", event, escape_attr(&js)))
}
}
}
}
/// Translate an el-ui expression to JavaScript.
/// Handles state assignments like `count = count + 1` → `__self.setState('count', count + 1)`
fn translate_interpolation(expr: &str, comp: &Component) -> String {
translate_expr(expr, comp)
}
fn translate_expr(expr: &str, comp: &Component) -> String {
let state_names: Vec<&str> = comp.state.iter().map(|s| s.name.as_str()).collect();
// Arrow functions: passthrough
// State assignment: `name = value` → `__self.setState('name', value)`
let trimmed = expr.trim();
// Check for simple assignment: `ident = expr`
if let Some(result) = try_translate_assignment(trimmed, &state_names) {
return result;
}
// Arrow function containing assignment: `() => count = count + 1`
if trimmed.starts_with('(') || trimmed.starts_with("e =>") || trimmed.starts_with("() =>") {
return translate_arrow_fn(trimmed, &state_names);
}
// Otherwise pass through as-is
trimmed.to_owned()
}
fn try_translate_assignment(expr: &str, state_names: &[&str]) -> Option<String> {
// Match: `name = value` where name is a state variable
// Must not be `==` (equality)
let parts: Vec<&str> = expr.splitn(2, '=').collect();
if parts.len() == 2 {
let lhs = parts[0].trim();
let rhs = parts[1].trim();
// Ensure it's not `==` or `!=` or `<=` or `>=`
if !rhs.starts_with('=') && !lhs.ends_with('!') && !lhs.ends_with('<') && !lhs.ends_with('>') {
if state_names.contains(&lhs) {
return Some(format!("__self.setState('{}', {})", lhs, rhs));
}
}
}
None
}
fn translate_arrow_fn(expr: &str, state_names: &[&str]) -> String {
// Translate assignments inside arrow functions
// This is a best-effort string transformation
let mut result = expr.to_owned();
for name in state_names {
// Replace `name = ` with `__self.setState('name', ` ... `)` is too complex
// for a simple string replacement, but we can handle common patterns.
// Pattern: `name = expr` at end of arrow fn or in braces
let pat = format!("{} = ", name);
if let Some(idx) = result.find(&pat) {
// Check it's not ==
let after = &result[idx + pat.len()..];
// Simple case: `() => count = count + 1`
let prefix = &result[..idx];
result = format!("{}__self.setState('{}', {})", prefix, name, after.trim_end_matches(')'));
}
}
result
}
fn translate_handler(handler: &str, comp: &Component) -> String {
translate_expr(handler, comp)
}
/// Translate method body — replace bare state assignments with setState calls.
fn translate_method_body(body: &str, comp: &Component) -> String {
let state_names: Vec<&str> = comp.state.iter().map(|s| s.name.as_str()).collect();
let mut lines: Vec<String> = Vec::new();
for line in body.lines() {
let trimmed = line.trim();
if let Some(translated) = try_translate_assignment(trimmed, &state_names) {
lines.push(format!("{};", translated));
} else if trimmed.starts_with("return ") {
lines.push(trimmed.to_owned());
} else {
lines.push(trimmed.to_owned());
}
}
lines.join("\n")
}
fn translate_el_to_js(expr: &str) -> String {
let s = expr.trim();
// Fn types — translate to null (not a valid JS value, handled at runtime)
if s.starts_with("Fn") { return "null".into(); }
// Boolean
if s == "true" { return "true".into(); }
if s == "false" { return "false".into(); }
// String literal
if s.starts_with('"') { return s.replace('"', "\"").to_owned(); }
// Numbers
if s.parse::<i64>().is_ok() { return s.to_owned(); }
if s.parse::<f64>().is_ok() { return s.to_owned(); }
// Empty string / void
if s.is_empty() { return "null".into(); }
s.to_owned()
}
fn capitalize(s: &str) -> String {
let mut c = s.chars();
match c.next() {
None => String::new(),
Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
}
}
fn escape_attr(s: &str) -> String {
s.replace('"', "&quot;").replace('\'', "&#39;")
}
+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)
}
}
+77
View File
@@ -0,0 +1,77 @@
//! 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 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)
}
}
+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) })
}
}
+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");
}
}