lumen-engine 0.2.1

Core composition model and rendering orchestration for Lumen.
Documentation
use crate::{
    error::{ExpressionError, LumenError},
    expr::{
        ExpressionContext,
        ast::{BinaryOp, ExprNode, Expression, ExpressionValue, GlobalVar, UnaryOp},
        builtins::{evaluate_builtin, evaluate_text_measure_builtin},
    },
    node::{PropertyExpression, PropertyValue},
};

impl Expression {
    pub fn evaluate(&self, ctx: &ExpressionContext<'_>) -> crate::Result<ExpressionValue> {
        evaluate_expr(&self.ast, ctx)
    }
}

pub fn property_value_to_expression_value(value: &PropertyValue) -> crate::Result<ExpressionValue> {
    match value {
        PropertyValue::Float(number) => Ok(ExpressionValue::Number(*number)),
        PropertyValue::Int(number) => Ok(ExpressionValue::Number(*number as f64)),
        PropertyValue::Bool(boolean) => Ok(ExpressionValue::Boolean(*boolean)),
        PropertyValue::String(text) => Ok(ExpressionValue::String(text.clone())),
        unsupported => Err(LumenError::Expression(ExpressionError::Evaluate {
            path: None,
            details: format!(
                "cannot convert node property `{}` into an expression value",
                node_property_type_name(unsupported)
            ),
        })),
    }
}

pub(crate) fn evaluate_expr(
    expr: &ExprNode,
    ctx: &ExpressionContext<'_>,
) -> crate::Result<ExpressionValue> {
    match expr {
        ExprNode::Literal(value) => Ok(value.clone()),
        ExprNode::Unary(op, value) => {
            let evaluated = evaluate_expr(value, ctx)?;
            match op {
                UnaryOp::Neg => Ok(ExpressionValue::Number(-to_number(&evaluated, ctx)?)),
                UnaryOp::Not => Ok(ExpressionValue::Boolean(!to_boolean(&evaluated))),
            }
        }
        ExprNode::Binary(left, op, right) => {
            let lhs = evaluate_expr(left, ctx)?;
            match op {
                BinaryOp::And => {
                    if !to_boolean(&lhs) {
                        return Ok(ExpressionValue::Boolean(false));
                    }
                    let rhs = evaluate_expr(right, ctx)?;
                    Ok(ExpressionValue::Boolean(to_boolean(&rhs)))
                }
                BinaryOp::Or => {
                    if to_boolean(&lhs) {
                        return Ok(ExpressionValue::Boolean(true));
                    }
                    let rhs = evaluate_expr(right, ctx)?;
                    Ok(ExpressionValue::Boolean(to_boolean(&rhs)))
                }
                BinaryOp::Eq => {
                    let rhs = evaluate_expr(right, ctx)?;
                    Ok(ExpressionValue::Boolean(lhs == rhs))
                }
                BinaryOp::Neq => {
                    let rhs = evaluate_expr(right, ctx)?;
                    Ok(ExpressionValue::Boolean(lhs != rhs))
                }
                BinaryOp::Add
                | BinaryOp::Sub
                | BinaryOp::Mul
                | BinaryOp::Div
                | BinaryOp::Mod
                | BinaryOp::Gt
                | BinaryOp::Lt
                | BinaryOp::Gte
                | BinaryOp::Lte => {
                    let rhs = evaluate_expr(right, ctx)?;
                    let lhs_num = to_number(&lhs, ctx)?;
                    let rhs_num = to_number(&rhs, ctx)?;
                    match op {
                        BinaryOp::Add => Ok(ExpressionValue::Number(lhs_num + rhs_num)),
                        BinaryOp::Sub => Ok(ExpressionValue::Number(lhs_num - rhs_num)),
                        BinaryOp::Mul => Ok(ExpressionValue::Number(lhs_num * rhs_num)),
                        BinaryOp::Div => {
                            if rhs_num.abs() <= f64::EPSILON {
                                return Err(LumenError::Expression(ExpressionError::Evaluate {
                                    path: ctx.path.clone(),
                                    details: "division by zero".to_string(),
                                }));
                            }
                            Ok(ExpressionValue::Number(lhs_num / rhs_num))
                        }
                        BinaryOp::Mod => {
                            if rhs_num.abs() <= f64::EPSILON {
                                return Err(LumenError::Expression(ExpressionError::Evaluate {
                                    path: ctx.path.clone(),
                                    details: "modulo by zero".to_string(),
                                }));
                            }
                            Ok(ExpressionValue::Number(lhs_num % rhs_num))
                        }
                        BinaryOp::Gt => Ok(ExpressionValue::Boolean(lhs_num > rhs_num)),
                        BinaryOp::Lt => Ok(ExpressionValue::Boolean(lhs_num < rhs_num)),
                        BinaryOp::Gte => Ok(ExpressionValue::Boolean(lhs_num >= rhs_num)),
                        BinaryOp::Lte => Ok(ExpressionValue::Boolean(lhs_num <= rhs_num)),
                        _ => unreachable!(),
                    }
                }
            }
        }
        ExprNode::Builtin(builtin, args) => {
            if matches!(
                builtin,
                crate::expr::ast::BuiltinFn::TextHeight | crate::expr::ast::BuiltinFn::TextWidth
            ) {
                return evaluate_text_measure_builtin(*builtin, args, ctx);
            }
            let mut evaluated_args = Vec::with_capacity(args.len());
            for arg in args {
                evaluated_args.push(evaluate_expr(arg, ctx)?);
            }
            evaluate_builtin(*builtin, &evaluated_args, ctx)
        }
        ExprNode::Global(global) => match global {
            GlobalVar::Frame => Ok(ExpressionValue::Number(f64::from(ctx.frame))),
            GlobalVar::Time => Ok(ExpressionValue::Number(ctx.time_seconds())),
            GlobalVar::Fps => Ok(ExpressionValue::Number(f64::from(ctx.fps))),
            GlobalVar::Width => Ok(ExpressionValue::Number(f64::from(ctx.width))),
            GlobalVar::Height => Ok(ExpressionValue::Number(f64::from(ctx.height))),
            GlobalVar::Custom(name) => {
                Err(LumenError::Expression(ExpressionError::UndefinedVariable {
                    path: ctx.path.clone(),
                    name: name.clone(),
                }))
            }
        },
        ExprNode::SymbolicPath(segments) => {
            Err(LumenError::Expression(ExpressionError::Evaluate {
                path: ctx.path.clone(),
                details: format!(
                    "unresolved symbolic property reference `{}`",
                    segments.join(".")
                ),
            }))
        }
        ExprNode::Node(node_id) => Err(LumenError::Expression(ExpressionError::Evaluate {
            path: ctx.path.clone(),
            details: format!(
                "node reference `{}` can only be used in builtins that accept node references",
                node_id.0
            ),
        })),
        ExprNode::PropertyValue(node_id, target_path) => {
            let graph = ctx.graph.ok_or_else(|| {
                LumenError::Expression(ExpressionError::Evaluate {
                    path: ctx.path.clone(),
                    details: format!(
                        "no graph available to resolve node property reference `{}`",
                        target_path.0
                    ),
                })
            })?;
            let node = graph.nodes.get(node_id).ok_or_else(|| {
                LumenError::Expression(ExpressionError::Evaluate {
                    path: ctx.path.clone(),
                    details: format!(
                        "node `{}` not found for property reference `{}`",
                        node_id.0, target_path.0
                    ),
                })
            })?;
            let prop = node
                .as_property_eval()
                .get_property(&target_path.0)?
                .ok_or_else(|| {
                    LumenError::Expression(ExpressionError::Evaluate {
                        path: ctx.path.clone(),
                        details: format!(
                            "property `{}` not found on node `{}`",
                            target_path.0, node_id.0
                        ),
                    })
                })?;
            match &prop {
                PropertyExpression::Expr(inner_expr) => inner_expr.evaluate(ctx),
                PropertyExpression::Value(value) => property_value_to_expression_value(value),
            }
        }
        ExprNode::VirtualProperty(id) => Err(LumenError::Expression(ExpressionError::Evaluate {
            path: ctx.path.clone(),
            details: format!("unresolved virtual property reference `{}`", id.0),
        })),
        ExprNode::Conditional(condition, when_true, when_false) => {
            let condition = evaluate_expr(condition, ctx)?;
            if to_boolean(&condition) {
                evaluate_expr(when_true, ctx)
            } else {
                evaluate_expr(when_false, ctx)
            }
        }
    }
}

