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("") { return String::new(); }
+ // Skip fragments
+ if trimmed == "<>" || 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: `
+── COMPILE ERROR ──
+{e}
+
+ if let Some(pos) = html.rfind("") {
+ format!("{}{}{}", &html[..pos], HMR_CLIENT_SCRIPT, &html[pos..])
+ } else {
+ // No tag — just append
+ format!("{html}{HMR_CLIENT_SCRIPT}")
+ }
+}
+
+pub 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#"
+