feat: dynamic lists (push/remove/pop) + TodoMVC demo

- 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
This commit is contained in:
enzotar 2026-02-26 16:46:06 -08:00
parent a7af39e900
commit cbd6dfc7a6
5 changed files with 97 additions and 30 deletions

View file

@ -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<String> = 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<String> = 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<String> = exprs.iter().map(|e| self.emit_event_handler_expr(e)).collect();
stmts.join("; ")

View file

@ -304,6 +304,8 @@ pub enum Expr {
},
/// Index access: `grid[i]`, `pads[8 + i]`
Index(Box<Expr>, Box<Expr>),
/// Method call: `items.push(x)`, `items.filter(fn)`
MethodCall(Box<Expr>, String, Vec<Expr>),
/// Slot: renders children passed to a component
Slot,
}

View file

@ -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 [

View file

@ -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()
}
}
}

View file

@ -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" }
]
]