fn to_number(value: &ExpressionValue, ctx: &ExpressionContext<'_>) -> crate::Result<f64> {
    match value {
        ExpressionValue::Number(number) => Ok(*number),
        ExpressionValue::Boolean(boolean) => Ok(if *boolean { 1.0 } else { 0.0 }),
        ExpressionValue::String(text) => text.parse::<f64>().map_err(|_| {
            LumenError::Expression(ExpressionError::Parse {
                path: ctx.path.clone(),
                details: format!("cannot convert `{text}` into f64"),
            })
        }),
    }
}

fn to_boolean(value: &ExpressionValue) -> bool {
    match value {
        ExpressionValue::Boolean(boolean) => *boolean,
        ExpressionValue::Number(number) => number.abs() > f64::EPSILON,
        ExpressionValue::String(text) => !text.is_empty(),
    }
}

fn node_property_type_name(value: &PropertyValue) -> &'static str {
    match value {
        PropertyValue::Float(_) => "float",
        PropertyValue::Int(_) => "int",
        PropertyValue::Bool(_) => "bool",
        PropertyValue::String(_) => "string",
        PropertyValue::Color(_) => "color",
        PropertyValue::Paint(_) => "paint",
        PropertyValue::Vec2(_) => "vec2",
        PropertyValue::FloatVec(_) => "float[]",
        PropertyValue::IntVec(_) => "int[]",
        PropertyValue::StringVec(_) => "string[]",
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{
        expr::ast::{BuiltinFn, ExprNode, ExpressionId},
        graph::Graph,
        node::{NodeId, NodeKind, PropertyValue, source::text::Text},
    };

    fn test_context() -> ExpressionContext<'static> {
        ExpressionContext {
            frame: 48,
            fps: 24.0,
            width: 1920,
            height: 1080,
            duration_frames: 240,
            path: Some("node.opacity".to_string()),
            graph: None,
        }
    }

    #[test]
    fn evaluates_globals_from_expression_context() {
        let ctx = test_context();

        assert_eq!(
            evaluate_expr(&ExprNode::Global(GlobalVar::Time), &ctx).unwrap(),
            ExpressionValue::Number(2.0)
        );
        assert_eq!(
            evaluate_expr(&ExprNode::Global(GlobalVar::Fps), &ctx).unwrap(),
            ExpressionValue::Number(24.0)
        );
        assert_eq!(
            evaluate_expr(&ExprNode::Global(GlobalVar::Width), &ctx).unwrap(),
            ExpressionValue::Number(1920.0)
        );
        assert_eq!(
            evaluate_expr(&ExprNode::Global(GlobalVar::Height), &ctx).unwrap(),
            ExpressionValue::Number(1080.0)
        );
    }

    #[test]
    fn converts_supported_node_properties() {
        assert_eq!(
            property_value_to_expression_value(&PropertyValue::Int(7)).unwrap(),
            ExpressionValue::Number(7.0)
        );
        assert_eq!(
            property_value_to_expression_value(&PropertyValue::Bool(true)).unwrap(),
            ExpressionValue::Boolean(true)
        );
    }

    #[test]
    fn rejects_non_scalar_node_properties() {
        let error = property_value_to_expression_value(&PropertyValue::Color([0, 0, 0, 255]))
            .unwrap_err()
            .to_string();

        assert!(error.contains("cannot convert node property `color`"));
    }

    #[test]
    fn evaluates_linear_and_step_builtins() {
        let ctx = test_context();
        let expression = Expression {
            id: ExpressionId(1),
            ast: ExprNode::Builtin(
                BuiltinFn::Linear,
                vec![
                    ExprNode::Literal(ExpressionValue::Number(10.0)),
                    ExprNode::Literal(ExpressionValue::Number(20.0)),
                    ExprNode::Literal(ExpressionValue::Number(0.25)),
                ],
            ),
            references: Vec::new(),
            source: "linear(10, 20, 0.25)".to_string(),
        };
        let stepped = Expression {
            id: ExpressionId(2),
            ast: ExprNode::Builtin(
                BuiltinFn::Step,
                vec![
                    ExprNode::Literal(ExpressionValue::Number(10.0)),
                    ExprNode::Literal(ExpressionValue::Number(20.0)),
                    ExprNode::Literal(ExpressionValue::Number(0.5)),
                ],
            ),
            references: Vec::new(),
            source: "step(10, 20, 0.5)".to_string(),
        };

        assert_eq!(
            expression.evaluate(&ctx).unwrap(),
            ExpressionValue::Number(12.5)
        );
        assert_eq!(
            stepped.evaluate(&ctx).unwrap(),
            ExpressionValue::Number(10.0)
        );
    }

    #[test]
    fn text_measure_builtins_use_explicit_text_inputs() {
        let ctx = test_context();

        let implicit = Expression::parse("text_width(\"Morning, update posted.\")").unwrap();
        let explicit =
            Expression::parse("text_width(node(8, \"content\"), 32, 300, \"Roboto\")").unwrap();
        let implicit_height =
            Expression::parse("text_height(\"Morning, update posted.\")").unwrap();
        let explicit_height =
            Expression::parse("text_height(node(8, \"content\"), 32, 300, \"Roboto\")").unwrap();

        assert!(implicit.evaluate(&ctx).is_ok());
        assert!(explicit.evaluate(&ctx).is_err());
        assert!(implicit_height.evaluate(&ctx).is_ok());
        assert!(explicit_height.evaluate(&ctx).is_err());
    }

    #[test]
    fn text_measure_builtins_resolve_text_nodes_from_graph() {
        let text_id = NodeId::new(8);
        let mut graph = Graph::new();
        graph.nodes.insert(
            text_id,
            NodeKind::Text(Text {
                id: text_id,
                params: crate::node::source::text::TextParamsDelegate {
                    content: crate::node::Deferred::value("Morning, update posted.".to_string()),
                    font_family: crate::node::Deferred::value("Roboto".to_string()),
                    font_size: crate::node::Deferred::value(32.0),
                    max_width: crate::node::Deferred::value(300.0),
                    ..Default::default()
                },
                ..Text::default()
            }),
        );
        let ctx = ExpressionContext {
            graph: Some(&graph),
            ..test_context()
        };

        let width = Expression::parse("text_width(node(8))")
            .unwrap()
            .evaluate(&ctx)
            .unwrap();
        let height = Expression::parse("text_height(node(8))")
            .unwrap()
            .evaluate(&ctx)
            .unwrap();

        assert!(matches!(width, ExpressionValue::Number(value) if value > 0.0));
        assert!(matches!(height, ExpressionValue::Number(value) if value > 32.0));
    }
}