diff --git a/Cargo.toml b/Cargo.toml index b67afce..f3fdc53 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ resolver = "2" members = [ "compiler/ds-parser", + "compiler/ds-diagnostic", "compiler/ds-analyzer", "compiler/ds-codegen", "compiler/ds-layout", @@ -21,6 +22,7 @@ license = "" [workspace.dependencies] ds-parser = { path = "compiler/ds-parser" } +ds-diagnostic = { path = "compiler/ds-diagnostic" } ds-analyzer = { path = "compiler/ds-analyzer" } ds-codegen = { path = "compiler/ds-codegen" } ds-layout = { path = "compiler/ds-layout" } diff --git a/compiler/ds-analyzer/Cargo.toml b/compiler/ds-analyzer/Cargo.toml index 26aedfc..4bad253 100644 --- a/compiler/ds-analyzer/Cargo.toml +++ b/compiler/ds-analyzer/Cargo.toml @@ -5,3 +5,4 @@ edition.workspace = true [dependencies] ds-parser = { workspace = true } +ds-diagnostic = { workspace = true } diff --git a/compiler/ds-analyzer/src/signal_graph.rs b/compiler/ds-analyzer/src/signal_graph.rs index 47ac690..f8362de 100644 --- a/compiler/ds-analyzer/src/signal_graph.rs +++ b/compiler/ds-analyzer/src/signal_graph.rs @@ -5,7 +5,8 @@ /// - 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}; +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. @@ -239,33 +240,114 @@ impl SignalGraph { } /// Get topological order for signal propagation. - pub fn topological_order(&self) -> Vec { + /// 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 order); + self.topo_visit(node.id, &mut visited, &mut in_stack, &mut order, &mut diagnostics); } } - order + (order, diagnostics) } - fn topo_visit(&self, id: usize, visited: &mut HashSet, order: &mut Vec) { + fn topo_visit( + &self, + id: usize, + visited: &mut HashSet, + in_stack: &mut HashSet, + order: &mut Vec, + diagnostics: &mut Vec, + ) { if visited.contains(&id) { return; } - visited.insert(id); + 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, order); + 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. @@ -497,7 +579,8 @@ mod tests { #[test] fn test_topological_order() { let (graph, _) = analyze("let count = 0\nlet doubled = count * 2"); - let order = graph.topological_order(); + 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(); @@ -536,4 +619,161 @@ view counter = 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"); + } } + diff --git a/compiler/ds-cli/Cargo.toml b/compiler/ds-cli/Cargo.toml index 365f9e4..896540f 100644 --- a/compiler/ds-cli/Cargo.toml +++ b/compiler/ds-cli/Cargo.toml @@ -12,6 +12,8 @@ ds-parser = { workspace = true } ds-analyzer = { workspace = true } ds-codegen = { workspace = true } ds-incremental = { workspace = true } +ds-diagnostic = { workspace = true } +ds-types = { workspace = true } clap = { version = "4", features = ["derive"] } notify = "8" tiny_http = "0.12" diff --git a/compiler/ds-cli/src/commands/add.rs b/compiler/ds-cli/src/commands/add.rs new file mode 100644 index 0000000..aef9c78 --- /dev/null +++ b/compiler/ds-cli/src/commands/add.rs @@ -0,0 +1,156 @@ +/// Add command — install components from the DreamStack registry. + +use std::fs; +use std::path::Path; + +struct RegistryItem { + name: &'static str, + description: &'static str, + source: &'static str, + deps: &'static [&'static str], +} + +const REGISTRY: &[RegistryItem] = &[ + RegistryItem { + name: "button", + description: "Styled button with variant support (primary, secondary, ghost, destructive)", + source: include_str!("../../../../registry/components/button.ds"), + deps: &[], + }, + RegistryItem { + name: "input", + description: "Text input with label, placeholder, and error state", + source: include_str!("../../../../registry/components/input.ds"), + deps: &[], + }, + RegistryItem { + name: "card", + description: "Content container with title and styled border", + source: include_str!("../../../../registry/components/card.ds"), + deps: &[], + }, + RegistryItem { + name: "badge", + description: "Status badge with color variants", + source: include_str!("../../../../registry/components/badge.ds"), + deps: &[], + }, + RegistryItem { + name: "dialog", + description: "Modal dialog with overlay and close button", + source: include_str!("../../../../registry/components/dialog.ds"), + deps: &["button"], + }, + RegistryItem { + name: "toast", + description: "Notification toast with auto-dismiss", + source: include_str!("../../../../registry/components/toast.ds"), + deps: &[], + }, + RegistryItem { + name: "progress", + description: "Animated progress bar with percentage", + source: include_str!("../../../../registry/components/progress.ds"), + deps: &[], + }, + RegistryItem { + name: "alert", + description: "Alert banner with info/warning/error/success variants", + source: include_str!("../../../../registry/components/alert.ds"), + deps: &[], + }, + RegistryItem { + name: "separator", + description: "Visual divider between content sections", + source: include_str!("../../../../registry/components/separator.ds"), + deps: &[], + }, + RegistryItem { + name: "toggle", + description: "On/off switch toggle", + source: include_str!("../../../../registry/components/toggle.ds"), + deps: &[], + }, + RegistryItem { + name: "avatar", + description: "User avatar with initials fallback", + source: include_str!("../../../../registry/components/avatar.ds"), + deps: &[], + }, +]; + +pub fn cmd_add(name: Option, list: bool, all: bool) { + if list { + println!("📦 Available DreamStack components:\n"); + for item in REGISTRY { + let deps = if item.deps.is_empty() { + String::new() + } else { + format!(" (deps: {})", item.deps.join(", ")) + }; + println!(" {} — {}{}", item.name, item.description, deps); + } + println!("\n Use: dreamstack add "); + return; + } + + let components_dir = Path::new("components"); + if !components_dir.exists() { + fs::create_dir_all(components_dir).expect("Failed to create components/ directory"); + } + + let names_to_add: Vec = if all { + REGISTRY.iter().map(|r| r.name.to_string()).collect() + } else if let Some(name) = name { + vec![name] + } else { + println!("Usage: dreamstack add \n dreamstack add --list\n dreamstack add --all"); + return; + }; + + let mut added = std::collections::HashSet::new(); + for name in &names_to_add { + add_component(name, components_dir, &mut added); + } + + if added.is_empty() { + println!("❌ No components found. Use 'dreamstack add --list' to see available."); + } +} + +fn add_component(name: &str, dest: &Path, added: &mut std::collections::HashSet) { + if added.contains(name) { + return; + } + + let item = match REGISTRY.iter().find(|r| r.name == name) { + Some(item) => item, + None => { + println!(" ❌ Unknown component: {name}"); + return; + } + }; + + // Add dependencies first + for dep in item.deps { + add_component(dep, dest, added); + } + + let dest_file = dest.join(format!("{}.ds", name)); + // Fix imports: ./button → ./button (relative within components/) + let source = item.source.replace("from \"./", "from \"./"); + fs::write(&dest_file, source).expect("Failed to write component file"); + + let dep_info = if !item.deps.is_empty() { + " (dependency)" + } else { + "" + }; + println!(" ✅ Added components/{}.ds{}", name, dep_info); + added.insert(name.to_string()); +} + +/// Get registry for use by init command. +pub fn get_registry_source(name: &str) -> Option<&'static str> { + REGISTRY.iter().find(|r| r.name == name).map(|r| r.source) +} diff --git a/compiler/ds-cli/src/commands/build.rs b/compiler/ds-cli/src/commands/build.rs new file mode 100644 index 0000000..a68ac2f --- /dev/null +++ b/compiler/ds-cli/src/commands/build.rs @@ -0,0 +1,213 @@ +/// Build command — compile .ds files to HTML+JS or Panel IR. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::collections::HashSet; + +/// Compile error with source for diagnostic rendering. +pub struct CompileError { + pub message: String, + pub source: Option, +} + +impl std::fmt::Display for CompileError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + + +pub fn compile(source: &str, base_dir: &Path, minify: bool) -> Result { + // 1. Lex + let mut lexer = ds_parser::Lexer::new(source); + let tokens = lexer.tokenize(); + + // Check for lexer errors + for tok in &tokens { + if let ds_parser::TokenKind::Error(msg) = &tok.kind { + return Err(CompileError { + message: format!("Lexer error at line {}: {}", tok.line, msg), + source: None, + }); + } + } + + // 2. Parse + let mut parser = ds_parser::Parser::with_source(tokens, source); + let mut program = parser.parse_program().map_err(|e| { + let diag = ds_diagnostic::Diagnostic::from(e); + CompileError { + message: ds_diagnostic::render(&diag, source), + source: Some(source.to_string()), + } + })?; + + // 3. Resolve imports — inline exported declarations from imported files + resolve_imports(&mut program, base_dir).map_err(|e| CompileError { message: e, source: None })?; + + // 4. Analyze + let graph = ds_analyzer::SignalGraph::from_program(&program); + let views = ds_analyzer::SignalGraph::analyze_views(&program); + + // 5. Codegen + let html = ds_codegen::JsEmitter::emit_html(&program, &graph, &views, minify); + + Ok(html) +} + +/// Compile a DreamStack source file to Panel IR JSON for ESP32 LVGL panels. +pub fn compile_panel_ir(source: &str, base_dir: &Path) -> Result { + // 1. Lex + let mut lexer = ds_parser::Lexer::new(source); + let tokens = lexer.tokenize(); + + for tok in &tokens { + if let ds_parser::TokenKind::Error(msg) = &tok.kind { + return Err(CompileError { + message: format!("Lexer error at line {}: {}", tok.line, msg), + source: None, + }); + } + } + + // 2. Parse + let mut parser = ds_parser::Parser::with_source(tokens, source); + let mut program = parser.parse_program().map_err(|e| { + let diag = ds_diagnostic::Diagnostic::from(e); + CompileError { + message: ds_diagnostic::render(&diag, source), + source: Some(source.to_string()), + } + })?; + + // 3. Resolve imports + resolve_imports(&mut program, base_dir).map_err(|e| CompileError { message: e, source: None })?; + + // 4. Analyze + let graph = ds_analyzer::SignalGraph::from_program(&program); + + // 5. Codegen → Panel IR + let ir = ds_codegen::IrEmitter::emit_ir(&program, &graph); + + Ok(ir) +} + +/// Resolve `import { X, Y } from "./file"` by parsing the imported file +/// and inlining the matching `export`ed declarations. +pub fn resolve_imports(program: &mut ds_parser::Program, base_dir: &Path) -> Result<(), String> { + let mut imported_decls = Vec::new(); + let mut seen_files: HashSet = HashSet::new(); + + for decl in &program.declarations { + if let ds_parser::Declaration::Import(import) = decl { + // Resolve the file path relative to base_dir + let mut import_path = base_dir.join(&import.source); + if !import_path.extension().map_or(false, |e| e == "ds") { + import_path.set_extension("ds"); + } + + let import_path = import_path.canonicalize().unwrap_or(import_path.clone()); + + if seen_files.contains(&import_path) { + continue; // Skip duplicate imports + } + seen_files.insert(import_path.clone()); + + // Read and parse the imported file + let imported_source = fs::read_to_string(&import_path) + .map_err(|e| format!("Cannot import '{}': {}", import.source, e))?; + + let mut lexer = ds_parser::Lexer::new(&imported_source); + let tokens = lexer.tokenize(); + for tok in &tokens { + if let ds_parser::TokenKind::Error(msg) = &tok.kind { + return Err(format!("Lexer error in '{}' at line {}: {}", import.source, tok.line, msg)); + } + } + let mut parser = ds_parser::Parser::new(tokens); + let mut imported_program = parser.parse_program() + .map_err(|e| format!("Parse error in '{}': {}", import.source, e))?; + + // Recursively resolve imports in the imported file + let imported_dir = import_path.parent().unwrap_or(base_dir); + resolve_imports(&mut imported_program, imported_dir)?; + + // Extract matching exports + let names: HashSet<&str> = import.names.iter().map(|s| s.as_str()).collect(); + for d in &imported_program.declarations { + match d { + ds_parser::Declaration::Export(name, inner) if names.contains(name.as_str()) => { + imported_decls.push(*inner.clone()); + } + // Also include non-exported decls that exports depend on + // (for now, include all let decls from the imported file) + ds_parser::Declaration::Let(_) => { + imported_decls.push(d.clone()); + } + _ => {} + } + } + } + } + + // Remove Import declarations and prepend imported decls + program.declarations.retain(|d| !matches!(d, ds_parser::Declaration::Import(_))); + let mut merged = imported_decls; + merged.append(&mut program.declarations); + program.declarations = merged; + + Ok(()) +} + +pub fn cmd_build(file: &Path, output: &Path, minify: bool, target: &str) { + println!("🔨 DreamStack build (target: {}){}", target, if minify { " (minified)" } else { "" }); + println!(" source: {}", file.display()); + + let source = match fs::read_to_string(file) { + Ok(s) => s, + Err(e) => { + eprintln!("❌ Could not read {}: {}", file.display(), e); + std::process::exit(1); + } + }; + + let base_dir = file.parent().unwrap_or(Path::new(".")); + + match target { + "panel" => { + // Panel IR target — emit JSON for ESP32 LVGL runtime + match compile_panel_ir(&source, base_dir) { + Ok(ir) => { + fs::create_dir_all(output).unwrap(); + let out_path = output.join("app.ir.json"); + fs::write(&out_path, &ir).unwrap(); + println!(" output: {}", out_path.display()); + println!("✅ Panel IR built ({} bytes)", ir.len()); + } + Err(e) => { + eprintln!("❌ {}", e.message); + std::process::exit(1); + } + } + } + _ => { + // Default HTML target + match compile(&source, base_dir, minify) { + Ok(html) => { + fs::create_dir_all(output).unwrap(); + let out_path = output.join("index.html"); + fs::write(&out_path, &html).unwrap(); + println!(" output: {}", out_path.display()); + println!("✅ Build complete! ({} bytes)", html.len()); + println!(); + println!(" Open in browser:"); + println!(" file://{}", fs::canonicalize(&out_path).unwrap().display()); + } + Err(e) => { + eprintln!("❌ {}", e.message); + std::process::exit(1); + } + } + } + } +} diff --git a/compiler/ds-cli/src/commands/check.rs b/compiler/ds-cli/src/commands/check.rs new file mode 100644 index 0000000..6d67fbe --- /dev/null +++ b/compiler/ds-cli/src/commands/check.rs @@ -0,0 +1,134 @@ +/// Check command — type-check and analyze without compiling. +/// Outputs Elm-style diagnostics for any errors found. + +use std::fs; +use std::path::Path; + +pub fn cmd_check(file: &Path) { + println!("🔍 DreamStack check"); + println!(" file: {}", file.display()); + + let source = match fs::read_to_string(file) { + Ok(s) => s, + Err(e) => { + eprintln!("❌ Could not read {}: {}", file.display(), e); + std::process::exit(1); + } + }; + + let mut diagnostics: Vec = Vec::new(); + + // Lex + let mut lexer = ds_parser::Lexer::new(&source); + let tokens = lexer.tokenize(); + + for tok in &tokens { + if let ds_parser::TokenKind::Error(msg) = &tok.kind { + diagnostics.push(ds_diagnostic::Diagnostic::error( + msg.clone(), + ds_parser::Span { + start: 0, + end: 0, + line: tok.line, + col: tok.col, + }, + ).with_code("E0000")); + } + } + + // Parse (resilient — collect multiple errors) + let mut parser = ds_parser::Parser::with_source(tokens, &source); + let parse_result = parser.parse_program_resilient(); + + // Convert parse errors → diagnostics + for err in &parse_result.errors { + diagnostics.push(ds_diagnostic::Diagnostic::from(err.clone())); + } + + let program = parse_result.program; + + // Type check + let mut checker = ds_types::TypeChecker::new(); + checker.check_program(&program); + if checker.has_errors() { + diagnostics.extend(checker.errors_as_diagnostics()); + } + + // Analyze + let graph = ds_analyzer::SignalGraph::from_program(&program); + let views = ds_analyzer::SignalGraph::analyze_views(&program); + + // Cycle detection diagnostics + let (topo, cycle_diags) = graph.topological_order(); + diagnostics.extend(cycle_diags); + + // Sort all diagnostics: errors first, then by line + ds_diagnostic::sort_diagnostics(&mut diagnostics); + + // Render diagnostics + let error_count = diagnostics.iter().filter(|d| d.severity == ds_diagnostic::Severity::Error).count(); + let warning_count = diagnostics.iter().filter(|d| d.severity == ds_diagnostic::Severity::Warning).count(); + + if !diagnostics.is_empty() { + println!(); + for diag in &diagnostics { + eprintln!("{}", ds_diagnostic::render(diag, &source)); + } + } + + // Signal graph report + println!(); + println!(" 📊 Signal Graph:"); + for node in &graph.nodes { + let kind_str = match &node.kind { + ds_analyzer::SignalKind::Source => "source", + ds_analyzer::SignalKind::Derived => "derived", + ds_analyzer::SignalKind::Handler { .. } => "handler", + }; + let deps: Vec<&str> = node.dependencies.iter().map(|d| d.signal_name.as_str()).collect(); + if deps.is_empty() { + println!(" {} [{}]", node.name, kind_str); + } else { + println!(" {} [{}] ← depends on: {}", node.name, kind_str, deps.join(", ")); + } + } + + println!(); + println!(" 🖼️ Views:"); + for view in &views { + println!(" {} ({} bindings)", view.name, view.bindings.len()); + for binding in &view.bindings { + match &binding.kind { + ds_analyzer::BindingKind::TextContent { signal } => { + println!(" 📝 text bound to: {signal}"); + } + ds_analyzer::BindingKind::EventHandler { element_tag, event, .. } => { + println!(" ⚡ {element_tag}.{event}"); + } + ds_analyzer::BindingKind::Conditional { condition_signals } => { + println!(" ❓ conditional on: {}", condition_signals.join(", ")); + } + ds_analyzer::BindingKind::StaticContainer { kind, child_count } => { + println!(" 📦 {kind} ({child_count} children)"); + } + ds_analyzer::BindingKind::StaticText { text } => { + println!(" 📄 static: \"{text}\""); + } + } + } + } + + println!(); + println!(" 🔄 Propagation order: {:?}", topo.iter().map(|&id| &graph.nodes[id].name).collect::>()); + + // Summary + println!(); + if error_count == 0 && warning_count == 0 { + println!("✅ No errors found"); + } else if error_count == 0 { + println!("⚠️ {} warning(s)", warning_count); + } else { + eprintln!("❌ {} error(s), {} warning(s)", error_count, warning_count); + std::process::exit(1); + } +} diff --git a/compiler/ds-cli/src/commands/convert.rs b/compiler/ds-cli/src/commands/convert.rs new file mode 100644 index 0000000..6eb7fe4 --- /dev/null +++ b/compiler/ds-cli/src/commands/convert.rs @@ -0,0 +1,519 @@ +/// Convert command — React/TSX → DreamStack converter. + +use std::fs; +use std::path::Path; + +pub fn cmd_convert(name: &str, shadcn: bool, output: Option<&Path>) { + let tsx_source = if shadcn { + // Fetch from shadcn/ui GitHub + let url = format!( + "https://raw.githubusercontent.com/shadcn-ui/taxonomy/main/components/ui/{}.tsx", + name + ); + println!(" 📥 Fetching {}.tsx from shadcn/ui...", name); + match fetch_url_blocking(&url) { + Ok(source) => source, + Err(e) => { + println!(" ❌ Failed to fetch: {e}"); + println!(" Try providing a local .tsx file instead."); + return; + } + } + } else { + // Read local file + match fs::read_to_string(name) { + Ok(source) => source, + Err(e) => { + println!(" ❌ Cannot read '{name}': {e}"); + return; + } + } + }; + + let ds_output = convert_tsx_to_ds(&tsx_source, name); + + if let Some(out_path) = output { + fs::write(out_path, &ds_output).expect("Failed to write output file"); + println!(" ✅ Converted to {}", out_path.display()); + } else { + println!("{}", ds_output); + } +} + +/// Best-effort TSX → DreamStack converter. +/// Pattern-matches common React/shadcn idioms rather than full TypeScript parsing. +fn convert_tsx_to_ds(tsx: &str, file_hint: &str) -> String { + let mut out = String::new(); + + // Extract component name + let comp_name = extract_component_name(tsx) + .unwrap_or_else(|| { + // Derive from filename + let base = Path::new(file_hint) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("Component"); + let mut chars = base.chars(); + match chars.next() { + Some(c) => format!("{}{}", c.to_uppercase().collect::(), chars.collect::()), + None => "Component".to_string(), + } + }); + + // Extract props + let props = extract_props(tsx); + + // Header comment + out.push_str(&format!("-- Converted from {}\n", file_hint)); + out.push_str("-- Auto-generated by dreamstack convert\n\n"); + + // Extract useState hooks → let declarations + let state_vars = extract_use_state(tsx); + for (name, default) in &state_vars { + out.push_str(&format!("let {} = {}\n", name, default)); + } + if !state_vars.is_empty() { + out.push('\n'); + } + + // Extract cva variants + let variants = extract_cva_variants(tsx); + if !variants.is_empty() { + out.push_str("-- Variants:\n"); + for (variant_name, values) in &variants { + out.push_str(&format!("-- {}: {}\n", variant_name, values.join(", "))); + } + out.push('\n'); + } + + // Extract JSX body + let jsx_body = extract_jsx_body(tsx); + + // Build component declaration + let props_str = if props.is_empty() { + String::new() + } else { + format!("({})", props.join(", ")) + }; + + out.push_str(&format!("export component {}{} =\n", comp_name, props_str)); + + if jsx_body.is_empty() { + out.push_str(" text \"TODO: convert JSX body\"\n"); + } else { + out.push_str(&jsx_body); + } + + out +} + +/// Extract component name from React.forwardRef or function/const declaration +fn extract_component_name(tsx: &str) -> Option { + // Pattern: `const Button = React.forwardRef` + for line in tsx.lines() { + let trimmed = line.trim(); + if trimmed.contains("forwardRef") || trimmed.contains("React.forwardRef") { + if let Some(pos) = trimmed.find("const ") { + let rest = &trimmed[pos + 6..]; + if let Some(eq_pos) = rest.find(" =") { + return Some(rest[..eq_pos].trim().to_string()); + } + } + } + // Pattern: `export function Button(` + if trimmed.starts_with("export function ") || trimmed.starts_with("function ") { + let rest = trimmed.strip_prefix("export ").unwrap_or(trimmed); + let rest = rest.strip_prefix("function ").unwrap_or(rest); + if let Some(paren) = rest.find('(') { + let name = rest[..paren].trim(); + if name.chars().next().map_or(false, |c| c.is_uppercase()) { + return Some(name.to_string()); + } + } + } + } + None +} + +/// Extract props from destructured function parameters +fn extract_props(tsx: &str) -> Vec { + let mut props = Vec::new(); + // Look for destructured props: `({ className, variant, size, ...props })` + if let Some(start) = tsx.find("({ ") { + if let Some(end) = tsx[start..].find(" })") { + let inner = &tsx[start + 3..start + end]; + for part in inner.split(',') { + let part = part.trim(); + if part.starts_with("...") { continue; } // skip rest props + if part.is_empty() { continue; } + // Handle defaults: `variant = "default"` → just `variant` + let name = part.split('=').next().unwrap_or(part).trim(); + let name = name.split(':').next().unwrap_or(name).trim(); + if !name.is_empty() + && name != "className" + && name != "ref" + && name != "children" + && !props.contains(&name.to_string()) + { + props.push(name.to_string()); + } + } + } + } + props +} + +/// Extract useState hooks: `const [open, setOpen] = useState(false)` → ("open", "false") +fn extract_use_state(tsx: &str) -> Vec<(String, String)> { + let mut states = Vec::new(); + for line in tsx.lines() { + let trimmed = line.trim(); + if trimmed.contains("useState") { + // const [name, setName] = useState(default) + if let Some(bracket_start) = trimmed.find('[') { + if let Some(comma) = trimmed[bracket_start..].find(',') { + let name = trimmed[bracket_start + 1..bracket_start + comma].trim(); + // Extract default value from useState(...) + if let Some(paren_start) = trimmed.find("useState(") { + let rest = &trimmed[paren_start + 9..]; + if let Some(paren_end) = rest.find(')') { + let default = rest[..paren_end].trim(); + let ds_default = convert_value(default); + states.push((name.to_string(), ds_default)); + } + } + } + } + } + } + states +} + +/// Extract cva variant definitions +fn extract_cva_variants(tsx: &str) -> Vec<(String, Vec)> { + let mut variants = Vec::new(); + let mut in_variants = false; + let mut current_variant = String::new(); + let mut current_values = Vec::new(); + + for line in tsx.lines() { + let trimmed = line.trim(); + if trimmed == "variants: {" { + in_variants = true; + continue; + } + if in_variants { + if trimmed == "}," || trimmed == "}" { + if !current_variant.is_empty() { + variants.push((current_variant.clone(), current_values.clone())); + current_variant.clear(); + current_values.clear(); + } + if trimmed == "}," { + // Could be end of a variant group or end of variants + if trimmed == "}," { continue; } + } + in_variants = false; + continue; + } + // Variant group: `variant: {` + if trimmed.ends_with(": {") || trimmed.ends_with(":{") { + if !current_variant.is_empty() { + variants.push((current_variant.clone(), current_values.clone())); + current_values.clear(); + } + current_variant = trimmed.split(':').next().unwrap_or("").trim().to_string(); + continue; + } + // Variant value: `default: "bg-primary text-primary-foreground",` + if trimmed.contains(":") && !current_variant.is_empty() { + let name = trimmed.split(':').next().unwrap_or("").trim().trim_matches('"'); + if !name.is_empty() { + current_values.push(name.to_string()); + } + } + } + } + if !current_variant.is_empty() { + variants.push((current_variant, current_values)); + } + variants +} + +/// Convert a JSX body to DreamStack view syntax (best-effort) +fn extract_jsx_body(tsx: &str) -> String { + let mut out = String::new(); + let mut in_return = false; + let mut depth = 0; + + for line in tsx.lines() { + let trimmed = line.trim(); + + // Find the return statement + if trimmed.starts_with("return (") || trimmed == "return (" { + in_return = true; + depth = 1; + continue; + } + + if !in_return { continue; } + + // Track parens + for c in trimmed.chars() { + match c { + '(' => depth += 1, + ')' => { + depth -= 1; + if depth <= 0 { + in_return = false; + } + } + _ => {} + } + } + + if !in_return && depth <= 0 { break; } + + // Convert JSX elements + let converted = convert_jsx_line(trimmed); + if !converted.is_empty() { + out.push_str(" "); + out.push_str(&converted); + out.push('\n'); + } + } + + out +} + +/// Convert a single JSX line to DreamStack syntax +fn convert_jsx_line(jsx: &str) -> String { + let trimmed = jsx.trim(); + + // Skip closing tags + if trimmed.starts_with("" || trimmed == "" { return String::new(); } + // Skip className-only attributes + if trimmed.starts_with("className=") { return String::new(); } + + // Self-closing tag: `` + if trimmed.starts_with('<') && trimmed.ends_with("/>") { + let inner = &trimmed[1..trimmed.len() - 2].trim(); + let parts: Vec<&str> = inner.splitn(2, ' ').collect(); + let tag = parts[0]; + let ds_tag = convert_html_tag(tag); + if parts.len() > 1 { + let attrs = convert_jsx_attrs(parts[1]); + return format!("{} {{ {} }}", ds_tag, attrs); + } + return ds_tag; + } + + // Opening tag: ` + + + + +
+
+
+
+
+
+
+
+
+
+ + + + + +"##; + +pub fn cmd_playground(file: Option<&Path>, port: u16) { + println!("🎮 DreamStack Playground"); + println!(" http://localhost:{port}"); + println!(); + + // Load initial source from file or use default + let initial_source = if let Some(path) = file { + match fs::read_to_string(path) { + Ok(s) => { + println!(" loaded: {}", path.display()); + s + } + Err(e) => { + eprintln!("⚠️ Could not read {}: {e}", path.display()); + default_playground_source() + } + } + } else { + default_playground_source() + }; + + // Build the playground HTML with the initial source injected + let escaped_source = initial_source + .replace('\\', "\\\\") + .replace('`', "\\`") + .replace("${", "\\${"); + let playground_html = PLAYGROUND_HTML.replace( + "INITIAL_SOURCE", + &format!("String.raw`{}`", escaped_source), + ); + + let server = tiny_http::Server::http(format!("0.0.0.0:{port}")).unwrap(); + println!("✅ Playground running at http://localhost:{port}"); + println!(" Press Ctrl+C to stop"); + println!(); + + let base_dir = file.and_then(|f| f.parent()).unwrap_or(Path::new(".")); + let _base_dir = base_dir.to_path_buf(); + let mut inc_compiler = ds_incremental::IncrementalCompiler::new(); + + for mut request in server.incoming_requests() { + let url = request.url().to_string(); + + if url == "/compile" && request.method() == &tiny_http::Method::Post { + // Read the body + let mut body = String::new(); + let reader = request.as_reader(); + match reader.read_to_string(&mut body) { + Ok(_) => {} + Err(e) => { + let resp = tiny_http::Response::from_string(format!("Read error: {e}")) + .with_status_code(400); + let _ = request.respond(resp); + continue; + } + } + + let start = Instant::now(); + match inc_compiler.compile(&body) { + ds_incremental::IncrementalResult::Full(html) => { + let ms = start.elapsed().as_millis(); + println!(" ✅ full compile in {ms}ms ({} bytes)", html.len()); + let json = format!(r#"{{"type":"full","html":{}}}"#, json_escape(&html)); + let response = tiny_http::Response::from_string(&json) + .with_header( + tiny_http::Header::from_bytes( + &b"Content-Type"[..], + &b"application/json; charset=utf-8"[..], + ).unwrap(), + ) + .with_header( + tiny_http::Header::from_bytes( + &b"Access-Control-Allow-Origin"[..], + &b"*"[..], + ).unwrap(), + ); + let _ = request.respond(response); + } + ds_incremental::IncrementalResult::Patch(js) => { + let ms = start.elapsed().as_millis(); + if js.is_empty() { + println!(" ⚡ unchanged ({ms}ms)"); + } else { + println!(" ⚡ incremental patch in {ms}ms ({} bytes)", js.len()); + } + let json = format!(r#"{{"type":"patch","js":{}}}"#, json_escape(&js)); + let response = tiny_http::Response::from_string(&json) + .with_header( + tiny_http::Header::from_bytes( + &b"Content-Type"[..], + &b"application/json; charset=utf-8"[..], + ).unwrap(), + ) + .with_header( + tiny_http::Header::from_bytes( + &b"Access-Control-Allow-Origin"[..], + &b"*"[..], + ).unwrap(), + ); + let _ = request.respond(response); + } + ds_incremental::IncrementalResult::Error(e) => { + println!(" ❌ compile error"); + let json = format!(r#"{{"type":"error","message":{}}}"#, json_escape(&e)); + let response = tiny_http::Response::from_string(&json) + .with_status_code(400) + .with_header( + tiny_http::Header::from_bytes( + &b"Content-Type"[..], + &b"application/json; charset=utf-8"[..], + ).unwrap(), + ); + let _ = request.respond(response); + } + } + } else { + // Serve the playground HTML + let response = tiny_http::Response::from_string(&playground_html) + .with_header( + tiny_http::Header::from_bytes( + &b"Content-Type"[..], + &b"text/html; charset=utf-8"[..], + ).unwrap(), + ); + let _ = request.respond(response); + } + } +} + +fn default_playground_source() -> String { + r#"let count = 0 +let label = "Hello, DreamStack!" + +on click -> count = count + 1 + +view main = column [ + text label + text count + button "Increment" { click: count += 1 } +] +"#.to_string() +} + +/// Escape a string for embedding in JSON. +pub fn json_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if c < '\x20' => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out.push('"'); + out +} diff --git a/compiler/ds-cli/src/commands/stream.rs b/compiler/ds-cli/src/commands/stream.rs new file mode 100644 index 0000000..48affaf --- /dev/null +++ b/compiler/ds-cli/src/commands/stream.rs @@ -0,0 +1,77 @@ +/// Stream command — compile and stream via bitstream relay. + +use std::fs; +use std::path::Path; + +use super::build::compile; +use super::dev::inject_hmr; + +pub fn cmd_stream(file: &Path, relay: &str, mode: &str, port: u16) { + println!("⚡ DreamStack stream"); + println!(" source: {}", file.display()); + println!(" relay: {}", relay); + println!(" mode: {}", mode); + println!(" port: {}", port); + println!(); + + let source = match fs::read_to_string(file) { + Ok(s) => s, + Err(e) => { + eprintln!("❌ Could not read {}: {}", file.display(), e); + std::process::exit(1); + } + }; + + // Inject stream declaration if not present + let stream_source = if source.contains("stream ") { + source + } else { + // Auto-inject a stream declaration for the first view + let view_name = { + let mut lexer = ds_parser::Lexer::new(&source); + let tokens = lexer.tokenize(); + let mut parser = ds_parser::Parser::new(tokens); + if let Ok(program) = parser.parse_program() { + program.declarations.iter() + .find_map(|d| if let ds_parser::ast::Declaration::View(v) = d { Some(v.name.clone()) } else { None }) + .unwrap_or_else(|| "main".to_string()) + } else { + "main".to_string() + } + }; + format!( + "{}\nstream {} on \"{}\" {{ mode: {} }}", + source, view_name, relay, mode + ) + }; + + match compile(&stream_source, file.parent().unwrap_or(Path::new(".")), false) { + Ok(html) => { + let html_with_hmr = inject_hmr(&html); + println!("✅ Compiled with streaming enabled"); + println!(" Open: http://localhost:{port}"); + println!(" Relay: {relay}"); + println!(); + println!(" Make sure the relay is running:"); + println!(" cargo run -p ds-stream"); + println!(); + + // Serve the compiled page + let server = tiny_http::Server::http(format!("0.0.0.0:{port}")).unwrap(); + for request in server.incoming_requests() { + let response = tiny_http::Response::from_string(&html_with_hmr) + .with_header( + tiny_http::Header::from_bytes( + &b"Content-Type"[..], + &b"text/html; charset=utf-8"[..], + ).unwrap(), + ); + let _ = request.respond(response); + } + } + Err(e) => { + eprintln!("❌ Compile error: {e}"); + std::process::exit(1); + } + } +} diff --git a/compiler/ds-cli/src/main.rs b/compiler/ds-cli/src/main.rs index f2d4042..6025e99 100644 --- a/compiler/ds-cli/src/main.rs +++ b/compiler/ds-cli/src/main.rs @@ -6,10 +6,9 @@ /// dreamstack check — analyze without emitting use clap::{Parser, Subcommand}; -use std::fs; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex, atomic::{AtomicU64, Ordering}}; -use std::time::{Duration, Instant}; +use std::path::PathBuf; + +mod commands; #[derive(Parser)] #[command(name = "dreamstack")] @@ -103,1935 +102,13 @@ fn main() { let cli = Cli::parse(); match cli.command { - Commands::Build { file, output, minify, target } => cmd_build(&file, &output, minify, &target), - Commands::Dev { file, port } => cmd_dev(&file, port), - Commands::Check { file } => cmd_check(&file), - Commands::Stream { file, relay, mode, port } => cmd_stream(&file, &relay, &mode, port), - Commands::Playground { file, port } => cmd_playground(file.as_deref(), port), - Commands::Add { name, list, all } => cmd_add(name, list, all), - Commands::Convert { name, shadcn, output } => cmd_convert(&name, shadcn, output.as_deref()), - Commands::Init { name } => cmd_init(name), + Commands::Build { file, output, minify, target } => commands::build::cmd_build(&file, &output, minify, &target), + Commands::Dev { file, port } => commands::dev::cmd_dev(&file, port), + Commands::Check { file } => commands::check::cmd_check(&file), + Commands::Stream { file, relay, mode, port } => commands::stream::cmd_stream(&file, &relay, &mode, port), + Commands::Playground { file, port } => commands::playground::cmd_playground(file.as_deref(), port), + Commands::Add { name, list, all } => commands::add::cmd_add(name, list, all), + Commands::Convert { name, shadcn, output } => commands::convert::cmd_convert(&name, shadcn, output.as_deref()), + Commands::Init { name } => commands::init::cmd_init(name), } } - -fn compile(source: &str, base_dir: &Path, minify: bool) -> Result { - // 1. Lex - let mut lexer = ds_parser::Lexer::new(source); - let tokens = lexer.tokenize(); - - // Check for lexer errors - for tok in &tokens { - if let ds_parser::TokenKind::Error(msg) = &tok.kind { - return Err(format!("Lexer error at line {}: {}", tok.line, msg)); - } - } - - // 2. Parse - let mut parser = ds_parser::Parser::with_source(tokens, source); - let mut program = parser.parse_program().map_err(|e| e.to_string())?; - - // 3. Resolve imports — inline exported declarations from imported files - resolve_imports(&mut program, base_dir)?; - - // 4. Analyze - let graph = ds_analyzer::SignalGraph::from_program(&program); - let views = ds_analyzer::SignalGraph::analyze_views(&program); - - // 5. Codegen - let html = ds_codegen::JsEmitter::emit_html(&program, &graph, &views, minify); - - Ok(html) -} - -/// Compile a DreamStack source file to Panel IR JSON for ESP32 LVGL panels. -fn compile_panel_ir(source: &str, base_dir: &Path) -> Result { - // 1. Lex - let mut lexer = ds_parser::Lexer::new(source); - let tokens = lexer.tokenize(); - - for tok in &tokens { - if let ds_parser::TokenKind::Error(msg) = &tok.kind { - return Err(format!("Lexer error at line {}: {}", tok.line, msg)); - } - } - - // 2. Parse - let mut parser = ds_parser::Parser::with_source(tokens, source); - let mut program = parser.parse_program().map_err(|e| e.to_string())?; - - // 3. Resolve imports - resolve_imports(&mut program, base_dir)?; - - // 4. Analyze - let graph = ds_analyzer::SignalGraph::from_program(&program); - - // 5. Codegen → Panel IR - let ir = ds_codegen::IrEmitter::emit_ir(&program, &graph); - - Ok(ir) -} - -/// Resolve `import { X, Y } from "./file"` by parsing the imported file -/// and inlining the matching `export`ed declarations. -fn resolve_imports(program: &mut ds_parser::Program, base_dir: &Path) -> Result<(), String> { - use std::collections::HashSet; - - let mut imported_decls = Vec::new(); - let mut seen_files: HashSet = HashSet::new(); - - for decl in &program.declarations { - if let ds_parser::Declaration::Import(import) = decl { - // Resolve the file path relative to base_dir - let mut import_path = base_dir.join(&import.source); - if !import_path.extension().map_or(false, |e| e == "ds") { - import_path.set_extension("ds"); - } - - let import_path = import_path.canonicalize().unwrap_or(import_path.clone()); - - if seen_files.contains(&import_path) { - continue; // Skip duplicate imports - } - seen_files.insert(import_path.clone()); - - // Read and parse the imported file - let imported_source = fs::read_to_string(&import_path) - .map_err(|e| format!("Cannot import '{}': {}", import.source, e))?; - - let mut lexer = ds_parser::Lexer::new(&imported_source); - let tokens = lexer.tokenize(); - for tok in &tokens { - if let ds_parser::TokenKind::Error(msg) = &tok.kind { - return Err(format!("Lexer error in '{}' at line {}: {}", import.source, tok.line, msg)); - } - } - let mut parser = ds_parser::Parser::new(tokens); - let mut imported_program = parser.parse_program() - .map_err(|e| format!("Parse error in '{}': {}", import.source, e))?; - - // Recursively resolve imports in the imported file - let imported_dir = import_path.parent().unwrap_or(base_dir); - resolve_imports(&mut imported_program, imported_dir)?; - - // Extract matching exports - let names: HashSet<&str> = import.names.iter().map(|s| s.as_str()).collect(); - for d in &imported_program.declarations { - match d { - ds_parser::Declaration::Export(name, inner) if names.contains(name.as_str()) => { - imported_decls.push(*inner.clone()); - } - // Also include non-exported decls that exports depend on - // (for now, include all let decls from the imported file) - ds_parser::Declaration::Let(_) => { - imported_decls.push(d.clone()); - } - _ => {} - } - } - } - } - - // Remove Import declarations and prepend imported decls - program.declarations.retain(|d| !matches!(d, ds_parser::Declaration::Import(_))); - let mut merged = imported_decls; - merged.append(&mut program.declarations); - program.declarations = merged; - - Ok(()) -} - -fn cmd_build(file: &Path, output: &Path, minify: bool, target: &str) { - println!("🔨 DreamStack build (target: {}){}", target, if minify { " (minified)" } else { "" }); - println!(" source: {}", file.display()); - - let source = match fs::read_to_string(file) { - Ok(s) => s, - Err(e) => { - eprintln!("❌ Could not read {}: {}", file.display(), e); - std::process::exit(1); - } - }; - - let base_dir = file.parent().unwrap_or(Path::new(".")); - - match target { - "panel" => { - // Panel IR target — emit JSON for ESP32 LVGL runtime - match compile_panel_ir(&source, base_dir) { - Ok(ir) => { - fs::create_dir_all(output).unwrap(); - let out_path = output.join("app.ir.json"); - fs::write(&out_path, &ir).unwrap(); - println!(" output: {}", out_path.display()); - println!("✅ Panel IR built ({} bytes)", ir.len()); - } - Err(e) => { - eprintln!("❌ Compile error: {e}"); - std::process::exit(1); - } - } - } - _ => { - // Default HTML target - match compile(&source, base_dir, minify) { - Ok(html) => { - fs::create_dir_all(output).unwrap(); - let out_path = output.join("index.html"); - fs::write(&out_path, &html).unwrap(); - println!(" output: {}", out_path.display()); - println!("✅ Build complete! ({} bytes)", html.len()); - println!(); - println!(" Open in browser:"); - println!(" file://{}", fs::canonicalize(&out_path).unwrap().display()); - } - Err(e) => { - eprintln!("❌ Compile error: {e}"); - std::process::exit(1); - } - } - } - } -} - -/// HMR client script injected into every page served by `dreamstack dev`. -/// Uses Server-Sent Events to receive reload notifications from the dev server. -const HMR_CLIENT_SCRIPT: &str = r#" - -"#; - -fn inject_hmr(html: &str) -> String { - // Inject the HMR script just before - if let Some(pos) = html.rfind("") { - format!("{}{}{}", &html[..pos], HMR_CLIENT_SCRIPT, &html[pos..]) - } else { - // No tag — just append - format!("{html}{HMR_CLIENT_SCRIPT}") - } -} - -fn cmd_dev(file: &Path, port: u16) { - use notify::{Watcher, RecursiveMode}; - use std::sync::mpsc; - use std::thread; - - println!("🚀 DreamStack dev server"); - println!(" watching: {}", file.display()); - println!(" serving: http://localhost:{port}"); - println!(); - - // Shared state: compiled HTML + version counter - let version = Arc::new(AtomicU64::new(1)); - let compiled_html = Arc::new(Mutex::new(String::new())); - - // Initial compile - let source = match fs::read_to_string(file) { - Ok(s) => s, - Err(e) => { - eprintln!("❌ Could not read {}: {}", file.display(), e); - std::process::exit(1); - } - }; - - let start = Instant::now(); - let base_dir = file.parent().unwrap_or(Path::new(".")); - match compile(&source, base_dir, false) { - Ok(html) => { - let ms = start.elapsed().as_millis(); - let html_with_hmr = inject_hmr(&html); - *compiled_html.lock().unwrap() = html_with_hmr; - println!("✅ Compiled in {ms}ms ({} bytes)", html.len()); - } - Err(e) => { - eprintln!("⚠️ Compile error: {e}"); - let error_html = format!( - r#" - -

── COMPILE ERROR ──

-
{e}
-"# - ); - *compiled_html.lock().unwrap() = inject_hmr(&error_html); - } - } - - // ── File Watcher Thread ───────────────────────── - let file_path = fs::canonicalize(file).unwrap_or_else(|_| file.to_path_buf()); - let watch_dir = file_path.parent().unwrap().to_path_buf(); - let watch_file = file_path.clone(); - let v_watcher = Arc::clone(&version); - let html_watcher = Arc::clone(&compiled_html); - - thread::spawn(move || { - let (tx, rx) = mpsc::channel(); - - let mut watcher = notify::recommended_watcher(move |res: Result| { - if let Ok(event) = res { - let _ = tx.send(event); - } - }).expect("Failed to create file watcher"); - - watcher.watch(&watch_dir, RecursiveMode::Recursive) - .expect("Failed to watch directory"); - - // Also watch project root (for registry/components etc.) - // Walk up from watch_dir to find a directory containing examples/ or registry/ - let mut project_root = watch_dir.clone(); - for _ in 0..5 { - if project_root.join("registry").is_dir() || project_root.join("examples").is_dir() { - if project_root != watch_dir { - let _ = watcher.watch(&project_root, RecursiveMode::Recursive); - println!("👁 Also watching {} (project root)", project_root.display()); - } - break; - } - if let Some(parent) = project_root.parent() { - project_root = parent.to_path_buf(); - } else { - break; - } - } - - println!("👁 Watching {} for changes (recursive)", watch_dir.display()); - println!(); - - // Debounce: coalesce rapid events - let mut last_compile = Instant::now(); - - loop { - match rx.recv_timeout(Duration::from_millis(100)) { - Ok(event) => { - // Only recompile for .ds file changes - let dominated = event.paths.iter().any(|p| { - p == &watch_file || - p.extension().map_or(false, |ext| ext == "ds") - }); - - if !dominated { continue; } - - // Debounce: skip if less than 100ms since last compile - if last_compile.elapsed() < Duration::from_millis(100) { - continue; - } - - // Recompile - if let Ok(src) = fs::read_to_string(&watch_file) { - let start = Instant::now(); - match compile(&src, watch_file.parent().unwrap_or(Path::new(".")), false) { - Ok(html) => { - let ms = start.elapsed().as_millis(); - let new_version = v_watcher.fetch_add(1, Ordering::SeqCst) + 1; - *html_watcher.lock().unwrap() = inject_hmr(&html); - println!("🔄 Recompiled in {ms}ms (v{new_version}, {} bytes)", html.len()); - last_compile = Instant::now(); - } - Err(e) => { - let new_version = v_watcher.fetch_add(1, Ordering::SeqCst) + 1; - let error_html = format!( - r#" - -

── COMPILE ERROR ──

-
{e}
-"# - ); - *html_watcher.lock().unwrap() = inject_hmr(&error_html); - eprintln!("❌ v{new_version}: {e}"); - last_compile = Instant::now(); - } - } - } - } - Err(mpsc::RecvTimeoutError::Timeout) => { - // No events — loop and check again - continue; - } - Err(mpsc::RecvTimeoutError::Disconnected) => break, - } - } - }); - - // ── HTTP Server ───────────────────────────────── - let server = tiny_http::Server::http(format!("0.0.0.0:{port}")).unwrap(); - println!("✅ Server running at http://localhost:{port}"); - println!(" Press Ctrl+C to stop"); - println!(); - - // Auto-open browser - let url = format!("http://localhost:{port}"); - #[cfg(target_os = "linux")] - { let _ = std::process::Command::new("xdg-open").arg(&url).spawn(); } - #[cfg(target_os = "macos")] - { let _ = std::process::Command::new("open").arg(&url).spawn(); } - #[cfg(target_os = "windows")] - { let _ = std::process::Command::new("cmd").args(["/C", "start", &url]).spawn(); } - - for request in server.incoming_requests() { - let url = request.url().to_string(); - - if url == "/__hmr" { - // Version endpoint for HMR polling - let v = version.load(Ordering::SeqCst); - let response = tiny_http::Response::from_string(format!("{v}")) - .with_header( - tiny_http::Header::from_bytes( - &b"Content-Type"[..], - &b"text/plain"[..], - ).unwrap(), - ) - .with_header( - tiny_http::Header::from_bytes( - &b"Cache-Control"[..], - &b"no-cache, no-store"[..], - ).unwrap(), - ); - let _ = request.respond(response); - } else { - // Serve the compiled HTML - let html = compiled_html.lock().unwrap().clone(); - let response = tiny_http::Response::from_string(&html) - .with_header( - tiny_http::Header::from_bytes( - &b"Content-Type"[..], - &b"text/html; charset=utf-8"[..], - ).unwrap(), - ); - let _ = request.respond(response); - } - } -} - -fn cmd_check(file: &Path) { - println!("🔍 DreamStack check"); - println!(" file: {}", file.display()); - - let source = match fs::read_to_string(file) { - Ok(s) => s, - Err(e) => { - eprintln!("❌ Could not read {}: {}", file.display(), e); - std::process::exit(1); - } - }; - - // Lex - let mut lexer = ds_parser::Lexer::new(&source); - let tokens = lexer.tokenize(); - - let mut errors = 0; - for tok in &tokens { - if let ds_parser::TokenKind::Error(msg) = &tok.kind { - eprintln!(" ❌ Lexer error at line {}: {}", tok.line, msg); - errors += 1; - } - } - - // Parse - let mut parser = ds_parser::Parser::with_source(tokens, &source); - let program = match parser.parse_program() { - Ok(p) => p, - Err(e) => { - eprintln!(" ❌ {}", e); - std::process::exit(1); - } - }; - - // Analyze - let graph = ds_analyzer::SignalGraph::from_program(&program); - let views = ds_analyzer::SignalGraph::analyze_views(&program); - - println!(); - println!(" 📊 Signal Graph:"); - for node in &graph.nodes { - let kind_str = match &node.kind { - ds_analyzer::SignalKind::Source => "source", - ds_analyzer::SignalKind::Derived => "derived", - ds_analyzer::SignalKind::Handler { .. } => "handler", - }; - let deps: Vec<&str> = node.dependencies.iter().map(|d| d.signal_name.as_str()).collect(); - if deps.is_empty() { - println!(" {} [{}]", node.name, kind_str); - } else { - println!(" {} [{}] ← depends on: {}", node.name, kind_str, deps.join(", ")); - } - } - - println!(); - println!(" 🖼️ Views:"); - for view in &views { - println!(" {} ({} bindings)", view.name, view.bindings.len()); - for binding in &view.bindings { - match &binding.kind { - ds_analyzer::BindingKind::TextContent { signal } => { - println!(" 📝 text bound to: {signal}"); - } - ds_analyzer::BindingKind::EventHandler { element_tag, event, .. } => { - println!(" ⚡ {element_tag}.{event}"); - } - ds_analyzer::BindingKind::Conditional { condition_signals } => { - println!(" ❓ conditional on: {}", condition_signals.join(", ")); - } - ds_analyzer::BindingKind::StaticContainer { kind, child_count } => { - println!(" 📦 {kind} ({child_count} children)"); - } - ds_analyzer::BindingKind::StaticText { text } => { - println!(" 📄 static: \"{text}\""); - } - } - } - } - - let topo = graph.topological_order(); - println!(); - println!(" 🔄 Propagation order: {:?}", topo.iter().map(|&id| &graph.nodes[id].name).collect::>()); - - if errors == 0 { - println!(); - println!("✅ No errors found"); - } else { - println!(); - eprintln!("❌ {} error(s) found", errors); - std::process::exit(1); - } -} - -fn cmd_stream(file: &Path, relay: &str, mode: &str, port: u16) { - println!("⚡ DreamStack stream"); - println!(" source: {}", file.display()); - println!(" relay: {}", relay); - println!(" mode: {}", mode); - println!(" port: {}", port); - println!(); - - let source = match fs::read_to_string(file) { - Ok(s) => s, - Err(e) => { - eprintln!("❌ Could not read {}: {}", file.display(), e); - std::process::exit(1); - } - }; - - // Inject stream declaration if not present - let stream_source = if source.contains("stream ") { - source - } else { - // Auto-inject a stream declaration for the first view - let view_name = { - let mut lexer = ds_parser::Lexer::new(&source); - let tokens = lexer.tokenize(); - let mut parser = ds_parser::Parser::new(tokens); - if let Ok(program) = parser.parse_program() { - program.declarations.iter() - .find_map(|d| if let ds_parser::ast::Declaration::View(v) = d { Some(v.name.clone()) } else { None }) - .unwrap_or_else(|| "main".to_string()) - } else { - "main".to_string() - } - }; - format!( - "{}\nstream {} on \"{}\" {{ mode: {} }}", - source, view_name, relay, mode - ) - }; - - match compile(&stream_source, file.parent().unwrap_or(Path::new(".")), false) { - Ok(html) => { - let html_with_hmr = inject_hmr(&html); - println!("✅ Compiled with streaming enabled"); - println!(" Open: http://localhost:{port}"); - println!(" Relay: {relay}"); - println!(); - println!(" Make sure the relay is running:"); - println!(" cargo run -p ds-stream"); - println!(); - - // Serve the compiled page - let server = tiny_http::Server::http(format!("0.0.0.0:{port}")).unwrap(); - for request in server.incoming_requests() { - let response = tiny_http::Response::from_string(&html_with_hmr) - .with_header( - tiny_http::Header::from_bytes( - &b"Content-Type"[..], - &b"text/html; charset=utf-8"[..], - ).unwrap(), - ); - let _ = request.respond(response); - } - } - Err(e) => { - eprintln!("❌ Compile error: {e}"); - std::process::exit(1); - } - } -} - -// ── Playground ────────────────────────────────────────────── - -/// The playground HTML page with Monaco editor + live preview. -const PLAYGROUND_HTML: &str = r##" - - - - -DreamStack Playground - - - - - -
- - -
- - Ready -
-
- - -
-
- -
-
-
-
-
-
-
-
-
-
- - - - - -"##; - -fn cmd_playground(file: Option<&Path>, port: u16) { - println!("🎮 DreamStack Playground"); - println!(" http://localhost:{port}"); - println!(); - - // Load initial source from file or use default - let initial_source = if let Some(path) = file { - match fs::read_to_string(path) { - Ok(s) => { - println!(" loaded: {}", path.display()); - s - } - Err(e) => { - eprintln!("⚠️ Could not read {}: {e}", path.display()); - default_playground_source() - } - } - } else { - default_playground_source() - }; - - // Build the playground HTML with the initial source injected - let escaped_source = initial_source - .replace('\\', "\\\\") - .replace('`', "\\`") - .replace("${", "\\${"); - let playground_html = PLAYGROUND_HTML.replace( - "INITIAL_SOURCE", - &format!("String.raw`{}`", escaped_source), - ); - - let server = tiny_http::Server::http(format!("0.0.0.0:{port}")).unwrap(); - println!("✅ Playground running at http://localhost:{port}"); - println!(" Press Ctrl+C to stop"); - println!(); - - let base_dir = file.and_then(|f| f.parent()).unwrap_or(Path::new(".")); - let base_dir = base_dir.to_path_buf(); - let mut inc_compiler = ds_incremental::IncrementalCompiler::new(); - - for mut request in server.incoming_requests() { - let url = request.url().to_string(); - - if url == "/compile" && request.method() == &tiny_http::Method::Post { - // Read the body - let mut body = String::new(); - let reader = request.as_reader(); - match reader.read_to_string(&mut body) { - Ok(_) => {} - Err(e) => { - let resp = tiny_http::Response::from_string(format!("Read error: {e}")) - .with_status_code(400); - let _ = request.respond(resp); - continue; - } - } - - let start = Instant::now(); - match inc_compiler.compile(&body) { - ds_incremental::IncrementalResult::Full(html) => { - let ms = start.elapsed().as_millis(); - println!(" ✅ full compile in {ms}ms ({} bytes)", html.len()); - let json = format!(r#"{{"type":"full","html":{}}}"#, json_escape(&html)); - let response = tiny_http::Response::from_string(&json) - .with_header( - tiny_http::Header::from_bytes( - &b"Content-Type"[..], - &b"application/json; charset=utf-8"[..], - ).unwrap(), - ) - .with_header( - tiny_http::Header::from_bytes( - &b"Access-Control-Allow-Origin"[..], - &b"*"[..], - ).unwrap(), - ); - let _ = request.respond(response); - } - ds_incremental::IncrementalResult::Patch(js) => { - let ms = start.elapsed().as_millis(); - if js.is_empty() { - println!(" ⚡ unchanged ({ms}ms)"); - } else { - println!(" ⚡ incremental patch in {ms}ms ({} bytes)", js.len()); - } - let json = format!(r#"{{"type":"patch","js":{}}}"#, json_escape(&js)); - let response = tiny_http::Response::from_string(&json) - .with_header( - tiny_http::Header::from_bytes( - &b"Content-Type"[..], - &b"application/json; charset=utf-8"[..], - ).unwrap(), - ) - .with_header( - tiny_http::Header::from_bytes( - &b"Access-Control-Allow-Origin"[..], - &b"*"[..], - ).unwrap(), - ); - let _ = request.respond(response); - } - ds_incremental::IncrementalResult::Error(e) => { - println!(" ❌ compile error"); - let json = format!(r#"{{"type":"error","message":{}}}"#, json_escape(&e)); - let response = tiny_http::Response::from_string(&json) - .with_status_code(400) - .with_header( - tiny_http::Header::from_bytes( - &b"Content-Type"[..], - &b"application/json; charset=utf-8"[..], - ).unwrap(), - ); - let _ = request.respond(response); - } - } - } else { - // Serve the playground HTML - let response = tiny_http::Response::from_string(&playground_html) - .with_header( - tiny_http::Header::from_bytes( - &b"Content-Type"[..], - &b"text/html; charset=utf-8"[..], - ).unwrap(), - ); - let _ = request.respond(response); - } - } -} - -fn default_playground_source() -> String { - r#"let count = 0 -let label = "Hello, DreamStack!" - -on click -> count = count + 1 - -view main = column [ - text label - text count - button "Increment" { click: count += 1 } -] -"#.to_string() -} - -/// Escape a string for embedding in JSON. -fn json_escape(s: &str) -> String { - let mut out = String::with_capacity(s.len() + 2); - out.push('"'); - for c in s.chars() { - match c { - '"' => out.push_str("\\\""), - '\\' => out.push_str("\\\\"), - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - c if c < '\x20' => out.push_str(&format!("\\u{:04x}", c as u32)), - c => out.push(c), - } - } - out.push('"'); - out -} - -// ── Registry: dreamstack add ── - -struct RegistryItem { - name: &'static str, - description: &'static str, - source: &'static str, - deps: &'static [&'static str], -} - -const REGISTRY: &[RegistryItem] = &[ - RegistryItem { - name: "button", - description: "Styled button with variant support (primary, secondary, ghost, destructive)", - source: include_str!("../../../registry/components/button.ds"), - deps: &[], - }, - RegistryItem { - name: "input", - description: "Text input with label, placeholder, and error state", - source: include_str!("../../../registry/components/input.ds"), - deps: &[], - }, - RegistryItem { - name: "card", - description: "Content container with title and styled border", - source: include_str!("../../../registry/components/card.ds"), - deps: &[], - }, - RegistryItem { - name: "badge", - description: "Status badge with color variants", - source: include_str!("../../../registry/components/badge.ds"), - deps: &[], - }, - RegistryItem { - name: "dialog", - description: "Modal dialog with overlay and close button", - source: include_str!("../../../registry/components/dialog.ds"), - deps: &["button"], - }, - RegistryItem { - name: "toast", - description: "Notification toast with auto-dismiss", - source: include_str!("../../../registry/components/toast.ds"), - deps: &[], - }, - RegistryItem { - name: "progress", - description: "Animated progress bar with percentage", - source: include_str!("../../../registry/components/progress.ds"), - deps: &[], - }, - RegistryItem { - name: "alert", - description: "Alert banner with info/warning/error/success variants", - source: include_str!("../../../registry/components/alert.ds"), - deps: &[], - }, - RegistryItem { - name: "separator", - description: "Visual divider between content sections", - source: include_str!("../../../registry/components/separator.ds"), - deps: &[], - }, - RegistryItem { - name: "toggle", - description: "On/off switch toggle", - source: include_str!("../../../registry/components/toggle.ds"), - deps: &[], - }, - RegistryItem { - name: "avatar", - description: "User avatar with initials fallback", - source: include_str!("../../../registry/components/avatar.ds"), - deps: &[], - }, -]; - -fn cmd_add(name: Option, list: bool, all: bool) { - if list { - println!("📦 Available DreamStack components:\n"); - for item in REGISTRY { - let deps = if item.deps.is_empty() { - String::new() - } else { - format!(" (deps: {})", item.deps.join(", ")) - }; - println!(" {} — {}{}", item.name, item.description, deps); - } - println!("\n Use: dreamstack add "); - return; - } - - let components_dir = Path::new("components"); - if !components_dir.exists() { - fs::create_dir_all(components_dir).expect("Failed to create components/ directory"); - } - - let names_to_add: Vec = if all { - REGISTRY.iter().map(|r| r.name.to_string()).collect() - } else if let Some(name) = name { - vec![name] - } else { - println!("Usage: dreamstack add \n dreamstack add --list\n dreamstack add --all"); - return; - }; - - let mut added = std::collections::HashSet::new(); - for name in &names_to_add { - add_component(name, &components_dir, &mut added); - } - - if added.is_empty() { - println!("❌ No components found. Use 'dreamstack add --list' to see available."); - } -} - -fn add_component(name: &str, dest: &Path, added: &mut std::collections::HashSet) { - if added.contains(name) { - return; - } - - let item = match REGISTRY.iter().find(|r| r.name == name) { - Some(item) => item, - None => { - println!(" ❌ Unknown component: {name}"); - return; - } - }; - - // Add dependencies first - for dep in item.deps { - add_component(dep, dest, added); - } - - let dest_file = dest.join(format!("{}.ds", name)); - // Fix imports: ./button → ./button (relative within components/) - let source = item.source.replace("from \"./", "from \"./"); - fs::write(&dest_file, source).expect("Failed to write component file"); - - let dep_info = if !item.deps.is_empty() { - " (dependency)" - } else { - "" - }; - println!(" ✅ Added components/{}.ds{}", name, dep_info); - added.insert(name.to_string()); -} - -// ── Converter: dreamstack convert ── - -fn cmd_init(name: Option) { - let project_dir = match &name { - Some(n) => PathBuf::from(n), - None => std::env::current_dir().expect("Failed to get current directory"), - }; - - if name.is_some() { - fs::create_dir_all(&project_dir).expect("Failed to create project directory"); - } - - let components_dir = project_dir.join("components"); - fs::create_dir_all(&components_dir).expect("Failed to create components/ directory"); - - // Write starter app.ds - let app_source = r#"-- My DreamStack App --- Showcases: imports, components, when/else, match, each, dynamic lists - -import { Card } from "./components/card" -import { Badge } from "./components/badge" -import { Button } from "./components/button" - -let count = 0 -let name = "" -let darkMode = false -let mood = "happy" -let todos = ["Learn DreamStack", "Build something cool"] -let newTodo = "" - -view main = column [ - - -- Header - text "🚀 My DreamStack App" { variant: "title" } - text "Built with DreamStack — edit app.ds and reload" { variant: "subtitle" } - - -- Dashboard cards - row [ - Card { title: "Counter", subtitle: "reactive state" } [ - text "Count: {count}" { variant: "title" } - row [ - Button { label: "+1", onClick: count += 1, variant: "primary" } - Button { label: "-1", onClick: count -= 1, variant: "secondary" } - Button { label: "Reset", onClick: count = 0, variant: "ghost" } - ] - ] - - Card { title: "Greeting", subtitle: "two-way binding" } [ - input { bind: name, placeholder: "Your name..." } - when name -> text "Hello, {name}! 👋" - else -> text "Type your name above" - ] - ] - - -- Mood selector with match - Card { title: "Mood", subtitle: "match expressions" } [ - row [ - button "😊 Happy" { click: mood = "happy", variant: "primary" } - button "😢 Sad" { click: mood = "sad", variant: "secondary" } - button "🔥 Fired up" { click: mood = "fired", variant: "ghost" } - ] - match mood - "happy" -> Badge { label: "FEELING GREAT 🌟", variant: "success" } - "sad" -> Badge { label: "HANG IN THERE 💙", variant: "info" } - "fired" -> Badge { label: "LET'S GO 🔥", variant: "warning" } - _ -> Badge { label: "HOW ARE YOU?", variant: "info" } - ] - - -- Todo list with dynamic arrays - Card { title: "Todos", subtitle: "dynamic lists" } [ - row [ - input { bind: newTodo, placeholder: "New task..." } - button "Add" { click: todos.push(newTodo), variant: "primary" } - ] - each todo in todos -> - row [ - text "→ {todo}" - button "×" { click: todos.remove(_idx), variant: "ghost" } - ] - button "Clear All" { click: todos = [], variant: "ghost" } - ] - -] -"#; - fs::write(project_dir.join("app.ds"), app_source).expect("Failed to write app.ds"); - - // Write dreamstack.json - let project_name = name.as_deref().unwrap_or("my-dreamstack-app"); - let config = format!(r#"{{ - "name": "{}", - "version": "0.1.0", - "entry": "app.ds" -}} -"#, project_name); - fs::write(project_dir.join("dreamstack.json"), config).expect("Failed to write dreamstack.json"); - - // Add starter components from registry - let starter_components = ["button", "card", "badge", "input"]; - for comp_name in &starter_components { - if let Some(item) = REGISTRY.iter().find(|r| r.name == *comp_name) { - let comp_path = components_dir.join(format!("{}.ds", comp_name)); - fs::write(&comp_path, item.source).expect("Failed to write component"); - } - } - - let display_name = name.as_deref().unwrap_or("."); - println!("🚀 DreamStack project initialized in {}/\n", display_name); - println!(" Created:"); - println!(" app.ds — your main application"); - println!(" dreamstack.json — project config"); - println!(" components/button.ds — button component"); - println!(" components/card.ds — card component"); - println!(" components/badge.ds — badge component"); - println!(" components/input.ds — input component\n"); - println!(" Next steps:"); - if name.is_some() { - println!(" cd {}", display_name); - } - println!(" dreamstack build app.ds -o dist"); - println!(" dreamstack dev app.ds"); - println!(" dreamstack add --list # see all 11 components"); - println!(" dreamstack add dialog # add with deps\n"); -} - -fn cmd_convert(name: &str, shadcn: bool, output: Option<&Path>) { - let tsx_source = if shadcn { - // Fetch from shadcn/ui GitHub - let url = format!( - "https://raw.githubusercontent.com/shadcn-ui/taxonomy/main/components/ui/{}.tsx", - name - ); - println!(" 📥 Fetching {}.tsx from shadcn/ui...", name); - match fetch_url_blocking(&url) { - Ok(source) => source, - Err(e) => { - println!(" ❌ Failed to fetch: {e}"); - println!(" Try providing a local .tsx file instead."); - return; - } - } - } else { - // Read local file - match fs::read_to_string(name) { - Ok(source) => source, - Err(e) => { - println!(" ❌ Cannot read '{name}': {e}"); - return; - } - } - }; - - let ds_output = convert_tsx_to_ds(&tsx_source, name); - - if let Some(out_path) = output { - fs::write(out_path, &ds_output).expect("Failed to write output file"); - println!(" ✅ Converted to {}", out_path.display()); - } else { - println!("{}", ds_output); - } -} - -/// Best-effort TSX → DreamStack converter. -/// Pattern-matches common React/shadcn idioms rather than full TypeScript parsing. -fn convert_tsx_to_ds(tsx: &str, file_hint: &str) -> String { - let mut out = String::new(); - - // Extract component name - let comp_name = extract_component_name(tsx) - .unwrap_or_else(|| { - // Derive from filename - let base = Path::new(file_hint) - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("Component"); - let mut chars = base.chars(); - match chars.next() { - Some(c) => format!("{}{}", c.to_uppercase().collect::(), chars.collect::()), - None => "Component".to_string(), - } - }); - - // Extract props - let props = extract_props(tsx); - - // Header comment - out.push_str(&format!("-- Converted from {}\n", file_hint)); - out.push_str("-- Auto-generated by dreamstack convert\n\n"); - - // Extract useState hooks → let declarations - let state_vars = extract_use_state(tsx); - for (name, default) in &state_vars { - out.push_str(&format!("let {} = {}\n", name, default)); - } - if !state_vars.is_empty() { - out.push('\n'); - } - - // Extract cva variants - let variants = extract_cva_variants(tsx); - if !variants.is_empty() { - out.push_str("-- Variants:\n"); - for (variant_name, values) in &variants { - out.push_str(&format!("-- {}: {}\n", variant_name, values.join(", "))); - } - out.push('\n'); - } - - // Extract JSX body - let jsx_body = extract_jsx_body(tsx); - - // Build component declaration - let props_str = if props.is_empty() { - String::new() - } else { - format!("({})", props.join(", ")) - }; - - out.push_str(&format!("export component {}{} =\n", comp_name, props_str)); - - if jsx_body.is_empty() { - out.push_str(" text \"TODO: convert JSX body\"\n"); - } else { - out.push_str(&jsx_body); - } - - out -} - -/// Extract component name from React.forwardRef or function/const declaration -fn extract_component_name(tsx: &str) -> Option { - // Pattern: `const Button = React.forwardRef` - for line in tsx.lines() { - let trimmed = line.trim(); - if trimmed.contains("forwardRef") || trimmed.contains("React.forwardRef") { - if let Some(pos) = trimmed.find("const ") { - let rest = &trimmed[pos + 6..]; - if let Some(eq_pos) = rest.find(" =") { - return Some(rest[..eq_pos].trim().to_string()); - } - } - } - // Pattern: `export function Button(` - if trimmed.starts_with("export function ") || trimmed.starts_with("function ") { - let rest = trimmed.strip_prefix("export ").unwrap_or(trimmed); - let rest = rest.strip_prefix("function ").unwrap_or(rest); - if let Some(paren) = rest.find('(') { - let name = rest[..paren].trim(); - if name.chars().next().map_or(false, |c| c.is_uppercase()) { - return Some(name.to_string()); - } - } - } - } - None -} - -/// Extract props from destructured function parameters -fn extract_props(tsx: &str) -> Vec { - let mut props = Vec::new(); - // Look for destructured props: `({ className, variant, size, ...props })` - if let Some(start) = tsx.find("({ ") { - if let Some(end) = tsx[start..].find(" })") { - let inner = &tsx[start + 3..start + end]; - for part in inner.split(',') { - let part = part.trim(); - if part.starts_with("...") { continue; } // skip rest props - if part.is_empty() { continue; } - // Handle defaults: `variant = "default"` → just `variant` - let name = part.split('=').next().unwrap_or(part).trim(); - let name = name.split(':').next().unwrap_or(name).trim(); - if !name.is_empty() - && name != "className" - && name != "ref" - && name != "children" - && !props.contains(&name.to_string()) - { - props.push(name.to_string()); - } - } - } - } - props -} - -/// Extract useState hooks: `const [open, setOpen] = useState(false)` → ("open", "false") -fn extract_use_state(tsx: &str) -> Vec<(String, String)> { - let mut states = Vec::new(); - for line in tsx.lines() { - let trimmed = line.trim(); - if trimmed.contains("useState") { - // const [name, setName] = useState(default) - if let Some(bracket_start) = trimmed.find('[') { - if let Some(comma) = trimmed[bracket_start..].find(',') { - let name = trimmed[bracket_start + 1..bracket_start + comma].trim(); - // Extract default value from useState(...) - if let Some(paren_start) = trimmed.find("useState(") { - let rest = &trimmed[paren_start + 9..]; - if let Some(paren_end) = rest.find(')') { - let default = rest[..paren_end].trim(); - let ds_default = convert_value(default); - states.push((name.to_string(), ds_default)); - } - } - } - } - } - } - states -} - -/// Extract cva variant definitions -fn extract_cva_variants(tsx: &str) -> Vec<(String, Vec)> { - let mut variants = Vec::new(); - let mut in_variants = false; - let mut current_variant = String::new(); - let mut current_values = Vec::new(); - - for line in tsx.lines() { - let trimmed = line.trim(); - if trimmed == "variants: {" { - in_variants = true; - continue; - } - if in_variants { - if trimmed == "}," || trimmed == "}" { - if !current_variant.is_empty() { - variants.push((current_variant.clone(), current_values.clone())); - current_variant.clear(); - current_values.clear(); - } - if trimmed == "}," { - // Could be end of a variant group or end of variants - if trimmed == "}," { continue; } - } - in_variants = false; - continue; - } - // Variant group: `variant: {` - if trimmed.ends_with(": {") || trimmed.ends_with(":{") { - if !current_variant.is_empty() { - variants.push((current_variant.clone(), current_values.clone())); - current_values.clear(); - } - current_variant = trimmed.split(':').next().unwrap_or("").trim().to_string(); - continue; - } - // Variant value: `default: "bg-primary text-primary-foreground",` - if trimmed.contains(":") && !current_variant.is_empty() { - let name = trimmed.split(':').next().unwrap_or("").trim().trim_matches('"'); - if !name.is_empty() { - current_values.push(name.to_string()); - } - } - } - } - if !current_variant.is_empty() { - variants.push((current_variant, current_values)); - } - variants -} - -/// Convert a JSX body to DreamStack view syntax (best-effort) -fn extract_jsx_body(tsx: &str) -> String { - let mut out = String::new(); - let mut in_return = false; - let mut depth = 0; - - for line in tsx.lines() { - let trimmed = line.trim(); - - // Find the return statement - if trimmed.starts_with("return (") || trimmed == "return (" { - in_return = true; - depth = 1; - continue; - } - - if !in_return { continue; } - - // Track parens - for c in trimmed.chars() { - match c { - '(' => depth += 1, - ')' => { - depth -= 1; - if depth <= 0 { - in_return = false; - } - } - _ => {} - } - } - - if !in_return && depth <= 0 { break; } - - // Convert JSX elements - let converted = convert_jsx_line(trimmed); - if !converted.is_empty() { - out.push_str(" "); - out.push_str(&converted); - out.push('\n'); - } - } - - out -} - -/// Convert a single JSX line to DreamStack syntax -fn convert_jsx_line(jsx: &str) -> String { - let trimmed = jsx.trim(); - - // Skip closing tags - if trimmed.starts_with("" || trimmed == "" { return String::new(); } - // Skip className-only attributes - if trimmed.starts_with("className=") { return String::new(); } - - // Self-closing tag: `` - if trimmed.starts_with('<') && trimmed.ends_with("/>") { - let inner = &trimmed[1..trimmed.len() - 2].trim(); - let parts: Vec<&str> = inner.splitn(2, ' ').collect(); - let tag = parts[0]; - let ds_tag = convert_html_tag(tag); - if parts.len() > 1 { - let attrs = convert_jsx_attrs(parts[1]); - return format!("{} {{ {} }}", ds_tag, attrs); - } - return ds_tag; - } - - // Opening tag: `