diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..66a2386 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +/dist +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..7bcccff --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,18 @@ +[workspace] +resolver = "2" +members = [ + "compiler/ds-parser", + "compiler/ds-analyzer", + "compiler/ds-codegen", + "compiler/ds-cli", +] + +[workspace.package] +version = "0.1.0" +edition = "2024" +license = "MIT" + +[workspace.dependencies] +ds-parser = { path = "compiler/ds-parser" } +ds-analyzer = { path = "compiler/ds-analyzer" } +ds-codegen = { path = "compiler/ds-codegen" } diff --git a/compiler/ds-analyzer/Cargo.toml b/compiler/ds-analyzer/Cargo.toml new file mode 100644 index 0000000..d1dd536 --- /dev/null +++ b/compiler/ds-analyzer/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "ds-analyzer" +version.workspace = true +edition.workspace = true + +[dependencies] +ds-parser = { workspace = true } diff --git a/compiler/ds-analyzer/src/lib.rs b/compiler/ds-analyzer/src/lib.rs new file mode 100644 index 0000000..e09c565 --- /dev/null +++ b/compiler/ds-analyzer/src/lib.rs @@ -0,0 +1,4 @@ +/// DreamStack Analyzer — extracts signal dependency graph from parsed AST. +pub mod signal_graph; + +pub use signal_graph::{SignalGraph, SignalNode, SignalKind, Dependency, InitialValue, AnalyzedView, DomBinding, BindingKind}; diff --git a/compiler/ds-analyzer/src/signal_graph.rs b/compiler/ds-analyzer/src/signal_graph.rs new file mode 100644 index 0000000..8edc974 --- /dev/null +++ b/compiler/ds-analyzer/src/signal_graph.rs @@ -0,0 +1,469 @@ +/// Signal graph extraction — the core of DreamStack's compile-time reactivity. +/// +/// Walks the AST and builds a directed acyclic graph (DAG) of signals: +/// - Source signals: `let count = 0` (mutable, user-controlled) +/// - Derived signals: `let doubled = count * 2` (computed, auto-tracked) +/// - Effects: DOM bindings that update when their dependencies change + +use ds_parser::{Program, Declaration, Expr, BinOp, Container, Element, LetDecl, ViewDecl}; +use std::collections::{HashMap, HashSet}; + +/// The complete signal dependency graph for a program. +#[derive(Debug)] +pub struct SignalGraph { + pub nodes: Vec, + pub name_to_id: HashMap, +} + +/// A node in the signal graph. +#[derive(Debug, Clone)] +pub struct SignalNode { + pub id: usize, + pub name: String, + pub kind: SignalKind, + pub dependencies: Vec, + pub initial_value: Option, +} + +#[derive(Debug, Clone)] +pub enum SignalKind { + /// Mutable source signal: `let count = 0` + Source, + /// Computed derived signal: `let doubled = count * 2` + Derived, + /// An event handler that mutates signals + Handler { event: String, mutations: Vec }, +} + +/// What a handler does to a signal. +#[derive(Debug, Clone)] +pub struct Mutation { + pub target: String, + pub op: MutationOp, +} + +#[derive(Debug, Clone)] +pub enum MutationOp { + Set(String), // expression source + AddAssign(String), + SubAssign(String), +} + +/// A dependency edge in the signal graph. +#[derive(Debug, Clone)] +pub struct Dependency { + pub signal_name: String, + pub signal_id: Option, +} + +/// Inferred initial value for source signals. +#[derive(Debug, Clone)] +pub enum InitialValue { + Int(i64), + Float(f64), + Bool(bool), + String(String), +} + +/// Analyzed view information. +#[derive(Debug)] +pub struct AnalyzedView { + pub name: String, + pub bindings: Vec, +} + +/// A reactive DOM binding extracted from a view. +#[derive(Debug, Clone)] +pub struct DomBinding { + pub kind: BindingKind, + pub dependencies: Vec, +} + +#[derive(Debug, Clone)] +pub enum BindingKind { + /// `text label` — text content bound to a signal + TextContent { signal: String }, + /// `button "+" { click: count += 1 }` — event handler on an element + EventHandler { element_tag: String, event: String, action: String }, + /// `when cond -> body` — conditional mount/unmount + Conditional { condition_signals: Vec }, + /// `column [ ... ]` — static container + StaticContainer { kind: String, child_count: usize }, + /// Static text with no binding + StaticText { text: String }, +} + +impl SignalGraph { + /// Build a signal graph from a parsed program. + pub fn from_program(program: &Program) -> Self { + let mut graph = SignalGraph { + nodes: Vec::new(), + name_to_id: HashMap::new(), + }; + + // First pass: register all let declarations as signals + for decl in &program.declarations { + if let Declaration::Let(let_decl) = decl { + let deps = extract_dependencies(&let_decl.value); + let kind = if deps.is_empty() { + SignalKind::Source + } else { + SignalKind::Derived + }; + + let initial = match &let_decl.value { + Expr::IntLit(n) => Some(InitialValue::Int(*n)), + Expr::FloatLit(n) => Some(InitialValue::Float(*n)), + Expr::BoolLit(b) => Some(InitialValue::Bool(*b)), + Expr::StringLit(s) => { + if s.segments.len() == 1 { + if let ds_parser::StringSegment::Literal(text) = &s.segments[0] { + Some(InitialValue::String(text.clone())) + } else { + None + } + } else { + None + } + } + _ => None, + }; + + let id = graph.nodes.len(); + let dependencies: Vec = deps.into_iter() + .map(|name| Dependency { signal_name: name, signal_id: None }) + .collect(); + + graph.name_to_id.insert(let_decl.name.clone(), id); + graph.nodes.push(SignalNode { + id, + name: let_decl.name.clone(), + kind, + dependencies, + initial_value: initial, + }); + } + } + + // Second pass: register event handlers + for decl in &program.declarations { + if let Declaration::OnHandler(handler) = decl { + let mutations = extract_mutations(&handler.body); + let deps: Vec = mutations.iter().map(|m| m.target.clone()).collect(); + let id = graph.nodes.len(); + + graph.nodes.push(SignalNode { + id, + name: format!("handler_{}", handler.event), + kind: SignalKind::Handler { + event: handler.event.clone(), + mutations, + }, + dependencies: deps.into_iter() + .map(|name| Dependency { signal_name: name, signal_id: None }) + .collect(), + initial_value: None, + }); + } + } + + // Third pass: resolve dependency IDs + let name_map = graph.name_to_id.clone(); + for node in &mut graph.nodes { + for dep in &mut node.dependencies { + dep.signal_id = name_map.get(&dep.signal_name).copied(); + } + } + + graph + } + + /// Analyze views and extract DOM bindings. + pub fn analyze_views(program: &Program) -> Vec { + let mut views = Vec::new(); + for decl in &program.declarations { + if let Declaration::View(view) = decl { + let bindings = extract_bindings(&view.body); + views.push(AnalyzedView { + name: view.name.clone(), + bindings, + }); + } + } + views + } + + /// Get topological order for signal propagation. + pub fn topological_order(&self) -> Vec { + let mut visited = HashSet::new(); + let mut order = Vec::new(); + + for node in &self.nodes { + if !visited.contains(&node.id) { + self.topo_visit(node.id, &mut visited, &mut order); + } + } + + order + } + + fn topo_visit(&self, id: usize, visited: &mut HashSet, order: &mut Vec) { + if visited.contains(&id) { + return; + } + visited.insert(id); + + for dep in &self.nodes[id].dependencies { + if let Some(dep_id) = dep.signal_id { + self.topo_visit(dep_id, visited, order); + } + } + + order.push(id); + } +} + +/// Extract all signal names referenced in an expression. +fn extract_dependencies(expr: &Expr) -> Vec { + let mut deps = Vec::new(); + collect_deps(expr, &mut deps); + deps.sort(); + deps.dedup(); + deps +} + +fn collect_deps(expr: &Expr, deps: &mut Vec) { + match expr { + Expr::Ident(name) => deps.push(name.clone()), + Expr::DotAccess(base, _) => collect_deps(base, deps), + Expr::BinOp(left, _, right) => { + collect_deps(left, deps); + collect_deps(right, deps); + } + Expr::UnaryOp(_, inner) => collect_deps(inner, deps), + Expr::Call(_, args) => { + for arg in args { + collect_deps(arg, deps); + } + } + Expr::If(cond, then_b, else_b) => { + collect_deps(cond, deps); + collect_deps(then_b, deps); + collect_deps(else_b, deps); + } + Expr::Pipe(left, right) => { + collect_deps(left, deps); + collect_deps(right, deps); + } + Expr::Container(c) => { + for child in &c.children { + collect_deps(child, deps); + } + } + Expr::Element(el) => { + for arg in &el.args { + collect_deps(arg, deps); + } + for (_, val) in &el.props { + collect_deps(val, deps); + } + } + Expr::Record(fields) => { + for (_, val) in fields { + collect_deps(val, deps); + } + } + Expr::List(items) => { + for item in items { + collect_deps(item, deps); + } + } + Expr::When(cond, body) => { + collect_deps(cond, deps); + collect_deps(body, deps); + } + Expr::Match(scrutinee, arms) => { + collect_deps(scrutinee, deps); + for arm in arms { + collect_deps(&arm.body, deps); + } + } + Expr::Assign(target, _, value) => { + collect_deps(target, deps); + collect_deps(value, deps); + } + Expr::Lambda(_, body) => collect_deps(body, deps), + Expr::StringLit(s) => { + for seg in &s.segments { + if let ds_parser::StringSegment::Interpolation(expr) = seg { + collect_deps(expr, deps); + } + } + } + _ => {} + } +} + +/// Extract mutations from a handler body (e.g., `count += 1`). +fn extract_mutations(expr: &Expr) -> Vec { + let mut mutations = Vec::new(); + match expr { + Expr::Assign(target, op, value) => { + if let Expr::Ident(name) = target.as_ref() { + let mutation_op = match op { + ds_parser::AssignOp::Set => MutationOp::Set(format!("{value:?}")), + ds_parser::AssignOp::AddAssign => MutationOp::AddAssign(format!("{value:?}")), + ds_parser::AssignOp::SubAssign => MutationOp::SubAssign(format!("{value:?}")), + }; + mutations.push(Mutation { target: name.clone(), op: mutation_op }); + } + } + Expr::Block(exprs) => { + for e in exprs { + mutations.extend(extract_mutations(e)); + } + } + _ => {} + } + mutations +} + +/// Extract DOM bindings from a view body. +fn extract_bindings(expr: &Expr) -> Vec { + let mut bindings = Vec::new(); + collect_bindings(expr, &mut bindings); + bindings +} + +fn collect_bindings(expr: &Expr, bindings: &mut Vec) { + match expr { + Expr::Container(c) => { + let kind_str = match &c.kind { + ds_parser::ContainerKind::Column => "column", + ds_parser::ContainerKind::Row => "row", + ds_parser::ContainerKind::Stack => "stack", + ds_parser::ContainerKind::Panel => "panel", + ds_parser::ContainerKind::List => "list", + ds_parser::ContainerKind::Form => "form", + ds_parser::ContainerKind::Custom(s) => s, + }; + bindings.push(DomBinding { + kind: BindingKind::StaticContainer { + kind: kind_str.to_string(), + child_count: c.children.len(), + }, + dependencies: Vec::new(), + }); + for child in &c.children { + collect_bindings(child, bindings); + } + } + Expr::Element(el) => { + // Check if any arg is an identifier (signal binding) + for arg in &el.args { + match arg { + Expr::Ident(name) => { + bindings.push(DomBinding { + kind: BindingKind::TextContent { signal: name.clone() }, + dependencies: vec![name.clone()], + }); + } + Expr::StringLit(s) => { + if let Some(ds_parser::StringSegment::Literal(text)) = s.segments.first() { + bindings.push(DomBinding { + kind: BindingKind::StaticText { text: text.clone() }, + dependencies: Vec::new(), + }); + } + } + _ => {} + } + } + // Check props for event handlers + for (key, val) in &el.props { + if matches!(key.as_str(), "click" | "input" | "change" | "submit" | "keydown" | "keyup") { + let action = format!("{val:?}"); + let deps = extract_dependencies(val); + bindings.push(DomBinding { + kind: BindingKind::EventHandler { + element_tag: el.tag.clone(), + event: key.clone(), + action, + }, + dependencies: deps, + }); + } + } + } + Expr::When(cond, body) => { + let deps = extract_dependencies(cond); + bindings.push(DomBinding { + kind: BindingKind::Conditional { condition_signals: deps.clone() }, + dependencies: deps, + }); + collect_bindings(body, bindings); + } + _ => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ds_parser::{Lexer, Parser}; + + fn analyze(src: &str) -> (SignalGraph, Vec) { + let mut lexer = Lexer::new(src); + let tokens = lexer.tokenize(); + let mut parser = Parser::new(tokens); + let program = parser.parse_program().expect("parse failed"); + let graph = SignalGraph::from_program(&program); + let views = SignalGraph::analyze_views(&program); + (graph, views) + } + + #[test] + fn test_source_signal() { + let (graph, _) = analyze("let count = 0"); + assert_eq!(graph.nodes.len(), 1); + assert!(matches!(graph.nodes[0].kind, SignalKind::Source)); + assert_eq!(graph.nodes[0].name, "count"); + } + + #[test] + fn test_derived_signal() { + let (graph, _) = analyze("let count = 0\nlet doubled = count * 2"); + assert_eq!(graph.nodes.len(), 2); + assert!(matches!(graph.nodes[0].kind, SignalKind::Source)); + assert!(matches!(graph.nodes[1].kind, SignalKind::Derived)); + assert_eq!(graph.nodes[1].dependencies[0].signal_name, "count"); + assert_eq!(graph.nodes[1].dependencies[0].signal_id, Some(0)); + } + + #[test] + fn test_topological_order() { + let (graph, _) = analyze("let count = 0\nlet doubled = count * 2"); + let order = graph.topological_order(); + // count (id=0) should come before doubled (id=1) + let pos_count = order.iter().position(|&id| id == 0).unwrap(); + let pos_doubled = order.iter().position(|&id| id == 1).unwrap(); + assert!(pos_count < pos_doubled); + } + + #[test] + fn test_view_bindings() { + let (_, views) = analyze( + r#"let label = "hi" + +view counter = + column [ + text label + button "+" { click: count += 1 } + ]"# + ); + assert_eq!(views.len(), 1); + assert_eq!(views[0].name, "counter"); + // Should have: container, text binding, static text, event handler + assert!(views[0].bindings.len() >= 3); + } +} diff --git a/compiler/ds-cli/Cargo.toml b/compiler/ds-cli/Cargo.toml new file mode 100644 index 0000000..8311ba9 --- /dev/null +++ b/compiler/ds-cli/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "ds-cli" +version.workspace = true +edition.workspace = true + +[[bin]] +name = "dreamstack" +path = "src/main.rs" + +[dependencies] +ds-parser = { workspace = true } +ds-analyzer = { workspace = true } +ds-codegen = { workspace = true } +clap = { version = "4", features = ["derive"] } +notify = "8" +tiny_http = "0.12" diff --git a/compiler/ds-cli/src/main.rs b/compiler/ds-cli/src/main.rs new file mode 100644 index 0000000..9ad7afc --- /dev/null +++ b/compiler/ds-cli/src/main.rs @@ -0,0 +1,249 @@ +/// 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}; + +#[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, + }, +} + +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), + } +} + +fn compile(source: &str) -> 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 program = parser.parse_program().map_err(|e| e.to_string())?; + + // 3. Analyze + let graph = ds_analyzer::SignalGraph::from_program(&program); + let views = ds_analyzer::SignalGraph::analyze_views(&program); + + // 4. Codegen + let html = ds_codegen::JsEmitter::emit_html(&program, &graph, &views); + + Ok(html) +} + +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); + } + }; + + match compile(&source) { + 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); + } + } +} + +fn cmd_dev(file: &Path, port: u16) { + println!("🚀 DreamStack dev server"); + println!(" watching: {}", file.display()); + println!(" serving: http://localhost:{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); + } + }; + + let html = match compile(&source) { + Ok(html) => html, + Err(e) => { + eprintln!("❌ Compile error: {e}"); + std::process::exit(1); + } + }; + + // Simple 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() { + // Re-compile on each request for dev mode (simple hot-reload) + let current_html = if let Ok(src) = fs::read_to_string(file) { + compile(&src).unwrap_or_else(|e| { + format!("
Compile error:\n{e}
") + }) + } else { + html.clone() + }; + + let response = tiny_http::Response::from_string(¤t_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 { event, .. } => "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); + } +} diff --git a/compiler/ds-codegen/Cargo.toml b/compiler/ds-codegen/Cargo.toml new file mode 100644 index 0000000..0a95c2f --- /dev/null +++ b/compiler/ds-codegen/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "ds-codegen" +version.workspace = true +edition.workspace = true + +[dependencies] +ds-parser = { workspace = true } +ds-analyzer = { workspace = true } diff --git a/compiler/ds-codegen/src/js_emitter.rs b/compiler/ds-codegen/src/js_emitter.rs new file mode 100644 index 0000000..becacef --- /dev/null +++ b/compiler/ds-codegen/src/js_emitter.rs @@ -0,0 +1,749 @@ +/// JavaScript emitter — generates executable JS from DreamStack AST + signal graph. + +use ds_parser::*; +use ds_analyzer::{SignalGraph, SignalKind, AnalyzedView, InitialValue}; + +pub struct JsEmitter { + output: String, + indent: usize, + node_id_counter: usize, +} + +impl JsEmitter { + pub fn new() -> Self { + Self { + output: String::new(), + indent: 0, + node_id_counter: 0, + } + } + + /// Generate a complete HTML page with embedded runtime and compiled app. + pub fn emit_html(program: &Program, graph: &SignalGraph, views: &[AnalyzedView]) -> String { + let mut emitter = Self::new(); + let app_js = emitter.emit_program(program, graph, views); + + format!( + r#" + + + + + DreamStack App + + + +
+ + + +"# + ) + } + + /// Generate the application JS module. + pub fn emit_program(&mut self, program: &Program, graph: &SignalGraph, views: &[AnalyzedView]) -> String { + self.output.clear(); + + self.emit_line("// DreamStack compiled output"); + self.emit_line("// Generated by dreamstack compiler v0.1.0"); + self.emit_line(""); + self.emit_line("(function() {"); + self.indent += 1; + + // Phase 1: Create all signals + self.emit_line("// ── Signals ──"); + for node in &graph.nodes { + match &node.kind { + SignalKind::Source => { + let init = match &node.initial_value { + Some(InitialValue::Int(n)) => format!("{n}"), + Some(InitialValue::Float(n)) => format!("{n}"), + Some(InitialValue::Bool(b)) => format!("{b}"), + Some(InitialValue::String(s)) => format!("\"{s}\""), + None => "null".to_string(), + }; + self.emit_line(&format!("const {} = DS.signal({});", node.name, init)); + } + SignalKind::Derived => { + // Find the let declaration to get the expression + let expr = self.find_let_expr(program, &node.name); + if let Some(expr) = expr { + let js_expr = self.emit_expr(expr); + self.emit_line(&format!( + "const {} = DS.derived(() => {});", + node.name, js_expr + )); + } + } + SignalKind::Handler { .. } => {} // Handled later + } + } + + self.emit_line(""); + + // Phase 2: Build views + self.emit_line("// ── Views ──"); + for decl in &program.declarations { + if let Declaration::View(view) = decl { + self.emit_view(view, graph); + } + } + + // Phase 3: Event handlers + self.emit_line(""); + self.emit_line("// ── Handlers ──"); + for decl in &program.declarations { + if let Declaration::OnHandler(handler) = decl { + self.emit_handler(handler); + } + } + + // Phase 4: Mount to DOM + self.emit_line(""); + self.emit_line("// ── Mount ──"); + if let Some(view) = views.first() { + self.emit_line(&format!( + "document.getElementById('ds-root').appendChild(view_{}());", + view.name + )); + } + + self.indent -= 1; + self.emit_line("})();"); + + self.output.clone() + } + + fn emit_view(&mut self, view: &ViewDecl, graph: &SignalGraph) { + self.emit_line(&format!("function view_{}() {{", view.name)); + self.indent += 1; + let root_id = self.emit_view_expr(&view.body, graph); + self.emit_line(&format!("return {};", root_id)); + self.indent -= 1; + self.emit_line("}"); + } + + /// Emit a view expression and return the variable name of the created DOM node. + fn emit_view_expr(&mut self, expr: &Expr, graph: &SignalGraph) -> String { + match expr { + Expr::Container(container) => { + let node_var = self.next_node_id(); + let tag = match &container.kind { + ContainerKind::Column => "div", + ContainerKind::Row => "div", + ContainerKind::Stack => "div", + ContainerKind::Panel => "div", + ContainerKind::Form => "form", + ContainerKind::List => "ul", + ContainerKind::Custom(s) => s, + }; + + let class = match &container.kind { + ContainerKind::Column => "ds-column", + ContainerKind::Row => "ds-row", + ContainerKind::Stack => "ds-stack", + ContainerKind::Panel => "ds-panel", + _ => "", + }; + + self.emit_line(&format!("const {} = document.createElement('{}');", node_var, tag)); + if !class.is_empty() { + self.emit_line(&format!("{}.className = '{}';", node_var, class)); + } + + // Handle container props + for (key, val) in &container.props { + let js_val = self.emit_expr(val); + if graph.name_to_id.contains_key(&js_val) || self.is_signal_ref(&js_val) { + self.emit_line(&format!( + "DS.effect(() => {{ {}.style.{} = {}.value + 'px'; }});", + node_var, key, js_val + )); + } else { + self.emit_line(&format!("{}.style.{} = {};", node_var, key, js_val)); + } + } + + // Emit children + for child in &container.children { + let child_var = self.emit_view_expr(child, graph); + self.emit_line(&format!("{}.appendChild({});", node_var, child_var)); + } + + node_var + } + + Expr::Element(element) => { + let node_var = self.next_node_id(); + let html_tag = match element.tag.as_str() { + "text" => "span", + "button" => "button", + "input" => "input", + "image" | "avatar" => "img", + "link" => "a", + "label" => "label", + "spinner" => "div", + "skeleton" => "div", + _ => "div", + }; + + self.emit_line(&format!("const {} = document.createElement('{}');", node_var, html_tag)); + self.emit_line(&format!("{}.className = 'ds-{}';", node_var, element.tag)); + + // Handle text content / arguments + for arg in &element.args { + match arg { + Expr::StringLit(s) => { + if let Some(StringSegment::Literal(text)) = s.segments.first() { + self.emit_line(&format!( + "{}.textContent = \"{}\";", + node_var, + text.replace('"', "\\\"") + )); + } + } + Expr::Ident(name) => { + // Reactive text binding! + self.emit_line(&format!( + "DS.effect(() => {{ {}.textContent = {}.value; }});", + node_var, name + )); + } + _ => { + let js = self.emit_expr(arg); + self.emit_line(&format!("{}.textContent = {};", node_var, js)); + } + } + } + + // Handle props (event handlers, attributes) + for (key, val) in &element.props { + match key.as_str() { + "click" | "input" | "change" | "submit" | "keydown" | "keyup" | "mousedown" | "mouseup" => { + let handler_js = self.emit_event_handler_expr(val); + let dom_event = match key.as_str() { + "click" => "click", + "input" => "input", + "change" => "change", + "submit" => "submit", + "keydown" => "keydown", + "keyup" => "keyup", + "mousedown" => "mousedown", + "mouseup" => "mouseup", + _ => key, + }; + self.emit_line(&format!( + "{}.addEventListener('{}', (e) => {{ {}; DS.flush(); }});", + node_var, dom_event, handler_js + )); + } + "class" => { + let js = self.emit_expr(val); + self.emit_line(&format!("{}.className += ' ' + {};", node_var, js)); + } + _ => { + let js = self.emit_expr(val); + self.emit_line(&format!("{}.setAttribute('{}', {});", node_var, key, js)); + } + } + } + + node_var + } + + Expr::When(cond, body) => { + let anchor_var = self.next_node_id(); + let container_var = self.next_node_id(); + let cond_js = self.emit_expr(cond); + + self.emit_line(&format!("const {} = document.createComment('when');", anchor_var)); + self.emit_line(&format!("let {} = null;", container_var)); + + // Build the conditional child + self.emit_line("DS.effect(() => {"); + self.indent += 1; + self.emit_line(&format!("const show = {};", cond_js)); + self.emit_line(&format!("if (show && !{}) {{", container_var)); + self.indent += 1; + let child_var = self.emit_view_expr(body, graph); + self.emit_line(&format!("{} = {};", container_var, child_var)); + self.emit_line(&format!( + "{}.parentNode.insertBefore({}, {}.nextSibling);", + anchor_var, container_var, anchor_var + )); + self.indent -= 1; + self.emit_line(&format!("}} else if (!show && {}) {{", container_var)); + self.indent += 1; + self.emit_line(&format!("{}.remove();", container_var)); + self.emit_line(&format!("{} = null;", container_var)); + self.indent -= 1; + self.emit_line("}"); + self.indent -= 1; + self.emit_line("});"); + + anchor_var + } + + Expr::Match(scrutinee, arms) => { + let container_var = self.next_node_id(); + self.emit_line(&format!("const {} = document.createElement('div');", container_var)); + self.emit_line(&format!("{}.className = 'ds-match';", container_var)); + + let scrutinee_js = self.emit_expr(scrutinee); + self.emit_line("DS.effect(() => {"); + self.indent += 1; + self.emit_line(&format!("const val = {};", scrutinee_js)); + self.emit_line(&format!("{}.innerHTML = '';", container_var)); + + for (i, arm) in arms.iter().enumerate() { + let prefix = if i == 0 { "if" } else { "} else if" }; + let pattern_js = self.emit_pattern_check(&arm.pattern, "val"); + self.emit_line(&format!("{} ({}) {{", prefix, pattern_js)); + self.indent += 1; + let child = self.emit_view_expr(&arm.body, graph); + self.emit_line(&format!("{}.appendChild({});", container_var, child)); + self.indent -= 1; + } + self.emit_line("}"); + self.indent -= 1; + self.emit_line("});"); + + container_var + } + + // Fallback: just create a text node + _ => { + let node_var = self.next_node_id(); + let js = self.emit_expr(expr); + self.emit_line(&format!( + "const {} = document.createTextNode({});", + node_var, js + )); + node_var + } + } + } + + fn emit_handler(&mut self, handler: &OnHandler) { + let handler_js = self.emit_event_handler_expr(&handler.body); + if let Some(param) = &handler.param { + self.emit_line(&format!( + "DS.onEvent('{}', ({}) => {{ {}; DS.flush(); }});", + handler.event, param, handler_js + )); + } else { + self.emit_line(&format!( + "DS.onEvent('{}', () => {{ {}; DS.flush(); }});", + handler.event, handler_js + )); + } + } + + // ── Expression emitters ───────────────────────────── + + fn emit_expr(&self, expr: &Expr) -> String { + match expr { + Expr::IntLit(n) => format!("{n}"), + Expr::FloatLit(n) => format!("{n}"), + Expr::BoolLit(b) => format!("{b}"), + Expr::StringLit(s) => { + if s.segments.len() == 1 { + if let StringSegment::Literal(text) = &s.segments[0] { + return format!("\"{}\"", text.replace('"', "\\\"")); + } + } + // Template literal with interpolation + let mut parts = Vec::new(); + for seg in &s.segments { + match seg { + StringSegment::Literal(text) => parts.push(text.clone()), + StringSegment::Interpolation(expr) => { + parts.push(format!("${{{}}}", self.emit_expr(expr))); + } + } + } + format!("`{}`", parts.join("")) + } + Expr::Ident(name) => format!("{name}.value"), + Expr::DotAccess(base, field) => { + let base_js = self.emit_expr(base); + format!("{base_js}.{field}") + } + Expr::BinOp(left, op, right) => { + let l = self.emit_expr(left); + let r = self.emit_expr(right); + let op_str = match op { + BinOp::Add => "+", + BinOp::Sub => "-", + BinOp::Mul => "*", + BinOp::Div => "/", + BinOp::Mod => "%", + BinOp::Eq => "===", + BinOp::Neq => "!==", + BinOp::Lt => "<", + BinOp::Gt => ">", + BinOp::Lte => "<=", + BinOp::Gte => ">=", + BinOp::And => "&&", + BinOp::Or => "||", + }; + format!("({l} {op_str} {r})") + } + Expr::UnaryOp(op, inner) => { + let inner_js = self.emit_expr(inner); + match op { + UnaryOp::Neg => format!("(-{inner_js})"), + UnaryOp::Not => format!("(!{inner_js})"), + } + } + Expr::Call(name, args) => { + let args_js: Vec = args.iter().map(|a| self.emit_expr(a)).collect(); + format!("{}({})", name, args_js.join(", ")) + } + Expr::If(cond, then_b, else_b) => { + let c = self.emit_expr(cond); + let t = self.emit_expr(then_b); + let e = self.emit_expr(else_b); + format!("({c} ? {t} : {e})") + } + Expr::Lambda(params, body) => { + let body_js = self.emit_expr(body); + format!("({}) => {}", params.join(", "), body_js) + } + Expr::Record(fields) => { + let fields_js: Vec = fields + .iter() + .map(|(k, v)| format!("{}: {}", k, self.emit_expr(v))) + .collect(); + format!("{{ {} }}", fields_js.join(", ")) + } + Expr::List(items) => { + let items_js: Vec = items.iter().map(|i| self.emit_expr(i)).collect(); + format!("[{}]", items_js.join(", ")) + } + _ => "null".to_string(), + } + } + + /// Emit an event handler expression (assignment, etc.) + fn emit_event_handler_expr(&self, expr: &Expr) -> String { + match expr { + Expr::Assign(target, op, value) => { + let target_js = match target.as_ref() { + Expr::Ident(name) => name.clone(), + Expr::DotAccess(base, field) => { + format!("{}.{}", self.emit_expr(base), field) + } + _ => self.emit_expr(target), + }; + let value_js = self.emit_expr(value); + match op { + AssignOp::Set => format!("{target_js}.value = {value_js}"), + AssignOp::AddAssign => format!("{target_js}.value += {value_js}"), + AssignOp::SubAssign => format!("{target_js}.value -= {value_js}"), + } + } + Expr::Block(exprs) => { + let stmts: Vec = exprs.iter().map(|e| self.emit_event_handler_expr(e)).collect(); + stmts.join("; ") + } + _ => self.emit_expr(expr), + } + } + + /// Emit a pattern matching condition check. + fn emit_pattern_check(&self, pattern: &Pattern, scrutinee: &str) -> String { + match pattern { + Pattern::Wildcard => "true".to_string(), + Pattern::Ident(name) => { + // Bind: always true, but assign + format!("(({name} = {scrutinee}), true)") + } + Pattern::Constructor(name, _fields) => { + format!("{scrutinee} === '{name}'") + } + Pattern::Literal(expr) => { + let val = self.emit_expr(expr); + format!("{scrutinee} === {val}") + } + } + } + + // ── Helpers ────────────────────────────────────────── + + fn next_node_id(&mut self) -> String { + let id = self.node_id_counter; + self.node_id_counter += 1; + format!("n{id}") + } + + fn emit_line(&mut self, line: &str) { + for _ in 0..self.indent { + self.output.push_str(" "); + } + self.output.push_str(line); + self.output.push('\n'); + } + + fn find_let_expr<'a>(&self, program: &'a Program, name: &str) -> Option<&'a Expr> { + for decl in &program.declarations { + if let Declaration::Let(let_decl) = decl { + if let_decl.name == name { + return Some(&let_decl.value); + } + } + } + None + } + + fn is_signal_ref(&self, expr: &str) -> bool { + // Simple heuristic: if it's a single identifier, it's likely a signal + expr.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') + } +} + +impl Default for JsEmitter { + fn default() -> Self { + Self::new() + } +} + +/// Minimal CSS reset and layout classes. +const CSS_RESET: &str = r#" +* { margin: 0; padding: 0; box-sizing: border-box; } +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; + background: #0a0a0f; + color: #e2e8f0; + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; +} +#ds-root { + width: 100%; + max-width: 800px; + padding: 2rem; +} +.ds-column { + display: flex; + flex-direction: column; + gap: 1rem; +} +.ds-row { + display: flex; + flex-direction: row; + gap: 1rem; + align-items: center; +} +.ds-stack { + position: relative; +} +.ds-panel { + position: absolute; +} +.ds-text, .ds-text span { + font-size: 1.25rem; + color: #e2e8f0; +} +.ds-button, button.ds-button { + background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); + color: white; + border: none; + padding: 0.75rem 1.5rem; + border-radius: 12px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + box-shadow: 0 4px 15px rgba(99, 102, 241, 0.3); +} +.ds-button:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(99, 102, 241, 0.4); +} +.ds-button:active { + transform: translateY(0); +} +.ds-input, input.ds-input { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + color: #e2e8f0; + padding: 0.75rem 1rem; + border-radius: 12px; + font-size: 1rem; + outline: none; + transition: border-color 0.2s; +} +.ds-input:focus { + border-color: #6366f1; + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15); +} +.ds-spinner { + width: 2rem; + height: 2rem; + border: 3px solid rgba(255,255,255,0.1); + border-top-color: #6366f1; + border-radius: 50%; + animation: ds-spin 0.8s linear infinite; +} +@keyframes ds-spin { + to { transform: rotate(360deg); } +} +@keyframes ds-fade-in { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} +.ds-fade-in { + animation: ds-fade-in 0.3s ease-out; +} +"#; + +/// The DreamStack client-side reactive runtime (~3KB). +const RUNTIME_JS: &str = r#" +const DS = (() => { + // ── Signal System ── + let currentEffect = null; + let batchDepth = 0; + let pendingEffects = new Set(); + + class Signal { + constructor(initialValue) { + this._value = initialValue; + this._subscribers = new Set(); + } + get value() { + if (currentEffect) { + this._subscribers.add(currentEffect); + } + return this._value; + } + set value(newValue) { + if (this._value === newValue) return; + this._value = newValue; + if (batchDepth > 0) { + for (const sub of this._subscribers) { + pendingEffects.add(sub); + } + } else { + for (const sub of [...this._subscribers]) { + sub._run(); + } + } + } + } + + class Derived { + constructor(fn) { + this._fn = fn; + this._value = undefined; + this._dirty = true; + this._subscribers = new Set(); + this._effect = new Effect(() => { + this._value = this._fn(); + this._dirty = false; + // Notify our subscribers + if (batchDepth > 0) { + for (const sub of this._subscribers) { + pendingEffects.add(sub); + } + } else { + for (const sub of [...this._subscribers]) { + sub._run(); + } + } + }); + this._effect._run(); + } + get value() { + if (currentEffect) { + this._subscribers.add(currentEffect); + } + return this._value; + } + } + + class Effect { + constructor(fn) { + this._fn = fn; + this._disposed = false; + } + _run() { + if (this._disposed) return; + const prev = currentEffect; + currentEffect = this; + try { + this._fn(); + } finally { + currentEffect = prev; + } + } + dispose() { + this._disposed = true; + } + } + + // ── Public API ── + function signal(value) { + return new Signal(value); + } + + function derived(fn) { + return new Derived(fn); + } + + function effect(fn) { + const eff = new Effect(fn); + eff._run(); + return eff; + } + + function batch(fn) { + batchDepth++; + try { + fn(); + } finally { + batchDepth--; + if (batchDepth === 0) { + flush(); + } + } + } + + function flush() { + const effects = [...pendingEffects]; + pendingEffects.clear(); + for (const eff of effects) { + eff._run(); + } + } + + // ── Event System ── + const eventHandlers = {}; + + function onEvent(name, handler) { + if (!eventHandlers[name]) eventHandlers[name] = []; + eventHandlers[name].push(handler); + } + + function emit(name, data) { + const handlers = eventHandlers[name]; + if (handlers) { + batch(() => { + for (const h of handlers) { + h(data); + } + }); + } + } + + return { signal, derived, effect, batch, flush, onEvent, emit, Signal, Derived, Effect }; +})(); +"#; diff --git a/compiler/ds-codegen/src/lib.rs b/compiler/ds-codegen/src/lib.rs new file mode 100644 index 0000000..51ec1da --- /dev/null +++ b/compiler/ds-codegen/src/lib.rs @@ -0,0 +1,7 @@ +/// DreamStack Code Generator — emits JavaScript from analyzed AST. +/// +/// Strategy: emit a single JS module that imports the DreamStack runtime +/// and creates signals, derived values, DOM bindings, and event handlers. +pub mod js_emitter; + +pub use js_emitter::JsEmitter; diff --git a/compiler/ds-parser/Cargo.toml b/compiler/ds-parser/Cargo.toml new file mode 100644 index 0000000..8a2182e --- /dev/null +++ b/compiler/ds-parser/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "ds-parser" +version.workspace = true +edition.workspace = true + +[dependencies] diff --git a/compiler/ds-parser/src/ast.rs b/compiler/ds-parser/src/ast.rs new file mode 100644 index 0000000..ee701f7 --- /dev/null +++ b/compiler/ds-parser/src/ast.rs @@ -0,0 +1,226 @@ +/// The AST for DreamStack. +/// Homoiconic: every node is also representable as data (tagged vectors/maps). + +/// A complete DreamStack program is a list of top-level declarations. +#[derive(Debug, Clone)] +pub struct Program { + pub declarations: Vec, +} + +/// Top-level declarations. +#[derive(Debug, Clone)] +pub enum Declaration { + /// `let name = expr` + Let(LetDecl), + /// `view name = expr` or `view name(params) = expr` + View(ViewDecl), + /// `effect name(params): ReturnType` + Effect(EffectDecl), + /// `on event_name -> body` + OnHandler(OnHandler), +} + +/// `let count = 0` or `let doubled = count * 2` +#[derive(Debug, Clone)] +pub struct LetDecl { + pub name: String, + pub value: Expr, + pub span: Span, +} + +/// `view counter = column [ ... ]` +/// `view profile(id: UserId) = ...` +#[derive(Debug, Clone)] +pub struct ViewDecl { + pub name: String, + pub params: Vec, + pub body: Expr, + pub span: Span, +} + +/// `effect fetchUser(id: UserId): Result` +#[derive(Debug, Clone)] +pub struct EffectDecl { + pub name: String, + pub params: Vec, + pub return_type: TypeExpr, + pub span: Span, +} + +/// `on toggle_sidebar -> ...` +#[derive(Debug, Clone)] +pub struct OnHandler { + pub event: String, + pub param: Option, + pub body: Expr, + pub span: Span, +} + +/// Function/view parameter. +#[derive(Debug, Clone)] +pub struct Param { + pub name: String, + pub type_annotation: Option, +} + +/// Type expressions (simplified for Phase 0). +#[derive(Debug, Clone)] +pub enum TypeExpr { + Named(String), + Generic(String, Vec), +} + +/// Expressions — the core of the language. +#[derive(Debug, Clone)] +pub enum Expr { + /// Integer literal: `42` + IntLit(i64), + /// Float literal: `3.14` + FloatLit(f64), + /// String literal: `"hello"` (may contain `{interpolation}`) + StringLit(StringLit), + /// Boolean literal: `true` / `false` + BoolLit(bool), + /// Identifier: `count`, `sidebar.width` + Ident(String), + /// Dotted access: `user.name` + DotAccess(Box, String), + /// Binary operation: `a + b`, `count > 10` + BinOp(Box, BinOp, Box), + /// Unary operation: `-x`, `!flag` + UnaryOp(UnaryOp, Box), + /// Assignment: `count += 1`, `panel_x.target = 0` + Assign(Box, AssignOp, Box), + /// Function call: `clamp(200, 20vw, 350)` + Call(String, Vec), + /// Block: multiple expressions, last is the value + Block(Vec), + /// View element: `text "hello"`, `button "+" { click: ... }` + Element(Element), + /// Container: `column [ ... ]`, `row [ ... ]` + Container(Container), + /// When conditional: `when count > 10 -> ...` + When(Box, Box), + /// Match expression + Match(Box, Vec), + /// Pipe: `expr | operator` + Pipe(Box, Box), + /// `perform effectName(args)` + Perform(String, Vec), + /// `stream from source` + StreamFrom(String), + /// Lambda: `(x -> x * 2)` + Lambda(Vec, Box), + /// Record literal: `{ key: value, ... }` + Record(Vec<(String, Expr)>), + /// List literal: `[a, b, c]` + List(Vec), + /// `if cond then a else b` + If(Box, Box, Box), + /// Spring: `spring(target: 0, stiffness: 300, damping: 30)` + Spring(Vec<(String, Expr)>), +} + +/// String literal with interpolation segments. +#[derive(Debug, Clone)] +pub struct StringLit { + pub segments: Vec, +} + +#[derive(Debug, Clone)] +pub enum StringSegment { + Literal(String), + Interpolation(Box), +} + +/// Binary operators. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum BinOp { + Add, + Sub, + Mul, + Div, + Mod, + Eq, + Neq, + Lt, + Gt, + Lte, + Gte, + And, + Or, +} + +/// Unary operators. +#[derive(Debug, Clone, Copy)] +pub enum UnaryOp { + Neg, + Not, +} + +/// Assignment operators. +#[derive(Debug, Clone, Copy)] +pub enum AssignOp { + Set, + AddAssign, + SubAssign, +} + +/// A UI element: `text label`, `button "+" { click: handler }` +#[derive(Debug, Clone)] +pub struct Element { + pub tag: String, + pub args: Vec, + pub props: Vec<(String, Expr)>, + pub modifiers: Vec, +} + +/// A container: `column [ child1, child2 ]` +#[derive(Debug, Clone)] +pub struct Container { + pub kind: ContainerKind, + pub children: Vec, + pub props: Vec<(String, Expr)>, +} + +#[derive(Debug, Clone)] +pub enum ContainerKind { + Column, + Row, + Stack, + List, + Panel, + Form, + Custom(String), +} + +/// Match arm: `Ok(u) -> column [ ... ]` +#[derive(Debug, Clone)] +pub struct MatchArm { + pub pattern: Pattern, + pub body: Expr, +} + +/// Pattern matching. +#[derive(Debug, Clone)] +pub enum Pattern { + Wildcard, + Ident(String), + Constructor(String, Vec), + Literal(Expr), +} + +/// Modifiers: `| animate fade-in 200ms` +#[derive(Debug, Clone)] +pub struct Modifier { + pub name: String, + pub args: Vec, +} + +/// Source location tracking. +#[derive(Debug, Clone, Copy, Default)] +pub struct Span { + pub start: usize, + pub end: usize, + pub line: usize, +} diff --git a/compiler/ds-parser/src/lexer.rs b/compiler/ds-parser/src/lexer.rs new file mode 100644 index 0000000..a194b06 --- /dev/null +++ b/compiler/ds-parser/src/lexer.rs @@ -0,0 +1,438 @@ +/// DreamStack Lexer — tokenizes source into a stream of tokens. + +#[derive(Debug, Clone, PartialEq)] +pub struct Token { + pub kind: TokenKind, + pub lexeme: String, + pub line: usize, + pub col: usize, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum TokenKind { + // Literals + Int(i64), + Float(f64), + StringStart, // opening " + StringFragment(String), // literal part of string + StringInterp, // { inside string + StringEnd, // closing " + True, + False, + + // Identifiers & keywords + Ident(String), + Let, + View, + Effect, + On, + When, + Match, + If, + Then, + Else, + Perform, + Handle, + With, + Stream, + From, + Spring, + Column, + Row, + Stack, + Panel, + List, + Form, + Animate, + + // Operators + Plus, + Minus, + Star, + Slash, + Percent, + Eq, // = + EqEq, // == + Neq, // != + Lt, // < + Gt, // > + Lte, // <= + Gte, // >= + And, // && + Or, // || + Not, // ! + PlusEq, // += + MinusEq, // -= + Arrow, // -> + Pipe, // | + Dot, // . + + // Delimiters + LParen, + RParen, + LBracket, + RBracket, + LBrace, + RBrace, + Comma, + Colon, + Newline, + + // Special + Comment(String), + Eof, + Error(String), +} + +pub struct Lexer { + source: Vec, + pos: usize, + line: usize, + col: usize, + in_string: bool, + interp_depth: usize, +} + +impl Lexer { + pub fn new(source: &str) -> Self { + Self { + source: source.chars().collect(), + pos: 0, + line: 1, + col: 1, + in_string: false, + interp_depth: 0, + } + } + + pub fn tokenize(&mut self) -> Vec { + let mut tokens = Vec::new(); + loop { + let tok = self.next_token(); + let is_eof = tok.kind == TokenKind::Eof; + // Skip comments and consecutive newlines + match &tok.kind { + TokenKind::Comment(_) => continue, + TokenKind::Newline => { + if tokens.last().is_some_and(|t: &Token| t.kind == TokenKind::Newline) { + continue; + } + } + _ => {} + } + tokens.push(tok); + if is_eof { + break; + } + } + tokens + } + + fn peek(&self) -> char { + self.source.get(self.pos).copied().unwrap_or('\0') + } + + fn peek_next(&self) -> char { + self.source.get(self.pos + 1).copied().unwrap_or('\0') + } + + fn advance(&mut self) -> char { + let c = self.peek(); + self.pos += 1; + if c == '\n' { + self.line += 1; + self.col = 1; + } else { + self.col += 1; + } + c + } + + fn make_token(&self, kind: TokenKind, lexeme: &str) -> Token { + Token { + kind, + lexeme: lexeme.to_string(), + line: self.line, + col: self.col, + } + } + + fn skip_whitespace(&mut self) { + while self.pos < self.source.len() { + match self.peek() { + ' ' | '\t' | '\r' => { self.advance(); } + _ => break, + } + } + } + + fn next_token(&mut self) -> Token { + // If we're inside string interpolation and hit }, return to string mode + if self.in_string && self.interp_depth == 0 { + return self.lex_string_continuation(); + } + + self.skip_whitespace(); + + if self.pos >= self.source.len() { + return self.make_token(TokenKind::Eof, ""); + } + + let line = self.line; + let col = self.col; + let c = self.peek(); + + let tok = match c { + '\n' => { self.advance(); Token { kind: TokenKind::Newline, lexeme: "\n".into(), line, col } } + '-' if self.peek_next() == '-' => self.lex_comment(), + '-' if self.peek_next() == '>' => { self.advance(); self.advance(); Token { kind: TokenKind::Arrow, lexeme: "->".into(), line, col } } + '-' if self.peek_next() == '=' => { self.advance(); self.advance(); Token { kind: TokenKind::MinusEq, lexeme: "-=".into(), line, col } } + '+' if self.peek_next() == '=' => { self.advance(); self.advance(); Token { kind: TokenKind::PlusEq, lexeme: "+=".into(), line, col } } + '=' if self.peek_next() == '=' => { self.advance(); self.advance(); Token { kind: TokenKind::EqEq, lexeme: "==".into(), line, col } } + '!' if self.peek_next() == '=' => { self.advance(); self.advance(); Token { kind: TokenKind::Neq, lexeme: "!=".into(), line, col } } + '<' if self.peek_next() == '=' => { self.advance(); self.advance(); Token { kind: TokenKind::Lte, lexeme: "<=".into(), line, col } } + '>' if self.peek_next() == '=' => { self.advance(); self.advance(); Token { kind: TokenKind::Gte, lexeme: ">=".into(), line, col } } + '&' if self.peek_next() == '&' => { self.advance(); self.advance(); Token { kind: TokenKind::And, lexeme: "&&".into(), line, col } } + '|' if self.peek_next() == '|' => { self.advance(); self.advance(); Token { kind: TokenKind::Or, lexeme: "||".into(), line, col } } + '+' => { self.advance(); Token { kind: TokenKind::Plus, lexeme: "+".into(), line, col } } + '-' => { self.advance(); Token { kind: TokenKind::Minus, lexeme: "-".into(), line, col } } + '*' => { self.advance(); Token { kind: TokenKind::Star, lexeme: "*".into(), line, col } } + '/' => { self.advance(); Token { kind: TokenKind::Slash, lexeme: "/".into(), line, col } } + '%' => { self.advance(); Token { kind: TokenKind::Percent, lexeme: "%".into(), line, col } } + '=' => { self.advance(); Token { kind: TokenKind::Eq, lexeme: "=".into(), line, col } } + '<' => { self.advance(); Token { kind: TokenKind::Lt, lexeme: "<".into(), line, col } } + '>' => { self.advance(); Token { kind: TokenKind::Gt, lexeme: ">".into(), line, col } } + '!' => { self.advance(); Token { kind: TokenKind::Not, lexeme: "!".into(), line, col } } + '|' => { self.advance(); Token { kind: TokenKind::Pipe, lexeme: "|".into(), line, col } } + '.' => { self.advance(); Token { kind: TokenKind::Dot, lexeme: ".".into(), line, col } } + '(' => { self.advance(); Token { kind: TokenKind::LParen, lexeme: "(".into(), line, col } } + ')' => { self.advance(); Token { kind: TokenKind::RParen, lexeme: ")".into(), line, col } } + '[' => { self.advance(); Token { kind: TokenKind::LBracket, lexeme: "[".into(), line, col } } + ']' => { self.advance(); Token { kind: TokenKind::RBracket, lexeme: "]".into(), line, col } } + '{' => { + self.advance(); + if self.in_string { + self.interp_depth += 1; + } + Token { kind: TokenKind::LBrace, lexeme: "{".into(), line, col } + } + '}' => { + self.advance(); + if self.interp_depth > 0 { + self.interp_depth -= 1; + } + Token { kind: TokenKind::RBrace, lexeme: "}".into(), line, col } + } + ',' => { self.advance(); Token { kind: TokenKind::Comma, lexeme: ",".into(), line, col } } + ':' => { self.advance(); Token { kind: TokenKind::Colon, lexeme: ":".into(), line, col } } + '"' => self.lex_string_start(), + c if c.is_ascii_digit() => self.lex_number(), + c if c.is_ascii_alphabetic() || c == '_' => self.lex_ident_or_keyword(), + _ => { + self.advance(); + Token { kind: TokenKind::Error(format!("unexpected character: {c}")), lexeme: c.to_string(), line, col } + } + }; + + tok + } + + fn lex_comment(&mut self) -> Token { + let line = self.line; + let col = self.col; + self.advance(); // - + self.advance(); // - + let mut text = String::new(); + while self.pos < self.source.len() && self.peek() != '\n' { + text.push(self.advance()); + } + Token { kind: TokenKind::Comment(text.trim().to_string()), lexeme: format!("--{text}"), line, col } + } + + fn lex_number(&mut self) -> Token { + let line = self.line; + let col = self.col; + let mut num = String::new(); + let mut is_float = false; + + while self.pos < self.source.len() && (self.peek().is_ascii_digit() || self.peek() == '.') { + if self.peek() == '.' { + if is_float { break; } + // Check it's not a method call (e.g. `foo.bar`) + if self.peek_next().is_ascii_alphabetic() { break; } + is_float = true; + } + num.push(self.advance()); + } + + if is_float { + let val: f64 = num.parse().unwrap_or(0.0); + Token { kind: TokenKind::Float(val), lexeme: num, line, col } + } else { + let val: i64 = num.parse().unwrap_or(0); + Token { kind: TokenKind::Int(val), lexeme: num, line, col } + } + } + + fn lex_ident_or_keyword(&mut self) -> Token { + let line = self.line; + let col = self.col; + let mut ident = String::new(); + + while self.pos < self.source.len() && (self.peek().is_ascii_alphanumeric() || self.peek() == '_') { + ident.push(self.advance()); + } + + let kind = match ident.as_str() { + "let" => TokenKind::Let, + "view" => TokenKind::View, + "effect" => TokenKind::Effect, + "on" => TokenKind::On, + "when" => TokenKind::When, + "match" => TokenKind::Match, + "if" => TokenKind::If, + "then" => TokenKind::Then, + "else" => TokenKind::Else, + "perform" => TokenKind::Perform, + "handle" => TokenKind::Handle, + "with" => TokenKind::With, + "stream" => TokenKind::Stream, + "from" => TokenKind::From, + "spring" => TokenKind::Spring, + "column" => TokenKind::Column, + "row" => TokenKind::Row, + "stack" => TokenKind::Stack, + "panel" => TokenKind::Panel, + "list" => TokenKind::List, + "form" => TokenKind::Form, + "animate" => TokenKind::Animate, + "true" => TokenKind::True, + "false" => TokenKind::False, + _ => TokenKind::Ident(ident.clone()), + }; + + Token { kind, lexeme: ident, line, col } + } + + fn lex_string_start(&mut self) -> Token { + let line = self.line; + let col = self.col; + self.advance(); // consume opening " + self.in_string = true; + // Now lex the string content + self.lex_string_body(line, col) + } + + fn lex_string_continuation(&mut self) -> Token { + let line = self.line; + let col = self.col; + self.lex_string_body(line, col) + } + + fn lex_string_body(&mut self, line: usize, col: usize) -> Token { + let mut text = String::new(); + + while self.pos < self.source.len() { + match self.peek() { + '"' => { + // End of string + self.advance(); + self.in_string = false; + if text.is_empty() { + return Token { kind: TokenKind::StringEnd, lexeme: "\"".into(), line, col }; + } + // Return fragment first, next call will return StringEnd + // Actually let's simplify: return the full string as a single token + return Token { kind: TokenKind::StringFragment(text.clone()), lexeme: format!("{text}\""), line, col }; + } + '{' => { + // String interpolation + self.advance(); + self.interp_depth += 1; + if text.is_empty() { + return Token { kind: TokenKind::StringInterp, lexeme: "{".into(), line, col }; + } + return Token { kind: TokenKind::StringFragment(text.clone()), lexeme: text, line, col }; + } + '\\' => { + self.advance(); + match self.peek() { + 'n' => { self.advance(); text.push('\n'); } + 't' => { self.advance(); text.push('\t'); } + '\\' => { self.advance(); text.push('\\'); } + '"' => { self.advance(); text.push('"'); } + '{' => { self.advance(); text.push('{'); } + _ => { text.push('\\'); } + } + } + c => { + self.advance(); + text.push(c); + } + } + } + + // Unterminated string + Token { kind: TokenKind::Error("unterminated string".into()), lexeme: text, line, col } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_basic_tokens() { + let mut lexer = Lexer::new("let count = 0"); + let tokens = lexer.tokenize(); + assert!(matches!(tokens[0].kind, TokenKind::Let)); + assert!(matches!(&tokens[1].kind, TokenKind::Ident(s) if s == "count")); + assert!(matches!(tokens[2].kind, TokenKind::Eq)); + assert!(matches!(tokens[3].kind, TokenKind::Int(0))); + } + + #[test] + fn test_view_declaration() { + let mut lexer = Lexer::new("view counter =\n column [\n text label\n ]"); + let tokens = lexer.tokenize(); + assert!(matches!(tokens[0].kind, TokenKind::View)); + assert!(matches!(&tokens[1].kind, TokenKind::Ident(s) if s == "counter")); + assert!(matches!(tokens[2].kind, TokenKind::Eq)); + assert!(matches!(tokens[3].kind, TokenKind::Newline)); + assert!(matches!(tokens[4].kind, TokenKind::Column)); + } + + #[test] + fn test_operators() { + let mut lexer = Lexer::new("count > 10 && x <= 5"); + let tokens = lexer.tokenize(); + assert!(matches!(tokens[1].kind, TokenKind::Gt)); + assert!(matches!(tokens[3].kind, TokenKind::And)); + assert!(matches!(tokens[5].kind, TokenKind::Lte)); + } + + #[test] + fn test_arrow() { + let mut lexer = Lexer::new("when x > 0 ->"); + let tokens = lexer.tokenize(); + assert!(matches!(tokens[4].kind, TokenKind::Arrow)); + } + + #[test] + fn test_string_simple() { + let mut lexer = Lexer::new(r#""hello world""#); + let tokens = lexer.tokenize(); + assert!(matches!(&tokens[0].kind, TokenKind::StringFragment(s) if s == "hello world")); + } + + #[test] + fn test_comment() { + let mut lexer = Lexer::new("let x = 5 -- this is a comment\nlet y = 10"); + let tokens = lexer.tokenize(); + // Comments are skipped + assert!(matches!(tokens[0].kind, TokenKind::Let)); + assert!(matches!(tokens[3].kind, TokenKind::Int(5))); + assert!(matches!(tokens[4].kind, TokenKind::Newline)); + assert!(matches!(tokens[5].kind, TokenKind::Let)); + } +} diff --git a/compiler/ds-parser/src/lib.rs b/compiler/ds-parser/src/lib.rs new file mode 100644 index 0000000..7b93c6d --- /dev/null +++ b/compiler/ds-parser/src/lib.rs @@ -0,0 +1,7 @@ +pub mod ast; +pub mod lexer; +pub mod parser; + +pub use ast::*; +pub use lexer::{Lexer, Token, TokenKind}; +pub use parser::Parser; diff --git a/compiler/ds-parser/src/parser.rs b/compiler/ds-parser/src/parser.rs new file mode 100644 index 0000000..4571cc8 --- /dev/null +++ b/compiler/ds-parser/src/parser.rs @@ -0,0 +1,967 @@ +/// DreamStack Parser — recursive descent parser producing AST from tokens. +use crate::ast::*; +use crate::lexer::{Token, TokenKind}; + +pub struct Parser { + tokens: Vec, + pos: usize, +} + +impl Parser { + pub fn new(tokens: Vec) -> Self { + Self { tokens, pos: 0 } + } + + pub fn parse_program(&mut self) -> Result { + let mut declarations = Vec::new(); + self.skip_newlines(); + + while !self.is_at_end() { + let decl = self.parse_declaration()?; + declarations.push(decl); + self.skip_newlines(); + } + + Ok(Program { declarations }) + } + + // ── Helpers ────────────────────────────────────────── + + fn peek(&self) -> &TokenKind { + self.tokens + .get(self.pos) + .map(|t| &t.kind) + .unwrap_or(&TokenKind::Eof) + } + + fn current_token(&self) -> &Token { + &self.tokens[self.pos.min(self.tokens.len() - 1)] + } + + fn advance(&mut self) -> &Token { + let tok = &self.tokens[self.pos.min(self.tokens.len() - 1)]; + self.pos += 1; + tok + } + + fn expect(&mut self, expected: &TokenKind) -> Result<&Token, ParseError> { + if std::mem::discriminant(self.peek()) == std::mem::discriminant(expected) { + Ok(self.advance()) + } else { + Err(self.error(format!("expected {expected:?}, got {:?}", self.peek()))) + } + } + + fn expect_ident(&mut self) -> Result { + match self.peek().clone() { + TokenKind::Ident(name) => { + self.advance(); + Ok(name) + } + // Also accept keywords that can be used as identifiers in some contexts + _ => Err(self.error(format!("expected identifier, got {:?}", self.peek()))), + } + } + + fn check(&self, kind: &TokenKind) -> bool { + std::mem::discriminant(self.peek()) == std::mem::discriminant(kind) + } + + fn is_at_end(&self) -> bool { + matches!(self.peek(), TokenKind::Eof) + } + + fn skip_newlines(&mut self) { + while matches!(self.peek(), TokenKind::Newline) { + self.advance(); + } + } + + fn error(&self, msg: String) -> ParseError { + let tok = self.current_token(); + ParseError { + message: msg, + line: tok.line, + col: tok.col, + } + } + + // ── Declarations ──────────────────────────────────── + + fn parse_declaration(&mut self) -> Result { + match self.peek() { + TokenKind::Let => self.parse_let_decl(), + TokenKind::View => self.parse_view_decl(), + TokenKind::Effect => self.parse_effect_decl(), + TokenKind::On => self.parse_on_handler(), + _ => Err(self.error(format!( + "expected declaration (let, view, effect, on), got {:?}", + self.peek() + ))), + } + } + + fn parse_let_decl(&mut self) -> Result { + let line = self.current_token().line; + self.advance(); // consume 'let' + let name = self.expect_ident()?; + self.expect(&TokenKind::Eq)?; + let value = self.parse_expr()?; + + Ok(Declaration::Let(LetDecl { + name, + value, + span: Span { start: 0, end: 0, line }, + })) + } + + fn parse_view_decl(&mut self) -> Result { + let line = self.current_token().line; + self.advance(); // consume 'view' + let name = self.expect_ident()?; + + // Optional parameters + let params = if self.check(&TokenKind::LParen) { + self.parse_params()? + } else { + Vec::new() + }; + + self.expect(&TokenKind::Eq)?; + self.skip_newlines(); + let body = self.parse_expr()?; + + Ok(Declaration::View(ViewDecl { + name, + params, + body, + span: Span { start: 0, end: 0, line }, + })) + } + + fn parse_effect_decl(&mut self) -> Result { + let line = self.current_token().line; + self.advance(); // consume 'effect' + let name = self.expect_ident()?; + let params = self.parse_params()?; + self.expect(&TokenKind::Colon)?; + let return_type = self.parse_type_expr()?; + + Ok(Declaration::Effect(EffectDecl { + name, + params, + return_type, + span: Span { start: 0, end: 0, line }, + })) + } + + fn parse_on_handler(&mut self) -> Result { + let line = self.current_token().line; + self.advance(); // consume 'on' + let event = self.expect_ident()?; + + // Optional parameter: `on drag(event) ->` + let param = if self.check(&TokenKind::LParen) { + self.advance(); + let p = self.expect_ident()?; + self.expect(&TokenKind::RParen)?; + Some(p) + } else { + None + }; + + self.expect(&TokenKind::Arrow)?; + self.skip_newlines(); + let body = self.parse_expr()?; + + Ok(Declaration::OnHandler(OnHandler { + event, + param, + body, + span: Span { start: 0, end: 0, line }, + })) + } + + fn parse_params(&mut self) -> Result, ParseError> { + self.expect(&TokenKind::LParen)?; + let mut params = Vec::new(); + + while !self.check(&TokenKind::RParen) && !self.is_at_end() { + let name = self.expect_ident()?; + let type_annotation = if self.check(&TokenKind::Colon) { + self.advance(); + Some(self.parse_type_expr()?) + } else { + None + }; + params.push(Param { name, type_annotation }); + if self.check(&TokenKind::Comma) { + self.advance(); + } + } + + self.expect(&TokenKind::RParen)?; + Ok(params) + } + + fn parse_type_expr(&mut self) -> Result { + let name = self.expect_ident()?; + if self.check(&TokenKind::Lt) { + self.advance(); + let mut type_args = Vec::new(); + while !self.check(&TokenKind::Gt) && !self.is_at_end() { + type_args.push(self.parse_type_expr()?); + if self.check(&TokenKind::Comma) { + self.advance(); + } + } + self.expect(&TokenKind::Gt)?; + Ok(TypeExpr::Generic(name, type_args)) + } else { + Ok(TypeExpr::Named(name)) + } + } + + // ── Expressions ───────────────────────────────────── + + fn parse_expr(&mut self) -> Result { + self.parse_pipe_expr() + } + + /// Pipe: `expr | operator` + fn parse_pipe_expr(&mut self) -> Result { + let mut left = self.parse_assignment()?; + + while self.check(&TokenKind::Pipe) { + self.advance(); + self.skip_newlines(); + let right = self.parse_assignment()?; + left = Expr::Pipe(Box::new(left), Box::new(right)); + } + + Ok(left) + } + + /// Assignment: `x = value`, `x += 1` + fn parse_assignment(&mut self) -> Result { + let expr = self.parse_or()?; + + match self.peek() { + TokenKind::Eq => { + self.advance(); + self.skip_newlines(); + let value = self.parse_expr()?; + Ok(Expr::Assign(Box::new(expr), AssignOp::Set, Box::new(value))) + } + TokenKind::PlusEq => { + self.advance(); + let value = self.parse_expr()?; + Ok(Expr::Assign(Box::new(expr), AssignOp::AddAssign, Box::new(value))) + } + TokenKind::MinusEq => { + self.advance(); + let value = self.parse_expr()?; + Ok(Expr::Assign(Box::new(expr), AssignOp::SubAssign, Box::new(value))) + } + _ => Ok(expr), + } + } + + /// `||` + fn parse_or(&mut self) -> Result { + let mut left = self.parse_and()?; + while self.check(&TokenKind::Or) { + self.advance(); + let right = self.parse_and()?; + left = Expr::BinOp(Box::new(left), BinOp::Or, Box::new(right)); + } + Ok(left) + } + + /// `&&` + fn parse_and(&mut self) -> Result { + let mut left = self.parse_comparison()?; + while self.check(&TokenKind::And) { + self.advance(); + let right = self.parse_comparison()?; + left = Expr::BinOp(Box::new(left), BinOp::And, Box::new(right)); + } + Ok(left) + } + + /// `==`, `!=`, `<`, `>`, `<=`, `>=` + fn parse_comparison(&mut self) -> Result { + let mut left = self.parse_additive()?; + loop { + let op = match self.peek() { + TokenKind::EqEq => BinOp::Eq, + TokenKind::Neq => BinOp::Neq, + TokenKind::Lt => BinOp::Lt, + TokenKind::Gt => BinOp::Gt, + TokenKind::Lte => BinOp::Lte, + TokenKind::Gte => BinOp::Gte, + _ => break, + }; + self.advance(); + let right = self.parse_additive()?; + left = Expr::BinOp(Box::new(left), op, Box::new(right)); + } + Ok(left) + } + + /// `+`, `-` + fn parse_additive(&mut self) -> Result { + let mut left = self.parse_multiplicative()?; + loop { + let op = match self.peek() { + TokenKind::Plus => BinOp::Add, + TokenKind::Minus => BinOp::Sub, + _ => break, + }; + self.advance(); + let right = self.parse_multiplicative()?; + left = Expr::BinOp(Box::new(left), op, Box::new(right)); + } + Ok(left) + } + + /// `*`, `/`, `%` + fn parse_multiplicative(&mut self) -> Result { + let mut left = self.parse_unary()?; + loop { + let op = match self.peek() { + TokenKind::Star => BinOp::Mul, + TokenKind::Slash => BinOp::Div, + TokenKind::Percent => BinOp::Mod, + _ => break, + }; + self.advance(); + let right = self.parse_unary()?; + left = Expr::BinOp(Box::new(left), op, Box::new(right)); + } + Ok(left) + } + + /// `-x`, `!flag` + fn parse_unary(&mut self) -> Result { + match self.peek() { + TokenKind::Minus => { + self.advance(); + let expr = self.parse_unary()?; + Ok(Expr::UnaryOp(UnaryOp::Neg, Box::new(expr))) + } + TokenKind::Not => { + self.advance(); + let expr = self.parse_unary()?; + Ok(Expr::UnaryOp(UnaryOp::Not, Box::new(expr))) + } + _ => self.parse_postfix(), + } + } + + /// Dot access: `user.name`, function calls: `clamp(a, b)` + fn parse_postfix(&mut self) -> Result { + let mut expr = self.parse_primary()?; + + loop { + match self.peek() { + TokenKind::Dot => { + self.advance(); + let field = self.expect_ident()?; + expr = Expr::DotAccess(Box::new(expr), field); + } + _ => break, + } + } + + Ok(expr) + } + + /// Primary expressions: literals, identifiers, containers, etc. + fn parse_primary(&mut self) -> Result { + match self.peek().clone() { + TokenKind::Int(n) => { + self.advance(); + Ok(Expr::IntLit(n)) + } + TokenKind::Float(n) => { + self.advance(); + Ok(Expr::FloatLit(n)) + } + TokenKind::True => { + self.advance(); + Ok(Expr::BoolLit(true)) + } + TokenKind::False => { + self.advance(); + Ok(Expr::BoolLit(false)) + } + TokenKind::StringFragment(s) => { + self.advance(); + Ok(Expr::StringLit(StringLit { + segments: vec![StringSegment::Literal(s)], + })) + } + TokenKind::StringEnd => { + self.advance(); + Ok(Expr::StringLit(StringLit { + segments: vec![StringSegment::Literal(String::new())], + })) + } + + // Containers + TokenKind::Column => self.parse_container(ContainerKind::Column), + TokenKind::Row => self.parse_container(ContainerKind::Row), + TokenKind::Stack => self.parse_container(ContainerKind::Stack), + TokenKind::Panel => self.parse_container_with_props(ContainerKind::Panel), + + // When conditional + TokenKind::When => { + self.advance(); + let cond = self.parse_comparison()?; + self.expect(&TokenKind::Arrow)?; + self.skip_newlines(); + let body = self.parse_expr()?; + Ok(Expr::When(Box::new(cond), Box::new(body))) + } + + // Match + TokenKind::Match => { + self.advance(); + let scrutinee = self.parse_primary()?; + self.skip_newlines(); + let mut arms = Vec::new(); + while !self.is_at_end() + && !matches!(self.peek(), TokenKind::Let | TokenKind::View | TokenKind::On | TokenKind::Effect) + { + self.skip_newlines(); + if self.is_at_end() || matches!(self.peek(), TokenKind::Let | TokenKind::View | TokenKind::On | TokenKind::Effect) { + break; + } + let pattern = self.parse_pattern()?; + self.expect(&TokenKind::Arrow)?; + self.skip_newlines(); + let body = self.parse_expr()?; + arms.push(MatchArm { pattern, body }); + self.skip_newlines(); + } + Ok(Expr::Match(Box::new(scrutinee), arms)) + } + + // If-then-else + TokenKind::If => { + self.advance(); + let cond = self.parse_expr()?; + self.expect(&TokenKind::Then)?; + let then_branch = self.parse_expr()?; + self.expect(&TokenKind::Else)?; + let else_branch = self.parse_expr()?; + Ok(Expr::If(Box::new(cond), Box::new(then_branch), Box::new(else_branch))) + } + + // Perform effect + TokenKind::Perform => { + self.advance(); + let name = self.expect_ident()?; + let args = if self.check(&TokenKind::LParen) { + self.parse_call_args()? + } else { + Vec::new() + }; + Ok(Expr::Perform(name, args)) + } + + // Stream from + TokenKind::Stream => { + self.advance(); + self.expect(&TokenKind::From)?; + let source = self.expect_ident()?; + // Allow dotted source: `button.click` + let mut full_source = source; + while self.check(&TokenKind::Dot) { + self.advance(); + let next = self.expect_ident()?; + full_source = format!("{full_source}.{next}"); + } + Ok(Expr::StreamFrom(full_source)) + } + + // Spring + TokenKind::Spring => { + self.advance(); + self.expect(&TokenKind::LParen)?; + let mut props = Vec::new(); + while !self.check(&TokenKind::RParen) && !self.is_at_end() { + let key = self.expect_ident()?; + self.expect(&TokenKind::Colon)?; + let val = self.parse_expr()?; + props.push((key, val)); + if self.check(&TokenKind::Comma) { + self.advance(); + } + } + self.expect(&TokenKind::RParen)?; + Ok(Expr::Spring(props)) + } + + // Record: `{ key: value }` + TokenKind::LBrace => { + self.advance(); + self.skip_newlines(); + let mut fields = Vec::new(); + while !self.check(&TokenKind::RBrace) && !self.is_at_end() { + self.skip_newlines(); + let key = self.expect_ident()?; + self.expect(&TokenKind::Colon)?; + self.skip_newlines(); + let val = self.parse_expr()?; + fields.push((key, val)); + self.skip_newlines(); + if self.check(&TokenKind::Comma) { + self.advance(); + } + self.skip_newlines(); + } + self.expect(&TokenKind::RBrace)?; + Ok(Expr::Record(fields)) + } + + // List: `[a, b, c]` + TokenKind::LBracket => { + self.advance(); + self.skip_newlines(); + let mut items = Vec::new(); + while !self.check(&TokenKind::RBracket) && !self.is_at_end() { + self.skip_newlines(); + items.push(self.parse_expr()?); + self.skip_newlines(); + if self.check(&TokenKind::Comma) { + self.advance(); + } + self.skip_newlines(); + } + self.expect(&TokenKind::RBracket)?; + Ok(Expr::List(items)) + } + + // Parenthesized expression or lambda + TokenKind::LParen => { + self.advance(); + // Check for lambda: `(x -> x * 2)` or `(x, y -> x + y)` + let expr = self.parse_expr()?; + if self.check(&TokenKind::Arrow) { + // This is a lambda + let params = vec![self.expr_to_ident(&expr)?]; + self.advance(); // consume -> + let body = self.parse_expr()?; + self.expect(&TokenKind::RParen)?; + Ok(Expr::Lambda(params, Box::new(body))) + } else if self.check(&TokenKind::Comma) { + // Could be multi-param lambda or tuple — check ahead + let mut items = vec![expr]; + while self.check(&TokenKind::Comma) { + self.advance(); + items.push(self.parse_expr()?); + } + if self.check(&TokenKind::Arrow) { + // Multi-param lambda + let mut params = Vec::new(); + for item in &items { + params.push(self.expr_to_ident(item)?); + } + self.advance(); // -> + let body = self.parse_expr()?; + self.expect(&TokenKind::RParen)?; + Ok(Expr::Lambda(params, Box::new(body))) + } else { + // Just a parenthesized expression (take first) + self.expect(&TokenKind::RParen)?; + Ok(items.into_iter().next().unwrap()) + } + } else { + self.expect(&TokenKind::RParen)?; + Ok(expr) + } + } + + // Animate modifier (used in pipe context) + TokenKind::Animate => { + self.advance(); + let name = self.expect_ident()?; + // Parse optional duration: `200ms` + let mut args = Vec::new(); + if let TokenKind::Int(n) = self.peek().clone() { + self.advance(); + // Check for unit suffix (we treat `ms` as part of value for now) + args.push(Expr::IntLit(n)); + } + Ok(Expr::Call(format!("animate_{name}"), args)) + } + + // Identifier — variable reference, element, or function call + TokenKind::Ident(name) => { + let name = name.clone(); + self.advance(); + + // Function call: `name(args)` + if self.check(&TokenKind::LParen) { + let args = self.parse_call_args()?; + Ok(Expr::Call(name, args)) + } + // Element with string arg: `text "hello"`, `button "+"` + else if matches!(self.peek(), TokenKind::StringFragment(_)) { + let fallback = name.clone(); + match self.parse_element(name)? { + Some(el) => Ok(el), + None => Ok(Expr::Ident(fallback)), + } + } + // Element with ident arg: `text label` + else if is_ui_element(&name) && matches!(self.peek(), TokenKind::Ident(_)) { + let fallback = name.clone(); + match self.parse_element(name)? { + Some(el) => Ok(el), + None => Ok(Expr::Ident(fallback)), + } + } + else { + Ok(Expr::Ident(name)) + } + } + + _ => Err(self.error(format!("unexpected token: {:?}", self.peek()))), + } + } + + fn parse_element(&mut self, tag: String) -> Result, ParseError> { + let mut args = Vec::new(); + let mut props = Vec::new(); + let mut modifiers = Vec::new(); + + // Parse string or ident args + loop { + match self.peek().clone() { + TokenKind::StringFragment(s) => { + self.advance(); + args.push(Expr::StringLit(StringLit { + segments: vec![StringSegment::Literal(s)], + })); + } + TokenKind::Ident(name) if !is_declaration_keyword(&name) => { + // Only consume if it looks like an element argument + self.advance(); + args.push(Expr::Ident(name)); + } + _ => break, + } + } + + // Parse props: `{ click: handler, class: "foo" }` + if self.check(&TokenKind::LBrace) { + self.advance(); + self.skip_newlines(); + while !self.check(&TokenKind::RBrace) && !self.is_at_end() { + self.skip_newlines(); + let key = self.expect_ident()?; + self.expect(&TokenKind::Colon)?; + self.skip_newlines(); + let val = self.parse_expr()?; + props.push((key, val)); + self.skip_newlines(); + if self.check(&TokenKind::Comma) { + self.advance(); + } + self.skip_newlines(); + } + self.expect(&TokenKind::RBrace)?; + } + + // Parse modifiers: `| animate fade-in 200ms` + while self.check(&TokenKind::Pipe) { + self.advance(); + let name = self.expect_ident()?; + let mut mod_args = Vec::new(); + while matches!(self.peek(), TokenKind::Ident(_) | TokenKind::Int(_) | TokenKind::Float(_)) { + mod_args.push(self.parse_primary()?); + } + modifiers.push(Modifier { name, args: mod_args }); + } + + if args.is_empty() && props.is_empty() && modifiers.is_empty() { + return Ok(None); + } + + Ok(Some(Expr::Element(Element { + tag, + args, + props, + modifiers, + }))) + } + + fn parse_container(&mut self, kind: ContainerKind) -> Result { + self.advance(); // consume container keyword + self.expect(&TokenKind::LBracket)?; + self.skip_newlines(); + + let mut children = Vec::new(); + while !self.check(&TokenKind::RBracket) && !self.is_at_end() { + self.skip_newlines(); + if self.check(&TokenKind::RBracket) { break; } + children.push(self.parse_expr()?); + self.skip_newlines(); + if self.check(&TokenKind::Comma) { + self.advance(); + } + self.skip_newlines(); + } + + self.expect(&TokenKind::RBracket)?; + Ok(Expr::Container(Container { + kind, + children, + props: Vec::new(), + })) + } + + fn parse_container_with_props(&mut self, kind: ContainerKind) -> Result { + self.advance(); // consume container keyword + + // Optional props: `panel { x: panel_x }` + let mut props = Vec::new(); + if self.check(&TokenKind::LBrace) { + self.advance(); + self.skip_newlines(); + while !self.check(&TokenKind::RBrace) && !self.is_at_end() { + self.skip_newlines(); + let key = self.expect_ident()?; + self.expect(&TokenKind::Colon)?; + let val = self.parse_expr()?; + props.push((key, val)); + self.skip_newlines(); + if self.check(&TokenKind::Comma) { + self.advance(); + } + self.skip_newlines(); + } + self.expect(&TokenKind::RBrace)?; + } + + self.expect(&TokenKind::LBracket)?; + self.skip_newlines(); + + let mut children = Vec::new(); + while !self.check(&TokenKind::RBracket) && !self.is_at_end() { + self.skip_newlines(); + if self.check(&TokenKind::RBracket) { break; } + children.push(self.parse_expr()?); + self.skip_newlines(); + if self.check(&TokenKind::Comma) { + self.advance(); + } + self.skip_newlines(); + } + + self.expect(&TokenKind::RBracket)?; + Ok(Expr::Container(Container { kind, children, props })) + } + + fn parse_call_args(&mut self) -> Result, ParseError> { + self.expect(&TokenKind::LParen)?; + let mut args = Vec::new(); + while !self.check(&TokenKind::RParen) && !self.is_at_end() { + args.push(self.parse_expr()?); + if self.check(&TokenKind::Comma) { + self.advance(); + } + } + self.expect(&TokenKind::RParen)?; + Ok(args) + } + + fn parse_pattern(&mut self) -> Result { + match self.peek().clone() { + TokenKind::Ident(name) => { + self.advance(); + if self.check(&TokenKind::LParen) { + // Constructor pattern: `Ok(value)` + self.advance(); + let mut fields = Vec::new(); + while !self.check(&TokenKind::RParen) && !self.is_at_end() { + fields.push(self.parse_pattern()?); + if self.check(&TokenKind::Comma) { + self.advance(); + } + } + self.expect(&TokenKind::RParen)?; + Ok(Pattern::Constructor(name, fields)) + } else { + Ok(Pattern::Ident(name)) + } + } + TokenKind::Int(n) => { + self.advance(); + Ok(Pattern::Literal(Expr::IntLit(n))) + } + TokenKind::StringFragment(s) => { + self.advance(); + Ok(Pattern::Literal(Expr::StringLit(StringLit { + segments: vec![StringSegment::Literal(s)], + }))) + } + _ => Err(self.error(format!("expected pattern, got {:?}", self.peek()))), + } + } + + fn expr_to_ident(&self, expr: &Expr) -> Result { + match expr { + Expr::Ident(name) => Ok(name.clone()), + _ => Err(self.error("expected identifier in lambda parameter".into())), + } + } +} + +fn is_ui_element(name: &str) -> bool { + matches!( + name, + "text" | "button" | "input" | "image" | "avatar" | "icon" + | "link" | "label" | "badge" | "chip" | "card" + | "header" | "footer" | "nav" | "section" | "div" + | "spinner" | "skeleton" + ) +} + +fn is_declaration_keyword(name: &str) -> bool { + matches!(name, "let" | "view" | "effect" | "on" | "handle") +} + +#[derive(Debug)] +pub struct ParseError { + pub message: String, + pub line: usize, + pub col: usize, +} + +impl std::fmt::Display for ParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Parse error at line {}:{}: {}", self.line, self.col, self.message) + } +} + +impl std::error::Error for ParseError {} + +#[cfg(test)] +mod tests { + use super::*; + use crate::lexer::Lexer; + + fn parse(src: &str) -> Program { + let mut lexer = Lexer::new(src); + let tokens = lexer.tokenize(); + let mut parser = Parser::new(tokens); + parser.parse_program().expect("parse failed") + } + + #[test] + fn test_let_int() { + let prog = parse("let count = 0"); + assert_eq!(prog.declarations.len(), 1); + match &prog.declarations[0] { + Declaration::Let(decl) => { + assert_eq!(decl.name, "count"); + assert!(matches!(decl.value, Expr::IntLit(0))); + } + _ => panic!("expected let"), + } + } + + #[test] + fn test_let_binop() { + let prog = parse("let doubled = count * 2"); + match &prog.declarations[0] { + Declaration::Let(decl) => { + assert_eq!(decl.name, "doubled"); + match &decl.value { + Expr::BinOp(_, BinOp::Mul, _) => {} + other => panic!("expected BinOp(Mul), got {other:?}"), + } + } + _ => panic!("expected let"), + } + } + + #[test] + fn test_view_simple() { + let prog = parse( + r#"view counter = + column [ + text "hello" + ]"# + ); + match &prog.declarations[0] { + Declaration::View(v) => { + assert_eq!(v.name, "counter"); + match &v.body { + Expr::Container(c) => { + assert!(matches!(c.kind, ContainerKind::Column)); + assert_eq!(c.children.len(), 1); + } + other => panic!("expected Container, got {other:?}"), + } + } + _ => panic!("expected view"), + } + } + + #[test] + fn test_when_expr() { + let prog = parse( + r#"view test = + column [ + when count > 10 -> + text "big" + ]"# + ); + match &prog.declarations[0] { + Declaration::View(v) => { + match &v.body { + Expr::Container(c) => { + assert!(matches!(&c.children[0], Expr::When(_, _))); + } + other => panic!("expected Container, got {other:?}"), + } + } + _ => panic!("expected view"), + } + } + + #[test] + fn test_on_handler() { + let prog = parse("on toggle_sidebar ->\n count += 1"); + match &prog.declarations[0] { + Declaration::OnHandler(h) => { + assert_eq!(h.event, "toggle_sidebar"); + assert!(matches!(&h.body, Expr::Assign(_, AssignOp::AddAssign, _))); + } + _ => panic!("expected on handler"), + } + } + + #[test] + fn test_full_counter() { + let prog = parse( + r#"let count = 0 +let doubled = count * 2 +let label = "hello" + +view counter = + column [ + text label + button "+" { click: count += 1 } + when count > 10 -> + text "big" + ]"# + ); + assert_eq!(prog.declarations.len(), 4); // 3 lets + 1 view + } +} diff --git a/examples/counter.ds b/examples/counter.ds new file mode 100644 index 0000000..b5634e4 --- /dev/null +++ b/examples/counter.ds @@ -0,0 +1,19 @@ +-- DreamStack Counter Example +-- A simple reactive counter demonstrating signals, derived values, +-- and declarative UI with automatic dependency tracking. + +let count = 0 +let doubled = count * 2 +let label = "hello" + +view counter = + column [ + text label + text doubled + row [ + button "-" { click: count -= 1 } + button "+" { click: count += 1 } + ] + when count > 10 -> + text "On fire!" + ]