remove rust scaffolding — el is the implementation

This commit is contained in:
Will Anderson
2026-05-03 04:05:04 -05:00
parent c5e34ed09b
commit 9f0da76ca2
114 changed files with 0 additions and 20239 deletions
-93
View File
@@ -1,93 +0,0 @@
//! 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
@@ -1,15 +0,0 @@
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
@@ -1,568 +0,0 @@
//! 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
@@ -1,78 +0,0 @@
//! 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)
}
}
-46
View File
@@ -1,46 +0,0 @@
//! 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
@@ -1,640 +0,0 @@
//! 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
@@ -1,523 +0,0 @@
//! 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
@@ -1,480 +0,0 @@
//! 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");
}
}