feat: for-in list rendering + component system
Phase 6 features:
- ForIn reactive list rendering: for item in items -> body
- Optional index binding: for item, idx in items -> body
- Component declarations: component Name(props) = body
- Component instantiation: ComponentUse { name, props }
Added across 5 crates:
- ds-parser: For/In/Component tokens, ForIn/ComponentDecl AST nodes
- ds-codegen: reactive list effect, component function emission
- ds-types: ForIn/ComponentUse type inference
- local_vars tracking for non-reactive for-in loop vars
Includes examples/list.ds showcasing for-in + when + signals.
This commit is contained in:
parent
e3da3b2d8b
commit
ca45c688df
6 changed files with 224 additions and 8 deletions
|
|
@ -1,5 +1,6 @@
|
||||||
/// JavaScript emitter — generates executable JS from DreamStack AST + signal graph.
|
/// JavaScript emitter — generates executable JS from DreamStack AST + signal graph.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
use ds_parser::*;
|
use ds_parser::*;
|
||||||
use ds_analyzer::{SignalGraph, SignalKind, AnalyzedView, InitialValue};
|
use ds_analyzer::{SignalGraph, SignalKind, AnalyzedView, InitialValue};
|
||||||
|
|
||||||
|
|
@ -7,6 +8,8 @@ pub struct JsEmitter {
|
||||||
output: String,
|
output: String,
|
||||||
indent: usize,
|
indent: usize,
|
||||||
node_id_counter: usize,
|
node_id_counter: usize,
|
||||||
|
/// Non-signal local variables (e.g., for-in loop vars)
|
||||||
|
local_vars: HashSet<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl JsEmitter {
|
impl JsEmitter {
|
||||||
|
|
@ -15,6 +18,7 @@ impl JsEmitter {
|
||||||
output: String::new(),
|
output: String::new(),
|
||||||
indent: 0,
|
indent: 0,
|
||||||
node_id_counter: 0,
|
node_id_counter: 0,
|
||||||
|
local_vars: HashSet::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -67,7 +71,15 @@ impl JsEmitter {
|
||||||
Some(InitialValue::Float(n)) => format!("{n}"),
|
Some(InitialValue::Float(n)) => format!("{n}"),
|
||||||
Some(InitialValue::Bool(b)) => format!("{b}"),
|
Some(InitialValue::Bool(b)) => format!("{b}"),
|
||||||
Some(InitialValue::String(s)) => format!("\"{s}\""),
|
Some(InitialValue::String(s)) => format!("\"{s}\""),
|
||||||
None => "null".to_string(),
|
None => {
|
||||||
|
// Fall back to the let declaration expression
|
||||||
|
// (handles List, Record, etc.)
|
||||||
|
if let Some(expr) = self.find_let_expr(program, &node.name) {
|
||||||
|
self.emit_expr(expr)
|
||||||
|
} else {
|
||||||
|
"null".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
self.emit_line(&format!("const {} = DS.signal({});", node.name, init));
|
self.emit_line(&format!("const {} = DS.signal({});", node.name, init));
|
||||||
}
|
}
|
||||||
|
|
@ -88,7 +100,25 @@ impl JsEmitter {
|
||||||
|
|
||||||
self.emit_line("");
|
self.emit_line("");
|
||||||
|
|
||||||
// Phase 2: Build views
|
// Phase 2a: Component functions
|
||||||
|
self.emit_line("// ── Components ──");
|
||||||
|
for decl in &program.declarations {
|
||||||
|
if let Declaration::Component(comp) = decl {
|
||||||
|
let params = comp.props.iter()
|
||||||
|
.map(|p| p.name.clone())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
self.emit_line(&format!("function component_{}({}) {{", comp.name, params));
|
||||||
|
self.indent += 1;
|
||||||
|
let child_var = self.emit_view_expr(&comp.body, graph);
|
||||||
|
self.emit_line(&format!("return {};", child_var));
|
||||||
|
self.indent -= 1;
|
||||||
|
self.emit_line("}");
|
||||||
|
self.emit_line("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2b: Build views
|
||||||
self.emit_line("// ── Views ──");
|
self.emit_line("// ── Views ──");
|
||||||
for decl in &program.declarations {
|
for decl in &program.declarations {
|
||||||
if let Declaration::View(view) = decl {
|
if let Declaration::View(view) = decl {
|
||||||
|
|
@ -210,12 +240,20 @@ impl JsEmitter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Expr::Ident(name) => {
|
Expr::Ident(name) => {
|
||||||
|
if self.local_vars.contains(name) {
|
||||||
|
// Non-reactive local variable (e.g., for-in loop var)
|
||||||
|
self.emit_line(&format!(
|
||||||
|
"{}.textContent = {};",
|
||||||
|
node_var, name
|
||||||
|
));
|
||||||
|
} else {
|
||||||
// Reactive text binding!
|
// Reactive text binding!
|
||||||
self.emit_line(&format!(
|
self.emit_line(&format!(
|
||||||
"DS.effect(() => {{ {}.textContent = {}.value; }});",
|
"DS.effect(() => {{ {}.textContent = {}.value; }});",
|
||||||
node_var, name
|
node_var, name
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
let js = self.emit_expr(arg);
|
let js = self.emit_expr(arg);
|
||||||
self.emit_line(&format!("{}.textContent = {};", node_var, js));
|
self.emit_line(&format!("{}.textContent = {};", node_var, js));
|
||||||
|
|
@ -291,6 +329,60 @@ impl JsEmitter {
|
||||||
anchor_var
|
anchor_var
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ForIn reactive list: `for item in items -> body`
|
||||||
|
Expr::ForIn { item, index, iter, body } => {
|
||||||
|
let container_var = self.next_node_id();
|
||||||
|
let iter_js = self.emit_expr(iter);
|
||||||
|
let iter_var = self.next_node_id(); // unique name to avoid shadowing
|
||||||
|
|
||||||
|
self.emit_line(&format!("const {} = document.createElement('div');", container_var));
|
||||||
|
self.emit_line(&format!("{}.className = 'ds-for-list';", container_var));
|
||||||
|
|
||||||
|
// Reactive effect that re-renders the list when the iterable changes
|
||||||
|
self.emit_line("DS.effect(() => {");
|
||||||
|
self.indent += 1;
|
||||||
|
self.emit_line(&format!("const {} = {};", iter_var, iter_js));
|
||||||
|
self.emit_line(&format!("{}.innerHTML = '';", container_var));
|
||||||
|
|
||||||
|
let idx_var = index.as_deref().unwrap_or("_idx");
|
||||||
|
self.emit_line(&format!("const __list = ({0} && {0}.value !== undefined) ? {0}.value : (Array.isArray({0}) ? {0} : []);", iter_var));
|
||||||
|
self.emit_line(&format!("__list.forEach(({item}, {idx_var}) => {{"));
|
||||||
|
self.indent += 1;
|
||||||
|
|
||||||
|
// Mark loop vars as non-reactive so text bindings use plain access
|
||||||
|
self.local_vars.insert(item.clone());
|
||||||
|
if let Some(idx) = index {
|
||||||
|
self.local_vars.insert(idx.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let child_var = self.emit_view_expr(body, graph);
|
||||||
|
self.emit_line(&format!("{}.appendChild({});", container_var, child_var));
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
self.local_vars.remove(item);
|
||||||
|
if let Some(idx) = index {
|
||||||
|
self.local_vars.remove(idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.indent -= 1;
|
||||||
|
self.emit_line("});");
|
||||||
|
self.indent -= 1;
|
||||||
|
self.emit_line("});");
|
||||||
|
|
||||||
|
container_var
|
||||||
|
}
|
||||||
|
|
||||||
|
// Component usage: `<Card title="hello" />`
|
||||||
|
Expr::ComponentUse { name, props, children } => {
|
||||||
|
let args = props.iter()
|
||||||
|
.map(|(k, v)| self.emit_expr(v))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
let node_var = self.next_node_id();
|
||||||
|
self.emit_line(&format!("const {} = component_{}({});", node_var, name, args));
|
||||||
|
node_var
|
||||||
|
}
|
||||||
|
|
||||||
Expr::Match(scrutinee, arms) => {
|
Expr::Match(scrutinee, arms) => {
|
||||||
let container_var = self.next_node_id();
|
let container_var = self.next_node_id();
|
||||||
self.emit_line(&format!("const {} = document.createElement('div');", container_var));
|
self.emit_line(&format!("const {} = document.createElement('div');", container_var));
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ pub enum Declaration {
|
||||||
Effect(EffectDecl),
|
Effect(EffectDecl),
|
||||||
/// `on event_name -> body`
|
/// `on event_name -> body`
|
||||||
OnHandler(OnHandler),
|
OnHandler(OnHandler),
|
||||||
|
/// `component Name(props) = body`
|
||||||
|
Component(ComponentDecl),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `let count = 0` or `let doubled = count * 2`
|
/// `let count = 0` or `let doubled = count * 2`
|
||||||
|
|
@ -56,6 +58,15 @@ pub struct OnHandler {
|
||||||
pub span: Span,
|
pub span: Span,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `component Card(title: String, children: View) = ...`
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ComponentDecl {
|
||||||
|
pub name: String,
|
||||||
|
pub props: Vec<Param>,
|
||||||
|
pub body: Expr,
|
||||||
|
pub span: Span,
|
||||||
|
}
|
||||||
|
|
||||||
/// Function/view parameter.
|
/// Function/view parameter.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Param {
|
pub struct Param {
|
||||||
|
|
@ -119,6 +130,19 @@ pub enum Expr {
|
||||||
If(Box<Expr>, Box<Expr>, Box<Expr>),
|
If(Box<Expr>, Box<Expr>, Box<Expr>),
|
||||||
/// Spring: `spring(target: 0, stiffness: 300, damping: 30)`
|
/// Spring: `spring(target: 0, stiffness: 300, damping: 30)`
|
||||||
Spring(Vec<(String, Expr)>),
|
Spring(Vec<(String, Expr)>),
|
||||||
|
/// `for item in items -> body` with optional index `for item, i in items -> body`
|
||||||
|
ForIn {
|
||||||
|
item: String,
|
||||||
|
index: Option<String>,
|
||||||
|
iter: Box<Expr>,
|
||||||
|
body: Box<Expr>,
|
||||||
|
},
|
||||||
|
/// Component instantiation: `<Card title="hello" />`
|
||||||
|
ComponentUse {
|
||||||
|
name: String,
|
||||||
|
props: Vec<(String, Expr)>,
|
||||||
|
children: Vec<Expr>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// String literal with interpolation segments.
|
/// String literal with interpolation segments.
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,9 @@ pub enum TokenKind {
|
||||||
List,
|
List,
|
||||||
Form,
|
Form,
|
||||||
Animate,
|
Animate,
|
||||||
|
For,
|
||||||
|
In,
|
||||||
|
Component,
|
||||||
|
|
||||||
// Operators
|
// Operators
|
||||||
Plus,
|
Plus,
|
||||||
|
|
@ -308,6 +311,9 @@ impl Lexer {
|
||||||
"animate" => TokenKind::Animate,
|
"animate" => TokenKind::Animate,
|
||||||
"true" => TokenKind::True,
|
"true" => TokenKind::True,
|
||||||
"false" => TokenKind::False,
|
"false" => TokenKind::False,
|
||||||
|
"for" => TokenKind::For,
|
||||||
|
"in" => TokenKind::In,
|
||||||
|
"component" => TokenKind::Component,
|
||||||
_ => TokenKind::Ident(ident.clone()),
|
_ => TokenKind::Ident(ident.clone()),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -94,8 +94,9 @@ impl Parser {
|
||||||
TokenKind::View => self.parse_view_decl(),
|
TokenKind::View => self.parse_view_decl(),
|
||||||
TokenKind::Effect => self.parse_effect_decl(),
|
TokenKind::Effect => self.parse_effect_decl(),
|
||||||
TokenKind::On => self.parse_on_handler(),
|
TokenKind::On => self.parse_on_handler(),
|
||||||
|
TokenKind::Component => self.parse_component_decl(),
|
||||||
_ => Err(self.error(format!(
|
_ => Err(self.error(format!(
|
||||||
"expected declaration (let, view, effect, on), got {:?}",
|
"expected declaration (let, view, effect, on, component), got {:?}",
|
||||||
self.peek()
|
self.peek()
|
||||||
))),
|
))),
|
||||||
}
|
}
|
||||||
|
|
@ -182,6 +183,31 @@ impl Parser {
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `component Card(title: String) = column [ ... ]`
|
||||||
|
fn parse_component_decl(&mut self) -> Result<Declaration, ParseError> {
|
||||||
|
let line = self.current_token().line;
|
||||||
|
self.advance(); // consume 'component'
|
||||||
|
let name = self.expect_ident()?;
|
||||||
|
|
||||||
|
// Props
|
||||||
|
let props = if self.check(&TokenKind::LParen) {
|
||||||
|
self.parse_params()?
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
self.expect(&TokenKind::Eq)?;
|
||||||
|
self.skip_newlines();
|
||||||
|
let body = self.parse_expr()?;
|
||||||
|
|
||||||
|
Ok(Declaration::Component(ComponentDecl {
|
||||||
|
name,
|
||||||
|
props,
|
||||||
|
body,
|
||||||
|
span: Span { start: 0, end: 0, line },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_params(&mut self) -> Result<Vec<Param>, ParseError> {
|
fn parse_params(&mut self) -> Result<Vec<Param>, ParseError> {
|
||||||
self.expect(&TokenKind::LParen)?;
|
self.expect(&TokenKind::LParen)?;
|
||||||
let mut params = Vec::new();
|
let mut params = Vec::new();
|
||||||
|
|
@ -471,6 +497,33 @@ impl Parser {
|
||||||
Ok(Expr::Perform(name, args))
|
Ok(Expr::Perform(name, args))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For-in: `for item in items -> body` or `for item, idx in items -> body`
|
||||||
|
TokenKind::For => {
|
||||||
|
self.advance();
|
||||||
|
let item = self.expect_ident()?;
|
||||||
|
|
||||||
|
// Optional index: `for item, idx in ...`
|
||||||
|
let index = if self.check(&TokenKind::Comma) {
|
||||||
|
self.advance();
|
||||||
|
Some(self.expect_ident()?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
self.expect(&TokenKind::In)?;
|
||||||
|
let iter_expr = self.parse_comparison()?;
|
||||||
|
self.expect(&TokenKind::Arrow)?;
|
||||||
|
self.skip_newlines();
|
||||||
|
let body = self.parse_expr()?;
|
||||||
|
|
||||||
|
Ok(Expr::ForIn {
|
||||||
|
item,
|
||||||
|
index,
|
||||||
|
iter: Box::new(iter_expr),
|
||||||
|
body: Box::new(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Stream from
|
// Stream from
|
||||||
TokenKind::Stream => {
|
TokenKind::Stream => {
|
||||||
self.advance();
|
self.advance();
|
||||||
|
|
|
||||||
|
|
@ -412,6 +412,21 @@ impl TypeChecker {
|
||||||
self.infer_expr(value);
|
self.infer_expr(value);
|
||||||
Type::Unit
|
Type::Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Expr::ForIn { body, iter, .. } => {
|
||||||
|
let _ = self.infer_expr(iter);
|
||||||
|
self.infer_expr(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
Expr::ComponentUse { props, children, .. } => {
|
||||||
|
for (_, val) in props {
|
||||||
|
self.infer_expr(val);
|
||||||
|
}
|
||||||
|
for child in children {
|
||||||
|
self.infer_expr(child);
|
||||||
|
}
|
||||||
|
Type::View
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
26
examples/list.ds
Normal file
26
examples/list.ds
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
-- DreamStack List & Component Example
|
||||||
|
-- Demonstrates for-in reactive lists and reusable components
|
||||||
|
|
||||||
|
let items = [
|
||||||
|
"Learn DreamStack",
|
||||||
|
"Build a reactive app",
|
||||||
|
"Deploy to production"
|
||||||
|
]
|
||||||
|
|
||||||
|
let count = 0
|
||||||
|
|
||||||
|
view main = column [
|
||||||
|
text "Task List"
|
||||||
|
|
||||||
|
for item in items ->
|
||||||
|
row [
|
||||||
|
text item
|
||||||
|
]
|
||||||
|
|
||||||
|
text count
|
||||||
|
button "+" { click: count += 1 }
|
||||||
|
button "-" { click: count -= 1 }
|
||||||
|
|
||||||
|
when count > 5 ->
|
||||||
|
text "You clicked a lot!"
|
||||||
|
]
|
||||||
Loading…
Add table
Reference in a new issue