feat: DreamStack compiler foundation — Phase 0/1

Complete compiler pipeline from .ds source to reactive browser apps:

- ds-parser: lexer (string interpolation, operators, keywords) + recursive
  descent parser with operator precedence + full AST types
- ds-analyzer: signal graph extraction (source/derived classification),
  topological sort for glitch-free propagation, DOM binding analysis
- ds-codegen: JavaScript emitter with embedded reactive runtime (~3KB
  signal/derived/effect system) and dark-theme CSS design system
- ds-cli: build (compile to HTML+JS), dev (live server), check (analyze)

Verified working: source signals, derived signals, event handlers,
conditional rendering (when), 12 unit tests passing, 6.8KB output.
This commit is contained in:
enzotar 2026-02-25 00:03:06 -08:00
parent cc45557248
commit a634152318
16 changed files with 3193 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
/target
/dist
Cargo.lock

18
Cargo.toml Normal file
View file

@ -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" }

View file

@ -0,0 +1,7 @@
[package]
name = "ds-analyzer"
version.workspace = true
edition.workspace = true
[dependencies]
ds-parser = { workspace = true }

View file

@ -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};

View file

@ -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<SignalNode>,
pub name_to_id: HashMap<String, usize>,
}
/// A node in the signal graph.
#[derive(Debug, Clone)]
pub struct SignalNode {
pub id: usize,
pub name: String,
pub kind: SignalKind,
pub dependencies: Vec<Dependency>,
pub initial_value: Option<InitialValue>,
}
#[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<Mutation> },
}
/// 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<usize>,
}
/// 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<DomBinding>,
}
/// A reactive DOM binding extracted from a view.
#[derive(Debug, Clone)]
pub struct DomBinding {
pub kind: BindingKind,
pub dependencies: Vec<String>,
}
#[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<String> },
/// `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<Dependency> = 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<String> = 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<AnalyzedView> {
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<usize> {
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<usize>, order: &mut Vec<usize>) {
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<String> {
let mut deps = Vec::new();
collect_deps(expr, &mut deps);
deps.sort();
deps.dedup();
deps
}
fn collect_deps(expr: &Expr, deps: &mut Vec<String>) {
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<Mutation> {
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<DomBinding> {
let mut bindings = Vec::new();
collect_bindings(expr, &mut bindings);
bindings
}
fn collect_bindings(expr: &Expr, bindings: &mut Vec<DomBinding>) {
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<AnalyzedView>) {
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);
}
}

View file

@ -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"

249
compiler/ds-cli/src/main.rs Normal file
View file

@ -0,0 +1,249 @@
/// DreamStack CLI — the compiler command-line interface.
///
/// Usage:
/// dreamstack build <file.ds> — compile to HTML+JS
/// dreamstack dev <file.ds> — dev server with hot reload
/// dreamstack check <file.ds> — 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<String, String> {
// 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!("<html><body><pre style='color:red'>Compile error:\n{e}</pre></body></html>")
})
} else {
html.clone()
};
let response = tiny_http::Response::from_string(&current_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::<Vec<_>>());
if errors == 0 {
println!("");
println!("✅ No errors found");
} else {
println!("");
eprintln!("{} error(s) found", errors);
std::process::exit(1);
}
}

View file

@ -0,0 +1,8 @@
[package]
name = "ds-codegen"
version.workspace = true
edition.workspace = true
[dependencies]
ds-parser = { workspace = true }
ds-analyzer = { workspace = true }

View file

@ -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#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DreamStack App</title>
<style>
{CSS_RESET}
</style>
</head>
<body>
<div id="ds-root"></div>
<script>
{RUNTIME_JS}
</script>
<script>
{app_js}
</script>
</body>
</html>"#
)
}
/// 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<String> = 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<String> = fields
.iter()
.map(|(k, v)| format!("{}: {}", k, self.emit_expr(v)))
.collect();
format!("{{ {} }}", fields_js.join(", "))
}
Expr::List(items) => {
let items_js: Vec<String> = 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<String> = 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 };
})();
"#;

View file

@ -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;

View file

@ -0,0 +1,6 @@
[package]
name = "ds-parser"
version.workspace = true
edition.workspace = true
[dependencies]

View file

