commit 3bf3c02854af1c41fcdbb6048751508a8ab9102b Author: Will Anderson Date: Mon Apr 27 19:15:53 2026 -0500 feat: el-ui — activation-based frontend framework, spreading activation reactivity, graph state diff --git a/ui/.gitignore b/ui/.gitignore new file mode 100644 index 0000000..b83d222 --- /dev/null +++ b/ui/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/ui/Cargo.lock b/ui/Cargo.lock new file mode 100644 index 0000000..943d912 --- /dev/null +++ b/ui/Cargo.lock @@ -0,0 +1,65 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "el-ui-compiler" +version = "0.1.0" +dependencies = [ + "thiserror", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" diff --git a/ui/Cargo.toml b/ui/Cargo.toml new file mode 100644 index 0000000..9c00085 --- /dev/null +++ b/ui/Cargo.toml @@ -0,0 +1,5 @@ +[workspace] +members = [ + "crates/el-ui-compiler", +] +resolver = "2" diff --git a/ui/crates/el-ui-compiler/Cargo.toml b/ui/crates/el-ui-compiler/Cargo.toml new file mode 100644 index 0000000..4e2e044 --- /dev/null +++ b/ui/crates/el-ui-compiler/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "el-ui-compiler" +version = "0.1.0" +edition = "2021" +description = "el-ui component compiler — transforms .el component files into JavaScript" +license = "MIT" + +[[bin]] +name = "el-ui-compiler" +path = "src/main.rs" + +[lib] +name = "el_ui_compiler" +path = "src/lib.rs" + +[dependencies] +thiserror = "1" + +[dev-dependencies] diff --git a/ui/crates/el-ui-compiler/src/ast.rs b/ui/crates/el-ui-compiler/src/ast.rs new file mode 100644 index 0000000..3ada557 --- /dev/null +++ b/ui/crates/el-ui-compiler/src/ast.rs @@ -0,0 +1,93 @@ +//! AST types for el-ui component files. + +/// A parsed component definition. +#[derive(Debug, Clone)] +pub struct Component { + pub name: String, + pub props: Vec, + pub state: Vec, + pub methods: Vec, + pub template: Template, +} + +/// A prop declaration inside `props { ... }`. +#[derive(Debug, Clone)] +pub struct PropDef { + pub name: String, + pub type_name: String, + pub default: Option, +} + +/// 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, +} + +/// A node within the template tree. +#[derive(Debug, Clone)] +pub enum TemplateNode { + /// A plain HTML element: `
...
` + Element { + tag: String, + attrs: Vec, + children: Vec, + }, + /// A component usage (uppercase first letter): `` + Component { + name: String, + props: Vec, + }, + /// 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, + else_: Option>, + }, + /// List rendering: `{#each items as item}...{/each}` + Each { + items: String, + item_name: String, + children: Vec, + }, + /// Semantic activation query: `{#activate "query" as results}...{/activate}` + Activate { + query: String, + result_name: String, + children: Vec, + }, +} + +/// 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 }, +} diff --git a/ui/crates/el-ui-compiler/src/codegen.rs b/ui/crates/el-ui-compiler/src/codegen.rs new file mode 100644 index 0000000..0477f19 --- /dev/null +++ b/ui/crates/el-ui-compiler/src/codegen.rs @@ -0,0 +1,403 @@ +//! Code generator — transforms el-ui AST into JavaScript module source. +//! +//! Each component becomes a class that: +//! 1. Extends `Component` from the el-ui runtime. +//! 2. Stores each `state` field as a node in an in-instance `Graph`. +//! 3. Implements `render()` returning an HTML string. +//! 4. Uses `setState()` to trigger spreading activation and DOM patching. + +use crate::ast::*; +use crate::error::CompileResult; + +pub struct Codegen { + runtime_path: String, +} + +impl Codegen { + pub fn new(runtime_path: &str) -> Self { + Self { runtime_path: runtime_path.to_owned() } + } + + pub fn generate(&self, components: &[Component]) -> CompileResult { + let mut out = String::new(); + + // Runtime import + out.push_str(&format!( + "import {{ Component, Graph, Renderer, Router, mount }} from '{}';\n\n", + self.runtime_path + )); + + for component in components { + out.push_str(&self.gen_component(component)?); + out.push('\n'); + } + + // Export all component names + let names: Vec<&str> = components.iter().map(|c| c.name.as_str()).collect(); + if !names.is_empty() { + out.push_str(&format!("export {{ {} }};\n", names.join(", "))); + } + + Ok(out) + } + + fn gen_component(&self, comp: &Component) -> CompileResult { + let mut out = String::new(); + + out.push_str(&format!("class {} extends Component {{\n", comp.name)); + + // constructor + out.push_str(" constructor(props = {}) {\n"); + out.push_str(" super();\n"); + out.push_str(" this.props = props;\n"); + out.push_str(" this._graph = new Graph();\n"); + out.push_str(" this._stateNodes = {};\n"); + out.push_str(" this._state = {};\n"); + + // Validate and set props + if !comp.props.is_empty() { + out.push_str(" // Props\n"); + for prop in &comp.props { + let default_js = prop.default.as_deref() + .map(|d| translate_el_to_js(d)) + .unwrap_or_else(|| "undefined".to_owned()); + out.push_str(&format!( + " this._props_{name} = props.{name} !== undefined ? props.{name} : {default};\n", + name = prop.name, + default = default_js, + )); + } + } + + // Seed state nodes + if !comp.state.is_empty() { + out.push_str(" // State nodes (Engram graph seeds)\n"); + for s in &comp.state { + let initial_js = translate_el_to_js(&s.initial); + out.push_str(&format!( + " this._stateNodes['{name}'] = this._graph.seed({{ type: 'state', name: '{name}', content: {initial} }});\n", + name = s.name, + initial = initial_js, + )); + out.push_str(&format!( + " this._state['{name}'] = {initial};\n", + name = s.name, + initial = initial_js, + )); + } + } + + // Subscribe to state node changes for reactive re-render + if !comp.state.is_empty() { + out.push_str(" // Subscribe to graph activation events\n"); + out.push_str(" for (const [key, nodeId] of Object.entries(this._stateNodes)) {\n"); + out.push_str(" this._graph.subscribe(nodeId, (node) => {\n"); + out.push_str(" this._state[key] = node.content;\n"); + out.push_str(" if (this._renderer) this._renderer.patch();\n"); + out.push_str(" });\n"); + out.push_str(" }\n"); + } + + out.push_str(" }\n\n"); + + // setState method + out.push_str(" setState(name, value) {\n"); + out.push_str(" if (this._stateNodes[name] !== undefined) {\n"); + out.push_str(" this._graph.update(this._stateNodes[name], value);\n"); + out.push_str(" }\n"); + out.push_str(" }\n\n"); + + // User-defined methods + for method in &comp.methods { + out.push_str(&self.gen_method(method, comp)?); + out.push('\n'); + } + + // render() + out.push_str(" render() {\n"); + out.push_str(" const __self = this;\n"); + + // Expose state variables + for s in &comp.state { + out.push_str(&format!( + " const {name} = this._state['{name}'];\n", + name = s.name, + )); + } + // Expose props + for p in &comp.props { + out.push_str(&format!( + " const {name} = this._props_{name};\n", + name = p.name, + )); + } + + out.push_str(" return `"); + let template_js = self.gen_template_nodes(&comp.template.nodes, comp)?; + out.push_str(&template_js); + out.push_str("`;\n"); + out.push_str(" }\n\n"); + + out.push_str("}\n"); + Ok(out) + } + + fn gen_method(&self, method: &Method, comp: &Component) -> CompileResult { + let mut out = String::new(); + let params: Vec = method.params.iter() + .map(|(n, _)| n.clone()) + .collect(); + + out.push_str(&format!( + " {}({}) {{\n", + method.name, + params.join(", ") + )); + + // Expose state in method body + for s in &comp.state { + out.push_str(&format!( + " const {name} = this._state['{name}'];\n", + name = s.name + )); + } + + // Translate body — simple pass-through with setState substitution + let body = translate_method_body(&method.body, comp); + for line in body.lines() { + out.push_str(&format!(" {}\n", line)); + } + + out.push_str(" }\n"); + Ok(out) + } + + fn gen_template_nodes(&self, nodes: &[TemplateNode], comp: &Component) -> CompileResult { + let mut out = String::new(); + for node in nodes { + out.push_str(&self.gen_template_node(node, comp)?); + } + Ok(out) + } + + fn gen_template_node(&self, node: &TemplateNode, comp: &Component) -> CompileResult { + match node { + TemplateNode::Text(t) => Ok(t.clone()), + + TemplateNode::Interpolation(expr) => { + let js_expr = translate_interpolation(expr, comp); + Ok(format!("${{{} }}", js_expr)) + } + + TemplateNode::Element { tag, attrs, children } => { + let mut out = format!("<{}", tag); + + for attr in attrs { + out.push_str(&self.gen_attr(attr, comp)?); + } + + if children.is_empty() { + out.push_str(&format!(" data-el-tag=\"{}\">", tag)); + out.push_str(&format!("", tag)); + } else { + out.push_str(&format!(" data-el-tag=\"{}\">", tag)); + out.push_str(&self.gen_template_nodes(children, comp)?); + out.push_str(&format!("", tag)); + } + Ok(out) + } + + TemplateNode::Component { name, props } => { + // Render as inline component call + let mut prop_entries: Vec = Vec::new(); + for prop in props { + match prop { + Attr::Static { name: pn, value } => { + prop_entries.push(format!("{}: \"{}\"", pn, value)); + } + Attr::Dynamic { name: pn, expr } => { + let js = translate_interpolation(expr, comp); + prop_entries.push(format!("{}: {}", pn, js)); + } + Attr::EventHandler { event, handler } => { + let js = translate_handler(handler, comp); + prop_entries.push(format!("on{}: {}", capitalize(event), js)); + } + Attr::BoolAttr { name: pn, expr } => { + prop_entries.push(format!("{}: {}", pn, expr)); + } + } + } + let props_js = format!("{{ {} }}", prop_entries.join(", ")); + Ok(format!("${{__self._child({}, {})}}", name, props_js)) + } + + TemplateNode::If { condition, then, else_ } => { + let cond_js = translate_interpolation(condition, comp); + let then_html = self.gen_template_nodes(then, comp)?; + let else_html = if let Some(els) = else_ { + self.gen_template_nodes(els, comp)? + } else { + String::new() + }; + Ok(format!( + "${{({}) ? `{}` : `{}`}}", + cond_js, then_html, else_html + )) + } + + TemplateNode::Each { items, item_name, children } => { + let items_js = translate_interpolation(items, comp); + let child_html = self.gen_template_nodes(children, comp)?; + // Generate a map over the array + Ok(format!( + "${{({}).map(({}) => `{}`).join('')}}", + items_js, item_name, child_html + )) + } + + TemplateNode::Activate { query, result_name, children } => { + let child_html = self.gen_template_nodes(children, comp)?; + Ok(format!( + "${{((__self._graph.search(\"{}\")) || []).map(({}) => `{}`).join('')}}", + query, result_name, child_html + )) + } + } + } + + fn gen_attr(&self, attr: &Attr, comp: &Component) -> CompileResult { + match attr { + Attr::Static { name, value } => { + Ok(format!(" {}=\"{}\"", name, value)) + } + Attr::Dynamic { name, expr } => { + let js = translate_interpolation(expr, comp); + Ok(format!(" {}=\"${{{} }}\"", name, js)) + } + Attr::BoolAttr { name, expr } => { + let js = translate_interpolation(expr, comp); + Ok(format!(" ${{({}) ? '{}' : '' }}", js, name)) + } + Attr::EventHandler { event, handler } => { + // We use data attributes to defer event binding + let js = translate_handler(handler, comp); + // Inline handler via data attribute — the renderer will bind these + Ok(format!(" data-el-{}=\"{}\"", event, escape_attr(&js))) + } + } + } +} + +/// Translate an el-ui expression to JavaScript. +/// Handles state assignments like `count = count + 1` → `__self.setState('count', count + 1)` +fn translate_interpolation(expr: &str, comp: &Component) -> String { + translate_expr(expr, comp) +} + +fn translate_expr(expr: &str, comp: &Component) -> String { + let state_names: Vec<&str> = comp.state.iter().map(|s| s.name.as_str()).collect(); + // Arrow functions: passthrough + // State assignment: `name = value` → `__self.setState('name', value)` + let trimmed = expr.trim(); + + // Check for simple assignment: `ident = expr` + if let Some(result) = try_translate_assignment(trimmed, &state_names) { + return result; + } + + // Arrow function containing assignment: `() => count = count + 1` + if trimmed.starts_with('(') || trimmed.starts_with("e =>") || trimmed.starts_with("() =>") { + return translate_arrow_fn(trimmed, &state_names); + } + + // Otherwise pass through as-is + trimmed.to_owned() +} + +fn try_translate_assignment(expr: &str, state_names: &[&str]) -> Option { + // Match: `name = value` where name is a state variable + // Must not be `==` (equality) + let parts: Vec<&str> = expr.splitn(2, '=').collect(); + if parts.len() == 2 { + let lhs = parts[0].trim(); + let rhs = parts[1].trim(); + // Ensure it's not `==` or `!=` or `<=` or `>=` + if !rhs.starts_with('=') && !lhs.ends_with('!') && !lhs.ends_with('<') && !lhs.ends_with('>') { + if state_names.contains(&lhs) { + return Some(format!("__self.setState('{}', {})", lhs, rhs)); + } + } + } + None +} + +fn translate_arrow_fn(expr: &str, state_names: &[&str]) -> String { + // Translate assignments inside arrow functions + // This is a best-effort string transformation + let mut result = expr.to_owned(); + for name in state_names { + // Replace `name = ` with `__self.setState('name', ` ... `)` is too complex + // for a simple string replacement, but we can handle common patterns. + // Pattern: `name = expr` at end of arrow fn or in braces + let pat = format!("{} = ", name); + if let Some(idx) = result.find(&pat) { + // Check it's not == + let after = &result[idx + pat.len()..]; + // Simple case: `() => count = count + 1` + let prefix = &result[..idx]; + result = format!("{}__self.setState('{}', {})", prefix, name, after.trim_end_matches(')')); + } + } + result +} + +fn translate_handler(handler: &str, comp: &Component) -> String { + translate_expr(handler, comp) +} + +/// Translate method body — replace bare state assignments with setState calls. +fn translate_method_body(body: &str, comp: &Component) -> String { + let state_names: Vec<&str> = comp.state.iter().map(|s| s.name.as_str()).collect(); + let mut lines: Vec = Vec::new(); + for line in body.lines() { + let trimmed = line.trim(); + if let Some(translated) = try_translate_assignment(trimmed, &state_names) { + lines.push(format!("{};", translated)); + } else if trimmed.starts_with("return ") { + lines.push(trimmed.to_owned()); + } else { + lines.push(trimmed.to_owned()); + } + } + lines.join("\n") +} + +fn translate_el_to_js(expr: &str) -> String { + let s = expr.trim(); + // Fn types — translate to null (not a valid JS value, handled at runtime) + if s.starts_with("Fn") { return "null".into(); } + // Boolean + if s == "true" { return "true".into(); } + if s == "false" { return "false".into(); } + // String literal + if s.starts_with('"') { return s.replace('"', "\"").to_owned(); } + // Numbers + if s.parse::().is_ok() { return s.to_owned(); } + if s.parse::().is_ok() { return s.to_owned(); } + // Empty string / void + if s.is_empty() { return "null".into(); } + s.to_owned() +} + +fn capitalize(s: &str) -> String { + let mut c = s.chars(); + match c.next() { + None => String::new(), + Some(f) => f.to_uppercase().collect::() + c.as_str(), + } +} + +fn escape_attr(s: &str) -> String { + s.replace('"', """).replace('\'', "'") +} diff --git a/ui/crates/el-ui-compiler/src/error.rs b/ui/crates/el-ui-compiler/src/error.rs new file mode 100644 index 0000000..7a92805 --- /dev/null +++ b/ui/crates/el-ui-compiler/src/error.rs @@ -0,0 +1,15 @@ +use thiserror::Error; + +pub type CompileResult = Result; + +#[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 }, +} diff --git a/ui/crates/el-ui-compiler/src/lexer.rs b/ui/crates/el-ui-compiler/src/lexer.rs new file mode 100644 index 0000000..b4d1bbb --- /dev/null +++ b/ui/crates/el-ui-compiler/src/lexer.rs @@ -0,0 +1,568 @@ +//! Lexer for el-ui component syntax. +//! +//! Produces a flat `Vec` 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), // + + // Raw text in templates + RawText(String), + + Eof, +} + +pub fn tokenize(source: &str) -> CompileResult> { + 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 { + self.src.get(self.pos).copied() + } + + fn peek2(&self) -> Option { + self.src.get(self.pos + 1).copied() + } + + fn advance(&mut self) -> Option { + 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 { + // 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> { + // 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 = 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 + 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) -> 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 { + if self.peek() == Some(b'"') { + self.advance(); + self.read_string() + } else { + Ok(self.read_attr_name()) + } + } + + fn read_until_brace_close(&mut self) -> CompileResult { + 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> { + let mut tokens: Vec = 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) + } +} diff --git a/ui/crates/el-ui-compiler/src/lib.rs b/ui/crates/el-ui-compiler/src/lib.rs new file mode 100644 index 0000000..6a05628 --- /dev/null +++ b/ui/crates/el-ui-compiler/src/lib.rs @@ -0,0 +1,77 @@ +//! el-ui-compiler — Transforms `.el` component files into JavaScript. +//! +//! Pipeline: +//! source text → lexer → tokens → parser → AST → codegen → JavaScript +//! +//! The output JavaScript uses the el-ui runtime (`el-ui.js`) to register +//! components, manage a spreading-activation graph for state, and patch the DOM. + +pub mod ast; +pub mod codegen; +pub mod error; +pub mod lexer; +pub mod parser; + +pub use ast::{Attr, Component, Method, PropDef, StateDef, Template, TemplateNode}; + +#[cfg(test)] +mod tests; +pub use codegen::Codegen; +pub use error::{CompileError, CompileResult}; + +/// High-level compiler entry point. +pub struct Compiler { + /// Runtime import path (default: `./el-ui.js`) + pub runtime_path: String, +} + +impl Default for Compiler { + fn default() -> Self { + Self { runtime_path: "./el-ui.js".into() } + } +} + +impl Compiler { + pub fn new() -> Self { + Self::default() + } + + pub fn with_runtime_path(mut self, path: impl Into) -> 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 { + 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 { + let mut all_components: Vec = 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) + } +} diff --git a/ui/crates/el-ui-compiler/src/main.rs b/ui/crates/el-ui-compiler/src/main.rs new file mode 100644 index 0000000..d4e9d5c --- /dev/null +++ b/ui/crates/el-ui-compiler/src/main.rs @@ -0,0 +1,46 @@ +//! el-ui-compiler CLI +//! +//! Usage: +//! el-ui-compiler [-o ] + +use std::fs; +use std::path::PathBuf; + +fn main() { + let args: Vec = std::env::args().collect(); + + if args.len() < 2 { + eprintln!("Usage: el-ui-compiler [-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); + } + } +} diff --git a/ui/crates/el-ui-compiler/src/parser.rs b/ui/crates/el-ui-compiler/src/parser.rs new file mode 100644 index 0000000..d6a7f23 --- /dev/null +++ b/ui/crates/el-ui-compiler/src/parser.rs @@ -0,0 +1,640 @@ +//! Parser for el-ui component files. +//! +//! Hand-written recursive descent. Produces a `Vec` from a token stream. + +use crate::ast::*; +use crate::error::{CompileError, CompileResult}; +use crate::lexer::Token; + +pub fn parse(tokens: &[Token]) -> CompileResult> { + 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 { + 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> { + 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 { + 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> { + 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> { + 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 { + 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 { + // 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 { + 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 { + 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