From ca45c688df2679fa03ae6fe5fc822e820c8d8fab Mon Sep 17 00:00:00 2001 From: enzotar Date: Wed, 25 Feb 2026 01:33:28 -0800 Subject: [PATCH] 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. --- compiler/ds-codegen/src/js_emitter.rs | 106 ++++++++++++++++++++++++-- compiler/ds-parser/src/ast.rs | 24 ++++++ compiler/ds-parser/src/lexer.rs | 6 ++ compiler/ds-parser/src/parser.rs | 55 ++++++++++++- compiler/ds-types/src/checker.rs | 15 ++++ examples/list.ds | 26 +++++++ 6 files changed, 224 insertions(+), 8 deletions(-) create mode 100644 examples/list.ds diff --git a/compiler/ds-codegen/src/js_emitter.rs b/compiler/ds-codegen/src/js_emitter.rs index becacef..5794976 100644 --- a/compiler/ds-codegen/src/js_emitter.rs +++ b/compiler/ds-codegen/src/js_emitter.rs @@ -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, } 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::>() + .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: `` + Expr::ComponentUse { name, props, children } => { + let args = props.iter() + .map(|(k, v)| self.emit_expr(v)) + .collect::>() + .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)); diff --git a/compiler/ds-parser/src/ast.rs b/compiler/ds-parser/src/ast.rs index ee701f7..fba59b2 100644 --- a/compiler/ds-parser/src/ast.rs +++ b/compiler/ds-parser/src/ast.rs @@ -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, + pub body: Expr, + pub span: Span, +} + /// Function/view parameter. #[derive(Debug, Clone)] pub struct Param { @@ -119,6 +130,19 @@ pub enum Expr { If(Box, Box, Box), /// 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, + iter: Box, + body: Box, + }, + /// Component instantiation: `` + ComponentUse { + name: String, + props: Vec<(String, Expr)>, + children: Vec, + }, } /// String literal with interpolation segments. diff --git a/compiler/ds-parser/src/lexer.rs b/compiler/ds-parser/src/lexer.rs index a194b06..3c675fb 100644 --- a/compiler/ds-parser/src/lexer.rs +++ b/compiler/ds-parser/src/lexer.rs @@ -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()), }; diff --git a/compiler/ds-parser/src/parser.rs b/compiler/ds-parser/src/parser.rs index 4571cc8..cc181a2 100644 --- a/compiler/ds-parser/src/parser.rs +++ b/compiler/ds-parser/src/parser.rs @@ -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 { + 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, 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(); diff --git a/compiler/ds-types/src/checker.rs b/compiler/ds-types/src/checker.rs index 1d69206..6ad1a04 100644 --- a/compiler/ds-types/src/checker.rs +++ b/compiler/ds-types/src/checker.rs @@ -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 + } } } diff --git a/examples/list.ds b/examples/list.ds new file mode 100644 index 0000000..3e9488d --- /dev/null +++ b/examples/list.ds @@ -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!" +]