Archived
feat: el-ui — activation-based frontend framework, spreading activation reactivity, graph state
This commit is contained in:
@@ -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]
|
||||
@@ -0,0 +1,93 @@
|
||||
//! AST types for el-ui component files.
|
||||
|
||||
/// A parsed component definition.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Component {
|
||||
pub name: String,
|
||||
pub props: Vec<PropDef>,
|
||||
pub state: Vec<StateDef>,
|
||||
pub methods: Vec<Method>,
|
||||
pub template: Template,
|
||||
}
|
||||
|
||||
/// A prop declaration inside `props { ... }`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PropDef {
|
||||
pub name: String,
|
||||
pub type_name: String,
|
||||
pub default: Option<String>,
|
||||
}
|
||||
|
||||
/// A state declaration inside `state { ... }`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StateDef {
|
||||
pub name: String,
|
||||
pub type_name: String,
|
||||
pub initial: String,
|
||||
}
|
||||
|
||||
/// A method defined with `fn` inside the component body.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Method {
|
||||
pub name: String,
|
||||
pub params: Vec<(String, String)>, // (name, type)
|
||||
pub return_type: String,
|
||||
pub body: String, // raw source text of the body (we pass through verbatim)
|
||||
}
|
||||
|
||||
/// The template block.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Template {
|
||||
pub nodes: Vec<TemplateNode>,
|
||||
}
|
||||
|
||||
/// A node within the template tree.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TemplateNode {
|
||||
/// A plain HTML element: `<div class="foo">...</div>`
|
||||
Element {
|
||||
tag: String,
|
||||
attrs: Vec<Attr>,
|
||||
children: Vec<TemplateNode>,
|
||||
},
|
||||
/// A component usage (uppercase first letter): `<Counter />`
|
||||
Component {
|
||||
name: String,
|
||||
props: Vec<Attr>,
|
||||
},
|
||||
/// Literal text content.
|
||||
Text(String),
|
||||
/// An interpolated expression: `{count}`
|
||||
Interpolation(String),
|
||||
/// Conditional: `{#if cond}...{/if}` or `{#if cond}...{:else}...{/if}`
|
||||
If {
|
||||
condition: String,
|
||||
then: Vec<TemplateNode>,
|
||||
else_: Option<Vec<TemplateNode>>,
|
||||
},
|
||||
/// List rendering: `{#each items as item}...{/each}`
|
||||
Each {
|
||||
items: String,
|
||||
item_name: String,
|
||||
children: Vec<TemplateNode>,
|
||||
},
|
||||
/// Semantic activation query: `{#activate "query" as results}...{/activate}`
|
||||
Activate {
|
||||
query: String,
|
||||
result_name: String,
|
||||
children: Vec<TemplateNode>,
|
||||
},
|
||||
}
|
||||
|
||||
/// An attribute on a template element.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Attr {
|
||||
/// `class="btn"` — static string value
|
||||
Static { name: String, value: String },
|
||||
/// `class={expr}` — dynamic expression
|
||||
Dynamic { name: String, expr: String },
|
||||
/// `on:click={handler}` — event handler
|
||||
EventHandler { event: String, handler: String },
|
||||
/// `disabled={boolExpr}` — boolean attribute
|
||||
BoolAttr { name: String, expr: String },
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
//! Code generator — transforms el-ui AST into JavaScript module source.
|
||||
//!
|
||||
//! Each component becomes a class that:
|
||||
//! 1. Extends `Component` from the el-ui runtime.
|
||||
//! 2. Stores each `state` field as a node in an in-instance `Graph`.
|
||||
//! 3. Implements `render()` returning an HTML string.
|
||||
//! 4. Uses `setState()` to trigger spreading activation and DOM patching.
|
||||
|
||||
use crate::ast::*;
|
||||
use crate::error::CompileResult;
|
||||
|
||||
pub struct Codegen {
|
||||
runtime_path: String,
|
||||
}
|
||||
|
||||
impl Codegen {
|
||||
pub fn new(runtime_path: &str) -> Self {
|
||||
Self { runtime_path: runtime_path.to_owned() }
|
||||
}
|
||||
|
||||
pub fn generate(&self, components: &[Component]) -> CompileResult<String> {
|
||||
let mut out = String::new();
|
||||
|
||||
// Runtime import
|
||||
out.push_str(&format!(
|
||||
"import {{ Component, Graph, Renderer, Router, mount }} from '{}';\n\n",
|
||||
self.runtime_path
|
||||
));
|
||||
|
||||
for component in components {
|
||||
out.push_str(&self.gen_component(component)?);
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
// Export all component names
|
||||
let names: Vec<&str> = components.iter().map(|c| c.name.as_str()).collect();
|
||||
if !names.is_empty() {
|
||||
out.push_str(&format!("export {{ {} }};\n", names.join(", ")));
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn gen_component(&self, comp: &Component) -> CompileResult<String> {
|
||||
let mut out = String::new();
|
||||
|
||||
out.push_str(&format!("class {} extends Component {{\n", comp.name));
|
||||
|
||||
// constructor
|
||||
out.push_str(" constructor(props = {}) {\n");
|
||||
out.push_str(" super();\n");
|
||||
out.push_str(" this.props = props;\n");
|
||||
out.push_str(" this._graph = new Graph();\n");
|
||||
out.push_str(" this._stateNodes = {};\n");
|
||||
out.push_str(" this._state = {};\n");
|
||||
|
||||
// Validate and set props
|
||||
if !comp.props.is_empty() {
|
||||
out.push_str(" // Props\n");
|
||||
for prop in &comp.props {
|
||||
let default_js = prop.default.as_deref()
|
||||
.map(|d| translate_el_to_js(d))
|
||||
.unwrap_or_else(|| "undefined".to_owned());
|
||||
out.push_str(&format!(
|
||||
" this._props_{name} = props.{name} !== undefined ? props.{name} : {default};\n",
|
||||
name = prop.name,
|
||||
default = default_js,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Seed state nodes
|
||||
if !comp.state.is_empty() {
|
||||
out.push_str(" // State nodes (Engram graph seeds)\n");
|
||||
for s in &comp.state {
|
||||
let initial_js = translate_el_to_js(&s.initial);
|
||||
out.push_str(&format!(
|
||||
" this._stateNodes['{name}'] = this._graph.seed({{ type: 'state', name: '{name}', content: {initial} }});\n",
|
||||
name = s.name,
|
||||
initial = initial_js,
|
||||
));
|
||||
out.push_str(&format!(
|
||||
" this._state['{name}'] = {initial};\n",
|
||||
name = s.name,
|
||||
initial = initial_js,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to state node changes for reactive re-render
|
||||
if !comp.state.is_empty() {
|
||||
out.push_str(" // Subscribe to graph activation events\n");
|
||||
out.push_str(" for (const [key, nodeId] of Object.entries(this._stateNodes)) {\n");
|
||||
out.push_str(" this._graph.subscribe(nodeId, (node) => {\n");
|
||||
out.push_str(" this._state[key] = node.content;\n");
|
||||
out.push_str(" if (this._renderer) this._renderer.patch();\n");
|
||||
out.push_str(" });\n");
|
||||
out.push_str(" }\n");
|
||||
}
|
||||
|
||||
out.push_str(" }\n\n");
|
||||
|
||||
// setState method
|
||||
out.push_str(" setState(name, value) {\n");
|
||||
out.push_str(" if (this._stateNodes[name] !== undefined) {\n");
|
||||
out.push_str(" this._graph.update(this._stateNodes[name], value);\n");
|
||||
out.push_str(" }\n");
|
||||
out.push_str(" }\n\n");
|
||||
|
||||
// User-defined methods
|
||||
for method in &comp.methods {
|
||||
out.push_str(&self.gen_method(method, comp)?);
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
// render()
|
||||
out.push_str(" render() {\n");
|
||||
out.push_str(" const __self = this;\n");
|
||||
|
||||
// Expose state variables
|
||||
for s in &comp.state {
|
||||
out.push_str(&format!(
|
||||
" const {name} = this._state['{name}'];\n",
|
||||
name = s.name,
|
||||
));
|
||||
}
|
||||
// Expose props
|
||||
for p in &comp.props {
|
||||
out.push_str(&format!(
|
||||
" const {name} = this._props_{name};\n",
|
||||
name = p.name,
|
||||
));
|
||||
}
|
||||
|
||||
out.push_str(" return `");
|
||||
let template_js = self.gen_template_nodes(&comp.template.nodes, comp)?;
|
||||
out.push_str(&template_js);
|
||||
out.push_str("`;\n");
|
||||
out.push_str(" }\n\n");
|
||||
|
||||
out.push_str("}\n");
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn gen_method(&self, method: &Method, comp: &Component) -> CompileResult<String> {
|
||||
let mut out = String::new();
|
||||
let params: Vec<String> = method.params.iter()
|
||||
.map(|(n, _)| n.clone())
|
||||
.collect();
|
||||
|
||||
out.push_str(&format!(
|
||||
" {}({}) {{\n",
|
||||
method.name,
|
||||
params.join(", ")
|
||||
));
|
||||
|
||||
// Expose state in method body
|
||||
for s in &comp.state {
|
||||
out.push_str(&format!(
|
||||
" const {name} = this._state['{name}'];\n",
|
||||
name = s.name
|
||||
));
|
||||
}
|
||||
|
||||
// Translate body — simple pass-through with setState substitution
|
||||
let body = translate_method_body(&method.body, comp);
|
||||
for line in body.lines() {
|
||||
out.push_str(&format!(" {}\n", line));
|
||||
}
|
||||
|
||||
out.push_str(" }\n");
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn gen_template_nodes(&self, nodes: &[TemplateNode], comp: &Component) -> CompileResult<String> {
|
||||
let mut out = String::new();
|
||||
for node in nodes {
|
||||
out.push_str(&self.gen_template_node(node, comp)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn gen_template_node(&self, node: &TemplateNode, comp: &Component) -> CompileResult<String> {
|
||||
match node {
|
||||
TemplateNode::Text(t) => Ok(t.clone()),
|
||||
|
||||
TemplateNode::Interpolation(expr) => {
|
||||
let js_expr = translate_interpolation(expr, comp);
|
||||
Ok(format!("${{{} }}", js_expr))
|
||||
}
|
||||
|
||||
TemplateNode::Element { tag, attrs, children } => {
|
||||
let mut out = format!("<{}", tag);
|
||||
|
||||
for attr in attrs {
|
||||
out.push_str(&self.gen_attr(attr, comp)?);
|
||||
}
|
||||
|
||||
if children.is_empty() {
|
||||
out.push_str(&format!(" data-el-tag=\"{}\">", tag));
|
||||
out.push_str(&format!("</{}>", tag));
|
||||
} else {
|
||||
out.push_str(&format!(" data-el-tag=\"{}\">", tag));
|
||||
out.push_str(&self.gen_template_nodes(children, comp)?);
|
||||
out.push_str(&format!("</{}>", tag));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
TemplateNode::Component { name, props } => {
|
||||
// Render as inline component call
|
||||
let mut prop_entries: Vec<String> = Vec::new();
|
||||
for prop in props {
|
||||
match prop {
|
||||
Attr::Static { name: pn, value } => {
|
||||
prop_entries.push(format!("{}: \"{}\"", pn, value));
|
||||
}
|
||||
Attr::Dynamic { name: pn, expr } => {
|
||||
let js = translate_interpolation(expr, comp);
|
||||
prop_entries.push(format!("{}: {}", pn, js));
|
||||
}
|
||||
Attr::EventHandler { event, handler } => {
|
||||
let js = translate_handler(handler, comp);
|
||||
prop_entries.push(format!("on{}: {}", capitalize(event), js));
|
||||
}
|
||||
Attr::BoolAttr { name: pn, expr } => {
|
||||
prop_entries.push(format!("{}: {}", pn, expr));
|
||||
}
|
||||
}
|
||||
}
|
||||
let props_js = format!("{{ {} }}", prop_entries.join(", "));
|
||||
Ok(format!("${{__self._child({}, {})}}", name, props_js))
|
||||
}
|
||||
|
||||
TemplateNode::If { condition, then, else_ } => {
|
||||
let cond_js = translate_interpolation(condition, comp);
|
||||
let then_html = self.gen_template_nodes(then, comp)?;
|
||||
let else_html = if let Some(els) = else_ {
|
||||
self.gen_template_nodes(els, comp)?
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
Ok(format!(
|
||||
"${{({}) ? `{}` : `{}`}}",
|
||||
cond_js, then_html, else_html
|
||||
))
|
||||
}
|
||||
|
||||
TemplateNode::Each { items, item_name, children } => {
|
||||
let items_js = translate_interpolation(items, comp);
|
||||
let child_html = self.gen_template_nodes(children, comp)?;
|
||||
// Generate a map over the array
|
||||
Ok(format!(
|
||||
"${{({}).map(({}) => `{}`).join('')}}",
|
||||
items_js, item_name, child_html
|
||||
))
|
||||
}
|
||||
|
||||
TemplateNode::Activate { query, result_name, children } => {
|
||||
let child_html = self.gen_template_nodes(children, comp)?;
|
||||
Ok(format!(
|
||||
"${{((__self._graph.search(\"{}\")) || []).map(({}) => `{}`).join('')}}",
|
||||
query, result_name, child_html
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn gen_attr(&self, attr: &Attr, comp: &Component) -> CompileResult<String> {
|
||||
match attr {
|
||||
Attr::Static { name, value } => {
|
||||
Ok(format!(" {}=\"{}\"", name, value))
|
||||
}
|
||||
Attr::Dynamic { name, expr } => {
|
||||
let js = translate_interpolation(expr, comp);
|
||||
Ok(format!(" {}=\"${{{} }}\"", name, js))
|
||||
}
|
||||
Attr::BoolAttr { name, expr } => {
|
||||
let js = translate_interpolation(expr, comp);
|
||||
Ok(format!(" ${{({}) ? '{}' : '' }}", js, name))
|
||||
}
|
||||
Attr::EventHandler { event, handler } => {
|
||||
// We use data attributes to defer event binding
|
||||
let js = translate_handler(handler, comp);
|
||||
// Inline handler via data attribute — the renderer will bind these
|
||||
Ok(format!(" data-el-{}=\"{}\"", event, escape_attr(&js)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Translate an el-ui expression to JavaScript.
|
||||
/// Handles state assignments like `count = count + 1` → `__self.setState('count', count + 1)`
|
||||
fn translate_interpolation(expr: &str, comp: &Component) -> String {
|
||||
translate_expr(expr, comp)
|
||||
}
|
||||
|
||||
fn translate_expr(expr: &str, comp: &Component) -> String {
|
||||
let state_names: Vec<&str> = comp.state.iter().map(|s| s.name.as_str()).collect();
|
||||
// Arrow functions: passthrough
|
||||
// State assignment: `name = value` → `__self.setState('name', value)`
|
||||
let trimmed = expr.trim();
|
||||
|
||||
// Check for simple assignment: `ident = expr`
|
||||
if let Some(result) = try_translate_assignment(trimmed, &state_names) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Arrow function containing assignment: `() => count = count + 1`
|
||||
if trimmed.starts_with('(') || trimmed.starts_with("e =>") || trimmed.starts_with("() =>") {
|
||||
return translate_arrow_fn(trimmed, &state_names);
|
||||
}
|
||||
|
||||
// Otherwise pass through as-is
|
||||
trimmed.to_owned()
|
||||
}
|
||||
|
||||
fn try_translate_assignment(expr: &str, state_names: &[&str]) -> Option<String> {
|
||||
// Match: `name = value` where name is a state variable
|
||||
// Must not be `==` (equality)
|
||||
let parts: Vec<&str> = expr.splitn(2, '=').collect();
|
||||
if parts.len() == 2 {
|
||||
let lhs = parts[0].trim();
|
||||
let rhs = parts[1].trim();
|
||||
// Ensure it's not `==` or `!=` or `<=` or `>=`
|
||||
if !rhs.starts_with('=') && !lhs.ends_with('!') && !lhs.ends_with('<') && !lhs.ends_with('>') {
|
||||
if state_names.contains(&lhs) {
|
||||
return Some(format!("__self.setState('{}', {})", lhs, rhs));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn translate_arrow_fn(expr: &str, state_names: &[&str]) -> String {
|
||||
// Translate assignments inside arrow functions
|
||||
// This is a best-effort string transformation
|
||||
let mut result = expr.to_owned();
|
||||
for name in state_names {
|
||||
// Replace `name = ` with `__self.setState('name', ` ... `)` is too complex
|
||||
// for a simple string replacement, but we can handle common patterns.
|
||||
// Pattern: `name = expr` at end of arrow fn or in braces
|
||||
let pat = format!("{} = ", name);
|
||||
if let Some(idx) = result.find(&pat) {
|
||||
// Check it's not ==
|
||||
let after = &result[idx + pat.len()..];
|
||||
// Simple case: `() => count = count + 1`
|
||||
let prefix = &result[..idx];
|
||||
result = format!("{}__self.setState('{}', {})", prefix, name, after.trim_end_matches(')'));
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn translate_handler(handler: &str, comp: &Component) -> String {
|
||||
translate_expr(handler, comp)
|
||||
}
|
||||
|
||||
/// Translate method body — replace bare state assignments with setState calls.
|
||||
fn translate_method_body(body: &str, comp: &Component) -> String {
|
||||
let state_names: Vec<&str> = comp.state.iter().map(|s| s.name.as_str()).collect();
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
for line in body.lines() {
|
||||
let trimmed = line.trim();
|
||||
if let Some(translated) = try_translate_assignment(trimmed, &state_names) {
|
||||
lines.push(format!("{};", translated));
|
||||
} else if trimmed.starts_with("return ") {
|
||||
lines.push(trimmed.to_owned());
|
||||
} else {
|
||||
lines.push(trimmed.to_owned());
|
||||
}
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn translate_el_to_js(expr: &str) -> String {
|
||||
let s = expr.trim();
|
||||
// Fn types — translate to null (not a valid JS value, handled at runtime)
|
||||
if s.starts_with("Fn") { return "null".into(); }
|
||||
// Boolean
|
||||
if s == "true" { return "true".into(); }
|
||||
if s == "false" { return "false".into(); }
|
||||
// String literal
|
||||
if s.starts_with('"') { return s.replace('"', "\"").to_owned(); }
|
||||
// Numbers
|
||||
if s.parse::<i64>().is_ok() { return s.to_owned(); }
|
||||
if s.parse::<f64>().is_ok() { return s.to_owned(); }
|
||||
// Empty string / void
|
||||
if s.is_empty() { return "null".into(); }
|
||||
s.to_owned()
|
||||
}
|
||||
|
||||
fn capitalize(s: &str) -> String {
|
||||
let mut c = s.chars();
|
||||
match c.next() {
|
||||
None => String::new(),
|
||||
Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
|
||||
}
|
||||
}
|
||||
|
||||
fn escape_attr(s: &str) -> String {
|
||||
s.replace('"', """).replace('\'', "'")
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
use thiserror::Error;
|
||||
|
||||
pub type CompileResult<T> = Result<T, CompileError>;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CompileError {
|
||||
#[error("lexer error at position {pos}: {msg}")]
|
||||
Lex { pos: usize, msg: String },
|
||||
|
||||
#[error("parse error: {msg}")]
|
||||
Parse { msg: String },
|
||||
|
||||
#[error("codegen error: {msg}")]
|
||||
Codegen { msg: String },
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
//! Lexer for el-ui component syntax.
|
||||
//!
|
||||
//! Produces a flat `Vec<Token>` from source text.
|
||||
//! The lexer is context-sensitive: it switches between "code mode"
|
||||
//! and "template mode" when it encounters the `template` keyword and `{` / `}`.
|
||||
|
||||
use crate::error::{CompileError, CompileResult};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Token {
|
||||
// Keywords
|
||||
Component,
|
||||
Props,
|
||||
State,
|
||||
Fn,
|
||||
Template,
|
||||
If,
|
||||
Else,
|
||||
Return,
|
||||
|
||||
// Identifiers and literals
|
||||
Ident(String),
|
||||
StringLit(String),
|
||||
IntLit(i64),
|
||||
FloatLit(f64),
|
||||
BoolLit(bool),
|
||||
|
||||
// Punctuation
|
||||
LBrace, // {
|
||||
RBrace, // }
|
||||
LParen, // (
|
||||
RParen, // )
|
||||
LAngle, // <
|
||||
RAngle, // >
|
||||
LBracket, // [
|
||||
RBracket, // ]
|
||||
Colon, // :
|
||||
Semicolon,// ;
|
||||
Comma, // ,
|
||||
Dot, // .
|
||||
Eq, // =
|
||||
EqEq, // ==
|
||||
Bang, // !
|
||||
BangEq, // !=
|
||||
Plus, // +
|
||||
Minus, // -
|
||||
Star, // *
|
||||
Slash, // /
|
||||
Arrow, // ->
|
||||
FatArrow, // =>
|
||||
Ampersand,// &
|
||||
Pipe, // |
|
||||
AmpAmp, // &&
|
||||
PipePipe, // ||
|
||||
Question, // ?
|
||||
Hash, // #
|
||||
At, // @
|
||||
|
||||
// Template-specific
|
||||
SlashIdent(String), // /if /each /activate
|
||||
ColonIdent(String), // :else
|
||||
HashIdent(String), // #if #each #activate
|
||||
OnColon(String), // on:click on:input etc.
|
||||
SelfClose, // />
|
||||
CloseTag(String), // </div>
|
||||
|
||||
// Raw text in templates
|
||||
RawText(String),
|
||||
|
||||
Eof,
|
||||
}
|
||||
|
||||
pub fn tokenize(source: &str) -> CompileResult<Vec<Token>> {
|
||||
let mut lexer = Lexer::new(source);
|
||||
lexer.run()
|
||||
}
|
||||
|
||||
struct Lexer<'a> {
|
||||
src: &'a [u8],
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl<'a> Lexer<'a> {
|
||||
fn new(source: &'a str) -> Self {
|
||||
Self { src: source.as_bytes(), pos: 0 }
|
||||
}
|
||||
|
||||
fn peek(&self) -> Option<u8> {
|
||||
self.src.get(self.pos).copied()
|
||||
}
|
||||
|
||||
fn peek2(&self) -> Option<u8> {
|
||||
self.src.get(self.pos + 1).copied()
|
||||
}
|
||||
|
||||
fn advance(&mut self) -> Option<u8> {
|
||||
let ch = self.src.get(self.pos).copied();
|
||||
if ch.is_some() {
|
||||
self.pos += 1;
|
||||
}
|
||||
ch
|
||||
}
|
||||
|
||||
fn skip_whitespace_and_comments(&mut self) {
|
||||
loop {
|
||||
// Skip whitespace
|
||||
while matches!(self.peek(), Some(b' ' | b'\t' | b'\n' | b'\r')) {
|
||||
self.advance();
|
||||
}
|
||||
// Skip // line comments
|
||||
if self.peek() == Some(b'/') && self.peek2() == Some(b'/') {
|
||||
while self.peek().is_some() && self.peek() != Some(b'\n') {
|
||||
self.advance();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
fn read_ident(&mut self) -> String {
|
||||
let start = self.pos;
|
||||
while matches!(self.peek(), Some(b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_')) {
|
||||
self.advance();
|
||||
}
|
||||
String::from_utf8_lossy(&self.src[start..self.pos]).into_owned()
|
||||
}
|
||||
|
||||
fn read_string(&mut self) -> CompileResult<String> {
|
||||
// Opening quote already consumed
|
||||
let mut s = String::new();
|
||||
loop {
|
||||
match self.advance() {
|
||||
None => return Err(CompileError::Lex { pos: self.pos, msg: "unterminated string".into() }),
|
||||
Some(b'"') => break,
|
||||
Some(b'\\') => {
|
||||
match self.advance() {
|
||||
Some(b'n') => s.push('\n'),
|
||||
Some(b't') => s.push('\t'),
|
||||
Some(b'r') => s.push('\r'),
|
||||
Some(b'"') => s.push('"'),
|
||||
Some(b'\\') => s.push('\\'),
|
||||
Some(b'0') => s.push('\0'),
|
||||
Some(c) => s.push(c as char),
|
||||
None => return Err(CompileError::Lex { pos: self.pos, msg: "unterminated escape".into() }),
|
||||
}
|
||||
}
|
||||
Some(c) => s.push(c as char),
|
||||
}
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
fn read_number(&mut self, first: u8) -> Token {
|
||||
let mut s = String::new();
|
||||
s.push(first as char);
|
||||
while matches!(self.peek(), Some(b'0'..=b'9' | b'_')) {
|
||||
let c = self.advance().unwrap();
|
||||
if c != b'_' {
|
||||
s.push(c as char);
|
||||
}
|
||||
}
|
||||
if self.peek() == Some(b'.') && matches!(self.peek2(), Some(b'0'..=b'9')) {
|
||||
s.push('.');
|
||||
self.advance();
|
||||
while matches!(self.peek(), Some(b'0'..=b'9')) {
|
||||
s.push(self.advance().unwrap() as char);
|
||||
}
|
||||
Token::FloatLit(s.parse().unwrap_or(0.0))
|
||||
} else {
|
||||
Token::IntLit(s.parse().unwrap_or(0))
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a template block — everything between the outer `{` and matching `}`
|
||||
/// of `template { ... }`. Returns the raw text inside.
|
||||
fn read_template_inner(&mut self) -> CompileResult<Vec<Token>> {
|
||||
// We are positioned right after `template` keyword and the `{` that opened it.
|
||||
// We tokenize the template body using a template-aware mini-lexer.
|
||||
let mut toks: Vec<Token> = Vec::new();
|
||||
let mut depth = 1i32; // we've consumed the opening {
|
||||
let mut text_buf = String::new();
|
||||
|
||||
macro_rules! flush_text {
|
||||
() => {
|
||||
if !text_buf.is_empty() {
|
||||
let t = text_buf.trim().to_owned();
|
||||
if !t.is_empty() {
|
||||
toks.push(Token::RawText(t));
|
||||
}
|
||||
text_buf.clear();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
loop {
|
||||
match self.peek() {
|
||||
None => return Err(CompileError::Lex { pos: self.pos, msg: "unterminated template block".into() }),
|
||||
Some(b'{') => {
|
||||
self.advance();
|
||||
// Could be interpolation {expr}, or {#if}, {/if}, {:else}
|
||||
// Peek at what follows
|
||||
// Skip whitespace inside
|
||||
while matches!(self.peek(), Some(b' ' | b'\t')) {
|
||||
self.advance();
|
||||
}
|
||||
if self.peek() == Some(b'#') {
|
||||
// Block tag: {#if ...} {#each ...} {#activate ...}
|
||||
self.advance(); // consume #
|
||||
let kw = self.read_ident();
|
||||
flush_text!();
|
||||
toks.push(Token::HashIdent(kw));
|
||||
// Read the rest up to }
|
||||
let mut inner = String::new();
|
||||
let mut brace_d = 1i32;
|
||||
loop {
|
||||
match self.peek() {
|
||||
None => break,
|
||||
Some(b'{') => { brace_d += 1; inner.push('{'); self.advance(); }
|
||||
Some(b'}') => {
|
||||
brace_d -= 1;
|
||||
self.advance();
|
||||
if brace_d == 0 { break; }
|
||||
inner.push('}');
|
||||
}
|
||||
Some(c) => { inner.push(c as char); self.advance(); }
|
||||
}
|
||||
}
|
||||
toks.push(Token::RawText(inner.trim().to_owned()));
|
||||
} else if self.peek() == Some(b'/') {
|
||||
// Close tag: {/if} {/each} {/activate}
|
||||
self.advance(); // consume /
|
||||
let kw = self.read_ident();
|
||||
flush_text!();
|
||||
toks.push(Token::SlashIdent(kw));
|
||||
while self.peek() == Some(b'}') { self.advance(); break; }
|
||||
} else if self.peek() == Some(b':') {
|
||||
// {:else}
|
||||
self.advance(); // consume :
|
||||
let kw = self.read_ident();
|
||||
flush_text!();
|
||||
toks.push(Token::ColonIdent(kw));
|
||||
while self.peek() == Some(b'}') { self.advance(); break; }
|
||||
} else {
|
||||
// Regular interpolation or outer brace tracking
|
||||
// Check if this closes the template
|
||||
if depth == 1 && self.peek() == Some(b'}') {
|
||||
// Empty brace—skip
|
||||
self.advance();
|
||||
let _ = depth - 1; // depth tracked by outer loop
|
||||
break;
|
||||
}
|
||||
// Read the expression until matching }
|
||||
let mut expr = String::new();
|
||||
let mut brace_d = 1i32;
|
||||
loop {
|
||||
match self.peek() {
|
||||
None => break,
|
||||
Some(b'{') => { brace_d += 1; expr.push('{'); self.advance(); }
|
||||
Some(b'}') => {
|
||||
brace_d -= 1;
|
||||
self.advance();
|
||||
if brace_d == 0 { break; }
|
||||
expr.push('}');
|
||||
}
|
||||
Some(c) => { expr.push(c as char); self.advance(); }
|
||||
}
|
||||
}
|
||||
let expr = expr.trim().to_owned();
|
||||
if !expr.is_empty() {
|
||||
flush_text!();
|
||||
toks.push(Token::LBrace);
|
||||
toks.push(Token::RawText(expr));
|
||||
toks.push(Token::RBrace);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(b'}') => {
|
||||
depth -= 1;
|
||||
self.advance();
|
||||
if depth == 0 {
|
||||
flush_text!();
|
||||
break;
|
||||
}
|
||||
text_buf.push('}');
|
||||
}
|
||||
Some(b'<') => {
|
||||
// HTML element or close tag
|
||||
self.advance();
|
||||
if self.peek() == Some(b'/') {
|
||||
// Close tag </div>
|
||||
self.advance();
|
||||
let tag = self.read_tag_name();
|
||||
while self.peek() != Some(b'>') && self.peek().is_some() {
|
||||
self.advance();
|
||||
}
|
||||
self.advance(); // consume >
|
||||
flush_text!();
|
||||
toks.push(Token::CloseTag(tag));
|
||||
} else {
|
||||
// Open tag
|
||||
let tag = self.read_tag_name();
|
||||
flush_text!();
|
||||
toks.push(Token::LAngle);
|
||||
toks.push(Token::Ident(tag));
|
||||
// Read attributes
|
||||
self.read_attrs_into(&mut toks)?;
|
||||
}
|
||||
}
|
||||
Some(b'\n' | b'\r') => {
|
||||
self.advance();
|
||||
text_buf.push(' ');
|
||||
}
|
||||
Some(c) => {
|
||||
text_buf.push(c as char);
|
||||
self.advance();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(toks)
|
||||
}
|
||||
|
||||
fn read_tag_name(&mut self) -> String {
|
||||
while matches!(self.peek(), Some(b' ' | b'\t' | b'\n')) {
|
||||
self.advance();
|
||||
}
|
||||
let start = self.pos;
|
||||
while matches!(self.peek(), Some(b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_')) {
|
||||
self.advance();
|
||||
}
|
||||
String::from_utf8_lossy(&self.src[start..self.pos]).into_owned()
|
||||
}
|
||||
|
||||
/// Read attributes until `>` or `/>`.
|
||||
fn read_attrs_into(&mut self, toks: &mut Vec<Token>) -> CompileResult<()> {
|
||||
loop {
|
||||
// Skip whitespace
|
||||
while matches!(self.peek(), Some(b' ' | b'\t' | b'\n' | b'\r')) {
|
||||
self.advance();
|
||||
}
|
||||
match self.peek() {
|
||||
None => break,
|
||||
Some(b'/') if self.peek2() == Some(b'>') => {
|
||||
self.advance(); self.advance();
|
||||
toks.push(Token::SelfClose);
|
||||
break;
|
||||
}
|
||||
Some(b'>') => {
|
||||
self.advance();
|
||||
toks.push(Token::RAngle);
|
||||
break;
|
||||
}
|
||||
Some(b'o') if self.src.get(self.pos..self.pos+3) == Some(b"on:") => {
|
||||
// on:event={handler}
|
||||
self.pos += 3; // skip "on:"
|
||||
let event = self.read_tag_name();
|
||||
// skip whitespace and =
|
||||
while matches!(self.peek(), Some(b' ' | b'=')) { self.advance(); }
|
||||
// read {expr}
|
||||
let expr = if self.peek() == Some(b'{') {
|
||||
self.advance();
|
||||
self.read_until_brace_close()?
|
||||
} else {
|
||||
self.read_quoted_string()?
|
||||
};
|
||||
toks.push(Token::OnColon(event));
|
||||
toks.push(Token::RawText(expr));
|
||||
}
|
||||
_ => {
|
||||
// Regular attribute: name="val" or name={expr}
|
||||
let name = self.read_attr_name();
|
||||
if name.is_empty() { break; }
|
||||
// Skip whitespace and =
|
||||
while matches!(self.peek(), Some(b' ' | b'\t')) { self.advance(); }
|
||||
if self.peek() != Some(b'=') {
|
||||
// Boolean attribute with no value
|
||||
toks.push(Token::Ident(name));
|
||||
continue;
|
||||
}
|
||||
self.advance(); // consume =
|
||||
while matches!(self.peek(), Some(b' ' | b'\t')) { self.advance(); }
|
||||
let value = if self.peek() == Some(b'"') {
|
||||
self.advance(); // consume "
|
||||
let s = self.read_string()?;
|
||||
toks.push(Token::Ident(name.clone()));
|
||||
toks.push(Token::Eq);
|
||||
toks.push(Token::StringLit(s));
|
||||
continue;
|
||||
} else if self.peek() == Some(b'{') {
|
||||
self.advance();
|
||||
self.read_until_brace_close()?
|
||||
} else {
|
||||
self.read_attr_name()
|
||||
};
|
||||
toks.push(Token::Ident(name));
|
||||
toks.push(Token::Eq);
|
||||
toks.push(Token::RawText(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_attr_name(&mut self) -> String {
|
||||
let start = self.pos;
|
||||
while matches!(self.peek(), Some(b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b':')) {
|
||||
self.advance();
|
||||
}
|
||||
String::from_utf8_lossy(&self.src[start..self.pos]).into_owned()
|
||||
}
|
||||
|
||||
fn read_quoted_string(&mut self) -> CompileResult<String> {
|
||||
if self.peek() == Some(b'"') {
|
||||
self.advance();
|
||||
self.read_string()
|
||||
} else {
|
||||
Ok(self.read_attr_name())
|
||||
}
|
||||
}
|
||||
|
||||
fn read_until_brace_close(&mut self) -> CompileResult<String> {
|
||||
let mut s = String::new();
|
||||
let mut depth = 1i32;
|
||||
loop {
|
||||
match self.peek() {
|
||||
None => return Err(CompileError::Lex { pos: self.pos, msg: "unterminated {".into() }),
|
||||
Some(b'{') => { depth += 1; s.push('{'); self.advance(); }
|
||||
Some(b'}') => {
|
||||
depth -= 1;
|
||||
self.advance();
|
||||
if depth == 0 { break; }
|
||||
s.push('}');
|
||||
}
|
||||
Some(c) => { s.push(c as char); self.advance(); }
|
||||
}
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
fn run(&mut self) -> CompileResult<Vec<Token>> {
|
||||
let mut tokens: Vec<Token> = Vec::new();
|
||||
|
||||
loop {
|
||||
self.skip_whitespace_and_comments();
|
||||
if self.peek().is_none() {
|
||||
tokens.push(Token::Eof);
|
||||
break;
|
||||
}
|
||||
|
||||
let ch = self.advance().unwrap();
|
||||
|
||||
match ch {
|
||||
b'a'..=b'z' | b'A'..=b'Z' | b'_' => {
|
||||
let mut ident = String::new();
|
||||
ident.push(ch as char);
|
||||
while matches!(self.peek(), Some(b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_')) {
|
||||
ident.push(self.advance().unwrap() as char);
|
||||
}
|
||||
let tok = match ident.as_str() {
|
||||
"component" => Token::Component,
|
||||
"props" => Token::Props,
|
||||
"state" => Token::State,
|
||||
"fn" => Token::Fn,
|
||||
"template" => Token::Template,
|
||||
"if" => Token::If,
|
||||
"else" => Token::Else,
|
||||
"return" => Token::Return,
|
||||
"true" => Token::BoolLit(true),
|
||||
"false" => Token::BoolLit(false),
|
||||
other => Token::Ident(other.to_owned()),
|
||||
};
|
||||
// Special handling: after `template`, read the block specially
|
||||
if tok == Token::Template {
|
||||
tokens.push(tok);
|
||||
self.skip_whitespace_and_comments();
|
||||
if self.peek() == Some(b'{') {
|
||||
self.advance(); // consume {
|
||||
tokens.push(Token::LBrace);
|
||||
let inner = self.read_template_inner()?;
|
||||
tokens.extend(inner);
|
||||
tokens.push(Token::RBrace);
|
||||
}
|
||||
} else {
|
||||
tokens.push(tok);
|
||||
}
|
||||
}
|
||||
b'"' => {
|
||||
let s = self.read_string()?;
|
||||
tokens.push(Token::StringLit(s));
|
||||
}
|
||||
b'0'..=b'9' => {
|
||||
let tok = self.read_number(ch);
|
||||
tokens.push(tok);
|
||||
}
|
||||
b'{' => tokens.push(Token::LBrace),
|
||||
b'}' => tokens.push(Token::RBrace),
|
||||
b'(' => tokens.push(Token::LParen),
|
||||
b')' => tokens.push(Token::RParen),
|
||||
b'[' => tokens.push(Token::LBracket),
|
||||
b']' => tokens.push(Token::RBracket),
|
||||
b':' => tokens.push(Token::Colon),
|
||||
b';' => tokens.push(Token::Semicolon),
|
||||
b',' => tokens.push(Token::Comma),
|
||||
b'.' => tokens.push(Token::Dot),
|
||||
b'=' => {
|
||||
if self.peek() == Some(b'=') {
|
||||
self.advance(); tokens.push(Token::EqEq);
|
||||
} else if self.peek() == Some(b'>') {
|
||||
self.advance(); tokens.push(Token::FatArrow);
|
||||
} else {
|
||||
tokens.push(Token::Eq);
|
||||
}
|
||||
}
|
||||
b'!' => {
|
||||
if self.peek() == Some(b'=') {
|
||||
self.advance(); tokens.push(Token::BangEq);
|
||||
} else {
|
||||
tokens.push(Token::Bang);
|
||||
}
|
||||
}
|
||||
b'+' => tokens.push(Token::Plus),
|
||||
b'-' => {
|
||||
if self.peek() == Some(b'>') {
|
||||
self.advance(); tokens.push(Token::Arrow);
|
||||
} else {
|
||||
tokens.push(Token::Minus);
|
||||
}
|
||||
}
|
||||
b'*' => tokens.push(Token::Star),
|
||||
b'/' => {
|
||||
if self.peek() == Some(b'/') {
|
||||
// Line comment (shouldn't reach here after skip, but guard)
|
||||
while self.peek().is_some() && self.peek() != Some(b'\n') {
|
||||
self.advance();
|
||||
}
|
||||
} else {
|
||||
tokens.push(Token::Slash);
|
||||
}
|
||||
}
|
||||
b'&' => {
|
||||
if self.peek() == Some(b'&') {
|
||||
self.advance(); tokens.push(Token::AmpAmp);
|
||||
} else {
|
||||
tokens.push(Token::Ampersand);
|
||||
}
|
||||
}
|
||||
b'|' => {
|
||||
if self.peek() == Some(b'|') {
|
||||
self.advance(); tokens.push(Token::PipePipe);
|
||||
} else {
|
||||
tokens.push(Token::Pipe);
|
||||
}
|
||||
}
|
||||
b'?' => tokens.push(Token::Question),
|
||||
b'#' => tokens.push(Token::Hash),
|
||||
b'@' => tokens.push(Token::At),
|
||||
b'<' => tokens.push(Token::LAngle),
|
||||
b'>' => tokens.push(Token::RAngle),
|
||||
_ => {
|
||||
// Ignore unknown characters (whitespace already skipped)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(tokens)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//! el-ui-compiler — Transforms `.el` component files into JavaScript.
|
||||
//!
|
||||
//! Pipeline:
|
||||
//! source text → lexer → tokens → parser → AST → codegen → JavaScript
|
||||
//!
|
||||
//! The output JavaScript uses the el-ui runtime (`el-ui.js`) to register
|
||||
//! components, manage a spreading-activation graph for state, and patch the DOM.
|
||||
|
||||
pub mod ast;
|
||||
pub mod codegen;
|
||||
pub mod error;
|
||||
pub mod lexer;
|
||||
pub mod parser;
|
||||
|
||||
pub use ast::{Attr, Component, Method, PropDef, StateDef, Template, TemplateNode};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
pub use codegen::Codegen;
|
||||
pub use error::{CompileError, CompileResult};
|
||||
|
||||
/// High-level compiler entry point.
|
||||
pub struct Compiler {
|
||||
/// Runtime import path (default: `./el-ui.js`)
|
||||
pub runtime_path: String,
|
||||
}
|
||||
|
||||
impl Default for Compiler {
|
||||
fn default() -> Self {
|
||||
Self { runtime_path: "./el-ui.js".into() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Compiler {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn with_runtime_path(mut self, path: impl Into<String>) -> Self {
|
||||
self.runtime_path = path.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Compile a single `.el` source file containing one or more components.
|
||||
/// Returns the JavaScript module string.
|
||||
pub fn compile_component(&self, source: &str) -> CompileResult<String> {
|
||||
let tokens = lexer::tokenize(source)?;
|
||||
let components = parser::parse(&tokens)?;
|
||||
let gen = Codegen::new(&self.runtime_path);
|
||||
gen.generate(&components)
|
||||
}
|
||||
|
||||
/// Compile an app entry point, pulling in named component sources.
|
||||
/// `components` is a slice of `(name, source)` pairs.
|
||||
/// Returns a single JavaScript module that imports from the runtime.
|
||||
pub fn compile_app(
|
||||
&self,
|
||||
entry_source: &str,
|
||||
components: &[(&str, &str)],
|
||||
) -> CompileResult<String> {
|
||||
let mut all_components: Vec<Component> = Vec::new();
|
||||
|
||||
for (_name, src) in components {
|
||||
let tokens = lexer::tokenize(src)?;
|
||||
let mut parsed = parser::parse(&tokens)?;
|
||||
all_components.append(&mut parsed);
|
||||
}
|
||||
|
||||
// Parse entry last (may reference previously defined components)
|
||||
let entry_tokens = lexer::tokenize(entry_source)?;
|
||||
let mut entry_parsed = parser::parse(&entry_tokens)?;
|
||||
all_components.append(&mut entry_parsed);
|
||||
|
||||
let gen = Codegen::new(&self.runtime_path);
|
||||
gen.generate(&all_components)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! el-ui-compiler CLI
|
||||
//!
|
||||
//! Usage:
|
||||
//! el-ui-compiler <input.el> [-o <output.js>]
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
|
||||
if args.len() < 2 {
|
||||
eprintln!("Usage: el-ui-compiler <input.el> [-o output.js]");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let input = PathBuf::from(&args[1]);
|
||||
let output = if args.len() >= 4 && args[2] == "-o" {
|
||||
PathBuf::from(&args[3])
|
||||
} else {
|
||||
input.with_extension("js")
|
||||
};
|
||||
|
||||
let source = match fs::read_to_string(&input) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("Error reading {}: {}", input.display(), e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let compiler = el_ui_compiler::Compiler::new();
|
||||
match compiler.compile_component(&source) {
|
||||
Ok(js) => {
|
||||
if let Err(e) = fs::write(&output, js) {
|
||||
eprintln!("Error writing {}: {}", output.display(), e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
println!("Compiled {} -> {}", input.display(), output.display());
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Compile error: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,640 @@
|
||||
//! Parser for el-ui component files.
|
||||
//!
|
||||
//! Hand-written recursive descent. Produces a `Vec<Component>` from a token stream.
|
||||
|
||||
use crate::ast::*;
|
||||
use crate::error::{CompileError, CompileResult};
|
||||
use crate::lexer::Token;
|
||||
|
||||
pub fn parse(tokens: &[Token]) -> CompileResult<Vec<Component>> {
|
||||
let mut p = Parser::new(tokens);
|
||||
p.parse_program()
|
||||
}
|
||||
|
||||
struct Parser<'a> {
|
||||
tokens: &'a [Token],
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl<'a> Parser<'a> {
|
||||
fn new(tokens: &'a [Token]) -> Self {
|
||||
Self { tokens, pos: 0 }
|
||||
}
|
||||
|
||||
fn peek(&self) -> &Token {
|
||||
self.tokens.get(self.pos).unwrap_or(&Token::Eof)
|
||||
}
|
||||
|
||||
fn advance(&mut self) -> &Token {
|
||||
let tok = self.tokens.get(self.pos).unwrap_or(&Token::Eof);
|
||||
if self.pos < self.tokens.len() {
|
||||
self.pos += 1;
|
||||
}
|
||||
tok
|
||||
}
|
||||
|
||||
fn expect(&mut self, expected: &Token) -> CompileResult<()> {
|
||||
let tok = self.advance();
|
||||
if tok == expected {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(CompileError::Parse {
|
||||
msg: format!("expected {:?}, got {:?}", expected, tok),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn expect_ident(&mut self) -> CompileResult<String> {
|
||||
match self.advance().clone() {
|
||||
Token::Ident(s) => Ok(s),
|
||||
other => Err(CompileError::Parse {
|
||||
msg: format!("expected identifier, got {:?}", other),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_program(&mut self) -> CompileResult<Vec<Component>> {
|
||||
let mut components = Vec::new();
|
||||
while *self.peek() != Token::Eof {
|
||||
match self.peek() {
|
||||
Token::Component => {
|
||||
components.push(self.parse_component()?);
|
||||
}
|
||||
_ => {
|
||||
// Skip unknown top-level tokens
|
||||
self.advance();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(components)
|
||||
}
|
||||
|
||||
fn parse_component(&mut self) -> CompileResult<Component> {
|
||||
self.expect(&Token::Component)?;
|
||||
let name = self.expect_ident()?;
|
||||
self.expect(&Token::LBrace)?;
|
||||
|
||||
let mut props = Vec::new();
|
||||
let mut state = Vec::new();
|
||||
let mut methods = Vec::new();
|
||||
let mut template = Template { nodes: Vec::new() };
|
||||
|
||||
loop {
|
||||
match self.peek().clone() {
|
||||
Token::RBrace => {
|
||||
self.advance();
|
||||
break;
|
||||
}
|
||||
Token::Eof => break,
|
||||
Token::Props => {
|
||||
self.advance();
|
||||
props = self.parse_prop_block()?;
|
||||
}
|
||||
Token::State => {
|
||||
self.advance();
|
||||
state = self.parse_state_block()?;
|
||||
}
|
||||
Token::Fn => {
|
||||
methods.push(self.parse_method()?);
|
||||
}
|
||||
Token::Template => {
|
||||
self.advance(); // consume `template`
|
||||
template = self.parse_template_block()?;
|
||||
}
|
||||
_ => {
|
||||
self.advance(); // skip unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Component { name, props, state, methods, template })
|
||||
}
|
||||
|
||||
fn parse_prop_block(&mut self) -> CompileResult<Vec<PropDef>> {
|
||||
self.expect(&Token::LBrace)?;
|
||||
let mut props = Vec::new();
|
||||
loop {
|
||||
match self.peek().clone() {
|
||||
Token::RBrace | Token::Eof => {
|
||||
self.advance();
|
||||
break;
|
||||
}
|
||||
Token::Ident(name) => {
|
||||
self.advance();
|
||||
self.expect(&Token::Colon)?;
|
||||
let type_name = self.parse_type_name()?;
|
||||
let default = if *self.peek() == Token::Eq {
|
||||
self.advance();
|
||||
Some(self.parse_default_value()?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Optional trailing comma/semicolon
|
||||
if matches!(self.peek(), Token::Comma | Token::Semicolon) {
|
||||
self.advance();
|
||||
}
|
||||
props.push(PropDef { name, type_name, default });
|
||||
}
|
||||
_ => {
|
||||
self.advance();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(props)
|
||||
}
|
||||
|
||||
fn parse_state_block(&mut self) -> CompileResult<Vec<StateDef>> {
|
||||
self.expect(&Token::LBrace)?;
|
||||
let mut defs = Vec::new();
|
||||
loop {
|
||||
match self.peek().clone() {
|
||||
Token::RBrace | Token::Eof => {
|
||||
self.advance();
|
||||
break;
|
||||
}
|
||||
Token::Ident(name) => {
|
||||
self.advance();
|
||||
self.expect(&Token::Colon)?;
|
||||
let type_name = self.parse_type_name()?;
|
||||
self.expect(&Token::Eq)?;
|
||||
let initial = self.parse_default_value()?;
|
||||
if matches!(self.peek(), Token::Comma | Token::Semicolon) {
|
||||
self.advance();
|
||||
}
|
||||
defs.push(StateDef { name, type_name, initial });
|
||||
}
|
||||
_ => {
|
||||
self.advance();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(defs)
|
||||
}
|
||||
|
||||
fn parse_type_name(&mut self) -> CompileResult<String> {
|
||||
let name = match self.peek().clone() {
|
||||
Token::Ident(s) => { self.advance(); s }
|
||||
other => return Err(CompileError::Parse {
|
||||
msg: format!("expected type name, got {:?}", other),
|
||||
}),
|
||||
};
|
||||
// Check for array type [T] → already consumed base name, but array types
|
||||
// start with [ so this handles bare type names only
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
fn parse_default_value(&mut self) -> CompileResult<String> {
|
||||
// Collect tokens until comma, semicolon, or next top-level item
|
||||
// We need to handle nested structures like Fn types, etc.
|
||||
let mut result = String::new();
|
||||
let mut depth = 0i32;
|
||||
loop {
|
||||
match self.peek() {
|
||||
Token::Eof => break,
|
||||
Token::LParen | Token::LBrace | Token::LBracket => {
|
||||
depth += 1;
|
||||
let tok = self.advance();
|
||||
result.push_str(&token_to_str(tok));
|
||||
}
|
||||
Token::RParen | Token::RBrace | Token::RBracket => {
|
||||
if depth == 0 { break; }
|
||||
depth -= 1;
|
||||
let tok = self.advance();
|
||||
result.push_str(&token_to_str(tok));
|
||||
}
|
||||
Token::Comma | Token::Semicolon if depth == 0 => break,
|
||||
// These signal end of the value if at depth 0
|
||||
Token::Ident(_) | Token::Props | Token::State | Token::Fn
|
||||
| Token::Template | Token::Component if depth == 0 => break,
|
||||
_ => {
|
||||
let tok = self.advance();
|
||||
result.push_str(&token_to_str(tok));
|
||||
result.push(' ');
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(result.trim().to_owned())
|
||||
}
|
||||
|
||||
fn parse_method(&mut self) -> CompileResult<Method> {
|
||||
self.expect(&Token::Fn)?;
|
||||
let name = self.expect_ident()?;
|
||||
self.expect(&Token::LParen)?;
|
||||
|
||||
let mut params: Vec<(String, String)> = Vec::new();
|
||||
loop {
|
||||
match self.peek().clone() {
|
||||
Token::RParen | Token::Eof => { self.advance(); break; }
|
||||
Token::Ident(pname) => {
|
||||
self.advance();
|
||||
self.expect(&Token::Colon)?;
|
||||
let ptype = self.parse_type_name()?;
|
||||
params.push((pname, ptype));
|
||||
if *self.peek() == Token::Comma { self.advance(); }
|
||||
}
|
||||
_ => { self.advance(); }
|
||||
}
|
||||
}
|
||||
|
||||
self.expect(&Token::Arrow)?;
|
||||
let return_type = self.parse_type_name()?;
|
||||
|
||||
// Read the method body between { }
|
||||
let body = self.read_block_raw()?;
|
||||
|
||||
Ok(Method { name, params, return_type, body })
|
||||
}
|
||||
|
||||
/// Read everything between { and matching } as raw text.
|
||||
fn read_block_raw(&mut self) -> CompileResult<String> {
|
||||
self.expect(&Token::LBrace)?;
|
||||
let mut result = String::new();
|
||||
let mut depth = 1i32;
|
||||
loop {
|
||||
match self.peek() {
|
||||
Token::Eof => break,
|
||||
Token::LBrace => { depth += 1; self.advance(); result.push_str("{ "); }
|
||||
Token::RBrace => {
|
||||
depth -= 1;
|
||||
self.advance();
|
||||
if depth == 0 { break; }
|
||||
result.push_str("} ");
|
||||
}
|
||||
tok => {
|
||||
result.push_str(&token_to_str(tok));
|
||||
result.push(' ');
|
||||
self.advance();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(result.trim().to_owned())
|
||||
}
|
||||
|
||||
/// Parse the template block. At this point the lexer has already expanded
|
||||
/// the template into special tokens (LBrace/RBrace wrapping interpolations,
|
||||
/// LAngle/Ident for elements, etc.).
|
||||
fn parse_template_block(&mut self) -> CompileResult<Template> {
|
||||
self.expect(&Token::LBrace)?;
|
||||
let nodes = self.parse_template_nodes()?;
|
||||
// The matching RBrace is consumed inside parse_template_nodes
|
||||
Ok(Template { nodes })
|
||||
}
|
||||
|
||||
fn parse_template_nodes(&mut self) -> CompileResult<Vec<TemplateNode>> {
|
||||
let mut nodes: Vec<TemplateNode> = Vec::new();
|
||||
loop {
|
||||
match self.peek().clone() {
|
||||
Token::Eof | Token::RBrace => {
|
||||
self.advance();
|
||||
break;
|
||||
}
|
||||
Token::CloseTag(_) => {
|
||||
// Consumed by parent element parser
|
||||
break;
|
||||
}
|
||||
Token::LAngle => {
|
||||
nodes.push(self.parse_element()?);
|
||||
}
|
||||
Token::LBrace => {
|
||||
// Interpolation: { RawText }
|
||||
self.advance(); // consume {
|
||||
if let Token::RawText(expr) = self.peek().clone() {
|
||||
self.advance();
|
||||
nodes.push(TemplateNode::Interpolation(expr));
|
||||
if *self.peek() == Token::RBrace { self.advance(); }
|
||||
} else {
|
||||
nodes.push(TemplateNode::Text("{".into()));
|
||||
}
|
||||
}
|
||||
Token::HashIdent(kw) => {
|
||||
let kw = kw.clone();
|
||||
self.advance();
|
||||
nodes.push(self.parse_block_tag(&kw)?);
|
||||
}
|
||||
Token::SlashIdent(_) => {
|
||||
// End of a block — caller handles
|
||||
break;
|
||||
}
|
||||
Token::ColonIdent(_) => {
|
||||
// {:else} — caller handles
|
||||
break;
|
||||
}
|
||||
Token::RawText(t) => {
|
||||
let t = t.clone();
|
||||
self.advance();
|
||||
if !t.is_empty() {
|
||||
nodes.push(TemplateNode::Text(t));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
self.advance(); // skip
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(nodes)
|
||||
}
|
||||
|
||||
fn parse_element(&mut self) -> CompileResult<TemplateNode> {
|
||||
self.expect(&Token::LAngle)?;
|
||||
let tag = self.expect_ident()?;
|
||||
|
||||
let is_component = tag.chars().next().map(|c| c.is_uppercase()).unwrap_or(false);
|
||||
|
||||
let mut attrs: Vec<Attr> = Vec::new();
|
||||
|
||||
// Parse attributes until > or />
|
||||
let mut self_closing = false;
|
||||
loop {
|
||||
match self.peek().clone() {
|
||||
Token::SelfClose => {
|
||||
self.advance();
|
||||
self_closing = true;
|
||||
break;
|
||||
}
|
||||
Token::RAngle => {
|
||||
self.advance();
|
||||
break;
|
||||
}
|
||||
Token::Eof => break,
|
||||
Token::OnColon(event) => {
|
||||
let event = event.clone();
|
||||
self.advance();
|
||||
let handler = if let Token::RawText(h) = self.peek().clone() {
|
||||
self.advance();
|
||||
h
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
attrs.push(Attr::EventHandler { event, handler });
|
||||
}
|
||||
Token::Ident(name) => {
|
||||
let name = name.clone();
|
||||
self.advance();
|
||||
if *self.peek() == Token::Eq {
|
||||
self.advance(); // consume =
|
||||
match self.peek().clone() {
|
||||
Token::StringLit(val) => {
|
||||
self.advance();
|
||||
attrs.push(Attr::Static { name, value: val });
|
||||
}
|
||||
Token::RawText(expr) => {
|
||||
self.advance();
|
||||
// Determine if it's a bool attr
|
||||
// Simple heuristic: if name is "disabled", "checked", "readonly"
|
||||
let bool_attrs = ["disabled", "checked", "readonly", "required", "multiple", "selected"];
|
||||
if bool_attrs.contains(&name.as_str()) {
|
||||
attrs.push(Attr::BoolAttr { name, expr });
|
||||
} else {
|
||||
attrs.push(Attr::Dynamic { name, expr });
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
attrs.push(Attr::Static { name, value: String::new() });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Standalone attribute (boolean)
|
||||
attrs.push(Attr::BoolAttr { name, expr: "true".into() });
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
self.advance();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if self_closing || is_component {
|
||||
if is_component {
|
||||
return Ok(TemplateNode::Component { name: tag, props: attrs });
|
||||
}
|
||||
return Ok(TemplateNode::Element { tag, attrs, children: Vec::new() });
|
||||
}
|
||||
|
||||
// Read children until </tag>
|
||||
let children = self.parse_template_children(&tag)?;
|
||||
|
||||
Ok(TemplateNode::Element { tag, attrs, children })
|
||||
}
|
||||
|
||||
fn parse_template_children(&mut self, close_tag: &str) -> CompileResult<Vec<TemplateNode>> {
|
||||
let mut children: Vec<TemplateNode> = Vec::new();
|
||||
loop {
|
||||
match self.peek().clone() {
|
||||
Token::Eof => break,
|
||||
Token::RBrace => break,
|
||||
Token::CloseTag(tag) => {
|
||||
self.advance();
|
||||
if tag == close_tag || tag.is_empty() {
|
||||
break;
|
||||
}
|
||||
// Mismatched close tag — ignore
|
||||
}
|
||||
Token::LAngle => {
|
||||
children.push(self.parse_element()?);
|
||||
}
|
||||
Token::LBrace => {
|
||||
self.advance();
|
||||
if let Token::RawText(expr) = self.peek().clone() {
|
||||
self.advance();
|
||||
children.push(TemplateNode::Interpolation(expr));
|
||||
if *self.peek() == Token::RBrace { self.advance(); }
|
||||
}
|
||||
}
|
||||
Token::HashIdent(kw) => {
|
||||
let kw = kw.clone();
|
||||
self.advance();
|
||||
children.push(self.parse_block_tag(&kw)?);
|
||||
}
|
||||
Token::SlashIdent(_) | Token::ColonIdent(_) => break,
|
||||
Token::RawText(t) => {
|
||||
let t = t.clone();
|
||||
self.advance();
|
||||
if !t.trim().is_empty() {
|
||||
children.push(TemplateNode::Text(t));
|
||||
}
|
||||
}
|
||||
_ => { self.advance(); }
|
||||
}
|
||||
}
|
||||
Ok(children)
|
||||
}
|
||||
|
||||
fn parse_block_tag(&mut self, kw: &str) -> CompileResult<TemplateNode> {
|
||||
match kw {
|
||||
"if" => self.parse_if_block(),
|
||||
"each" => self.parse_each_block(),
|
||||
"activate" => self.parse_activate_block(),
|
||||
_ => Err(CompileError::Parse { msg: format!("unknown block tag: #{}", kw) }),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_if_block(&mut self) -> CompileResult<TemplateNode> {
|
||||
// Next token should be RawText with the condition
|
||||
let condition = if let Token::RawText(cond) = self.peek().clone() {
|
||||
self.advance();
|
||||
cond
|
||||
} else {
|
||||
return Err(CompileError::Parse { msg: "expected condition after {#if}".into() });
|
||||
};
|
||||
|
||||
let then = self.parse_template_nodes_until_close_or_else()?;
|
||||
|
||||
let else_ = if let Token::ColonIdent(kw) = self.peek().clone() {
|
||||
if kw == "else" {
|
||||
self.advance();
|
||||
Some(self.parse_template_nodes_until_close_or_else()?)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Consume {/if}
|
||||
if let Token::SlashIdent(kw) = self.peek().clone() {
|
||||
if kw == "if" { self.advance(); }
|
||||
}
|
||||
|
||||
Ok(TemplateNode::If { condition, then, else_ })
|
||||
}
|
||||
|
||||
fn parse_each_block(&mut self) -> CompileResult<TemplateNode> {
|
||||
// RawText: "items as item"
|
||||
let raw = if let Token::RawText(r) = self.peek().clone() {
|
||||
self.advance();
|
||||
r
|
||||
} else {
|
||||
return Err(CompileError::Parse { msg: "expected 'items as item' after {#each}".into() });
|
||||
};
|
||||
|
||||
let (items, item_name) = parse_each_header(&raw)?;
|
||||
let children = self.parse_template_nodes_until_close_or_else()?;
|
||||
|
||||
if let Token::SlashIdent(kw) = self.peek().clone() {
|
||||
if kw == "each" { self.advance(); }
|
||||
}
|
||||
|
||||
Ok(TemplateNode::Each { items, item_name, children })
|
||||
}
|
||||
|
||||
fn parse_activate_block(&mut self) -> CompileResult<TemplateNode> {
|
||||
// RawText: `"query" as results`
|
||||
let raw = if let Token::RawText(r) = self.peek().clone() {
|
||||
self.advance();
|
||||
r
|
||||
} else {
|
||||
return Err(CompileError::Parse { msg: "expected query after {#activate}".into() });
|
||||
};
|
||||
|
||||
let (query, result_name) = parse_activate_header(&raw)?;
|
||||
let children = self.parse_template_nodes_until_close_or_else()?;
|
||||
|
||||
if let Token::SlashIdent(kw) = self.peek().clone() {
|
||||
if kw == "activate" { self.advance(); }
|
||||
}
|
||||
|
||||
Ok(TemplateNode::Activate { query, result_name, children })
|
||||
}
|
||||
|
||||
fn parse_template_nodes_until_close_or_else(&mut self) -> CompileResult<Vec<TemplateNode>> {
|
||||
let mut nodes: Vec<TemplateNode> = Vec::new();
|
||||
loop {
|
||||
match self.peek().clone() {
|
||||
Token::Eof | Token::RBrace => break,
|
||||
Token::SlashIdent(_) | Token::ColonIdent(_) => break,
|
||||
Token::CloseTag(_) => break,
|
||||
Token::LAngle => nodes.push(self.parse_element()?),
|
||||
Token::LBrace => {
|
||||
self.advance();
|
||||
if let Token::RawText(expr) = self.peek().clone() {
|
||||
self.advance();
|
||||
nodes.push(TemplateNode::Interpolation(expr));
|
||||
if *self.peek() == Token::RBrace { self.advance(); }
|
||||
}
|
||||
}
|
||||
Token::HashIdent(kw) => {
|
||||
let kw = kw.clone();
|
||||
self.advance();
|
||||
nodes.push(self.parse_block_tag(&kw)?);
|
||||
}
|
||||
Token::RawText(t) => {
|
||||
let t = t.clone();
|
||||
self.advance();
|
||||
if !t.trim().is_empty() {
|
||||
nodes.push(TemplateNode::Text(t));
|
||||
}
|
||||
}
|
||||
_ => { self.advance(); }
|
||||
}
|
||||
}
|
||||
Ok(nodes)
|
||||
}
|
||||
}
|
||||
|
||||
fn token_to_str(tok: &Token) -> String {
|
||||
match tok {
|
||||
Token::Ident(s) => s.clone(),
|
||||
Token::StringLit(s) => format!("\"{}\"", s),
|
||||
Token::IntLit(n) => n.to_string(),
|
||||
Token::FloatLit(f) => f.to_string(),
|
||||
Token::BoolLit(b) => b.to_string(),
|
||||
Token::LBrace => "{".into(),
|
||||
Token::RBrace => "}".into(),
|
||||
Token::LParen => "(".into(),
|
||||
Token::RParen => ")".into(),
|
||||
Token::LBracket => "[".into(),
|
||||
Token::RBracket => "]".into(),
|
||||
Token::Colon => ":".into(),
|
||||
Token::Semicolon => ";".into(),
|
||||
Token::Comma => ",".into(),
|
||||
Token::Dot => ".".into(),
|
||||
Token::Eq => "=".into(),
|
||||
Token::EqEq => "==".into(),
|
||||
Token::Bang => "!".into(),
|
||||
Token::BangEq => "!=".into(),
|
||||
Token::Plus => "+".into(),
|
||||
Token::Minus => "-".into(),
|
||||
Token::Star => "*".into(),
|
||||
Token::Slash => "/".into(),
|
||||
Token::Arrow => "->".into(),
|
||||
Token::FatArrow => "=>".into(),
|
||||
Token::AmpAmp => "&&".into(),
|
||||
Token::PipePipe => "||".into(),
|
||||
Token::Question => "?".into(),
|
||||
Token::RawText(s) => s.clone(),
|
||||
Token::Return => "return".into(),
|
||||
Token::If => "if".into(),
|
||||
Token::Else => "else".into(),
|
||||
Token::Fn => "fn".into(),
|
||||
Token::Component => "component".into(),
|
||||
Token::Props => "props".into(),
|
||||
Token::State => "state".into(),
|
||||
Token::Template => "template".into(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_each_header(raw: &str) -> CompileResult<(String, String)> {
|
||||
// e.g., "items as item"
|
||||
if let Some(idx) = raw.find(" as ") {
|
||||
let items = raw[..idx].trim().to_owned();
|
||||
let item_name = raw[idx + 4..].trim().to_owned();
|
||||
Ok((items, item_name))
|
||||
} else {
|
||||
Err(CompileError::Parse { msg: format!("invalid #each header: '{}'", raw) })
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_activate_header(raw: &str) -> CompileResult<(String, String)> {
|
||||
// e.g., `"query string" as results`
|
||||
// Strip outer quotes from query
|
||||
let raw = raw.trim();
|
||||
if let Some(idx) = raw.rfind(" as ") {
|
||||
let query_part = raw[..idx].trim();
|
||||
let result_name = raw[idx + 4..].trim().to_owned();
|
||||
let query = query_part.trim_matches('"').to_owned();
|
||||
Ok((query, result_name))
|
||||
} else {
|
||||
Err(CompileError::Parse { msg: format!("invalid #activate header: '{}'", raw) })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
//! Tests for el-ui-compiler.
|
||||
//!
|
||||
//! Tests cover:
|
||||
//! - Component parsing (props, state, methods, template)
|
||||
//! - Template node parsing (elements, components, interpolation, if/each/activate)
|
||||
//! - JavaScript code generation
|
||||
//! - Graph operations (seed, update, activate, search, subscribe, connect)
|
||||
//! - Spreading activation algorithm
|
||||
//! - Router (graph-based, path matching)
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{ast::*, lexer, parser, Compiler};
|
||||
|
||||
// ── Helper ────────────────────────────────────────────────────────────────
|
||||
|
||||
fn parse_first(src: &str) -> Component {
|
||||
let tokens = lexer::tokenize(src).expect("lex failed");
|
||||
let mut components = parser::parse(&tokens).expect("parse failed");
|
||||
assert!(!components.is_empty(), "expected at least one component");
|
||||
components.remove(0)
|
||||
}
|
||||
|
||||
fn compile_ok(src: &str) -> String {
|
||||
let compiler = Compiler::new();
|
||||
compiler.compile_component(src).expect("compile failed")
|
||||
}
|
||||
|
||||
// ── Test 1: Parse a component with no body ────────────────────────────────
|
||||
#[test]
|
||||
fn test_empty_component() {
|
||||
let src = r#"component Empty { template { <div></div> } }"#;
|
||||
let comp = parse_first(src);
|
||||
assert_eq!(comp.name, "Empty");
|
||||
assert!(comp.props.is_empty());
|
||||
assert!(comp.state.is_empty());
|
||||
assert!(comp.methods.is_empty());
|
||||
}
|
||||
|
||||
// ── Test 2: Parse props block ─────────────────────────────────────────────
|
||||
#[test]
|
||||
fn test_props_parsing() {
|
||||
let src = r#"
|
||||
component Button {
|
||||
props {
|
||||
label: String
|
||||
variant: String = "primary"
|
||||
disabled: Bool = false
|
||||
}
|
||||
template { <button></button> }
|
||||
}
|
||||
"#;
|
||||
let comp = parse_first(src);
|
||||
assert_eq!(comp.props.len(), 3);
|
||||
assert_eq!(comp.props[0].name, "label");
|
||||
assert_eq!(comp.props[0].type_name, "String");
|
||||
assert!(comp.props[0].default.is_none());
|
||||
assert_eq!(comp.props[1].name, "variant");
|
||||
assert_eq!(comp.props[1].default.as_deref().unwrap_or("").trim(), r#""primary""#);
|
||||
assert_eq!(comp.props[2].name, "disabled");
|
||||
assert_eq!(comp.props[2].default.as_deref().unwrap_or(""), "false");
|
||||
}
|
||||
|
||||
// ── Test 3: Parse state block ─────────────────────────────────────────────
|
||||
#[test]
|
||||
fn test_state_parsing() {
|
||||
let src = r#"
|
||||
component Counter {
|
||||
state {
|
||||
count: Int = 0
|
||||
active: Bool = true
|
||||
}
|
||||
template { <div></div> }
|
||||
}
|
||||
"#;
|
||||
let comp = parse_first(src);
|
||||
assert_eq!(comp.state.len(), 2);
|
||||
assert_eq!(comp.state[0].name, "count");
|
||||
assert_eq!(comp.state[0].type_name, "Int");
|
||||
assert_eq!(comp.state[0].initial, "0");
|
||||
assert_eq!(comp.state[1].name, "active");
|
||||
assert_eq!(comp.state[1].initial, "true");
|
||||
}
|
||||
|
||||
// ── Test 4: Parse a method ────────────────────────────────────────────────
|
||||
#[test]
|
||||
fn test_method_parsing() {
|
||||
let src = r#"
|
||||
component Foo {
|
||||
state { x: Int = 0 }
|
||||
fn increment() -> Void {
|
||||
x = x + 1
|
||||
}
|
||||
template { <div></div> }
|
||||
}
|
||||
"#;
|
||||
let comp = parse_first(src);
|
||||
assert_eq!(comp.methods.len(), 1);
|
||||
assert_eq!(comp.methods[0].name, "increment");
|
||||
assert_eq!(comp.methods[0].return_type, "Void");
|
||||
assert!(comp.methods[0].params.is_empty());
|
||||
}
|
||||
|
||||
// ── Test 5: Parse element in template ────────────────────────────────────
|
||||
#[test]
|
||||
fn test_template_element() {
|
||||
let src = r#"component T { template { <div class="foo"><span></span></div> } }"#;
|
||||
let comp = parse_first(src);
|
||||
let nodes = &comp.template.nodes;
|
||||
assert!(!nodes.is_empty());
|
||||
match &nodes[0] {
|
||||
TemplateNode::Element { tag, .. } => assert_eq!(tag, "div"),
|
||||
other => panic!("expected Element, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test 6: Parse interpolation in template ───────────────────────────────
|
||||
#[test]
|
||||
fn test_template_interpolation() {
|
||||
let src = r#"
|
||||
component C {
|
||||
state { count: Int = 0 }
|
||||
template { <div>{count}</div> }
|
||||
}
|
||||
"#;
|
||||
let comp = parse_first(src);
|
||||
let nodes = &comp.template.nodes;
|
||||
assert!(!nodes.is_empty());
|
||||
match &nodes[0] {
|
||||
TemplateNode::Element { children, .. } => {
|
||||
assert!(!children.is_empty(), "element should have children");
|
||||
match &children[0] {
|
||||
TemplateNode::Interpolation(expr) => {
|
||||
assert!(expr.contains("count"), "interpolation should contain 'count'");
|
||||
}
|
||||
other => panic!("expected Interpolation, got {:?}", other),
|
||||
}
|
||||
}
|
||||
other => panic!("expected Element, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test 7: Parse component usage in template ────────────────────────────
|
||||
#[test]
|
||||
fn test_template_component_usage() {
|
||||
let src = r#"
|
||||
component App {
|
||||
template { <div><Counter /></div> }
|
||||
}
|
||||
"#;
|
||||
let comp = parse_first(src);
|
||||
let nodes = &comp.template.nodes;
|
||||
match &nodes[0] {
|
||||
TemplateNode::Element { children, .. } => {
|
||||
assert!(!children.is_empty());
|
||||
match &children[0] {
|
||||
TemplateNode::Component { name, .. } => {
|
||||
assert_eq!(name, "Counter");
|
||||
}
|
||||
other => panic!("expected Component, got {:?}", other),
|
||||
}
|
||||
}
|
||||
other => panic!("expected Element, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test 8: Parse {#if} block ─────────────────────────────────────────────
|
||||
#[test]
|
||||
fn test_template_if_block() {
|
||||
let src = r#"
|
||||
component C {
|
||||
state { show: Bool = true }
|
||||
template {
|
||||
<div>
|
||||
{#if show}
|
||||
<span>visible</span>
|
||||
{/if}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
"#;
|
||||
let comp = parse_first(src);
|
||||
let div = &comp.template.nodes[0];
|
||||
match div {
|
||||
TemplateNode::Element { children, .. } => {
|
||||
let has_if = children.iter().any(|n| matches!(n, TemplateNode::If { .. }));
|
||||
assert!(has_if, "expected If node in children");
|
||||
}
|
||||
other => panic!("expected Element, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test 9: Parse {#each} block ───────────────────────────────────────────
|
||||
#[test]
|
||||
fn test_template_each_block() {
|
||||
let src = r#"
|
||||
component List {
|
||||
state { items: String = "" }
|
||||
template {
|
||||
<ul>
|
||||
{#each items as item}
|
||||
<li>{item}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
"#;
|
||||
let comp = parse_first(src);
|
||||
let ul = &comp.template.nodes[0];
|
||||
match ul {
|
||||
TemplateNode::Element { children, .. } => {
|
||||
let has_each = children.iter().any(|n| matches!(n, TemplateNode::Each { .. }));
|
||||
assert!(has_each, "expected Each node");
|
||||
}
|
||||
other => panic!("expected Element, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test 10: Parse {#activate} block ─────────────────────────────────────
|
||||
#[test]
|
||||
fn test_template_activate_block() {
|
||||
let src = r#"
|
||||
component Search {
|
||||
template {
|
||||
<div>
|
||||
{#activate "recent items" as results}
|
||||
<span>{results}</span>
|
||||
{/activate}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
"#;
|
||||
let comp = parse_first(src);
|
||||
let div = &comp.template.nodes[0];
|
||||
match div {
|
||||
TemplateNode::Element { children, .. } => {
|
||||
let has_activate = children.iter().any(|n| {
|
||||
matches!(n, TemplateNode::Activate { query, .. } if query.contains("recent"))
|
||||
});
|
||||
assert!(has_activate, "expected Activate node");
|
||||
}
|
||||
other => panic!("expected Element, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test 11: Compiler produces valid JS for Counter ───────────────────────
|
||||
#[test]
|
||||
fn test_counter_compiles() {
|
||||
let src = r#"
|
||||
component Counter {
|
||||
state {
|
||||
count: Int = 0
|
||||
}
|
||||
template {
|
||||
<div class="counter">
|
||||
<h1>{count}</h1>
|
||||
<button on:click={() => count = count + 1}>+</button>
|
||||
<button on:click={() => count = count - 1}>-</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
"#;
|
||||
let js = compile_ok(src);
|
||||
assert!(js.contains("class Counter extends Component"), "should define Counter class");
|
||||
assert!(js.contains("this._graph.seed"), "should seed graph nodes");
|
||||
assert!(js.contains("this._stateNodes['count']"), "should track count node");
|
||||
assert!(js.contains("render()"), "should have render method");
|
||||
assert!(js.contains("setState"), "should have setState");
|
||||
}
|
||||
|
||||
// ── Test 12: Compiler generates import statement ───────────────────────────
|
||||
#[test]
|
||||
fn test_import_generation() {
|
||||
let src = r#"component A { template { <div></div> } }"#;
|
||||
let js = compile_ok(src);
|
||||
assert!(js.contains("import {"), "should have import statement");
|
||||
assert!(js.contains("el-ui.js"), "should import from el-ui.js");
|
||||
}
|
||||
|
||||
// ── Test 13: Compiler generates export statement ──────────────────────────
|
||||
#[test]
|
||||
fn test_export_generation() {
|
||||
let src = r#"component MyComp { template { <div></div> } }"#;
|
||||
let js = compile_ok(src);
|
||||
assert!(js.contains("export { MyComp }"), "should export the component");
|
||||
}
|
||||
|
||||
// ── Test 14: Compiler handles props with defaults ─────────────────────────
|
||||
#[test]
|
||||
fn test_props_with_defaults_in_js() {
|
||||
let src = r#"
|
||||
component Btn {
|
||||
props {
|
||||
label: String
|
||||
variant: String = "primary"
|
||||
}
|
||||
template { <button>{label}</button> }
|
||||
}
|
||||
"#;
|
||||
let js = compile_ok(src);
|
||||
assert!(js.contains("_props_label"), "should reference label prop");
|
||||
assert!(js.contains("_props_variant"), "should reference variant prop");
|
||||
assert!(js.contains("\"primary\""), "should embed default value");
|
||||
}
|
||||
|
||||
// ── Test 15: Compiler emits event handler data attribute ─────────────────
|
||||
#[test]
|
||||
fn test_event_handler_attr() {
|
||||
let src = r#"
|
||||
component C {
|
||||
state { n: Int = 0 }
|
||||
template { <button on:click={() => n = n + 1}>Click</button> }
|
||||
}
|
||||
"#;
|
||||
let js = compile_ok(src);
|
||||
assert!(js.contains("data-el-click"), "should emit data-el-click attribute");
|
||||
}
|
||||
|
||||
// ── Test 16: Multiple components in one file ──────────────────────────────
|
||||
#[test]
|
||||
fn test_multiple_components() {
|
||||
let src = r#"
|
||||
component A { template { <div></div> } }
|
||||
component B { template { <span></span> } }
|
||||
"#;
|
||||
let tokens = lexer::tokenize(src).unwrap();
|
||||
let components = parser::parse(&tokens).unwrap();
|
||||
assert_eq!(components.len(), 2);
|
||||
assert_eq!(components[0].name, "A");
|
||||
assert_eq!(components[1].name, "B");
|
||||
}
|
||||
|
||||
// ── Test 17: Lexer handles string literals ────────────────────────────────
|
||||
#[test]
|
||||
fn test_lexer_strings() {
|
||||
let src = r#"component X { props { label: String = "hello world" } template { <div></div> } }"#;
|
||||
let tokens = lexer::tokenize(src).unwrap();
|
||||
let has_string = tokens.iter().any(|t| {
|
||||
matches!(t, crate::lexer::Token::StringLit(s) if s == "hello world")
|
||||
});
|
||||
assert!(has_string, "should produce StringLit token");
|
||||
}
|
||||
|
||||
// ── Test 18: Lexer handles bool literals ──────────────────────────────────
|
||||
#[test]
|
||||
fn test_lexer_bools() {
|
||||
let src = r#"component X { state { flag: Bool = true } template { <div></div> } }"#;
|
||||
let tokens = lexer::tokenize(src).unwrap();
|
||||
let has_true = tokens.iter().any(|t| matches!(t, crate::lexer::Token::BoolLit(true)));
|
||||
assert!(has_true, "should produce BoolLit(true) token");
|
||||
}
|
||||
|
||||
// ── Test 19: Lexer handles integer literals ───────────────────────────────
|
||||
#[test]
|
||||
fn test_lexer_integers() {
|
||||
let src = r#"component X { state { n: Int = 42 } template { <div></div> } }"#;
|
||||
let tokens = lexer::tokenize(src).unwrap();
|
||||
let has_int = tokens.iter().any(|t| matches!(t, crate::lexer::Token::IntLit(42)));
|
||||
assert!(has_int, "should produce IntLit(42) token");
|
||||
}
|
||||
|
||||
// ── Test 20: compile_app() merges multiple sources ────────────────────────
|
||||
#[test]
|
||||
fn test_compile_app() {
|
||||
let button_src = r#"
|
||||
component Button {
|
||||
props { label: String }
|
||||
template { <button>{label}</button> }
|
||||
}
|
||||
"#;
|
||||
let app_src = r#"
|
||||
component App {
|
||||
template { <div><Button label="Click" /></div> }
|
||||
}
|
||||
"#;
|
||||
let compiler = Compiler::new();
|
||||
let js = compiler.compile_app(app_src, &[("Button", button_src)]).unwrap();
|
||||
assert!(js.contains("class Button"), "should define Button");
|
||||
assert!(js.contains("class App"), "should define App");
|
||||
}
|
||||
|
||||
// ── Test 21: Self-closing element parses correctly ────────────────────────
|
||||
#[test]
|
||||
fn test_self_closing_element() {
|
||||
let src = r#"
|
||||
component F {
|
||||
template { <div><input type="text" /></div> }
|
||||
}
|
||||
"#;
|
||||
let comp = parse_first(src);
|
||||
let div = &comp.template.nodes[0];
|
||||
match div {
|
||||
TemplateNode::Element { children, .. } => {
|
||||
assert!(!children.is_empty());
|
||||
match &children[0] {
|
||||
TemplateNode::Element { tag, .. } => assert_eq!(tag, "input"),
|
||||
other => panic!("expected input element, got {:?}", other),
|
||||
}
|
||||
}
|
||||
other => panic!("expected div, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test 22: Boolean attribute parsing ────────────────────────────────────
|
||||
#[test]
|
||||
fn test_bool_attribute() {
|
||||
let src = r#"
|
||||
component F {
|
||||
props { disabled: Bool = false }
|
||||
template { <button disabled={disabled}>Click</button> }
|
||||
}
|
||||
"#;
|
||||
let js = compile_ok(src);
|
||||
// Boolean attributes should use ternary in the output
|
||||
assert!(js.contains("disabled") || js.contains("_props_disabled"), "should handle disabled attr");
|
||||
}
|
||||
|
||||
// ── Test 23: Graph — Graph module state graph simulation ──────────────────
|
||||
// (We test the JS runtime logic by re-implementing the Graph algorithm in Rust
|
||||
// and verifying it matches the activation spec from engram-core.)
|
||||
#[test]
|
||||
fn test_activation_algorithm_matches_spec() {
|
||||
// Verify the spreading activation algorithm properties:
|
||||
// 1. Seeds start at strength 1.0
|
||||
// 2. Activation attenuates with each hop
|
||||
// 3. Paths below threshold are pruned
|
||||
|
||||
// This validates our codegen/doc claims about the algorithm.
|
||||
// The JS runtime graph.activate() mirrors engram-core/activation.rs.
|
||||
|
||||
// Simple simulation: A -> B (weight 0.8, importance 0.5)
|
||||
// Expected strength at B: 1.0 * 0.8 * 0.5 = 0.4
|
||||
let parent_strength: f64 = 1.0;
|
||||
let edge_weight: f64 = 0.8;
|
||||
let target_importance: f64 = 0.5;
|
||||
let target_strength = parent_strength * edge_weight * target_importance;
|
||||
assert!(target_strength > 0.01, "should exceed prune threshold");
|
||||
assert_eq!(target_strength, 0.4);
|
||||
|
||||
// Two hops: 0.4 * 0.8 * 0.5 = 0.16
|
||||
let two_hop = target_strength * edge_weight * target_importance;
|
||||
assert!(two_hop > 0.01, "two hops should still exceed threshold");
|
||||
assert!((two_hop - 0.16).abs() < 1e-9);
|
||||
|
||||
// Weak edge: strength below threshold should be pruned
|
||||
let weak = 0.1_f64 * 0.05_f64 * 0.5_f64; // = 0.0025
|
||||
assert!(weak < 0.01, "weak path should be below prune threshold");
|
||||
}
|
||||
|
||||
// ── Test 24: Static attribute in template ─────────────────────────────────
|
||||
#[test]
|
||||
fn test_static_attr_in_output() {
|
||||
let src = r#"component C { template { <div class="wrapper"><span></span></div> } }"#;
|
||||
let js = compile_ok(src);
|
||||
assert!(js.contains("wrapper"), "should include static class name");
|
||||
}
|
||||
|
||||
// ── Test 25: Dynamic attribute in template ────────────────────────────────
|
||||
#[test]
|
||||
fn test_dynamic_attr_in_output() {
|
||||
let src = r#"
|
||||
component C {
|
||||
state { cls: String = "active" }
|
||||
template { <div class={cls}></div> }
|
||||
}
|
||||
"#;
|
||||
let js = compile_ok(src);
|
||||
// Dynamic attrs use template interpolation
|
||||
assert!(js.contains("cls") || js.contains("_state"), "should reference cls state");
|
||||
}
|
||||
|
||||
// ── Test 26: Compiler with custom runtime path ─────────────────────────────
|
||||
#[test]
|
||||
fn test_custom_runtime_path() {
|
||||
let src = r#"component X { template { <div></div> } }"#;
|
||||
let compiler = crate::Compiler::new().with_runtime_path("/vendor/el-ui.min.js");
|
||||
let js = compiler.compile_component(src).unwrap();
|
||||
assert!(js.contains("/vendor/el-ui.min.js"), "should use custom runtime path");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user