#![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, Expr, HttpMethod, StorageArea, StorageOperation, UpdateOperation};
use super::budget::Budget;
use super::error::{ConstelaError, Diagnostic, codes, excerpt};
use super::eval::{
Env, EvalCtx, RouteValues, coerce, eval, eval_ref, 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 Budget,
}
const fn eval_ctx<'c>(
state: &'c Map<String, Value>,
env: &'c Env<'c>,
ctx: &'c DispatchCtx<'_>,
) -> EvalCtx<'c> {
EvalCtx {
state,
env,
route: ctx.route,
styles: ctx.styles,
depth: ctx.depth,
budget: ctx.budget,
}
}
fn kept_text(expr: &Expr, eval_ctx: &EvalCtx<'_>, path: &str) -> Result<String, ConstelaError> {
let value = eval_ref(expr, eval_ctx, path)?;
eval_ctx
.budget
.own_str(coerce(&value, eval_ctx.budget, path)?, path)
}
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> {
match step {
ActionStep::Set { target, value } => {
let value = eval(value, &eval_ctx(state, env, ctx), &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(
expr,
&eval_ctx(state, env, ctx),
&format!("{path}.value"),
)?),
None => None,
};
let index = match index {
Some(expr) => array_index(&*eval_ref(
expr,
&eval_ctx(state, env, ctx),
&format!("{path}.index"),
)?),
None => None,
};
let count = match delete_count {
Some(expr) => array_index(&*eval_ref(
expr,
&eval_ctx(state, env, ctx),
&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, index, count);
}
ActionStep::SetPath {
target,
path: target_path,
value,
} => {
let segments_path = format!("{path}.path");
let segments = eval_ref(target_path, &eval_ctx(state, env, ctx), &segments_path)?;
ctx.budget.charge_value(&segments, &segments_path)?;
let segments = path_segments(&segments, ctx.depth);
let value = eval(value, &eval_ctx(state, env, ctx), &format!("{path}.value"))?;
let Some(segments) = segments else {
return Err(ConstelaError::Render(Diagnostic::new(
format!("{path}.path"),
codes::RENDER_LIMIT,
format!(
"`setPath` path has more than the {} segments the depth limit allows",
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 = truthy(&*eval_ref(
condition,
&eval_ctx(state, env, ctx),
&format!("{path}.condition"),
)?);
let branch = if test { then } else { otherwise };
let branch_path = if 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> {
let effect = match step {
ActionStep::Fetch {
url,
method,
body,
result,
..
} => {
let url = kept_text(url, &eval_ctx(state, env, ctx), &format!("{path}.url"))?;
check_url(&url, &format!("{path}.url"))?;
let body = match body {
Some(expr) => Some(eval(
expr,
&eval_ctx(state, env, ctx),
&format!("{path}.body"),
)?),
None => None,
};
Effect::Fetch {
url,
method: *method,
body,
result: result.clone(),
}
}
ActionStep::Storage {
operation,
key,
value,
storage,
result,
..
} => {
let key = kept_text(key, &eval_ctx(state, env, ctx), &format!("{path}.key"))?;
let value = match value {
Some(expr) => Some(eval(
expr,
&eval_ctx(state, env, ctx),
&format!("{path}.value"),
)?),
None => None,
};
Effect::Storage {
operation: *operation,
area: *storage,
key,
value,
result: result.clone(),
}
}
ActionStep::Navigate { url, replace } => {
let url = kept_text(url, &eval_ctx(state, env, ctx), &format!("{path}.url"))?;
check_url(&url, &format!("{path}.url"))?;
Effect::Navigate {
url,
replace: *replace,
}
}
ActionStep::Delay { ms, .. } => Effect::Delay {
ms: eval_ref(ms, &eval_ctx(state, env, ctx), &format!("{path}.ms"))?
.as_f64()
.unwrap_or(0.0),
},
ActionStep::Interval { ms, action } => Effect::Interval {
ms: eval_ref(ms, &eval_ctx(state, env, ctx), &format!("{path}.ms"))?
.as_f64()
.unwrap_or(0.0),
action: action.clone(),
},
ActionStep::Focus { target, operation } => Effect::Focus {
target: kept_text(
target,
&eval_ctx(state, env, ctx),
&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 {:?} uses a scheme that is not allowed",
excerpt(url)
),
)))
}
}
fn apply_update(
state: &mut Map<String, Value>,
target: &str,
operation: UpdateOperation,
value: Option<Value>,
index: Option<usize>,
delete_count: Option<usize>,
) {
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.as_ref().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, value);
}
}
UpdateOperation::Push => {
let (Some(items), Some(value)) = (slot.as_array_mut(), value) else {
return;
};
items.push(value);
}
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) else {
return;
};
if let Some(entry) = items.get_mut(at) {
*entry = value;
}
}
UpdateOperation::InsertAt => {
let (Some(items), Some(value), Some(at)) = (slot.as_array_mut(), value, index) else {
return;
};
items.insert(at.min(items.len()), value);
}
UpdateOperation::Splice => {
let (Some(items), Some(at), Some(count)) = (slot.as_array_mut(), index, delete_count)
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,
Some(other) => vec![other],
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, limit: usize) -> Option<Vec<String>> {
match value {
Value::Array(items) => {
(items.len() <= limit).then(|| items.iter().map(js_string).collect())
}
Value::String(text) => split_segments(text, limit),
other => split_segments(&js_string(other), limit),
}
}
fn split_segments(text: &str, limit: usize) -> Option<Vec<String>> {
let segments: Vec<String> = text
.split('.')
.filter(|segment| !segment.is_empty())
.take(limit.saturating_add(1))
.map(ToString::to_string)
.collect();
(segments.len() <= limit).then_some(segments)
}
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,
index.as_ref().and_then(array_index),
count.as_ref().and_then(array_index),
);
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"), 8),
Some(vec!["a".to_string(), "b".to_string(), "c".to_string()])
);
assert_eq!(
path_segments(&json!(["a", 0, "c"]), 8),
Some(vec!["a".to_string(), "0".to_string(), "c".to_string()])
);
}
#[test]
fn path_segments_refuses_a_path_over_the_limit() {
assert_eq!(path_segments(&json!("a.b.c"), 2), None);
assert_eq!(path_segments(&json!(["a", "b", "c"]), 2), None);
assert!(path_segments(&json!("a.b"), 2).is_some());
}
fn dispatch_steps(
state: &str,
steps: &str,
initial: &[(&str, Value)],
payload: &[(&str, Value)],
) -> (Result<Dispatched, ConstelaError>, Map<String, Value>) {
let source = format!(
r#"{{"version":"1.0","state":{{{state}}},
"actions":[{{"name":"go","steps":[{steps}]}}],
"view":{{"kind":"element","tag":"div"}}}}"#
);
let document =
crate::constela::Document::parse(&source, &crate::constela::Limits::default())
.unwrap_or_else(|err| panic!("valid document: {err}"));
let mut values = document.initial_state();
for (key, value) in initial {
values.insert((*key).to_string(), value.clone());
}
let payload: Map<String, Value> = payload
.iter()
.map(|(key, value)| ((*key).to_string(), value.clone()))
.collect();
let outcome = document.dispatch("go", &mut values, &payload);
(outcome, values)
}
#[test]
fn effects_keep_their_evaluated_operands() {
let cases = [
(
r#"{"do":"fetch","url":{"expr":"var","name":"u"},"method":"POST",
"body":{"expr":"lit","value":{"a":1}},"result":"r"}"#,
Effect::Fetch {
url: "/api".into(),
method: HttpMethod::Post,
body: Some(json!({"a": 1})),
result: Some("r".into()),
},
),
(
r#"{"do":"storage","operation":"set","key":{"expr":"lit","value":7},
"value":{"expr":"var","name":"u"},"storage":"local"}"#,
Effect::Storage {
operation: StorageOperation::Set,
area: StorageArea::Local,
key: "7".into(),
value: Some(json!("/api")),
result: None,
},
),
(
r#"{"do":"navigate","url":{"expr":"var","name":"u"},"replace":true}"#,
Effect::Navigate {
url: "/api".into(),
replace: true,
},
),
(
r#"{"do":"delay","ms":{"expr":"lit","value":250},"then":[]}"#,
Effect::Delay { ms: 250.0 },
),
(
r#"{"do":"interval","ms":{"expr":"lit","value":"x"},"action":"go"}"#,
Effect::Interval {
ms: 0.0,
action: "go".into(),
},
),
(
r#"{"do":"focus","target":{"expr":"lit","value":"field"},"operation":"focus"}"#,
Effect::Focus {
target: "field".into(),
operation: "focus".into(),
},
),
];
for (step, expected) in cases {
let (outcome, _) = dispatch_steps("", step, &[], &[("u", json!("/api"))]);
let outcome = outcome.unwrap_or_else(|err| panic!("{step}: {err}"));
assert_eq!(outcome.effects, vec![expected], "{step}");
assert!(outcome.is_suspended());
}
}
#[test]
fn a_kept_effect_operand_charges_the_budget() {
let (outcome, _) = dispatch_steps(
"",
r#"{"do":"navigate","url":{"expr":"var","name":"u"}}"#,
&[],
&[("u", Value::String("/".repeat(5 * 1024 * 1024)))],
);
let err = outcome.expect_err("a 5 MiB URL copy is over the budget");
assert_eq!(err.diagnostics()[0].code, codes::RENDER_LIMIT);
}
#[test]
fn a_computed_url_with_a_bad_scheme_is_refused() {
let (outcome, _) = dispatch_steps(
"",
r#"{"do":"fetch","url":{"expr":"var","name":"u"}}"#,
&[],
&[("u", json!("javascript:alert(1)"))],
);
let err = outcome.expect_err("scheme refused");
assert_eq!(err.diagnostics()[0].code, codes::URL_SCHEME);
}
#[test]
fn pure_steps_write_moved_values_into_state() {
let (outcome, state) = dispatch_steps(
r#""items":{"type":"list","initial":[1,2,3]},
"form":{"type":"object","initial":{}},
"on":{"type":"boolean","initial":false}"#,
r#"{"do":"update","target":"items","operation":"splice",
"index":{"expr":"lit","value":1},"deleteCount":{"expr":"lit","value":1},
"value":{"expr":"var","name":"extra"}},
{"do":"setPath","target":"form","path":{"expr":"lit","value":"a.b"},
"value":{"expr":"var","name":"extra"}},
{"do":"if","condition":{"expr":"state","name":"on"},
"then":[{"do":"set","target":"on","value":{"expr":"lit","value":"then"}}],
"else":[{"do":"update","target":"on","operation":"toggle"}]}"#,
&[],
&[("extra", json!(["x", "y"]))],
);
assert!(outcome.expect("pure").is_pure());
assert_eq!(state["items"], json!([1, "x", "y", 3]));
assert_eq!(state["form"], json!({"a": {"b": ["x", "y"]}}));
assert_eq!(state["on"], json!(true));
}
#[test]
fn a_set_path_over_the_depth_limit_is_refused() {
let (outcome, _) = dispatch_steps(
r#""form":{"type":"object","initial":{}}"#,
r#"{"do":"setPath","target":"form","path":{"expr":"var","name":"p"},
"value":{"expr":"lit","value":1}}"#,
&[],
&[("p", Value::Array(vec![json!("a"); 500]))],
);
let err = outcome.expect_err("path too deep");
assert_eq!(err.diagnostics()[0].code, codes::RENDER_LIMIT);
}
}