/// 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
── 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}")
}
}
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#"