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:
parent
cc45557248
commit
a634152318
16 changed files with 3193 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
/target
|
||||
/dist
|
||||
Cargo.lock
|
||||
18
Cargo.toml
Normal file
18
Cargo.toml
Normal 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" }
|
||||
7
compiler/ds-analyzer/Cargo.toml
Normal file
7
compiler/ds-analyzer/Cargo.toml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
[package]
|
||||
name = "ds-analyzer"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ds-parser = { workspace = true }
|
||||
4
compiler/ds-analyzer/src/lib.rs
Normal file
4
compiler/ds-analyzer/src/lib.rs
Normal 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};
|
||||
469
compiler/ds-analyzer/src/signal_graph.rs
Normal file
469
compiler/ds-analyzer/src/signal_graph.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
16
compiler/ds-cli/Cargo.toml
Normal file
16
compiler/ds-cli/Cargo.toml
Normal 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
249
compiler/ds-cli/src/main.rs
Normal 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(¤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::<Vec<_>>());
|
||||
|
||||
if errors == 0 {
|
||||
println!("");
|
||||
println!("✅ No errors found");
|
||||
} else {
|
||||
println!("");
|
||||
eprintln!("❌ {} error(s) found", errors);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
8
compiler/ds-codegen/Cargo.toml
Normal file
8
compiler/ds-codegen/Cargo.toml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
[package]
|
||||
name = "ds-codegen"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ds-parser = { workspace = true }
|
||||
ds-analyzer = { workspace = true }
|
||||
749
compiler/ds-codegen/src/js_emitter.rs
Normal file
749
compiler/ds-codegen/src/js_emitter.rs
Normal 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 };
|
||||
})();
|
||||
"#;
|
||||
7
compiler/ds-codegen/src/lib.rs
Normal file
7
compiler/ds-codegen/src/lib.rs
Normal 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;
|
||||
6
compiler/ds-parser/Cargo.toml
Normal file
6
compiler/ds-parser/Cargo.toml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[package]
|
||||
name = "ds-parser"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
226
compiler/ds-parser/src/ast.rs
Normal file
226
compiler/ds-parser/src/ast.rs
Normal 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,
|
||||
}
|
||||
438
compiler/ds-parser/src/lexer.rs
Normal file
438
compiler/ds-parser/src/lexer.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
7
compiler/ds-parser/src/lib.rs
Normal file
7
compiler/ds-parser/src/lib.rs
Normal 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;
|
||||
967
compiler/ds-parser/src/parser.rs
Normal file
967
compiler/ds-parser/src/parser.rs
Normal 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
19
examples/counter.ds
Normal 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!"
|
||||
]
|
||||
Loading…
Add table
Reference in a new issue