Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 13 additions & 11 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -442,17 +442,19 @@ pub struct Predicate {
- Attribute names starting with '?' are treated as boolean attributes using the `Attribute` fragment type with a `condition_tree`. The attribute is rendered only if the condition evaluates to true.

## State Management (webui-state)
### Path Resolution
The `find_value_by_dotted_path` function provides a high-performance way to query JSON state:
```rust
pub fn find_value_by_dotted_path(path: &str, state: &Value) -> Result<Value, StateError>
```
### Requirements
- Dot notation support (e.g., user.profile.name)
- Array indexing support (e.g., users.0.name)
- Special length property support for arrays (e.g., users.length)
- Nullable path handling
- Missing path error reporting
### Path Resolution
The `find_value_by_dotted_path_ref` function provides the render-time state lookup contract:
```rust
pub fn find_value_by_dotted_path_ref<'a>(path: &str, state: &'a Value) -> Option<Cow<'a, Value>>
```
Existing JSON values are returned as `Cow::Borrowed` so handler and expression hot paths do not clone the state tree. Synthetic values, currently string and array `.length`, are returned as `Cow::Owned`. The owned `find_value_by_dotted_path(path, state) -> Option<Value>` wrapper is retained for API boundaries that must materialize an owned `serde_json::Value`.

### Requirements
- Dot notation support (e.g., user.profile.name)
- Special length property support for arrays and strings (e.g., users.length)
- Numeric array indexes are not resolved by dotted path lookup; loops bind array items by moniker instead
- Nullable path handling via `Option`
- Missing paths return `None`; handler text and attribute bindings render empty, and missing condition values evaluate as false

## Expression Evaluation (webui-expressions)
### Core Function
Expand Down
47 changes: 33 additions & 14 deletions crates/webui-expressions/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@
//!
//! This module handles the evaluation of condition expressions in WebUI templates.

use std::borrow::Cow;

use serde_json::Value;
use thiserror::Error;
use webui_protocol::{
condition_expr, ComparisonOperator, CompoundCondition, ConditionExpr, LogicalOperator,
Predicate,
};
use webui_state::find_value_by_dotted_path;
use webui_state::find_value_by_dotted_path_ref;

/// Error types for expression evaluation.
#[derive(Debug, Error)]
Expand Down Expand Up @@ -39,7 +41,7 @@ pub type Result<T> = std::result::Result<T, ExpressionError>;

/// Evaluate a condition expression with the given state
pub fn evaluate(condition: &ConditionExpr, state: &Value) -> Result<bool> {
evaluate_with_resolver(condition, |path| find_value_by_dotted_path(path, state))
evaluate_with_resolver(condition, |path| find_value_by_dotted_path_ref(path, state))
}

/// Evaluate a condition expression using a custom resolver for value lookups.
Expand All @@ -48,9 +50,9 @@ pub fn evaluate(condition: &ConditionExpr, state: &Value) -> Result<bool> {
/// returns the resolved value. This allows callers to provide merged views
/// (e.g., local variables overlaid on global state) without cloning the
/// entire state tree.
pub fn evaluate_with_resolver<F>(condition: &ConditionExpr, resolver: F) -> Result<bool>
pub fn evaluate_with_resolver<'a, F>(condition: &ConditionExpr, resolver: F) -> Result<bool>
where
F: Fn(&str) -> Option<Value>,
F: Fn(&str) -> Option<Cow<'a, Value>>,
{
let (logical_op_count, has_mixed_ops) = count_logical_operators(condition);

Expand Down Expand Up @@ -109,9 +111,9 @@ fn count_logical_operators(condition: &ConditionExpr) -> (usize, bool) {
}

// Iterative evaluation of expressions using a resolver closure
fn evaluate_expr<F>(condition: &ConditionExpr, resolver: &F) -> Result<bool>
fn evaluate_expr<'a, F>(condition: &ConditionExpr, resolver: &F) -> Result<bool>
where
F: Fn(&str) -> Option<Value>,
F: Fn(&str) -> Option<Cow<'a, Value>>,
{
match &condition.expr {
Some(condition_expr::Expr::Predicate(pred)) => evaluate_predicate(pred, resolver),
Expand All @@ -125,8 +127,8 @@ where
Some(condition_expr::Expr::Compound(compound)) => evaluate_compound(compound, resolver),
Some(condition_expr::Expr::Identifier(id)) => {
if let Some(val) = resolver(&id.value) {
match val {
Value::Bool(b) => Ok(b),
match val.as_ref() {
Value::Bool(b) => Ok(*b),
Value::Null => Ok(false),
Value::Number(n) => Ok(!(n.as_f64() == Some(0.0))),
Value::String(s) => Ok(!s.is_empty()),
Expand All @@ -143,9 +145,9 @@ where
}
}

fn evaluate_compound<F>(compound: &CompoundCondition, resolver: &F) -> Result<bool>
fn evaluate_compound<'a, F>(compound: &CompoundCondition, resolver: &F) -> Result<bool>
where
F: Fn(&str) -> Option<Value>,
F: Fn(&str) -> Option<Cow<'a, Value>>,
{
let left = compound.left.as_ref().ok_or_else(|| {
ExpressionError::Evaluation("Compound missing left expression".to_string())
Expand Down Expand Up @@ -178,17 +180,17 @@ where
}
}

fn evaluate_predicate<F>(predicate: &Predicate, resolver: &F) -> Result<bool>
fn evaluate_predicate<'a, F>(predicate: &Predicate, resolver: &F) -> Result<bool>
where
F: Fn(&str) -> Option<Value>,
F: Fn(&str) -> Option<Cow<'a, Value>>,
{
let left_val = match resolver(&predicate.left) {
Some(val) => val,
None => return Err(ExpressionError::MissingValue(predicate.left.clone())),
};

let right_val = if is_literal(&predicate.right) {
parse_literal(&predicate.right)?
Cow::Owned(parse_literal(&predicate.right)?)
} else {
match resolver(&predicate.right) {
Some(val) => val,
Expand All @@ -203,7 +205,7 @@ where
))
})?;

compare_values(&left_val, &op, &right_val)
compare_values(left_val.as_ref(), &op, right_val.as_ref())
}

// Check if a string is a literal value
Expand Down Expand Up @@ -323,6 +325,7 @@ fn extract_number(val: &Value) -> Result<f64> {
#[cfg(test)]
mod tests {
use super::*;
use std::borrow::Cow;
use webui_protocol::{ComparisonOperator, ConditionExpr, LogicalOperator};
use webui_test_utils::test_json;

Expand Down Expand Up @@ -601,6 +604,22 @@ mod tests {
);
}

#[test]
fn test_borrowed_resolver_value() {
let condition = ConditionExpr::identifier("flag");
let value = Value::Bool(true);

let result = evaluate_with_resolver(&condition, |path| {
if path == "flag" {
Some(Cow::Borrowed(&value))
} else {
None
}
});

assert!(matches!(result, Ok(true)));
}

#[test]
fn test_missing_value() {
let state = test_json!({
Expand Down
Loading
Loading