#![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 serde_json::{Map, Value};
use super::ast::{ActionStep, HttpMethod, StorageArea, StorageOperation, UpdateOperation};
use super::error::{ConstelaError, Diagnostic, codes};
use super::eval::{Env, EvalCtx, RouteValues, eval, js_string, number_value, truthy};
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum Effect {
Fetch {
url: String,
method: HttpMethod,
body: Option<Value>,
result: Option<String>,
},
Storage {
operation: StorageOperation,
area: StorageArea,
key: String,
value: Option<Value>,
result: Option<String>,
},
Navigate {
url: String,
replace: bool,
},
Delay {
ms: f64,
},
Interval {
ms: f64,
action: String,
},
Focus {
target: String,
operation: String,
},
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Dispatched {
pub effects: Vec<Effect>,
pub suspended_at: Option<String>,
}
impl Dispatched {
#[must_use]
pub const fn is_pure(&self) -> bool {
self.effects.is_empty()
}
#[must_use]
pub const fn is_suspended(&self) -> bool {
self.suspended_at.is_some()
}
}
pub struct DispatchCtx<'a> {
pub route: &'a RouteValues,
pub styles: &'a std::collections::BTreeMap<String, super::ast::StylePreset>,
pub depth: usize,
pub budget: &'a std::cell::Cell<usize>,
}
pub fn run_steps(
steps: &[ActionStep],
state: &mut Map<String, Value>,
env: &Env,
ctx: &DispatchCtx<'_>,
path: &str,
out: &mut Dispatched,
) -> Result<(), ConstelaError> {
for (i, step) in steps.iter().enumerate() {
run_step(step, state, env, ctx, &format!("{path}[{i}]"), out)?;
if out.is_suspended() {
return Ok(());
}
}
Ok(())
}
fn run_step(
step: &ActionStep,
state: &mut Map<String, Value>,
env: &Env,
ctx: &DispatchCtx<'_>,
path: &str,
out: &mut Dispatched,
) -> Result<(), ConstelaError> {
macro_rules! eval_in {
($expr:expr, $sub:expr) => {{
let eval_ctx = EvalCtx {
state,
env,
route: ctx.route,
styles: ctx.styles,
depth: ctx.depth,
budget: ctx.budget,
};
eval($expr, &eval_ctx, $sub)?
}};
}
match step {
ActionStep::Set { target, value } => {
let value = eval_in!(value, &format!("{path}.value"));
check_write_depth(&value, 0, ctx.depth, &format!("{path}.value"))?;
state.insert(target.clone(), value);
}
ActionStep::Update {
target,
operation,
value,
index,
delete_count,
} => {
let operand = match value {
Some(expr) => Some(eval_in!(expr, &format!("{path}.value"))),
None => None,
};
let index = match index {
Some(expr) => Some(eval_in!(expr, &format!("{path}.index"))),
None => None,
};
let count = match delete_count {
Some(expr) => Some(eval_in!(expr, &format!("{path}.deleteCount"))),
None => None,
};
if let Some(operand) = operand.as_ref() {
check_write_depth(operand, 1, ctx.depth, &format!("{path}.value"))?;
}
apply_update(
state,
target,
*operation,
operand.as_ref(),
index.as_ref(),
count.as_ref(),
);
}
ActionStep::SetPath {
target,
path: target_path,
value,
} => {
let segments = eval_in!(target_path, &format!("{path}.path"));
let value = eval_in!(value, &format!("{path}.value"));
let segments = path_segments(&segments);
if segments.len() > ctx.depth {
return Err(ConstelaError::Render(Diagnostic::new(
format!("{path}.path"),
codes::RENDER_LIMIT,
format!(
"`setPath` path has {} segments, over the {} the depth limit allows",
segments.len(),
ctx.depth
),
)));
}
check_write_depth(&value, segments.len(), ctx.depth, &format!("{path}.value"))?;
if let Some(slot) = state.get_mut(target) {
set_at_path(slot, &segments, value);
}
}
ActionStep::If {
condition,
then,
otherwise,
} => {
let test = eval_in!(condition, &format!("{path}.condition"));
let branch = if truthy(&test) { then } else { otherwise };
let branch_path = if truthy(&test) {
format!("{path}.then")
} else {
format!("{path}.else")
};
run_steps(branch, state, env, ctx, &branch_path, out)?;
}
effectful => record_effect(effectful, state, env, ctx, path, out)?,
}
Ok(())
}
fn record_effect(
step: &ActionStep,
state: &Map<String, Value>,
env: &Env,
ctx: &DispatchCtx<'_>,
path: &str,
out: &mut Dispatched,
) -> Result<(), ConstelaError> {
macro_rules! eval_in {
($expr:expr, $sub:expr) => {{
let eval_ctx = EvalCtx {
state,
env,
route: ctx.route,
styles: ctx.styles,
depth: ctx.depth,
budget: ctx.budget,
};
eval($expr, &eval_ctx, $sub)?
}};
}
let effect = match step {
ActionStep::Fetch {
url,
method,
body,
result,
..
} => {
let url = js_string(&eval_in!(url, &format!("{path}.url")));
check_url(&url, &format!("{path}.url"))?;
let body = match body {
Some(expr) => Some(eval_in!(expr, &format!("{path}.body"))),
None => None,
};
Effect::Fetch {
url,
method: *method,
body,
result: result.clone(),
}
}
ActionStep::Storage {
operation,
key,
value,
storage,
result,
..
} => {
let key = js_string(&eval_in!(key, &format!("{path}.key")));
let value = match value {
Some(expr) => Some(eval_in!(expr, &format!("{path}.value"))),
None => None,
};
Effect::Storage {
operation: *operation,
area: *storage,
key,
value,
result: result.clone(),
}
}
ActionStep::Navigate { url, replace } => {
let url = js_string(&eval_in!(url, &format!("{path}.url")));
check_url(&url, &format!("{path}.url"))?;
Effect::Navigate {
url,
replace: *replace,
}
}
ActionStep::Delay { ms, .. } => Effect::Delay {
ms: eval_in!(ms, &format!("{path}.ms")).as_f64().unwrap_or(0.0),
},
ActionStep::Interval { ms, action } => Effect::Interval {
ms: eval_in!(ms, &format!("{path}.ms")).as_f64().unwrap_or(0.0),
action: action.clone(),
},
ActionStep::Focus { target, operation } => Effect::Focus {
target: js_string(&eval_in!(target, &format!("{path}.target"))),
operation: operation.clone(),
},
pure => {
debug_assert!(
pure.is_pure(),
"{} is neither pure nor an effect",
pure.kind()
);
return Ok(());
}
};
out.effects.push(effect);
out.suspended_at = Some(path.to_string());
Ok(())
}
fn depth_exceeds(value: &Value, limit: usize) -> bool {
let mut stack = vec![(value, 1usize)];
while let Some((current, depth)) = stack.pop() {
if depth > limit {
return true;
}
let next = depth.saturating_add(1);
match current {
Value::Array(items) => stack.extend(items.iter().map(|item| (item, next))),
Value::Object(entries) => {
stack.extend(entries.iter().map(|(_, item)| (item, next)));
}
_ => {}
}
}
false
}
fn check_write_depth(
value: &Value,
nesting: usize,
limit: usize,
path: &str,
) -> Result<(), ConstelaError> {
let room = limit.saturating_sub(nesting);
if room == 0 || depth_exceeds(value, room) {
return Err(ConstelaError::Render(Diagnostic::new(
path,
codes::RENDER_LIMIT,
format!("the value written here nests deeper than the {limit}-level state depth limit"),
)));
}
Ok(())
}
fn check_url(url: &str, path: &str) -> Result<(), ConstelaError> {
if super::policy::is_allowed_url(url) {
Ok(())
} else {
Err(ConstelaError::Render(Diagnostic::new(
path,
codes::URL_SCHEME,
format!("computed URL {url:?} uses a scheme that is not allowed"),
)))
}
}
fn apply_update(
state: &mut Map<String, Value>,
target: &str,
operation: UpdateOperation,
value: Option<&Value>,
index: Option<&Value>,
delete_count: Option<&Value>,
) {
let Some(slot) = state.get_mut(target) else {
return;
};
match operation {
UpdateOperation::Increment | UpdateOperation::Decrement => {
let Some(current) = slot.as_f64() else { return };
let step = value.and_then(Value::as_f64).unwrap_or(1.0);
let next = if operation == UpdateOperation::Increment {
current + step
} else {
current - step
};
let next = number_value(next);
if !next.is_null() {
*slot = next;
}
}
UpdateOperation::Toggle => {
if let Some(current) = slot.as_bool() {
*slot = Value::Bool(!current);
}
}
UpdateOperation::Merge => {
let (Some(target_map), Some(Value::Object(source))) = (slot.as_object_mut(), value)
else {
return;
};
for (key, value) in source {
target_map.insert(key.clone(), value.clone());
}
}
UpdateOperation::Push => {
let (Some(items), Some(value)) = (slot.as_array_mut(), value) else {
return;
};
items.push(value.clone());
}
UpdateOperation::Pop => {
if let Some(items) = slot.as_array_mut() {
items.pop();
}
}
UpdateOperation::Remove => {
let (Some(items), Some(value)) = (slot.as_array_mut(), value) else {
return;
};
items.retain(|item| item != value);
}
UpdateOperation::ReplaceAt => {
let (Some(items), Some(value), Some(at)) =
(slot.as_array_mut(), value, index.and_then(array_index))
else {
return;
};
if let Some(entry) = items.get_mut(at) {
*entry = value.clone();
}
}
UpdateOperation::InsertAt => {
let (Some(items), Some(value), Some(at)) =
(slot.as_array_mut(), value, index.and_then(array_index))
else {
return;
};
items.insert(at.min(items.len()), value.clone());
}
UpdateOperation::Splice => {
let (Some(items), Some(at), Some(count)) = (
slot.as_array_mut(),
index.and_then(array_index),
delete_count.and_then(array_index),
) else {
return;
};
let at = at.min(items.len());
let end = at.saturating_add(count).min(items.len());
let inserted: Vec<Value> = match value {
Some(Value::Array(values)) => values.clone(),
Some(other) => vec![other.clone()],
None => Vec::new(),
};
items.splice(at..end, inserted);
}
}
}
fn array_index(value: &Value) -> Option<usize> {
let raw = value.as_f64()?;
if raw < 0.0 || raw.fract() != 0.0 {
return None;
}
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "guarded above: non-negative and integral"
)]
let index = raw as usize;
Some(index)
}
fn path_segments(value: &Value) -> Vec<String> {
match value {
Value::Array(items) => items.iter().map(js_string).collect(),
other => js_string(other)
.split('.')
.filter(|segment| !segment.is_empty())
.map(ToString::to_string)
.collect(),
}
}
fn set_at_path(slot: &mut Value, segments: &[String], value: Value) {
let Some((last, parents)) = segments.split_last() else {
*slot = value;
return;
};
let mut current = slot;
for segment in parents {
if !current.is_object() && !current.is_array() {
*current = Value::Object(Map::new());
}
current = match current {
Value::Array(items) => {
let Ok(index) = segment.parse::<usize>() else {
return;
};
let Some(entry) = items.get_mut(index) else {
return;
};
entry
}
Value::Object(map) => map.entry(segment.clone()).or_insert(Value::Null),
_ => return,
};
}
if !current.is_object() && !current.is_array() {
*current = Value::Object(Map::new());
}
match current {
Value::Array(items) => {
let Ok(index) = last.parse::<usize>() else {
return;
};
if let Some(entry) = items.get_mut(index) {
*entry = value;
}
}
Value::Object(map) => {
map.insert(last.clone(), value);
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn state_of(value: &Value) -> Map<String, Value> {
value.as_object().expect("object").clone()
}
#[expect(
clippy::needless_pass_by_value,
reason = "a test helper called with `json!(...)` literals; taking \
references would put `&` on every call site for nothing"
)]
fn update(
initial: Value,
operation: UpdateOperation,
value: Option<Value>,
index: Option<Value>,
count: Option<Value>,
) -> Value {
let mut state = state_of(&json!({ "x": initial }));
apply_update(
&mut state,
"x",
operation,
value.as_ref(),
index.as_ref(),
count.as_ref(),
);
state["x"].clone()
}
#[test]
fn increment_defaults_to_one_and_honours_an_operand() {
assert_eq!(
update(json!(1), UpdateOperation::Increment, None, None, None),
json!(2)
);
assert_eq!(
update(
json!(1),
UpdateOperation::Increment,
Some(json!(5)),
None,
None
),
json!(6)
);
assert_eq!(
update(json!(1), UpdateOperation::Decrement, None, None, None),
json!(0)
);
}
#[test]
fn list_operations() {
assert_eq!(
update(
json!([1]),
UpdateOperation::Push,
Some(json!(2)),
None,
None
),
json!([1, 2])
);
assert_eq!(
update(json!([1, 2]), UpdateOperation::Pop, None, None, None),
json!([1])
);
assert_eq!(
update(
json!([1, 2, 1]),
UpdateOperation::Remove,
Some(json!(1)),
None,
None
),
json!([2])
);
assert_eq!(
update(
json!([1, 2]),
UpdateOperation::ReplaceAt,
Some(json!(9)),
Some(json!(1)),
None
),
json!([1, 9])
);
assert_eq!(
update(
json!([1, 2]),
UpdateOperation::InsertAt,
Some(json!(9)),
Some(json!(1)),
None
),
json!([1, 9, 2])
);
assert_eq!(
update(
json!([1, 2, 3]),
UpdateOperation::Splice,
Some(json!([8, 9])),
Some(json!(1)),
Some(json!(1))
),
json!([1, 8, 9, 3])
);
}
#[test]
fn out_of_range_indices_are_no_ops_not_panics() {
assert_eq!(
update(
json!([1]),
UpdateOperation::ReplaceAt,
Some(json!(9)),
Some(json!(7)),
None
),
json!([1])
);
assert_eq!(
update(
json!([1]),
UpdateOperation::InsertAt,
Some(json!(9)),
Some(json!(7)),
None
),
json!([1, 9])
);
assert_eq!(
update(
json!([1]),
UpdateOperation::Splice,
None,
Some(json!(7)),
Some(json!(7))
),
json!([1])
);
assert_eq!(
update(
json!([1]),
UpdateOperation::ReplaceAt,
Some(json!(9)),
Some(json!(-1)),
None
),
json!([1])
);
}
#[test]
fn toggle_and_merge() {
assert_eq!(
update(json!(true), UpdateOperation::Toggle, None, None, None),
json!(false)
);
assert_eq!(
update(
json!({"a": 1}),
UpdateOperation::Merge,
Some(json!({"b": 2})),
None,
None
),
json!({"a": 1, "b": 2})
);
}
#[test]
fn a_type_mismatch_at_runtime_is_dropped_not_fatal() {
assert_eq!(
update(json!("text"), UpdateOperation::Increment, None, None, None),
json!("text")
);
assert_eq!(
update(json!(3), UpdateOperation::Push, Some(json!(1)), None, None),
json!(3)
);
}
#[test]
fn set_path_walks_and_creates_objects() {
let mut slot = json!({"user": {"name": "a"}});
set_at_path(&mut slot, &["user".into(), "name".into()], json!("b"));
assert_eq!(slot, json!({"user": {"name": "b"}}));
let mut created = json!({});
set_at_path(&mut created, &["a".into(), "b".into()], json!(1));
assert_eq!(created, json!({"a": {"b": 1}}));
}
#[test]
fn set_path_indexes_arrays_and_ignores_out_of_range() {
let mut slot = json!({"posts": [{"liked": false}]});
set_at_path(
&mut slot,
&["posts".into(), "0".into(), "liked".into()],
json!(true),
);
assert_eq!(slot, json!({"posts": [{"liked": true}]}));
let mut untouched = json!({"posts": [{"liked": false}]});
set_at_path(
&mut untouched,
&["posts".into(), "5".into(), "liked".into()],
json!(true),
);
assert_eq!(untouched, json!({"posts": [{"liked": false}]}));
}
#[test]
fn a_deep_path_is_written_iteratively() {
let segments: Vec<String> = (0..1_000).map(|i| format!("s{i}")).collect();
let mut slot = json!({});
set_at_path(&mut slot, &segments, json!("deep"));
let mut cursor = &slot;
for segment in &segments {
cursor = cursor.get(segment).expect("segment exists");
}
assert_eq!(cursor, &json!("deep"));
}
#[test]
fn path_segments_accepts_dotted_strings_and_arrays() {
assert_eq!(
path_segments(&json!("a.b.c")),
vec!["a".to_string(), "b".to_string(), "c".to_string()]
);
assert_eq!(
path_segments(&json!(["a", 0, "c"])),
vec!["a".to_string(), "0".to_string(), "c".to_string()]
);
}
}