#![cfg_attr(
not(test),
deny(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unreachable,
clippy::todo,
clippy::unimplemented,
clippy::indexing_slicing,
clippy::string_slice,
clippy::arithmetic_side_effects,
)
)]
use std::borrow::Cow;
use std::collections::BTreeMap;
use std::rc::Rc;
use serde_json::{Map, Value};
use super::ast::{BinaryOp, Expr, RouteSource, StylePreset};
use super::budget::{Budget, SLOT_BYTES};
use super::error::{ConstelaError, Diagnostic, codes};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RouteValues {
pub params: BTreeMap<String, String>,
pub query: BTreeMap<String, String>,
pub path: String,
}
#[derive(Debug, Clone)]
pub(crate) enum Bound<'a> {
Borrowed(&'a Value),
Shared(Rc<Value>),
}
impl Bound<'_> {
pub(crate) fn get(&self) -> &Value {
match self {
Self::Borrowed(value) => value,
Self::Shared(value) => value,
}
}
}
impl<'a> From<Cow<'a, Value>> for Bound<'a> {
fn from(value: Cow<'a, Value>) -> Self {
match value {
Cow::Borrowed(value) => Self::Borrowed(value),
Cow::Owned(value) => Self::Shared(Rc::new(value)),
}
}
}
#[derive(Debug, Clone, Default)]
pub(crate) struct Env<'a> {
pub params: Rc<BTreeMap<&'a str, Bound<'a>>>,
pub vars: Vec<(&'a str, Bound<'a>)>,
}
impl<'a> Env<'a> {
fn var(&self, name: &str) -> Option<&Value> {
self.vars
.iter()
.rev()
.find(|(key, _)| *key == name)
.map(|(_, value)| value.get())
}
fn param(&self, name: &str) -> Option<&Value> {
self.params.get(name).map(Bound::get)
}
pub(crate) fn with_var(&self, name: &'a str, value: Bound<'a>) -> Self {
let mut next = self.clone();
next.vars.push((name, value));
next
}
}
#[derive(Clone, Copy)]
pub(crate) struct EvalCtx<'a> {
pub state: &'a Map<String, Value>,
pub env: &'a Env<'a>,
pub route: &'a RouteValues,
pub styles: &'a BTreeMap<String, StylePreset>,
pub depth: usize,
pub budget: &'a Budget,
}
impl EvalCtx<'_> {
fn descend(&self, path: &str) -> Result<Self, ConstelaError> {
if self.depth == 0 {
return Err(ConstelaError::Render(Diagnostic::new(
path,
codes::RENDER_LIMIT,
"expression nests deeper than the configured render depth limit",
)));
}
Ok(Self {
depth: self.depth.saturating_sub(1),
..*self
})
}
}
#[must_use]
pub fn truthy(value: &Value) -> bool {
match value {
Value::Null => false,
Value::Bool(b) => *b,
Value::Number(n) => n.as_f64().is_some_and(|f| f != 0.0 && !f.is_nan()),
Value::String(s) => !s.is_empty(),
Value::Array(_) | Value::Object(_) => true,
}
}
#[must_use]
pub fn js_string(value: &Value) -> String {
match value {
Value::Null => "null".to_string(),
Value::Bool(true) => "true".to_string(),
Value::Bool(false) => "false".to_string(),
Value::Number(n) => number_to_string(n),
Value::String(s) => s.clone(),
Value::Array(items) => items
.iter()
.map(|item| {
if item.is_null() {
String::new()
} else {
js_string(item)
}
})
.collect::<Vec<_>>()
.join(","),
Value::Object(_) => "[object Object]".to_string(),
}
}
#[must_use]
pub fn text_of(value: &Value) -> String {
if value.is_null() {
String::new()
} else {
js_string(value)
}
}
pub(crate) fn coerce<'v>(
value: &'v Value,
budget: &Budget,
path: &str,
) -> Result<Cow<'v, str>, ConstelaError> {
match value {
Value::String(text) => Ok(Cow::Borrowed(text)),
other => {
budget.charge_value(other, path)?;
Ok(Cow::Owned(js_string(other)))
}
}
}
pub(crate) fn text_coerce<'v>(
value: &'v Value,
budget: &Budget,
path: &str,
) -> Result<Cow<'v, str>, ConstelaError> {
if value.is_null() {
Ok(Cow::Borrowed(""))
} else {
coerce(value, budget, path)
}
}
fn number_to_string(n: &serde_json::Number) -> String {
let Some(f) = n.as_f64() else {
return n.to_string();
};
if f == 0.0 {
return "0".to_string();
}
let magnitude = f.abs();
if !(1e-6..1e21).contains(&magnitude) {
let exponential = format!("{f:e}");
return match exponential.split_once('e') {
Some((mantissa, exponent)) if !exponent.starts_with('-') => {
format!("{mantissa}e+{exponent}")
}
_ => exponential,
};
}
if f.fract() == 0.0 {
format!("{f:.0}")
} else {
f.to_string()
}
}
fn as_number(value: &Value) -> f64 {
value.as_f64().unwrap_or(0.0)
}
pub(crate) fn number_value(f: f64) -> Value {
if f.fract() == 0.0 && f >= -(2f64.powi(53)) && f <= 2f64.powi(53) {
#[expect(
clippy::cast_possible_truncation,
reason = "guarded above: integral and within i64's exactly-representable range"
)]
return Value::Number((f as i64).into());
}
serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number)
}
fn strict_eq(left: &Value, right: &Value) -> bool {
match (left.as_f64(), right.as_f64()) {
(Some(left), Some(right)) if left.is_finite() && right.is_finite() => left == right,
_ => left == right,
}
}
fn follow_path<'a>(mut current: &'a Value, path: &str) -> &'a Value {
for segment in path.split('.') {
if segment.is_empty() {
continue;
}
current = match current {
Value::Object(map) => map.get(segment).unwrap_or(&Value::Null),
Value::Array(items) => segment
.parse::<usize>()
.ok()
.and_then(|i| items.get(i))
.unwrap_or(&Value::Null),
_ => &Value::Null,
};
if current.is_null() {
return &Value::Null;
}
}
current
}
fn take_path(mut current: Value, path: &str) -> Value {
for segment in path.split('.') {
if segment.is_empty() {
continue;
}
current = match current {
Value::Object(mut map) => map.remove(segment).unwrap_or(Value::Null),
Value::Array(items) => segment
.parse::<usize>()
.ok()
.and_then(|i| items.into_iter().nth(i))
.unwrap_or(Value::Null),
_ => Value::Null,
};
if current.is_null() {
return Value::Null;
}
}
current
}
fn select_path<'a>(base: Cow<'a, Value>, path: &str) -> Cow<'a, Value> {
match base {
Cow::Borrowed(base) => Cow::Borrowed(follow_path(base, path)),
Cow::Owned(base) => Cow::Owned(take_path(base, path)),
}
}
fn lookup<'a>(base: Option<&'a Value>, path: Option<&str>) -> Cow<'a, Value> {
let Some(base) = base else {
return null();
};
Cow::Borrowed(path.map_or(base, |path| follow_path(base, path)))
}
pub(crate) fn eval(expr: &Expr, ctx: &EvalCtx<'_>, path: &str) -> Result<Value, ConstelaError> {
let value = eval_ref(expr, ctx, path)?;
ctx.budget.own(value, path)
}
pub(crate) fn eval_ref<'a>(
expr: &'a Expr,
ctx: &EvalCtx<'a>,
path: &str,
) -> Result<Cow<'a, Value>, ConstelaError> {
let inner = ctx.descend(path)?;
match expr {
Expr::Lit { value } => Ok(Cow::Borrowed(value)),
Expr::State { name, path: sub } => Ok(lookup(ctx.state.get(name), sub.as_deref())),
Expr::Var { name, path: sub } => Ok(lookup(ctx.env.var(name), sub.as_deref())),
Expr::Param { name, path: sub } => Ok(lookup(ctx.env.param(name), sub.as_deref())),
Expr::Route { name, source } => {
let looked_up = match source {
RouteSource::Param => ctx.route.params.get(name).map(String::as_str),
RouteSource::Query => ctx.route.query.get(name).map(String::as_str),
RouteSource::Path => Some(ctx.route.path.as_str()),
};
let Some(text) = looked_up else {
return Ok(null());
};
ctx.budget.charge(text.len(), path)?;
Ok(Cow::Owned(Value::String(text.to_string())))
}
Expr::Not { operand } => {
let value = eval_ref(operand, &inner, &format!("{path}.operand"))?;
Ok(Cow::Owned(Value::Bool(!truthy(&value))))
}
Expr::Cond {
condition,
then,
otherwise,
} => {
let test = eval_ref(condition, &inner, &format!("{path}.if"))?;
if truthy(&test) {
eval_ref(then, &inner, &format!("{path}.then"))
} else {
eval_ref(otherwise, &inner, &format!("{path}.else"))
}
}
Expr::Get { base, path: sub } => {
let base = eval_ref(base, &inner, &format!("{path}.base"))?;
Ok(select_path(base, sub))
}
Expr::Index { base, key } => {
let base = eval_ref(base, &inner, &format!("{path}.base"))?;
let key = eval_ref(key, &inner, &format!("{path}.key"))?;
index_into(base, &key, ctx.budget, path)
}
Expr::Concat { items } => {
let mut out = String::new();
for (i, item) in items.iter().enumerate() {
let value = eval_ref(item, &inner, &format!("{path}.items[{i}]"))?;
match text_coerce(&value, ctx.budget, path)? {
Cow::Borrowed(piece) => {
ctx.budget.charge(piece.len(), path)?;
out.push_str(piece);
}
Cow::Owned(piece) => out.push_str(&piece),
}
}
Ok(Cow::Owned(Value::String(out)))
}
Expr::Array { elements } => {
ctx.budget
.charge(SLOT_BYTES.saturating_mul(elements.len()), path)?;
let mut out = Vec::with_capacity(elements.len());
for (i, element) in elements.iter().enumerate() {
out.push(eval(element, &inner, &format!("{path}.elements[{i}]"))?);
}
Ok(Cow::Owned(Value::Array(out)))
}
Expr::Obj { props } => {
let mut out = Map::new();
for (key, value) in props {
ctx.budget
.charge(key.len().saturating_add(SLOT_BYTES), path)?;
let value = eval(value, &inner, &format!("{path}.props.{key}"))?;
out.insert(key.clone(), value);
}
Ok(Cow::Owned(Value::Object(out)))
}
Expr::Bin { op, left, right } => eval_binary(*op, left, right, &inner, path),
Expr::Style { name, variants } => {
let Some(preset) = ctx.styles.get(name) else {
return Ok(Cow::Owned(Value::String(String::new())));
};
let mut values = Vec::with_capacity(variants.len());
for (axis, expr) in variants {
let value = eval_ref(expr, &inner, &format!("{path}.variants.{axis}"))?;
values.push((axis.as_str(), value));
}
let mut selected = BTreeMap::new();
for (axis, value) in &values {
selected.insert(*axis, text_coerce(value, ctx.budget, path)?);
}
let classes = resolve_style(preset, &selected);
ctx.budget.charge(classes.len(), path)?;
Ok(Cow::Owned(Value::String(classes)))
}
}
}
const fn null<'a>() -> Cow<'a, Value> {
Cow::Borrowed(&Value::Null)
}
fn array_slot(key: &Value) -> Option<usize> {
let index = key.as_f64()?;
if index < 0.0 || index.fract() != 0.0 {
return None;
}
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "guarded above: non-negative and integral"
)]
let index = index as usize;
Some(index)
}
fn string_char<'a>(text: &str, key: &Value) -> Cow<'a, Value> {
key.as_u64()
.and_then(|index| usize::try_from(index).ok())
.and_then(|index| text.chars().nth(index))
.map_or_else(null, |c| Cow::Owned(Value::String(c.to_string())))
}
fn index_into<'a>(
base: Cow<'a, Value>,
key: &Value,
budget: &Budget,
path: &str,
) -> Result<Cow<'a, Value>, ConstelaError> {
Ok(match base {
Cow::Borrowed(base) => match base {
Value::Array(items) => array_slot(key)
.and_then(|index| items.get(index))
.map_or_else(null, Cow::Borrowed),
Value::Object(map) => {
let key = coerce(key, budget, path)?;
map.get(key.as_ref()).map_or_else(null, Cow::Borrowed)
}
Value::String(text) => string_char(text, key),
_ => null(),
},
Cow::Owned(base) => match base {
Value::Array(items) => array_slot(key)
.and_then(|index| items.into_iter().nth(index))
.map_or_else(null, Cow::Owned),
Value::Object(mut map) => {
let key = coerce(key, budget, path)?;
map.remove(key.as_ref()).map_or_else(null, Cow::Owned)
}
Value::String(text) => string_char(&text, key),
_ => null(),
},
})
}
fn resolve_style(preset: &StylePreset, selected: &BTreeMap<&str, Cow<'_, str>>) -> String {
let mut classes = Vec::new();
if !preset.base.is_empty() {
classes.push(preset.base.as_str());
}
for (axis, options) in &preset.variants {
let choice = selected
.get(axis.as_str())
.map(AsRef::as_ref)
.or_else(|| preset.default_variants.get(axis).map(String::as_str));
if let Some(class) = choice.and_then(|choice| options.get(choice))
&& !class.is_empty()
{
classes.push(class.as_str());
}
}
classes.join(" ")
}
fn eval_binary<'a>(
op: BinaryOp,
left: &'a Expr,
right: &'a Expr,
ctx: &EvalCtx<'a>,
path: &str,
) -> Result<Cow<'a, Value>, ConstelaError> {
let left_path = format!("{path}.left");
let right_path = format!("{path}.right");
if matches!(op, BinaryOp::And | BinaryOp::Or) {
let left = eval_ref(left, ctx, &left_path)?;
let take_left = if op == BinaryOp::And {
!truthy(&left)
} else {
truthy(&left)
};
return if take_left {
Ok(left)
} else {
eval_ref(right, ctx, &right_path)
};
}
let left = eval_ref(left, ctx, &left_path)?;
let right = eval_ref(right, ctx, &right_path)?;
Ok(Cow::Owned(match op {
BinaryOp::Add => {
if left.is_number() && right.is_number() {
number_value(as_number(&left) + as_number(&right))
} else {
let left = coerce(&left, ctx.budget, path)?;
let right = coerce(&right, ctx.budget, path)?;
let borrowed = |text: &Cow<'_, str>| match text {
Cow::Borrowed(text) => text.len(),
Cow::Owned(_) => 0,
};
ctx.budget
.charge(borrowed(&left).saturating_add(borrowed(&right)), path)?;
let len = left.len().saturating_add(right.len());
let mut joined = String::with_capacity(len);
joined.push_str(&left);
joined.push_str(&right);
Value::String(joined)
}
}
BinaryOp::Sub => number_value(as_number(&left) - as_number(&right)),
BinaryOp::Mul => number_value(as_number(&left) * as_number(&right)),
BinaryOp::Div => {
let divisor = as_number(&right);
if divisor == 0.0 {
Value::Null
} else {
number_value(as_number(&left) / divisor)
}
}
BinaryOp::Rem => {
let divisor = as_number(&right);
if divisor == 0.0 {
Value::Null
} else {
number_value(as_number(&left) % divisor)
}
}
BinaryOp::Eq => Value::Bool(strict_eq(&left, &right)),
BinaryOp::Ne => Value::Bool(!strict_eq(&left, &right)),
BinaryOp::Lt | BinaryOp::Le | BinaryOp::Gt | BinaryOp::Ge => {
let ordering = if left.is_number() && right.is_number() {
as_number(&left).partial_cmp(&as_number(&right))
} else {
Some(coerce(&left, ctx.budget, path)?.cmp(&coerce(&right, ctx.budget, path)?))
};
let Some(ordering) = ordering else {
return Ok(Cow::Owned(Value::Bool(false)));
};
Value::Bool(match op {
BinaryOp::Lt => ordering.is_lt(),
BinaryOp::Le => ordering.is_le(),
BinaryOp::Gt => ordering.is_gt(),
_ => ordering.is_ge(),
})
}
BinaryOp::And | BinaryOp::Or => {
debug_assert!(false, "&& and || are short-circuited above");
Value::Bool(false)
}
}))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn eval_str(source: &str) -> Value {
eval_with(source, &Map::new(), &Env::default())
}
fn eval_with(source: &str, state: &Map<String, Value>, env: &Env) -> Value {
let expr: Expr = serde_json::from_str(source).expect("expression parses");
let styles = BTreeMap::new();
let route = RouteValues::default();
let budget = Budget::new(usize::MAX);
let ctx = EvalCtx {
state,
env,
route: &route,
styles: &styles,
depth: 64,
budget: &budget,
};
eval(&expr, &ctx, "expr").expect("evaluates")
}
#[test]
fn truthiness_matches_javascript() {
assert!(!truthy(&json!(null)));
assert!(!truthy(&json!(false)));
assert!(!truthy(&json!(0)));
assert!(!truthy(&json!("")));
assert!(truthy(&json!("0")));
assert!(truthy(&json!([])));
assert!(truthy(&json!({})));
}
#[test]
fn js_string_matches_javascript() {
assert_eq!(js_string(&json!(null)), "null");
assert_eq!(js_string(&json!(true)), "true");
assert_eq!(js_string(&json!(3)), "3");
assert_eq!(js_string(&json!(3.5)), "3.5");
assert_eq!(js_string(&json!([1, 2])), "1,2");
assert_eq!(js_string(&json!([1, null, 2])), "1,,2");
assert_eq!(js_string(&json!({"a": 1})), "[object Object]");
}
#[test]
fn text_of_renders_null_as_nothing() {
assert_eq!(text_of(&json!(null)), "");
assert_eq!(text_of(&json!(0)), "0");
}
#[test]
fn plus_adds_numbers_and_concatenates_everything_else() {
assert_eq!(
eval_str(
r#"{"expr":"bin","op":"+","left":{"expr":"lit","value":1},"right":{"expr":"lit","value":2}}"#
),
json!(3)
);
assert_eq!(
eval_str(
r#"{"expr":"bin","op":"+","left":{"expr":"lit","value":"a"},"right":{"expr":"lit","value":1}}"#
),
json!("a1")
);
assert_eq!(
eval_str(
r#"{"expr":"bin","op":"+","left":{"expr":"lit","value":[1,2]},"right":{"expr":"lit","value":""}}"#
),
json!("1,2")
);
}
#[test]
fn arithmetic_treats_non_numbers_as_zero() {
assert_eq!(
eval_str(
r#"{"expr":"bin","op":"-","left":{"expr":"lit","value":"x"},"right":{"expr":"lit","value":2}}"#
),
json!(-2)
);
}
#[test]
fn division_by_zero_is_null_not_nan() {
for op in ["/", "%"] {
let source = format!(
r#"{{"expr":"bin","op":"{op}","left":{{"expr":"lit","value":1}},"right":{{"expr":"lit","value":0}}}}"#
);
assert_eq!(eval_str(&source), json!(null), "{op} by zero");
}
}
#[test]
fn equality_is_strict() {
assert_eq!(
eval_str(
r#"{"expr":"bin","op":"==","left":{"expr":"lit","value":1},"right":{"expr":"lit","value":"1"}}"#
),
json!(false)
);
}
#[test]
fn a_computed_number_equals_the_literal_it_is() {
let sum = eval_str(
r#"{"expr":"bin","op":"+","left":{"expr":"lit","value":1},"right":{"expr":"lit","value":2}}"#,
);
assert_eq!(sum.to_string(), "3");
assert_eq!(
eval_str(
r#"{"expr":"bin","op":"==",
"left":{"expr":"bin","op":"+","left":{"expr":"lit","value":1},"right":{"expr":"lit","value":2}},
"right":{"expr":"lit","value":3}}"#
),
json!(true)
);
assert_eq!(
eval_str(
r#"{"expr":"bin","op":"==","left":{"expr":"lit","value":3.0},"right":{"expr":"lit","value":3}}"#
),
json!(true)
);
}
#[test]
fn logical_operators_yield_the_operand() {
assert_eq!(
eval_str(
r#"{"expr":"bin","op":"||","left":{"expr":"lit","value":""},"right":{"expr":"lit","value":"fallback"}}"#
),
json!("fallback")
);
assert_eq!(
eval_str(
r#"{"expr":"bin","op":"&&","left":{"expr":"lit","value":0},"right":{"expr":"lit","value":"unused"}}"#
),
json!(0)
);
}
#[test]
fn comparison_falls_back_to_string_ordering() {
assert_eq!(
eval_str(
r#"{"expr":"bin","op":"<","left":{"expr":"lit","value":"apple"},"right":{"expr":"lit","value":"banana"}}"#
),
json!(true)
);
}
#[test]
fn missing_references_are_null_not_errors() {
assert_eq!(eval_str(r#"{"expr":"state","name":"nope"}"#), json!(null));
assert_eq!(eval_str(r#"{"expr":"var","name":"nope"}"#), json!(null));
assert_eq!(eval_str(r#"{"expr":"param","name":"nope"}"#), json!(null));
}
#[test]
fn dotted_paths_walk_objects_and_arrays() {
let mut state = Map::new();
state.insert(
"user".into(),
json!({"address": {"city": "Kyoto"}, "tags": ["a", "b"]}),
);
assert_eq!(
eval_with(
r#"{"expr":"state","name":"user","path":"address.city"}"#,
&state,
&Env::default()
),
json!("Kyoto")
);
assert_eq!(
eval_with(
r#"{"expr":"state","name":"user","path":"tags.1"}"#,
&state,
&Env::default()
),
json!("b")
);
assert_eq!(
eval_with(
r#"{"expr":"state","name":"user","path":"address.zip"}"#,
&state,
&Env::default()
),
json!(null)
);
}
#[test]
fn index_reads_arrays_objects_and_strings() {
let mut state = Map::new();
state.insert("items".into(), json!(["x", "y"]));
let read = |key: &str| {
eval_with(
&format!(
r#"{{"expr":"index","base":{{"expr":"state","name":"items"}},"key":{{"expr":"lit","value":{key}}}}}"#
),
&state,
&Env::default(),
)
};
assert_eq!(read("1"), json!("y"));
assert_eq!(read("5"), json!(null));
assert_eq!(read("-1"), json!(null));
assert_eq!(read("1.5"), json!(null));
}
#[test]
fn concat_skips_nulls() {
assert_eq!(
eval_str(
r#"{"expr":"concat","items":[{"expr":"lit","value":"a"},{"expr":"state","name":"missing"},{"expr":"lit","value":"b"}]}"#
),
json!("ab")
);
}
#[test]
fn style_preset_resolves_base_selection_and_defaults() {
let preset: StylePreset = serde_json::from_str(
r#"{"base":"btn","variants":{"size":{"sm":"btn-sm","lg":"btn-lg"},"tone":{"primary":"btn-primary"}},
"defaultVariants":{"tone":"primary"}}"#,
)
.expect("preset parses");
let mut selected = BTreeMap::new();
selected.insert("size", Cow::Borrowed("lg"));
assert_eq!(resolve_style(&preset, &selected), "btn btn-lg btn-primary");
let mut unknown = BTreeMap::new();
unknown.insert("size", Cow::Borrowed("xl"));
assert_eq!(resolve_style(&preset, &unknown), "btn btn-primary");
}
#[test]
fn depth_budget_is_enforced() {
let mut source = String::new();
for _ in 0..10 {
source.push_str(r#"{"expr":"not","operand":"#);
}
source.push_str(r#"{"expr":"lit","value":true}"#);
for _ in 0..10 {
source.push('}');
}
let expr: Expr = serde_json::from_str(&source).expect("parses");
let styles = BTreeMap::new();
let route = RouteValues::default();
let state = Map::new();
let env = Env::default();
let budget = Budget::new(usize::MAX);
let ctx = EvalCtx {
state: &state,
env: &env,
route: &route,
styles: &styles,
depth: 3,
budget: &budget,
};
let err = eval(&expr, &ctx, "expr").expect_err("over budget");
assert_eq!(err.diagnostics()[0].code, codes::RENDER_LIMIT);
}
fn eval_full(
source: &str,
route: &RouteValues,
styles: &BTreeMap<String, StylePreset>,
budget: &Budget,
) -> Result<Value, ConstelaError> {
let expr: Expr = serde_json::from_str(source).expect("expression parses");
let state = Map::new();
let env = Env::default();
let ctx = EvalCtx {
state: &state,
env: &env,
route,
styles,
depth: 64,
budget,
};
eval(&expr, &ctx, "expr")
}
#[test]
fn get_and_index_move_out_of_a_built_value() {
let built = r#"{"expr":"obj","props":{"a":{"expr":"array","elements":[
{"expr":"lit","value":"x"},{"expr":"lit","value":{"k":"v"}}]}}}"#;
assert_eq!(
eval_str(&format!(
r#"{{"expr":"get","base":{built},"path":"a.1.k"}}"#
)),
json!("v")
);
assert_eq!(
eval_str(&format!(r#"{{"expr":"get","base":{built},"path":"a.9"}}"#)),
json!(null)
);
assert_eq!(
eval_str(&format!(
r#"{{"expr":"index","base":{built},"key":{{"expr":"lit","value":"a"}}}}"#
)),
json!(["x", {"k": "v"}])
);
let list =
r#"{"expr":"array","elements":[{"expr":"lit","value":1},{"expr":"lit","value":2}]}"#;
assert_eq!(
eval_str(&format!(
r#"{{"expr":"index","base":{list},"key":{{"expr":"lit","value":1}}}}"#
)),
json!(2)
);
assert_eq!(
eval_str(&format!(
r#"{{"expr":"index","base":{list},"key":{{"expr":"lit","value":5}}}}"#
)),
json!(null)
);
}
#[test]
fn index_reads_objects_strings_and_nothing_else() {
let read = |base: &str, key: &str| {
eval_str(&format!(
r#"{{"expr":"index","base":{{"expr":"lit","value":{base}}},"key":{{"expr":"lit","value":{key}}}}}"#
))
};
assert_eq!(read(r#"{"1":"one"}"#, "1"), json!("one"));
assert_eq!(read(r#"{"a":1}"#, r#""b""#), json!(null));
assert_eq!(read(r#""héllo""#, "1"), json!("é"));
assert_eq!(read(r#""abc""#, "9"), json!(null));
assert_eq!(read(r#""abc""#, r#""x""#), json!(null));
assert_eq!(read("7", "0"), json!(null));
assert_eq!(
eval_str(
r#"{"expr":"index","base":{"expr":"concat","items":[{"expr":"lit","value":"ab"}]},
"key":{"expr":"lit","value":1}}"#
),
json!("b")
);
}
#[test]
fn route_values_are_read_by_source() {
let mut route = RouteValues::default();
route.params.insert("id".into(), "7".into());
route.query.insert("q".into(), "tea".into());
route.path = "/posts/7".into();
let styles = BTreeMap::new();
let budget = Budget::new(usize::MAX);
let read = |source: &str| {
eval_full(
&format!(
r#"{{"expr":"route","name":"{}","source":"{source}"}}"#,
if source == "query" { "q" } else { "id" }
),
&route,
&styles,
&budget,
)
.expect("evaluates")
};
assert_eq!(read("param"), json!("7"));
assert_eq!(read("query"), json!("tea"));
assert_eq!(read("path"), json!("/posts/7"));
assert_eq!(
eval_full(
r#"{"expr":"route","name":"missing","source":"param"}"#,
&route,
&styles,
&budget
)
.expect("evaluates"),
json!(null)
);
}
#[test]
fn a_style_expression_resolves_its_preset() {
let mut styles = BTreeMap::new();
styles.insert(
"btn".to_string(),
serde_json::from_str::<StylePreset>(
r#"{"base":"btn","variants":{"size":{"lg":"btn-lg"}}}"#,
)
.expect("preset parses"),
);
let route = RouteValues::default();
let budget = Budget::new(usize::MAX);
assert_eq!(
eval_full(
r#"{"expr":"style","name":"btn","variants":{"size":{"expr":"lit","value":"lg"}}}"#,
&route,
&styles,
&budget
)
.expect("evaluates"),
json!("btn btn-lg")
);
assert_eq!(
eval_full(
r#"{"expr":"style","name":"none"}"#,
&route,
&styles,
&budget
)
.expect("evaluates"),
json!("")
);
}
#[test]
fn coercions_follow_javascript() {
let bin = |op: &str, left: &str, right: &str| {
eval_str(&format!(
r#"{{"expr":"bin","op":"{op}","left":{{"expr":"lit","value":{left}}},"right":{{"expr":"lit","value":{right}}}}}"#
))
};
assert_eq!(bin("+", "true", r#""x""#), json!("truex"));
assert_eq!(bin("+", "null", r#""x""#), json!("nullx"));
assert_eq!(bin("+", "[1,2]", "3"), json!("1,23"));
assert_eq!(bin("<", "[1]", r#""2""#), json!(true));
assert_eq!(bin(">=", r#""b""#, r#""a""#), json!(true));
assert_eq!(bin("<=", "1", "1"), json!(true));
assert_eq!(bin("!=", "1", "2"), json!(true));
assert_eq!(bin("*", "3", "4"), json!(12));
assert_eq!(bin("%", "7", "4"), json!(3));
assert_eq!(bin("/", "7", "2"), json!(3.5));
assert_eq!(
eval_str(
r#"{"expr":"concat","items":[{"expr":"lit","value":1},{"expr":"lit","value":null},{"expr":"lit","value":true}]}"#
),
json!("1true")
);
assert_eq!(
eval_str(
r#"{"expr":"cond","if":{"expr":"lit","value":0},"then":{"expr":"lit","value":"a"},"else":{"expr":"lit","value":"b"}}"#
),
json!("b")
);
assert_eq!(
eval_str(r#"{"expr":"not","operand":{"expr":"lit","value":[]}}"#),
json!(false)
);
}
#[test]
fn a_read_borrows_and_a_kept_copy_charges() {
let mut state = Map::new();
state.insert("big".into(), json!("x".repeat(100)));
let expr: Expr = serde_json::from_str(r#"{"expr":"state","name":"big"}"#).expect("parses");
let styles = BTreeMap::new();
let route = RouteValues::default();
let env = Env::default();
let budget = Budget::new(10);
let ctx = EvalCtx {
state: &state,
env: &env,
route: &route,
styles: &styles,
depth: 64,
budget: &budget,
};
let read = eval_ref(&expr, &ctx, "expr").expect("borrows");
assert!(matches!(read, Cow::Borrowed(_)));
let err = eval(&expr, &ctx, "expr").expect_err("copy over budget");
assert_eq!(err.diagnostics()[0].code, codes::RENDER_LIMIT);
}
#[test]
fn coercing_a_non_string_charges_the_budget() {
let budget = Budget::new(4);
let value = json!([1, 2, 3]);
coerce(&value, &budget, "p").expect_err("over a 4-byte budget");
let text = json!("plain");
assert!(matches!(
coerce(&text, &Budget::new(0), "p").expect("strings borrow"),
Cow::Borrowed("plain")
));
assert_eq!(
text_coerce(&json!(null), &Budget::new(0), "p").expect("null is empty"),
""
);
}
#[test]
fn env_bindings_resolve_innermost_first() {
let outer = json!("outer");
let env = Env::default()
.with_var("v", Bound::Borrowed(&outer))
.with_var("v", Bound::Shared(Rc::new(json!("inner"))));
assert_eq!(env.var("v"), Some(&json!("inner")));
assert_eq!(env.var("missing"), None);
let mut params = BTreeMap::new();
params.insert("p", Bound::from(Cow::Owned(json!(1))));
let env = Env {
params: Rc::new(params),
vars: Vec::new(),
};
assert_eq!(env.param("p"), Some(&json!(1)));
}
}