/// DreamStack CLI — the compiler command-line interface. /// /// Usage: /// dreamstack build — compile to HTML+JS /// dreamstack dev — dev server with hot reload /// 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}; #[derive(Parser)] #[command(name = "dreamstack")] #[command(about = "The DreamStack UI compiler", version = "0.1.0")] struct Cli { #[command(subcommand)] command: Commands, } #[derive(Subcommand)] enum Commands { /// Compile a .ds file to HTML+JS Build { /// Input .ds file file: PathBuf, /// Output directory (default: dist/) #[arg(short, long, default_value = "dist")] output: PathBuf, }, /// Start a dev server with hot reload Dev { /// Input .ds file file: PathBuf, /// Port to serve on #[arg(short, long, default_value_t = 3000)] port: u16, }, /// Type-check and analyze without compiling Check { /// Input .ds file file: PathBuf, }, /// Compile and stream a .ds file via bitstream relay Stream { /// Input .ds file file: PathBuf, /// WebSocket relay URL #[arg(short, long, default_value = "ws://localhost:9100")] relay: String, /// Stream mode: pixel | delta | signal #[arg(short, long, default_value = "signal")] mode: String, /// Port to serve the source page on #[arg(short, long, default_value_t = 3000)] port: u16, }, /// Launch the interactive playground with Monaco editor Playground { /// Optional .ds file to pre-load file: Option, /// Port to serve on #[arg(short, long, default_value_t = 4000)] port: u16, }, /// Add a component from the DreamStack registry Add { /// Component name (e.g., button, card, dialog) or --list to show available name: Option, /// List all available components #[arg(long)] list: bool, /// Add all registry components #[arg(long)] all: bool, }, /// Convert a React/TSX component to DreamStack .ds format Convert { /// Input .tsx file path, or component name with --shadcn name: String, /// Fetch from shadcn/ui registry instead of local file #[arg(long)] shadcn: bool, /// Output file path (default: stdout) #[arg(short, long)] output: Option, }, } fn main() { let cli = Cli::parse(); match cli.command { Commands::Build { file, output } => cmd_build(&file, &output), 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()), } } fn compile(source: &str, base_dir: &Path) -> 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::new(tokens); 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); Ok(html) } /// 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) { println!("🔨 DreamStack build"); 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 compile(&source, base_dir) { 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) { 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::NonRecursive) .expect("Failed to watch directory"); println!("👁 Watching {} for changes", 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("."))) { 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!(); 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::new(tokens); 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("."))) { 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_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: `