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