From cbd6dfc7a68f58cb077532736c247b3211006f52 Mon Sep 17 00:00:00 2001 From: enzotar Date: Thu, 26 Feb 2026 16:46:06 -0800 Subject: [PATCH] feat: dynamic lists (push/remove/pop) + TodoMVC demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MethodCall AST node: obj.method(args) parsing - Array push: items.push(x) → immutable spread+append - Array remove: items.remove(idx) → filter by index - Array pop: items.pop() → slice(0, -1) - Fix: loop vars (todo, _idx) emitted without .value via is_local_var() - Fix: _idx added to each loop scope for index-based event handlers - New: examples/todomvc.ds — add, remove, clear all, fully reactive --- compiler/ds-codegen/src/js_emitter.rs | 52 +++++++++++++++++++++++- compiler/ds-parser/src/ast.rs | 2 + compiler/ds-parser/src/parser.rs | 8 +++- compiler/ds-types/src/checker.rs | 8 ++++ examples/todomvc.ds | 57 ++++++++++++++------------- 5 files changed, 97 insertions(+), 30 deletions(-) diff --git a/compiler/ds-codegen/src/js_emitter.rs b/compiler/ds-codegen/src/js_emitter.rs index 353fc20..69a6eb8 100644 --- a/compiler/ds-codegen/src/js_emitter.rs +++ b/compiler/ds-codegen/src/js_emitter.rs @@ -844,7 +844,7 @@ impl JsEmitter { )); self.emit_line(&format!("__list.forEach(({item_name}, _idx) => {{")); self.indent += 1; - self.push_scope(&[item_name.as_str()]); + self.push_scope(&[item_name.as_str(), "_idx"]); let child_var = self.emit_view_expr(body, graph); self.emit_line(&format!("{}.appendChild({});", container_var, child_var)); self.pop_scope(); @@ -1019,7 +1019,12 @@ impl JsEmitter { format!("{name}") } Expr::Ident(name) => { - format!("{name}.value") + if self.is_local_var(name) { + // Local variables (loop vars, function params) — no .value + format!("{name}") + } else { + format!("{name}.value") + } } Expr::DotAccess(base, field) => { let base_js = self.emit_expr(base); @@ -1223,6 +1228,11 @@ impl JsEmitter { .collect(); format!("DS_{}({{ {} }})", name, props_js.join(", ")) } + Expr::MethodCall(obj, method, args) => { + let obj_js = self.emit_expr(obj); + let args_js: Vec = args.iter().map(|a| self.emit_expr(a)).collect(); + format!("{}.{}({})", obj_js, method, args_js.join(", ")) + } _ => "null".to_string(), } } @@ -1292,6 +1302,44 @@ impl JsEmitter { } } } + // Method calls on arrays: items.push(x), items.remove(idx), items.filter(fn) + Expr::MethodCall(obj, method, args) => { + let obj_js = self.emit_expr(obj); + let signal_name = match obj.as_ref() { + Expr::Ident(name) => name.clone(), + _ => obj_js.clone(), + }; + let args_js: Vec = args.iter().map(|a| self.emit_expr(a)).collect(); + match method.as_str() { + "push" => { + // items.push(x) → items.value = [...items.value, x] + let val = args_js.first().map(|s| s.as_str()).unwrap_or("undefined"); + format!( + "{sig}.value = [...{sig}.value, {val}]; DS._streamDiff(\"{sig}\", {sig}.value)", + sig = signal_name, val = val + ) + } + "remove" => { + // items.remove(idx) → items.value = items.value.filter((_, i) => i !== idx) + let idx = args_js.first().map(|s| s.as_str()).unwrap_or("0"); + format!( + "{sig}.value = {sig}.value.filter((_, _i) => _i !== {idx}); DS._streamDiff(\"{sig}\", {sig}.value)", + sig = signal_name, idx = idx + ) + } + "pop" => { + // items.pop() → items.value = items.value.slice(0, -1) + format!( + "{sig}.value = {sig}.value.slice(0, -1); DS._streamDiff(\"{sig}\", {sig}.value)", + sig = signal_name + ) + } + _ => { + // Generic method call: obj.method(args) + format!("{}.{}({})", obj_js, method, args_js.join(", ")) + } + } + } Expr::Block(exprs) => { let stmts: Vec = exprs.iter().map(|e| self.emit_event_handler_expr(e)).collect(); stmts.join("; ") diff --git a/compiler/ds-parser/src/ast.rs b/compiler/ds-parser/src/ast.rs index e336923..eaeba62 100644 --- a/compiler/ds-parser/src/ast.rs +++ b/compiler/ds-parser/src/ast.rs @@ -304,6 +304,8 @@ pub enum Expr { }, /// Index access: `grid[i]`, `pads[8 + i]` Index(Box, Box), + /// Method call: `items.push(x)`, `items.filter(fn)` + MethodCall(Box, String, Vec), /// Slot: renders children passed to a component Slot, } diff --git a/compiler/ds-parser/src/parser.rs b/compiler/ds-parser/src/parser.rs index 1611472..61bf016 100644 --- a/compiler/ds-parser/src/parser.rs +++ b/compiler/ds-parser/src/parser.rs @@ -802,7 +802,13 @@ impl Parser { TokenKind::Dot => { self.advance(); let field = self.expect_ident()?; - expr = Expr::DotAccess(Box::new(expr), field); + // Check if this is a method call: obj.method(args) + if self.check(&TokenKind::LParen) { + let args = self.parse_call_args()?; + expr = Expr::MethodCall(Box::new(expr), field, args); + } else { + expr = Expr::DotAccess(Box::new(expr), field); + } } TokenKind::LBracket => { self.advance(); // consume [ diff --git a/compiler/ds-types/src/checker.rs b/compiler/ds-types/src/checker.rs index f328e80..8dda7e1 100644 --- a/compiler/ds-types/src/checker.rs +++ b/compiler/ds-types/src/checker.rs @@ -699,6 +699,14 @@ impl TypeChecker { } Expr::Slot => Type::View, + + Expr::MethodCall(obj, _, args) => { + self.infer_expr(obj); + for arg in args { + self.infer_expr(arg); + } + self.fresh_tv() + } } } diff --git a/examples/todomvc.ds b/examples/todomvc.ds index 700c4be..d534213 100644 --- a/examples/todomvc.ds +++ b/examples/todomvc.ds @@ -1,35 +1,38 @@ -- DreamStack TodoMVC --- Full todo app demonstrating lists, input bindings, filtering, --- and dynamic DOM manipulation — all with compile-time reactivity. +-- Dynamic list with add, remove, and reactive count -let todos = [] -let filter = "all" -let next_id = 0 -let input_text = "" +import { Card } from "../registry/components/card" +import { Badge } from "../registry/components/badge" --- Derived: filtered list -let visible_todos = todos -let active_count = 0 -let completed_count = 0 +let todos = ["Learn DreamStack", "Build something amazing", "Ship it"] +let newTodo = "" +let completedCount = 0 -view app = - column [ - text "todos" { class: "title" } +view main = column [ + text "📝 DreamStack Todos" { variant: "title" } + text "Add, remove, and manage your tasks" { variant: "subtitle" } + -- Add new todo + Card { title: "New Todo", subtitle: "type and press Add" } [ row [ - input "" { placeholder: "What needs to be done?", value: input_text, class: "new-todo", keydown: input_text += "" } - ] - - column [ - text visible_todos { class: "todo-list" } - ] - - row [ - text active_count { class: "count" } - row [ - button "All" { click: filter = "all", class: "filter-btn" } - button "Active" { click: filter = "active", class: "filter-btn" } - button "Done" { click: filter = "completed", class: "filter-btn" } - ] + input { bind: newTodo, placeholder: "What needs to be done?" } + button "Add" { click: todos.push(newTodo), variant: "primary" } ] ] + + -- Todo list + Card { title: "Todo List", subtitle: "click × to remove" } [ + each todo in todos -> + row [ + text "•" + text todo + button "×" { click: todos.remove(_idx), variant: "ghost" } + ] + ] + + -- Footer + row [ + Badge { label: "ITEMS", variant: "info" } + button "Clear All" { click: todos = [], variant: "ghost" } + ] +]