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:
enzotar 2026-02-25 01:33:28 -08:00
parent e3da3b2d8b
commit ca45c688df
6 changed files with 224 additions and 8 deletions

View file

@ -1,5 +1,6 @@
/// JavaScript emitter — generates executable JS from DreamStack AST + signal graph.
use std::collections::HashSet;
use ds_parser::*;
use ds_analyzer::{SignalGraph, SignalKind, AnalyzedView, InitialValue};
@ -7,6 +8,8 @@ pub struct JsEmitter {
output: String,
indent: usize,
node_id_counter: usize,
/// Non-signal local variables (e.g., for-in loop vars)
local_vars: HashSet<String>,
}
impl JsEmitter {
@ -15,6 +18,7 @@ impl JsEmitter {
output: String::new(),
indent: 0,
node_id_counter: 0,
local_vars: HashSet::new(),
}
}
@ -67,7 +71,15 @@ impl JsEmitter {
Some(InitialValue::Float(n)) => format!("{n}"),
Some(InitialValue::Bool(b)) => format!("{b}"),
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));
}
@ -88,7 +100,25 @@ impl JsEmitter {
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 ──");
for decl in &program.declarations {
if let Declaration::View(view) = decl {
@ -210,11 +240,19 @@ impl JsEmitter {
}
}
Expr::Ident(name) => {
// Reactive text binding!
self.emit_line(&format!(
"DS.effect(() => {{ {}.textContent = {}.value; }});",
node_var, 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!
self.emit_line(&format!(
"DS.effect(() => {{ {}.textContent = {}.value; }});",
node_var, name
));
}
}
_ => {
let js = self.emit_expr(arg);
@ -291,6 +329,60 @@ impl JsEmitter {
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) => {
let container_var = self.next_node_id();
self.emit_line(&format!("const {} = document.createElement('div');", container_var));

View file

@ -18,6 +18,8 @@ pub enum Declaration {
Effect(EffectDecl),
/// `on event_name -> body`
OnHandler(OnHandler),
/// `component Name(props) = body`
Component(ComponentDecl),
}
/// `let count = 0` or `let doubled = count * 2`
@ -56,6 +58,15 @@ pub struct OnHandler {
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.
#[derive(Debug, Clone)]
pub struct Param {
@ -119,6 +130,19 @@ pub enum Expr {
If(Box<Expr>, Box<Expr>, Box<Expr>),
/// Spring: `spring(target: 0, stiffness: 300, damping: 30)`
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.

View file

@ -44,6 +44,9 @@ pub enum TokenKind {
List,
Form,
Animate,
For,
In,
Component,
// Operators
Plus,
@ -308,6 +311,9 @@ impl Lexer {
"animate" => TokenKind::Animate,
"true" => TokenKind::True,
"false" => TokenKind::False,
"for" => TokenKind::For,
"in" => TokenKind::In,
"component" => TokenKind::Component,
_ => TokenKind::Ident(ident.clone()),
};

View file

@ -94,8 +94,9 @@ impl Parser {
TokenKind::View => self.parse_view_decl(),
TokenKind::Effect => self.parse_effect_decl(),
TokenKind::On => self.parse_on_handler(),
TokenKind::Component => self.parse_component_decl(),
_ => Err(self.error(format!(
"expected declaration (let, view, effect, on), got {:?}",
"expected declaration (let, view, effect, on, component), got {:?}",
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> {
self.expect(&TokenKind::LParen)?;
let mut params = Vec::new();
@ -471,6 +497,33 @@ impl Parser {
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
TokenKind::Stream => {
self.advance();

View file

@ -412,6 +412,21 @@ impl TypeChecker {
self.infer_expr(value);
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
View 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!"
]