@ -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<Declaration>,
}
/// 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<Param>,
pub body: Expr,
pub span: Span,
}
/// `effect fetchUser(id: UserId): Result<User, ApiError>`
#[derive(Debug, Clone)]
pub struct EffectDecl {
pub name: String,
pub params: Vec<Param>,
pub return_type: TypeExpr,
pub span: Span,
}
/// `on toggle_sidebar -> ...`
#[derive(Debug, Clone)]
pub struct OnHandler {
pub event: String,
pub param: Option<String>,
pub body: Expr,
pub span: Span,
}
/// Function/view parameter.
#[derive(Debug, Clone)]
pub struct Param {
pub name: String,
pub type_annotation: Option<TypeExpr>,
}
/// Type expressions (simplified for Phase 0).
#[derive(Debug, Clone)]
pub enum TypeExpr {
Named(String),
Generic(String, Vec<TypeExpr>),
}
/// 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<Expr>, String),
/// Binary operation: `a + b`, `count > 10`
BinOp(Box<Expr>, BinOp, Box<Expr>),
/// Unary operation: `-x`, `!flag`
UnaryOp(UnaryOp, Box<Expr>),
/// Assignment: `count += 1`, `panel_x.target = 0`
Assign(Box<Expr>, AssignOp, Box<Expr>),
/// Function call: `clamp(200, 20vw, 350)`
Call(String, Vec<Expr>),
/// Block: multiple expressions, last is the value
Block(Vec<Expr>),
/// View element: `text "hello"`, `button "+" { click: ... }`
Element(Element),
/// Container: `column [ ... ]`, `row [ ... ]`
Container(Container),
/// When conditional: `when count > 10 -> ...`
When(Box<Expr>, Box<Expr>),
/// Match expression
Match(Box<Expr>, Vec<MatchArm>),
/// Pipe: `expr | operator`
Pipe(Box<Expr>, Box<Expr>),
/// `perform effectName(args)`
Perform(String, Vec<Expr>),
/// `stream from source`
StreamFrom(String),
/// Lambda: `(x -> x * 2)`
Lambda(Vec<String>, Box<Expr>),
/// Record literal: `{ key: value, ... }`
Record(Vec<(String, Expr)>),
/// List literal: `[a, b, c]`
List(Vec<Expr>),
/// `if cond then a else b`
If(Box<Expr>, Box<Expr>, Box<Expr>),
/// 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<StringSegment>,
}
#[derive(Debug, Clone)]
pub enum StringSegment {
Literal(String),
Interpolation(Box<Expr>),
}
/// 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<Expr>,
pub props: Vec<(String, Expr)>,
pub modifiers: Vec<Modifier>,
}
/// A container: `column [ child1, child2 ]`
#[derive(Debug, Clone)]
pub struct Container {
pub kind: ContainerKind,
pub children: Vec<Expr>,
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<Pattern>),
Literal(Expr),
}
/// Modifiers: `| animate fade-in 200ms`
#[derive(Debug, Clone)]
pub struct Modifier {
pub name: String,
pub args: Vec<Expr>,
}
/// Source location tracking.
#[derive(Debug, Clone, Copy, Default)]
pub struct Span {
pub start: usize,
pub end: usize,
pub line: usize,
}

View file

@ -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<char>,
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<Token> {
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));
}
}

View file

@ -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;

View file

@ -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<Token>,
pos: usize,
}
impl Parser {
pub fn new(tokens: Vec<Token>) -> Self {
Self { tokens, pos: 0 }
}
pub fn parse_program(&mut self) -> Result<Program, ParseError> {
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<String, ParseError> {
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<Declaration, ParseError> {
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<Declaration, ParseError> {
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<Declaration, ParseError> {
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<Declaration, ParseError> {
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<Declaration, ParseError> {
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<Vec<Param>, 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<TypeExpr, ParseError> {
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<Expr, ParseError> {
self.parse_pipe_expr()
}
/// Pipe: `expr | operator`
fn parse_pipe_expr(&mut self) -> Result<Expr, ParseError> {
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<Expr, ParseError> {
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<Expr, ParseError> {
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<Expr, ParseError> {
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<Expr, ParseError> {
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<Expr, ParseError> {
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<Expr, ParseError> {
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<Expr, ParseError> {
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<Expr, ParseError> {
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<Expr, ParseError> {
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<Option<Expr>, 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<Expr, ParseError> {
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<Expr, ParseError> {
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<Vec<Expr>, 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<Pattern, ParseError> {
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<String, ParseError> {
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
}
}

19
examples/counter.ds Normal file
View file

@ -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!"
]