899 lines
32 KiB
Rust
899 lines
32 KiB
Rust
/// DreamStack Panel IR Emitter — generates compact JSON IR for ESP32 LVGL panels.
|
|
///
|
|
/// Takes the same Program + SignalGraph inputs as JsEmitter but outputs
|
|
/// a JSON IR that the on-device LVGL runtime can parse and render.
|
|
|
|
use std::collections::HashMap;
|
|
use ds_parser::*;
|
|
use ds_analyzer::{SignalGraph, SignalKind};
|
|
|
|
/// Panel IR emitter — produces JSON describing the UI tree, signals, and events.
|
|
pub struct IrEmitter {
|
|
/// Signal name → integer ID mapping
|
|
signal_ids: HashMap<String, u16>,
|
|
/// Next signal ID to assign
|
|
next_signal_id: u16,
|
|
/// Next node ID to assign
|
|
next_node_id: u16,
|
|
}
|
|
|
|
impl IrEmitter {
|
|
pub fn new() -> Self {
|
|
IrEmitter {
|
|
signal_ids: HashMap::new(),
|
|
next_signal_id: 0,
|
|
next_node_id: 0,
|
|
}
|
|
}
|
|
|
|
/// Generate Panel IR JSON from a DreamStack program + signal graph.
|
|
pub fn emit_ir(program: &Program, graph: &SignalGraph) -> String {
|
|
let mut emitter = IrEmitter::new();
|
|
|
|
// Phase 1: Assign signal IDs
|
|
for node in &graph.nodes {
|
|
emitter.assign_signal_id(&node.name);
|
|
}
|
|
|
|
// Phase 2: Build signal list
|
|
let signals = emitter.emit_signals(program, graph);
|
|
|
|
// Phase 3: Build derived signals
|
|
let derived = emitter.emit_derived(graph);
|
|
|
|
// Phase 4: Build UI tree from views
|
|
let root = emitter.emit_views(program, graph);
|
|
|
|
// Phase 5: Build timers from `every` declarations
|
|
let timers = emitter.emit_timers(program);
|
|
|
|
// Assemble the IR
|
|
format!(
|
|
r#"{{"t":"ui","signals":[{}],"derived":[{}],"timers":[{}],"root":{}}}"#,
|
|
signals, derived, timers, root
|
|
)
|
|
}
|
|
|
|
fn assign_signal_id(&mut self, name: &str) -> u16 {
|
|
if let Some(&id) = self.signal_ids.get(name) {
|
|
id
|
|
} else {
|
|
let id = self.next_signal_id;
|
|
self.signal_ids.insert(name.to_string(), id);
|
|
self.next_signal_id += 1;
|
|
id
|
|
}
|
|
}
|
|
|
|
fn get_signal_id(&self, name: &str) -> Option<u16> {
|
|
self.signal_ids.get(name).copied()
|
|
}
|
|
|
|
fn next_node(&mut self) -> u16 {
|
|
let id = self.next_node_id;
|
|
self.next_node_id += 1;
|
|
id
|
|
}
|
|
|
|
/// Emit the signals array: [{"id":0,"v":72,"type":"int"}, ...]
|
|
fn emit_signals(&self, program: &Program, graph: &SignalGraph) -> String {
|
|
let mut entries = Vec::new();
|
|
|
|
for node in &graph.nodes {
|
|
if !matches!(node.kind, SignalKind::Source) {
|
|
continue;
|
|
}
|
|
let id = match self.get_signal_id(&node.name) {
|
|
Some(id) => id,
|
|
None => continue,
|
|
};
|
|
|
|
// Find the initial value from the Let declaration
|
|
let (value_str, type_str) = self.find_initial_value(program, &node.name);
|
|
|
|
entries.push(format!(
|
|
r#"{{"id":{},"v":{},"type":"{}"}}"#,
|
|
id, value_str, type_str
|
|
));
|
|
}
|
|
|
|
entries.join(",")
|
|
}
|
|
|
|
/// Find initial value of a signal from the program's Let declarations.
|
|
fn find_initial_value(&self, program: &Program, name: &str) -> (String, &'static str) {
|
|
for decl in &program.declarations {
|
|
if let Declaration::Let(let_decl) = decl {
|
|
if let_decl.name == name {
|
|
return self.expr_to_value(&let_decl.value);
|
|
}
|
|
}
|
|
}
|
|
("null".to_string(), "null")
|
|
}
|
|
|
|
/// Convert an expression to a JSON value + type string.
|
|
fn expr_to_value(&self, expr: &Expr) -> (String, &'static str) {
|
|
match expr {
|
|
Expr::IntLit(n) => (n.to_string(), "int"),
|
|
Expr::FloatLit(n) => (n.to_string(), "float"),
|
|
Expr::BoolLit(b) => (b.to_string(), "bool"),
|
|
Expr::StringLit(s) => {
|
|
let text = self.string_lit_to_plain(s);
|
|
(format!(r#""{}""#, escape_json(&text)), "str")
|
|
}
|
|
Expr::List(items) => {
|
|
let vals: Vec<String> = items.iter()
|
|
.map(|item| self.expr_to_value(item).0)
|
|
.collect();
|
|
(format!("[{}]", vals.join(",")), "list")
|
|
}
|
|
Expr::Record(fields) => {
|
|
let entries: Vec<String> = fields.iter()
|
|
.map(|(k, v)| format!(r#""{}":{}"#, k, self.expr_to_value(v).0))
|
|
.collect();
|
|
(format!("{{{}}}", entries.join(",")), "map")
|
|
}
|
|
_ => ("null".to_string(), "null"),
|
|
}
|
|
}
|
|
|
|
/// Emit derived signals: [{"id":3,"expr":"s0 * 2","deps":[0]}, ...]
|
|
fn emit_derived(&self, graph: &SignalGraph) -> String {
|
|
let mut entries = Vec::new();
|
|
|
|
for node in &graph.nodes {
|
|
if !matches!(node.kind, SignalKind::Derived) {
|
|
continue;
|
|
}
|
|
let id = match self.get_signal_id(&node.name) {
|
|
Some(id) => id,
|
|
None => continue,
|
|
};
|
|
|
|
let deps: Vec<String> = node.dependencies.iter()
|
|
.filter_map(|dep| self.get_signal_id(&dep.signal_name).map(|id| id.to_string()))
|
|
.collect();
|
|
|
|
entries.push(format!(
|
|
r#"{{"id":{},"deps":[{}]}}"#,
|
|
id, deps.join(",")
|
|
));
|
|
}
|
|
|
|
entries.join(",")
|
|
}
|
|
|
|
/// Emit UI tree from views.
|
|
fn emit_views(&mut self, program: &Program, graph: &SignalGraph) -> String {
|
|
// Find the main view (first view, or one named "main")
|
|
for decl in &program.declarations {
|
|
if let Declaration::View(view) = decl {
|
|
return self.emit_view_expr(&view.body, graph);
|
|
}
|
|
}
|
|
|
|
// No view found — empty container
|
|
r#"{"t":"col","c":[]}"#.to_string()
|
|
}
|
|
|
|
/// Emit timers from `every` declarations.
|
|
fn emit_timers(&self, program: &Program) -> String {
|
|
let mut timers = Vec::new();
|
|
for decl in &program.declarations {
|
|
if let Declaration::Every(every) = decl {
|
|
let ms = match &every.interval_ms {
|
|
Expr::IntLit(n) => *n as u32,
|
|
Expr::FloatLit(f) => *f as u32,
|
|
_ => 1000, // default to 1s
|
|
};
|
|
let action = self.expr_to_action(&every.body);
|
|
timers.push(format!(r#"{{"ms":{},"action":{}}}"#, ms, action));
|
|
}
|
|
}
|
|
timers.join(",")
|
|
}
|
|
|
|
/// Emit a view expression as an IR node.
|
|
fn emit_view_expr(&mut self, expr: &Expr, graph: &SignalGraph) -> String {
|
|
match expr {
|
|
Expr::Container(container) => self.emit_container(container, graph),
|
|
Expr::Element(element) => self.emit_element(element, graph),
|
|
Expr::When(cond, then_expr, else_expr) => {
|
|
self.emit_conditional(cond, then_expr, else_expr.as_deref(), graph)
|
|
}
|
|
Expr::Each(item, list, body) | Expr::ForIn { item, iter: list, body, .. } => {
|
|
self.emit_each(item, list, body, graph)
|
|
}
|
|
Expr::ComponentUse { name, props, children } => {
|
|
self.emit_component_use(name, props, children, graph)
|
|
}
|
|
Expr::StringLit(s) => {
|
|
let id = self.next_node();
|
|
let text = self.string_lit_to_ir_text(s);
|
|
format!(r#"{{"t":"lbl","id":{},"text":"{}"}}"#, id, escape_json(&text))
|
|
}
|
|
_ => {
|
|
// Fallback: try to emit as a label with the expression value
|
|
let id = self.next_node();
|
|
format!(r#"{{"t":"lbl","id":{},"text":"?"}}"#, id)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Emit a container (column, row, stack, etc.)
|
|
fn emit_container(&mut self, container: &Container, graph: &SignalGraph) -> String {
|
|
let id = self.next_node();
|
|
let kind = match &container.kind {
|
|
ContainerKind::Column => "col",
|
|
ContainerKind::Row => "row",
|
|
ContainerKind::Stack => "stk",
|
|
ContainerKind::List => "lst",
|
|
ContainerKind::Panel => "pnl",
|
|
ContainerKind::Form => "col",
|
|
ContainerKind::Scene => "col", // scene → col fallback for IR
|
|
ContainerKind::Custom(_) => "col",
|
|
};
|
|
|
|
let children: Vec<String> = container.children.iter()
|
|
.map(|child| self.emit_view_expr(child, graph))
|
|
.collect();
|
|
|
|
// Extract style props
|
|
let style = self.extract_style_props(&container.props);
|
|
|
|
let mut parts = vec![
|
|
format!(r#""t":"{}""#, kind),
|
|
format!(r#""id":{}"#, id),
|
|
format!(r#""c":[{}]"#, children.join(",")),
|
|
];
|
|
|
|
if !style.is_empty() {
|
|
parts.push(format!(r#""style":{{{}}}"#, style));
|
|
}
|
|
|
|
// Gap and padding from props
|
|
for (key, val) in &container.props {
|
|
match key.as_str() {
|
|
"gap" => if let Expr::IntLit(n) = val {
|
|
parts.push(format!(r#""gap":{}"#, n));
|
|
},
|
|
"pad" | "padding" => if let Expr::IntLit(n) = val {
|
|
parts.push(format!(r#""pad":{}"#, n));
|
|
},
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
format!("{{{}}}", parts.join(","))
|
|
}
|
|
|
|
/// Emit an element (text, button, input, slider, etc.)
|
|
fn emit_element(&mut self, element: &Element, graph: &SignalGraph) -> String {
|
|
let id = self.next_node();
|
|
let tag = element.tag.as_str();
|
|
|
|
match tag {
|
|
"text" | "label" | "heading" | "h1" | "h2" | "h3" | "p" | "span" => {
|
|
self.emit_label(id, element, graph)
|
|
}
|
|
"button" | "btn" => {
|
|
self.emit_button(id, element, graph)
|
|
}
|
|
"input" | "textfield" => {
|
|
self.emit_input(id, element, graph)
|
|
}
|
|
"slider" | "range" => {
|
|
self.emit_slider(id, element, graph)
|
|
}
|
|
"toggle" | "switch" | "checkbox" => {
|
|
self.emit_switch(id, element, graph)
|
|
}
|
|
"image" | "img" => {
|
|
self.emit_image(id, element, graph)
|
|
}
|
|
"progress" | "bar" => {
|
|
self.emit_progress(id, element, graph)
|
|
}
|
|
_ => {
|
|
// Unknown element → warn and label fallback
|
|
eprintln!(" ⚠ Unknown element type '{}' — rendered as label", tag);
|
|
let text = self.args_to_text(&element.args);
|
|
format!(r#"{{"t":"lbl","id":{},"text":"{}"}}"#, id, escape_json(&text))
|
|
}
|
|
}
|
|
}
|
|
|
|
fn emit_label(&mut self, id: u16, element: &Element, _graph: &SignalGraph) -> String {
|
|
let text = self.args_to_text(&element.args);
|
|
let style = self.extract_style_props(&element.props);
|
|
|
|
let mut parts = vec![
|
|
format!(r#""t":"lbl""#),
|
|
format!(r#""id":{}"#, id),
|
|
format!(r#""text":"{}""#, escape_json(&text)),
|
|
];
|
|
|
|
// Font size from tag
|
|
match element.tag.as_str() {
|
|
"h1" | "heading" => parts.push(r#""size":28"#.to_string()),
|
|
"h2" => parts.push(r#""size":22"#.to_string()),
|
|
"h3" => parts.push(r#""size":18"#.to_string()),
|
|
_ => {}
|
|
}
|
|
|
|
if !style.is_empty() {
|
|
parts.push(format!(r#""style":{{{}}}"#, style));
|
|
}
|
|
|
|
format!("{{{}}}", parts.join(","))
|
|
}
|
|
|
|
fn emit_button(&mut self, id: u16, element: &Element, graph: &SignalGraph) -> String {
|
|
let text = self.args_to_text(&element.args);
|
|
let on_action = self.extract_event_action(element, graph);
|
|
|
|
let mut parts = vec![
|
|
format!(r#""t":"btn""#),
|
|
format!(r#""id":{}"#, id),
|
|
format!(r#""text":"{}""#, escape_json(&text)),
|
|
];
|
|
|
|
if !on_action.is_empty() {
|
|
parts.push(format!(r#""on":{{{}}}"#, on_action));
|
|
}
|
|
|
|
let style = self.extract_style_props(&element.props);
|
|
if !style.is_empty() {
|
|
parts.push(format!(r#""style":{{{}}}"#, style));
|
|
}
|
|
|
|
format!("{{{}}}", parts.join(","))
|
|
}
|
|
|
|
fn emit_input(&mut self, id: u16, element: &Element, _graph: &SignalGraph) -> String {
|
|
let mut parts = vec![
|
|
format!(r#""t":"inp""#),
|
|
format!(r#""id":{}"#, id),
|
|
];
|
|
|
|
for (key, val) in &element.props {
|
|
match key.as_str() {
|
|
"placeholder" => {
|
|
let text = self.expr_to_text(val);
|
|
parts.push(format!(r#""placeholder":"{}""#, escape_json(&text)));
|
|
}
|
|
"bind" => {
|
|
if let Expr::Ident(name) = val {
|
|
if let Some(sig_id) = self.get_signal_id(name) {
|
|
parts.push(format!(r#""bind":{}"#, sig_id));
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
format!("{{{}}}", parts.join(","))
|
|
}
|
|
|
|
fn emit_slider(&mut self, id: u16, element: &Element, _graph: &SignalGraph) -> String {
|
|
let mut parts = vec![
|
|
format!(r#""t":"sld""#),
|
|
format!(r#""id":{}"#, id),
|
|
];
|
|
|
|
for (key, val) in &element.props {
|
|
match key.as_str() {
|
|
"min" => if let Expr::IntLit(n) = val {
|
|
parts.push(format!(r#""min":{}"#, n));
|
|
},
|
|
"max" => if let Expr::IntLit(n) = val {
|
|
parts.push(format!(r#""max":{}"#, n));
|
|
},
|
|
"bind" => {
|
|
if let Expr::Ident(name) = val {
|
|
if let Some(sig_id) = self.get_signal_id(name) {
|
|
parts.push(format!(r#""bind":{}"#, sig_id));
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
format!("{{{}}}", parts.join(","))
|
|
}
|
|
|
|
fn emit_switch(&mut self, id: u16, element: &Element, _graph: &SignalGraph) -> String {
|
|
let mut parts = vec![
|
|
format!(r#""t":"sw""#),
|
|
format!(r#""id":{}"#, id),
|
|
];
|
|
|
|
for (key, val) in &element.props {
|
|
if key == "bind" {
|
|
if let Expr::Ident(name) = val {
|
|
if let Some(sig_id) = self.get_signal_id(name) {
|
|
parts.push(format!(r#""bind":{}"#, sig_id));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
format!("{{{}}}", parts.join(","))
|
|
}
|
|
|
|
fn emit_image(&mut self, id: u16, element: &Element, _graph: &SignalGraph) -> String {
|
|
let src = self.args_to_text(&element.args);
|
|
format!(r#"{{"t":"img","id":{},"src":"{}"}}"#, id, escape_json(&src))
|
|
}
|
|
|
|
fn emit_progress(&mut self, id: u16, element: &Element, _graph: &SignalGraph) -> String {
|
|
let mut parts = vec![
|
|
format!(r#""t":"bar""#),
|
|
format!(r#""id":{}"#, id),
|
|
];
|
|
|
|
for (key, val) in &element.props {
|
|
match key.as_str() {
|
|
"min" => if let Expr::IntLit(n) = val {
|
|
parts.push(format!(r#""min":{}"#, n));
|
|
},
|
|
"max" => if let Expr::IntLit(n) = val {
|
|
parts.push(format!(r#""max":{}"#, n));
|
|
},
|
|
"bind" => {
|
|
if let Expr::Ident(name) = val {
|
|
if let Some(sig_id) = self.get_signal_id(name) {
|
|
parts.push(format!(r#""bind":{}"#, sig_id));
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
format!("{{{}}}", parts.join(","))
|
|
}
|
|
|
|
/// Emit a conditional (when/if).
|
|
fn emit_conditional(
|
|
&mut self, cond: &Expr, then_expr: &Expr,
|
|
else_expr: Option<&Expr>, graph: &SignalGraph
|
|
) -> String {
|
|
let id = self.next_node();
|
|
let cond_str = self.expr_to_condition(cond);
|
|
let then_node = self.emit_view_expr(then_expr, graph);
|
|
|
|
let mut parts = vec![
|
|
format!(r#""t":"cond""#),
|
|
format!(r#""id":{}"#, id),
|
|
format!(r#""if":"{}""#, escape_json(&cond_str)),
|
|
format!(r#""then":{}"#, then_node),
|
|
];
|
|
|
|
if let Some(else_e) = else_expr {
|
|
let else_node = self.emit_view_expr(else_e, graph);
|
|
parts.push(format!(r#""else":{}"#, else_node));
|
|
}
|
|
|
|
format!("{{{}}}", parts.join(","))
|
|
}
|
|
|
|
/// Emit an each loop.
|
|
fn emit_each(&mut self, item: &str, list: &Expr, body: &Expr, graph: &SignalGraph) -> String {
|
|
let id = self.next_node();
|
|
let list_ref = self.expr_to_signal_ref(list);
|
|
let body_node = self.emit_view_expr(body, graph);
|
|
|
|
format!(
|
|
r#"{{"t":"each","id":{},"item":"{}","list":"{}","body":{}}}"#,
|
|
id, item, escape_json(&list_ref), body_node
|
|
)
|
|
}
|
|
|
|
/// Emit a component use — extract props and render with children.
|
|
fn emit_component_use(
|
|
&mut self, name: &str, props: &[(String, Expr)],
|
|
children: &[Expr], graph: &SignalGraph
|
|
) -> String {
|
|
let id = self.next_node();
|
|
|
|
let child_nodes: Vec<String> = children.iter()
|
|
.map(|c| self.emit_view_expr(c, graph))
|
|
.collect();
|
|
|
|
let mut parts = vec![
|
|
format!(r#""t":"pnl""#),
|
|
format!(r#""id":{}"#, id),
|
|
format!(r#""_comp":"{}""#, name),
|
|
];
|
|
|
|
// Extract key component props
|
|
for (key, val) in props {
|
|
match key.as_str() {
|
|
"label" | "title" => {
|
|
let text = self.expr_to_text(val);
|
|
parts.push(format!(r#""text":"{}""#, escape_json(&text)));
|
|
}
|
|
"variant" => {
|
|
let v = self.expr_to_text(val);
|
|
parts.push(format!(r#""variant":"{}""#, escape_json(&v)));
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
parts.push(format!(r#""c":[{}]"#, child_nodes.join(",")));
|
|
|
|
format!("{{{}}}", parts.join(","))
|
|
}
|
|
|
|
// ── Helpers ──────────────────────────────────────────
|
|
|
|
/// Convert string lit args to IR text with signal references.
|
|
fn args_to_text(&self, args: &[Expr]) -> String {
|
|
if args.is_empty() {
|
|
return String::new();
|
|
}
|
|
args.iter().map(|a| self.expr_to_text(a)).collect::<Vec<_>>().join(" ")
|
|
}
|
|
|
|
/// Convert an expression to display text, using {N} for signal references.
|
|
fn expr_to_text(&self, expr: &Expr) -> String {
|
|
match expr {
|
|
Expr::StringLit(s) => self.string_lit_to_ir_text(s),
|
|
Expr::Ident(name) => {
|
|
if let Some(id) = self.get_signal_id(name) {
|
|
format!("{{{}}}", id)
|
|
} else {
|
|
name.clone()
|
|
}
|
|
}
|
|
Expr::IntLit(n) => n.to_string(),
|
|
Expr::FloatLit(n) => n.to_string(),
|
|
Expr::BoolLit(b) => b.to_string(),
|
|
_ => "?".to_string(),
|
|
}
|
|
}
|
|
|
|
/// Convert a StringLit to IR text: "Hello {name}" → "Hello {0}"
|
|
fn string_lit_to_ir_text(&self, s: &StringLit) -> String {
|
|
let mut out = String::new();
|
|
for seg in &s.segments {
|
|
match seg {
|
|
StringSegment::Literal(text) => out.push_str(text),
|
|
StringSegment::Interpolation(expr) => {
|
|
if let Expr::Ident(name) = expr.as_ref() {
|
|
if let Some(id) = self.get_signal_id(name) {
|
|
out.push_str(&format!("{{{}}}", id));
|
|
} else {
|
|
out.push_str(&format!("{{{}}}", name));
|
|
}
|
|
} else if let Expr::DotAccess(base, field) = expr.as_ref() {
|
|
if let Expr::Ident(name) = base.as_ref() {
|
|
if let Some(id) = self.get_signal_id(name) {
|
|
out.push_str(&format!("{{{}.{}}}", id, field));
|
|
} else {
|
|
out.push_str(&format!("{{{}.{}}}", name, field));
|
|
}
|
|
}
|
|
} else {
|
|
out.push_str("{?}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Convert a StringLit to plain text without signal references.
|
|
fn string_lit_to_plain(&self, s: &StringLit) -> String {
|
|
let mut out = String::new();
|
|
for seg in &s.segments {
|
|
match seg {
|
|
StringSegment::Literal(text) => out.push_str(text),
|
|
StringSegment::Interpolation(expr) => {
|
|
out.push_str(&self.expr_to_text(expr));
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Extract event actions from element props (on click, etc.)
|
|
fn extract_event_action(&self, element: &Element, _graph: &SignalGraph) -> String {
|
|
let mut actions = Vec::new();
|
|
|
|
for (key, val) in &element.props {
|
|
// Check for "on" + event patterns: "click", "press", etc.
|
|
let event_name = match key.as_str() {
|
|
"click" | "on_click" => Some("click"),
|
|
"press" | "on_press" => Some("press"),
|
|
_ => None,
|
|
};
|
|
|
|
if let Some(event) = event_name {
|
|
let action = self.expr_to_action(val);
|
|
actions.push(format!(r#""{}":{}"#, event, action));
|
|
}
|
|
}
|
|
|
|
// Also check for explicit on handlers in modifiers or inline props
|
|
// Pattern: button "+" { on click { count += 1 } }
|
|
// In the AST, this comes through as props with key "click" and value being the handler body
|
|
|
|
actions.join(",")
|
|
}
|
|
|
|
/// Convert an expression to an action opcode.
|
|
fn expr_to_action(&self, expr: &Expr) -> String {
|
|
match expr {
|
|
// count += 1 → inc
|
|
Expr::Assign(target, AssignOp::AddAssign, val) => {
|
|
if let (Expr::Ident(name), Expr::IntLit(1)) = (target.as_ref(), val.as_ref()) {
|
|
if let Some(id) = self.get_signal_id(name) {
|
|
return format!(r#"{{"op":"inc","s":{}}}"#, id);
|
|
}
|
|
}
|
|
if let Expr::Ident(name) = target.as_ref() {
|
|
if let Some(id) = self.get_signal_id(name) {
|
|
let v = self.expr_to_value(val).0;
|
|
return format!(r#"{{"op":"add","s":{},"v":{}}}"#, id, v);
|
|
}
|
|
}
|
|
r#"{"op":"noop"}"#.to_string()
|
|
}
|
|
// count -= 1 → dec
|
|
Expr::Assign(target, AssignOp::SubAssign, val) => {
|
|
if let (Expr::Ident(name), Expr::IntLit(1)) = (target.as_ref(), val.as_ref()) {
|
|
if let Some(id) = self.get_signal_id(name) {
|
|
return format!(r#"{{"op":"dec","s":{}}}"#, id);
|
|
}
|
|
}
|
|
if let Expr::Ident(name) = target.as_ref() {
|
|
if let Some(id) = self.get_signal_id(name) {
|
|
let v = self.expr_to_value(val).0;
|
|
return format!(r#"{{"op":"sub","s":{},"v":{}}}"#, id, v);
|
|
}
|
|
}
|
|
r#"{"op":"noop"}"#.to_string()
|
|
}
|
|
// count = expr → set
|
|
Expr::Assign(target, AssignOp::Set, val) => {
|
|
if let Expr::Ident(name) = target.as_ref() {
|
|
if let Some(id) = self.get_signal_id(name) {
|
|
// Check for toggle: x = !x
|
|
if let Expr::UnaryOp(UnaryOp::Not, inner) = val.as_ref() {
|
|
if let Expr::Ident(inner_name) = inner.as_ref() {
|
|
if inner_name == name {
|
|
return format!(r#"{{"op":"toggle","s":{}}}"#, id);
|
|
}
|
|
}
|
|
}
|
|
let v = self.expr_to_value(val).0;
|
|
return format!(r#"{{"op":"set","s":{},"v":{}}}"#, id, v);
|
|
}
|
|
}
|
|
r#"{"op":"noop"}"#.to_string()
|
|
}
|
|
// Block → multiple actions as an array
|
|
Expr::Block(exprs) => {
|
|
if exprs.len() == 1 {
|
|
self.expr_to_action(&exprs[0])
|
|
} else {
|
|
let actions: Vec<String> = exprs.iter()
|
|
.map(|e| self.expr_to_action(e))
|
|
.filter(|a| !a.contains("noop"))
|
|
.collect();
|
|
if actions.len() == 1 {
|
|
actions[0].clone()
|
|
} else {
|
|
format!("[{}]", actions.join(","))
|
|
}
|
|
}
|
|
}
|
|
// Function call → remote action
|
|
Expr::Call(name, _args) => {
|
|
format!(r#"{{"op":"remote","name":"{}"}}"#, name)
|
|
}
|
|
_ => r#"{"op":"noop"}"#.to_string(),
|
|
}
|
|
}
|
|
|
|
/// Convert an expression to a condition string for the runtime.
|
|
fn expr_to_condition(&self, expr: &Expr) -> String {
|
|
match expr {
|
|
Expr::BinOp(left, op, right) => {
|
|
let l = self.expr_to_signal_ref(left);
|
|
let r = self.expr_to_signal_ref(right);
|
|
let op_str = match op {
|
|
BinOp::Gt => ">",
|
|
BinOp::Lt => "<",
|
|
BinOp::Gte => ">=",
|
|
BinOp::Lte => "<=",
|
|
BinOp::Eq => "==",
|
|
BinOp::Neq => "!=",
|
|
BinOp::And => "&&",
|
|
BinOp::Or => "||",
|
|
_ => "?",
|
|
};
|
|
format!("{} {} {}", l, op_str, r)
|
|
}
|
|
Expr::Ident(name) => {
|
|
if let Some(id) = self.get_signal_id(name) {
|
|
format!("s{}", id)
|
|
} else {
|
|
name.clone()
|
|
}
|
|
}
|
|
Expr::UnaryOp(UnaryOp::Not, inner) => {
|
|
format!("!{}", self.expr_to_condition(inner))
|
|
}
|
|
_ => "true".to_string(),
|
|
}
|
|
}
|
|
|
|
/// Convert an expression to a signal reference string.
|
|
fn expr_to_signal_ref(&self, expr: &Expr) -> String {
|
|
match expr {
|
|
Expr::Ident(name) => {
|
|
if let Some(id) = self.get_signal_id(name) {
|
|
format!("s{}", id)
|
|
} else {
|
|
name.clone()
|
|
}
|
|
}
|
|
Expr::IntLit(n) => n.to_string(),
|
|
Expr::FloatLit(n) => n.to_string(),
|
|
Expr::BoolLit(b) => b.to_string(),
|
|
Expr::StringLit(s) => format!(r#""{}""#, self.string_lit_to_plain(s)),
|
|
_ => "?".to_string(),
|
|
}
|
|
}
|
|
|
|
/// Extract style properties from props list.
|
|
fn extract_style_props(&self, props: &[(String, Expr)]) -> String {
|
|
let mut style_parts = Vec::new();
|
|
|
|
for (key, val) in props {
|
|
match key.as_str() {
|
|
"bg" | "background" | "backgroundColor" => {
|
|
let v = self.expr_to_text(val);
|
|
style_parts.push(format!(r#""bg":"{}""#, escape_json(&v)));
|
|
}
|
|
"fg" | "color" | "textColor" => {
|
|
let v = self.expr_to_text(val);
|
|
style_parts.push(format!(r#""fg":"{}""#, escape_json(&v)));
|
|
}
|
|
"size" | "fontSize" => {
|
|
if let Expr::IntLit(n) = val {
|
|
style_parts.push(format!(r#""size":{}"#, n));
|
|
}
|
|
}
|
|
"radius" | "borderRadius" => {
|
|
if let Expr::IntLit(n) = val {
|
|
style_parts.push(format!(r#""radius":{}"#, n));
|
|
}
|
|
}
|
|
"align" | "textAlign" => {
|
|
let v = self.expr_to_text(val);
|
|
style_parts.push(format!(r#""align":"{}""#, escape_json(&v)));
|
|
}
|
|
"width" => {
|
|
if let Expr::IntLit(n) = val {
|
|
style_parts.push(format!(r#""w":{}"#, n));
|
|
}
|
|
}
|
|
"height" => {
|
|
if let Expr::IntLit(n) = val {
|
|
style_parts.push(format!(r#""h":{}"#, n));
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
style_parts.join(",")
|
|
}
|
|
}
|
|
|
|
/// Escape a string for JSON.
|
|
fn escape_json(s: &str) -> String {
|
|
s.replace('\\', "\\\\")
|
|
.replace('"', "\\\"")
|
|
.replace('\n', "\\n")
|
|
.replace('\r', "\\r")
|
|
.replace('\t', "\\t")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_escape_json() {
|
|
assert_eq!(escape_json(r#"hello "world""#), r#"hello \"world\""#);
|
|
assert_eq!(escape_json("line1\nline2"), "line1\\nline2");
|
|
}
|
|
|
|
#[test]
|
|
fn test_simple_counter() {
|
|
let source = r#"
|
|
let count = 0
|
|
|
|
view main = column [
|
|
text "Count: {count}"
|
|
button "+" {
|
|
click: count += 1
|
|
}
|
|
]
|
|
"#;
|
|
let mut lexer = ds_parser::Lexer::new(source);
|
|
let tokens = lexer.tokenize();
|
|
let mut parser = ds_parser::Parser::new(tokens);
|
|
let program = parser.parse_program().unwrap();
|
|
let graph = ds_analyzer::SignalGraph::from_program(&program);
|
|
|
|
let ir = IrEmitter::emit_ir(&program, &graph);
|
|
|
|
// Should contain signal for count
|
|
assert!(ir.contains(r#""id":0"#));
|
|
assert!(ir.contains(r#""v":0"#));
|
|
assert!(ir.contains(r#""type":"int""#));
|
|
|
|
// Should contain a label with signal reference
|
|
assert!(ir.contains(r#""t":"lbl""#));
|
|
|
|
// Should contain a button
|
|
assert!(ir.contains(r#""t":"btn""#));
|
|
|
|
println!("IR output: {}", ir);
|
|
}
|
|
|
|
// ── v0.8 IR Emitter Tests ───────────────────────────────
|
|
|
|
fn emit_ir(source: &str) -> String {
|
|
let mut lexer = ds_parser::Lexer::new(source);
|
|
let tokens = lexer.tokenize();
|
|
let mut parser = ds_parser::Parser::new(tokens);
|
|
let program = parser.parse_program().unwrap();
|
|
let graph = ds_analyzer::SignalGraph::from_program(&program);
|
|
IrEmitter::emit_ir(&program, &graph)
|
|
}
|
|
|
|
#[test]
|
|
fn test_ir_multi_signal() {
|
|
let ir = emit_ir("let count = 0\nlet doubled = count * 2\nview main = text count");
|
|
// Should contain at least one signal
|
|
assert!(ir.contains(r#""v":0"#), "should have count signal with value 0");
|
|
assert!(ir.contains(r#""t":"lbl""#), "should have a label node");
|
|
}
|
|
|
|
#[test]
|
|
fn test_ir_container_children() {
|
|
let ir = emit_ir("view main = column [\n text \"hello\"\n text \"world\"\n]");
|
|
assert!(ir.contains(r#""t":"col""#), "should emit column container");
|
|
assert!(ir.contains(r#""c":["#), "should have children array");
|
|
}
|
|
|
|
#[test]
|
|
fn test_ir_empty_view() {
|
|
let ir = emit_ir("view main = text \"empty\"");
|
|
// Minimal program — no signals, just a view
|
|
assert!(ir.contains(r#""signals":[]"#) || ir.contains(r#""signals":["#),
|
|
"should have signals array (empty or not)");
|
|
assert!(ir.contains(r#""t":"lbl""#), "should have label");
|
|
}
|
|
|
|
#[test]
|
|
fn test_ir_button_handler() {
|
|
let ir = emit_ir("let x = 0\nview main = button \"Click\" { click: x += 1 }");
|
|
assert!(ir.contains(r#""t":"btn""#), "should emit button");
|
|
// Button should have click handler or action reference
|
|
assert!(ir.contains("click") || ir.contains("act") || ir.contains("Click"),
|
|
"should reference click action or label");
|
|
}
|
|
}
|
|
|