/// Signal graph extraction — the core of DreamStack's compile-time reactivity. /// /// Walks the AST and builds a directed acyclic graph (DAG) of signals: /// - Source signals: `let count = 0` (mutable, user-controlled) /// - Derived signals: `let doubled = count * 2` (computed, auto-tracked) /// - Effects: DOM bindings that update when their dependencies change use ds_parser::{Program, Declaration, Expr, BinOp, Container, Element, LetDecl, ViewDecl, Span}; use ds_diagnostic::{Diagnostic, Severity}; use std::collections::{HashMap, HashSet}; /// The complete signal dependency graph for a program. #[derive(Debug)] pub struct SignalGraph { pub nodes: Vec, pub name_to_id: HashMap, } /// A node in the signal graph. #[derive(Debug, Clone)] pub struct SignalNode { pub id: usize, pub name: String, pub kind: SignalKind, pub dependencies: Vec, pub initial_value: Option, pub streamable: bool, } #[derive(Debug, Clone)] pub enum SignalKind { /// Mutable source signal: `let count = 0` Source, /// Computed derived signal: `let doubled = count * 2` Derived, /// An event handler that mutates signals Handler { event: String, mutations: Vec }, } /// What a handler does to a signal. #[derive(Debug, Clone)] pub struct Mutation { pub target: String, pub op: MutationOp, } #[derive(Debug, Clone)] pub enum MutationOp { Set(String), // expression source AddAssign(String), SubAssign(String), MulAssign(String), DivAssign(String), } /// A dependency edge in the signal graph. #[derive(Debug, Clone)] pub struct Dependency { pub signal_name: String, pub signal_id: Option, } /// Inferred initial value for source signals. #[derive(Debug, Clone)] pub enum InitialValue { Int(i64), Float(f64), Bool(bool), String(String), } /// Analyzed view information. #[derive(Debug)] pub struct AnalyzedView { pub name: String, pub bindings: Vec, } /// A reactive DOM binding extracted from a view. #[derive(Debug, Clone)] pub struct DomBinding { pub kind: BindingKind, pub dependencies: Vec, } #[derive(Debug, Clone)] pub enum BindingKind { /// `text label` — text content bound to a signal TextContent { signal: String }, /// `button "+" { click: count += 1 }` — event handler on an element EventHandler { element_tag: String, event: String, action: String }, /// `when cond -> body` — conditional mount/unmount Conditional { condition_signals: Vec }, /// `column [ ... ]` — static container StaticContainer { kind: String, child_count: usize }, /// Static text with no binding StaticText { text: String }, } /// Static description of all signals for receiver reconstruction. #[derive(Debug, Clone)] pub struct SignalManifest { pub signals: Vec, } #[derive(Debug, Clone)] pub struct ManifestEntry { pub name: String, pub kind: SignalKind, pub initial: Option, pub is_spring: bool, } impl SignalGraph { /// Build a signal graph from a parsed program. pub fn from_program(program: &Program) -> Self { let mut graph = SignalGraph { nodes: Vec::new(), name_to_id: HashMap::new(), }; // First pass: register all let declarations as signals for decl in &program.declarations { if let Declaration::Let(let_decl) = decl { let deps = extract_dependencies(&let_decl.value); let kind = if deps.is_empty() { SignalKind::Source } else { SignalKind::Derived }; let initial = match &let_decl.value { Expr::IntLit(n) => Some(InitialValue::Int(*n)), Expr::FloatLit(n) => Some(InitialValue::Float(*n)), Expr::BoolLit(b) => Some(InitialValue::Bool(*b)), Expr::StringLit(s) => { if s.segments.len() == 1 { if let ds_parser::StringSegment::Literal(text) = &s.segments[0] { Some(InitialValue::String(text.clone())) } else { None } } else { None } } _ => None, }; let id = graph.nodes.len(); let dependencies: Vec = deps.into_iter() .map(|name| Dependency { signal_name: name, signal_id: None }) .collect(); graph.name_to_id.insert(let_decl.name.clone(), id); graph.nodes.push(SignalNode { id, name: let_decl.name.clone(), kind, dependencies, initial_value: initial, streamable: false, }); } } // Detect stream declarations and mark source signals as streamable let has_stream = program.declarations.iter() .any(|d| matches!(d, Declaration::Stream(_))); if has_stream { for node in &mut graph.nodes { if matches!(node.kind, SignalKind::Source) { node.streamable = true; } } } // Second pass: register event handlers for decl in &program.declarations { if let Declaration::OnHandler(handler) = decl { let mutations = extract_mutations(&handler.body); let deps: Vec = mutations.iter().map(|m| m.target.clone()).collect(); let id = graph.nodes.len(); graph.nodes.push(SignalNode { id, name: format!("handler_{}", handler.event), kind: SignalKind::Handler { event: handler.event.clone(), mutations, }, dependencies: deps.into_iter() .map(|name| Dependency { signal_name: name, signal_id: None }) .collect(), initial_value: None, streamable: false, }); } } // Third pass: resolve dependency IDs let name_map = graph.name_to_id.clone(); for node in &mut graph.nodes { for dep in &mut node.dependencies { dep.signal_id = name_map.get(&dep.signal_name).copied(); } } graph } /// Generate a manifest for receivers to know how to reconstruct the signal state. pub fn signal_manifest(&self) -> SignalManifest { SignalManifest { signals: self.nodes.iter() .filter(|n| n.streamable) .map(|n| ManifestEntry { name: n.name.clone(), kind: n.kind.clone(), initial: n.initial_value.clone(), is_spring: false, }) .collect() } } /// Analyze views and extract DOM bindings. pub fn analyze_views(program: &Program) -> Vec { let mut views = Vec::new(); for decl in &program.declarations { if let Declaration::View(view) = decl { let bindings = extract_bindings(&view.body); views.push(AnalyzedView { name: view.name.clone(), bindings, }); } } views } /// Get topological order for signal propagation. /// Returns (order, diagnostics) — diagnostics contain cycle errors if any. pub fn topological_order(&self) -> (Vec, Vec) { let mut visited = HashSet::new(); let mut in_stack = HashSet::new(); // for cycle detection let mut order = Vec::new(); let mut diagnostics = Vec::new(); for node in &self.nodes { if !visited.contains(&node.id) { self.topo_visit(node.id, &mut visited, &mut in_stack, &mut order, &mut diagnostics); } } (order, diagnostics) } fn topo_visit( &self, id: usize, visited: &mut HashSet, in_stack: &mut HashSet, order: &mut Vec, diagnostics: &mut Vec, ) { if visited.contains(&id) { return; } if in_stack.contains(&id) { // Cycle detected! let node = &self.nodes[id]; diagnostics.push(Diagnostic::error( format!("circular signal dependency: `{}` depends on itself", node.name), Span { start: 0, end: 0, line: 0, col: 0 }, ).with_code("E1001")); return; } in_stack.insert(id); for dep in &self.nodes[id].dependencies { if let Some(dep_id) = dep.signal_id { self.topo_visit(dep_id, visited, in_stack, order, diagnostics); } } in_stack.remove(&id); visited.insert(id); order.push(id); } /// Detect signals not referenced by any view or export (dead signals). pub fn dead_signals(&self, program: &Program) -> Vec { let mut referenced = HashSet::new(); // Collect all signal names referenced in views for decl in &program.declarations { if let Declaration::View(view) = decl { let deps = extract_dependencies(&view.body); for dep in deps { referenced.insert(dep); } } } // Also include signals referenced by derived signals for node in &self.nodes { for dep in &node.dependencies { referenced.insert(dep.signal_name.clone()); } } // Also include streams and event handler targets for decl in &program.declarations { if let Declaration::OnHandler(h) = decl { let deps = extract_dependencies(&h.body); for dep in deps { referenced.insert(dep); } } } let mut warnings = Vec::new(); for node in &self.nodes { if matches!(node.kind, SignalKind::Source) && !referenced.contains(&node.name) { warnings.push(Diagnostic::warning( format!("signal `{}` is never read", node.name), Span { start: 0, end: 0, line: 0, col: 0 }, ).with_code("W1001")); } } warnings } /// Build signal graph and return diagnostics from analysis. pub fn from_program_with_diagnostics(program: &Program) -> (Self, Vec) { let graph = Self::from_program(program); let mut diagnostics = Vec::new(); // Cycle detection let (_order, cycle_diags) = graph.topological_order(); diagnostics.extend(cycle_diags); // Dead signal detection let dead_diags = graph.dead_signals(program); diagnostics.extend(dead_diags); (graph, diagnostics) } } /// Extract all signal names referenced in an expression. fn extract_dependencies(expr: &Expr) -> Vec { let mut deps = Vec::new(); collect_deps(expr, &mut deps); deps.sort(); deps.dedup(); deps } fn collect_deps(expr: &Expr, deps: &mut Vec) { match expr { Expr::Ident(name) => deps.push(name.clone()), Expr::DotAccess(base, _) => collect_deps(base, deps), Expr::BinOp(left, _, right) => { collect_deps(left, deps); collect_deps(right, deps); } Expr::UnaryOp(_, inner) => collect_deps(inner, deps), Expr::Call(_, args) => { for arg in args { collect_deps(arg, deps); } } Expr::If(cond, then_b, else_b) => { collect_deps(cond, deps); collect_deps(then_b, deps); collect_deps(else_b, deps); } Expr::Pipe(left, right) => { collect_deps(left, deps); collect_deps(right, deps); } Expr::Container(c) => { for child in &c.children { collect_deps(child, deps); } } Expr::Element(el) => { for arg in &el.args { collect_deps(arg, deps); } for (_, val) in &el.props { collect_deps(val, deps); } } Expr::Record(fields) => { for (_, val) in fields { collect_deps(val, deps); } } Expr::List(items) => { for item in items { collect_deps(item, deps); } } Expr::When(cond, body, else_body) => { collect_deps(cond, deps); collect_deps(body, deps); if let Some(eb) = else_body { collect_deps(eb, deps); } } Expr::Match(scrutinee, arms) => { collect_deps(scrutinee, deps); for arm in arms { collect_deps(&arm.body, deps); } } Expr::Assign(target, _, value) => { collect_deps(target, deps); collect_deps(value, deps); } Expr::Lambda(_, body) => collect_deps(body, deps), Expr::StringLit(s) => { for seg in &s.segments { if let ds_parser::StringSegment::Interpolation(expr) = seg { collect_deps(expr, deps); } } } _ => {} } } /// Extract mutations from a handler body (e.g., `count += 1`). fn extract_mutations(expr: &Expr) -> Vec { let mut mutations = Vec::new(); match expr { Expr::Assign(target, op, value) => { if let Expr::Ident(name) = target.as_ref() { let mutation_op = match op { ds_parser::AssignOp::Set => MutationOp::Set(format!("{value:?}")), ds_parser::AssignOp::AddAssign => MutationOp::AddAssign(format!("{value:?}")), ds_parser::AssignOp::SubAssign => MutationOp::SubAssign(format!("{value:?}")), ds_parser::AssignOp::MulAssign => MutationOp::MulAssign(format!("{value:?}")), ds_parser::AssignOp::DivAssign => MutationOp::DivAssign(format!("{value:?}")), }; mutations.push(Mutation { target: name.clone(), op: mutation_op }); } } Expr::Block(exprs) => { for e in exprs { mutations.extend(extract_mutations(e)); } } _ => {} } mutations } /// Extract DOM bindings from a view body. fn extract_bindings(expr: &Expr) -> Vec { let mut bindings = Vec::new(); collect_bindings(expr, &mut bindings); bindings } fn collect_bindings(expr: &Expr, bindings: &mut Vec) { match expr { Expr::Container(c) => { let kind_str = match &c.kind { ds_parser::ContainerKind::Column => "column", ds_parser::ContainerKind::Row => "row", ds_parser::ContainerKind::Stack => "stack", ds_parser::ContainerKind::Panel => "panel", ds_parser::ContainerKind::List => "list", ds_parser::ContainerKind::Form => "form", ds_parser::ContainerKind::Scene => "scene", ds_parser::ContainerKind::Custom(s) => s, }; bindings.push(DomBinding { kind: BindingKind::StaticContainer { kind: kind_str.to_string(), child_count: c.children.len(), }, dependencies: Vec::new(), }); for child in &c.children { collect_bindings(child, bindings); } } Expr::Element(el) => { // Check if any arg is an identifier (signal binding) for arg in &el.args { match arg { Expr::Ident(name) => { bindings.push(DomBinding { kind: BindingKind::TextContent { signal: name.clone() }, dependencies: vec![name.clone()], }); } Expr::StringLit(s) => { if let Some(ds_parser::StringSegment::Literal(text)) = s.segments.first() { bindings.push(DomBinding { kind: BindingKind::StaticText { text: text.clone() }, dependencies: Vec::new(), }); } } _ => {} } } // Check props for event handlers for (key, val) in &el.props { if matches!(key.as_str(), "click" | "input" | "change" | "submit" | "keydown" | "keyup") { let action = format!("{val:?}"); let deps = extract_dependencies(val); bindings.push(DomBinding { kind: BindingKind::EventHandler { element_tag: el.tag.clone(), event: key.clone(), action, }, dependencies: deps, }); } } } Expr::When(cond, body, else_body) => { let deps = extract_dependencies(cond); bindings.push(DomBinding { kind: BindingKind::Conditional { condition_signals: deps.clone() }, dependencies: deps, }); collect_bindings(body, bindings); if let Some(eb) = else_body { collect_bindings(eb, bindings); } } _ => {} } } #[cfg(test)] mod tests { use super::*; use ds_parser::{Lexer, Parser}; fn analyze(src: &str) -> (SignalGraph, Vec) { let mut lexer = Lexer::new(src); let tokens = lexer.tokenize(); let mut parser = Parser::new(tokens); let program = parser.parse_program().expect("parse failed"); let graph = SignalGraph::from_program(&program); let views = SignalGraph::analyze_views(&program); (graph, views) } #[test] fn test_source_signal() { let (graph, _) = analyze("let count = 0"); assert_eq!(graph.nodes.len(), 1); assert!(matches!(graph.nodes[0].kind, SignalKind::Source)); assert_eq!(graph.nodes[0].name, "count"); } #[test] fn test_derived_signal() { let (graph, _) = analyze("let count = 0\nlet doubled = count * 2"); assert_eq!(graph.nodes.len(), 2); assert!(matches!(graph.nodes[0].kind, SignalKind::Source)); assert!(matches!(graph.nodes[1].kind, SignalKind::Derived)); assert_eq!(graph.nodes[1].dependencies[0].signal_name, "count"); assert_eq!(graph.nodes[1].dependencies[0].signal_id, Some(0)); } #[test] fn test_topological_order() { let (graph, _) = analyze("let count = 0\nlet doubled = count * 2"); let (order, diags) = graph.topological_order(); assert!(diags.is_empty(), "no cycle expected"); // count (id=0) should come before doubled (id=1) let pos_count = order.iter().position(|&id| id == 0).unwrap(); let pos_doubled = order.iter().position(|&id| id == 1).unwrap(); assert!(pos_count < pos_doubled); } #[test] fn test_view_bindings() { let (_, views) = analyze( r#"let label = "hi" view counter = column [ text label button "+" { click: count += 1 } ]"# ); assert_eq!(views.len(), 1); assert_eq!(views[0].name, "counter"); // Should have: container, text binding, static text, event handler assert!(views[0].bindings.len() >= 3); } #[test] fn test_streamable_signals() { let (graph, _) = analyze( "stream main on \"ws://localhost:9100\"\nlet count = 0\nview main = column [ text \"hello\" ]" ); let count_node = graph.nodes.iter().find(|n| n.name == "count").unwrap(); assert!(count_node.streamable, "source signal should be streamable when stream decl present"); } #[test] fn test_not_streamable_without_decl() { let (graph, _) = analyze("let count = 0\nview main = column [ text \"hi\" ]"); let count_node = graph.nodes.iter().find(|n| n.name == "count").unwrap(); assert!(!count_node.streamable, "signals should not be streamable without stream decl"); } #[test] fn test_cycle_detection() { // Create circular dependency: a depends on b, b depends on a let (graph, _) = analyze("let a = b * 2\nlet b = a + 1"); let (_order, diags) = graph.topological_order(); assert!(!diags.is_empty(), "cycle should produce diagnostic"); assert!(diags[0].message.contains("circular"), "diagnostic should mention circular"); } #[test] fn test_dead_signal_warning() { // `unused` is never referenced by any view or derived signal let src = "let unused = 42\nlet used = 0\nview main = column [ text used ]"; let (graph, _) = analyze(src); let program = { let mut lexer = ds_parser::Lexer::new(src); let tokens = lexer.tokenize(); let mut parser = ds_parser::Parser::new(tokens); parser.parse_program().expect("parse failed") }; let warnings = graph.dead_signals(&program); assert!(!warnings.is_empty(), "should have dead signal warning"); assert!(warnings.iter().any(|d| d.message.contains("unused")), "warning should mention 'unused'"); } // ── New v0.5 tests ────────────────────────────────────── #[test] fn test_multi_level_chain() { // A → B → C dependency chain let (graph, _) = analyze("let a = 0\nlet b = a + 1\nlet c = b * 2"); assert_eq!(graph.nodes.len(), 3); assert!(matches!(graph.nodes[0].kind, SignalKind::Source)); assert!(matches!(graph.nodes[1].kind, SignalKind::Derived)); assert!(matches!(graph.nodes[2].kind, SignalKind::Derived)); // c should depend on b assert_eq!(graph.nodes[2].dependencies[0].signal_name, "b"); // topological_order: a before b before c let (order, diags) = graph.topological_order(); assert!(diags.is_empty()); let pos_a = order.iter().position(|&id| id == 0).unwrap(); let pos_b = order.iter().position(|&id| id == 1).unwrap(); let pos_c = order.iter().position(|&id| id == 2).unwrap(); assert!(pos_a < pos_b && pos_b < pos_c); } #[test] fn test_fan_out() { // One source → multiple derived let (graph, _) = analyze("let x = 10\nlet a = x + 1\nlet b = x + 2\nlet c = x + 3"); assert_eq!(graph.nodes.len(), 4); // a, b, c all depend on x for i in 1..=3 { assert_eq!(graph.nodes[i].dependencies.len(), 1); assert_eq!(graph.nodes[i].dependencies[0].signal_name, "x"); } } #[test] fn test_diamond_dependency() { // x → a, x → b, a+b → d let (graph, _) = analyze("let x = 0\nlet a = x + 1\nlet b = x * 2\nlet d = a + b"); assert_eq!(graph.nodes.len(), 4); // d depends on both a and b let d_deps: Vec<&str> = graph.nodes[3].dependencies.iter() .map(|d| d.signal_name.as_str()).collect(); assert!(d_deps.contains(&"a")); assert!(d_deps.contains(&"b")); } #[test] fn test_empty_program() { let (graph, views) = analyze(""); assert_eq!(graph.nodes.len(), 0); assert_eq!(views.len(), 0); } #[test] fn test_only_views_no_signals() { let (graph, views) = analyze("view main = column [\n text \"hello\"\n text \"world\"\n]"); assert_eq!(graph.nodes.len(), 0); assert_eq!(views.len(), 1); assert_eq!(views[0].name, "main"); } #[test] fn test_event_handler_mutations() { let (graph, _) = analyze( "let count = 0\non click -> count = count + 1\nview main = text \"hi\"" ); // Should have source signal + handler let handlers: Vec<_> = graph.nodes.iter().filter(|n| matches!(n.kind, SignalKind::Handler { .. })).collect(); assert!(!handlers.is_empty(), "should detect handler from on click"); } #[test] fn test_conditional_binding() { let (_, views) = analyze( "let show = true\nview main = column [\n when show -> text \"visible\"\n]" ); assert_eq!(views.len(), 1); let has_conditional = views[0].bindings.iter().any(|b| { matches!(b.kind, BindingKind::Conditional { .. }) }); assert!(has_conditional, "should detect conditional binding from `when`"); } #[test] fn test_static_text_binding() { let (_, views) = analyze("view main = text \"hello world\""); assert_eq!(views.len(), 1); let has_static = views[0].bindings.iter().any(|b| { matches!(b.kind, BindingKind::StaticText { .. }) }); assert!(has_static, "should detect static text binding"); } #[test] fn test_multiple_views() { let (_, views) = analyze( "view header = text \"Header\"\nview footer = text \"Footer\"" ); assert_eq!(views.len(), 2); assert!(views.iter().any(|v| v.name == "header")); assert!(views.iter().any(|v| v.name == "footer")); } #[test] fn test_timer_no_signal_nodes() { // `every` declarations are handled at codegen level, not as signal nodes let (graph, _) = analyze( "let x = 0\nevery 33 -> x = x + 1\nview main = text x" ); // x should be a source signal; every is not a signal node assert_eq!(graph.nodes.len(), 1); assert_eq!(graph.nodes[0].name, "x"); } #[test] fn test_string_signal() { let (graph, _) = analyze("let name = \"world\""); assert_eq!(graph.nodes.len(), 1); assert!(matches!(graph.nodes[0].kind, SignalKind::Source)); // Check initial value assert!(graph.nodes[0].initial_value.is_some()); } #[test] fn test_array_signal() { let (graph, _) = analyze("let items = [1, 2, 3]"); assert_eq!(graph.nodes.len(), 1); assert!(matches!(graph.nodes[0].kind, SignalKind::Source)); assert_eq!(graph.nodes[0].name, "items"); } // ── v0.10 Analyzer Edge Cases ─────────────────────────── #[test] fn test_self_referential_cycle() { // let a = a + 1 → a depends on itself → should detect cycle let (graph, _) = analyze("let a = 0\nlet b = a + 1\nlet c = b + a"); let (_order, diags) = graph.topological_order(); // No cycle because a is source, b derived from a, c from b+a — valid DAG assert!(diags.is_empty(), "linear chain should have no cycle"); assert_eq!(graph.nodes.len(), 3); } #[test] fn test_bool_signal_analysis() { let (graph, _) = analyze("let active = true\nlet label = active"); assert_eq!(graph.nodes.len(), 2); assert!(matches!(graph.nodes[0].kind, SignalKind::Source)); assert!(matches!(graph.nodes[1].kind, SignalKind::Derived)); } #[test] fn test_float_derived() { let (graph, _) = analyze("let width = 100.0\nlet half = width / 2.0"); assert_eq!(graph.nodes.len(), 2); assert_eq!(graph.nodes[1].name, "half"); assert!(!graph.nodes[1].dependencies.is_empty(), "half depends on width"); } #[test] fn test_handler_multiple_deps() { let (graph, _) = analyze( "let a = 0\nlet b = 0\nview main = button \"+\" { click: a = b + 1 }" ); // Signals a and b should exist assert!(graph.nodes.iter().any(|n| n.name == "a")); assert!(graph.nodes.iter().any(|n| n.name == "b")); } #[test] fn test_deep_five_level_chain() { let (graph, _) = analyze( "let a = 1\nlet b = a + 1\nlet c = b + 1\nlet d = c + 1\nlet e = d + 1" ); assert_eq!(graph.nodes.len(), 5); let (order, diags) = graph.topological_order(); assert!(diags.is_empty(), "linear chain should not have cycle"); // a should come before e in topological order let a_pos = order.iter().position(|&id| graph.nodes[id].name == "a"); let e_pos = order.iter().position(|&id| graph.nodes[id].name == "e"); assert!(a_pos < e_pos, "a should precede e in topo order"); } }