Archived
remove rust scaffolding — el is the implementation
This commit is contained in:
@@ -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
@@ -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 },
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) })
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user