simple-update-in 0.2.0

Immutable nested update with structural sharing
Documentation
//! The update engine.
//!
//! Three phases run per call. Resolve the path, turning predicates into the
//! concrete keys and indices they match. Read the current leaf for the updater.
//! Write the new leaf back immutably, sharing every subtree off the path.

use crate::path::{Accessor, Selector, Step};
use crate::value::{Map, Value};

/// Reserved keys blocked to prevent prototype pollution.
const RESERVED_KEYS: [&str; 3] = ["__proto__", "constructor", "prototype"];

/// Whether `key` is reserved.
fn reserved(key: &str) -> bool {
    RESERVED_KEYS.contains(&key)
}

/// Read `obj[accessor]` with JavaScript semantics, returning `Undefined` for a
/// missing or inapplicable access.
///
/// A string key reads a map. A numeric index reads an array or, matching JS,
/// reads one character of a string. Anything else is `Undefined`.
fn index(obj: &Value, step: &Step) -> Value {
    match (obj, step) {
        (Value::Object(map), Step::Key(key)) => map.get(key).cloned().unwrap_or(Value::Undefined),
        (Value::Array(items), Step::Index(i)) => items.get(*i).cloned().unwrap_or(Value::Undefined),
        (Value::String(s), Step::Index(i)) => s
            .chars()
            .nth(*i)
            .map_or(Value::Undefined, |c| Value::String(c.to_string())),
        _ => Value::Undefined,
    }
}

/// JavaScript truthiness.
///
/// `undefined`, `null`, `false`, `0`, `-0`, `NaN`, and the empty string are
/// falsy. Everything else is truthy, including all arrays and maps.
fn truthy(value: &Value) -> bool {
    match value {
        Value::Undefined | Value::Null => false,
        Value::Bool(b) => *b,
        Value::Number(n) => *n != 0.0 && !n.is_nan(),
        Value::String(s) => !s.is_empty(),
        Value::Array(_) | Value::Object(_) => true,
    }
}

/// The child value `typeof obj !== 'undefined' && obj[accessor]`.
///
/// An `undefined` parent short-circuits to `false`, exactly as the JavaScript
/// `&&` does. This `false` reads as not-an-array and as an empty map during
/// enumeration, so it never gets indexed.
fn child(obj: &Value, step: &Step) -> Value {
    if obj.is_undefined() {
        Value::Bool(false)
    } else {
        index(obj, step)
    }
}

/// Turn a literal accessor into a step. Predicates never reach here.
fn accessor_to_step(accessor: &Accessor) -> Step {
    match accessor {
        Accessor::Key(k) => Step::Key(k.clone()),
        Accessor::Index(i) => Step::Index(*i),
        Accessor::Predicate(_) => unreachable!("predicate resolved before step"),
    }
}

/// Resolve a path against `obj`, turning predicates into concrete paths.
///
/// Returns `None` for an empty input path. Returns `Some(vec![])` when the path
/// is non-empty but a predicate matched nothing. Returns one or more concrete
/// step lists otherwise. Each concrete path holds only keys and indices.
fn get_paths(obj: &Value, path: &[Accessor]) -> Option<Vec<Vec<Step>>> {
    let (accessor, rest) = path.split_first()?;

    if let Accessor::Predicate(pred) = accessor {
        let mut results = Vec::new();
        match obj {
            Value::Array(items) => {
                for (i, item) in items.iter().enumerate() {
                    if pred(item, &Selector::Index(i)) {
                        results.extend(resolve_literal(obj, Step::Index(i), rest));
                    }
                }
            }
            Value::Object(map) => {
                for (key, val) in map.iter() {
                    if pred(val, &Selector::Key(key.clone())) {
                        results.extend(resolve_literal(obj, Step::Key(key.clone()), rest));
                    }
                }
            }
            // A non-container parent enumerates nothing, so the predicate
            // matches nothing.
            _ => {}
        }
        return Some(results);
    }

    Some(resolve_literal(obj, accessor_to_step(accessor), rest))
}

/// Resolve a concrete first step, then descend into the rest.
///
/// Mirrors the literal-accessor branch. Descend one level using the child value,
/// resolve the remainder, and prepend this step. When the remainder is empty the
/// recursion returns `None`, which becomes a single one-step path.
fn resolve_literal(obj: &Value, step: Step, rest: &[Accessor]) -> Vec<Vec<Step>> {
    let next_obj = child(obj, &step);
    match get_paths(&next_obj, rest) {
        Some(paths) => paths
            .into_iter()
            .map(|mut p| {
                p.insert(0, step.clone());
                p
            })
            .collect(),
        None => vec![vec![step]],
    }
}

/// Read the leaf at a concrete path.
///
/// Walks the steps, short-circuiting to a falsy value if any intermediate is
/// falsy, matching `obj && obj[accessor]`. The result is the updater's input.
fn get_value(obj: &Value, path: &[Step]) -> Value {
    let mut current = obj.clone();
    for step in path {
        if !truthy(&current) {
            return current;
        }
        current = index(&current, step);
    }
    current
}

/// Whether a step addresses a reserved key.
fn step_is_reserved(step: &Step) -> bool {
    matches!(step, Step::Key(k) if reserved(k))
}

/// Coerce `obj` to the container type the step needs.
///
/// A key accessor wants a map. Any node that is not an object, `Null` included,
/// is replaced with a fresh empty map. An index accessor wants an array. Any
/// node that is not an array is replaced with a fresh empty array.
///
/// JavaScript reaches the empty map by spreading the node (`{...null}` yields
/// `{}`). This code skips the spread and builds the empty map directly. The
/// write result is the same map. One case differs: removing a missing key from
/// a `null` node throws in JavaScript (`'x' in null` is a `TypeError`), while
/// here it produces an empty map. That path is off the tested set.
fn coerce(obj: &Value, step: &Step) -> Value {
    match step {
        Step::Key(_) => match obj {
            Value::Object(_) => obj.clone(),
            _ => Value::object(Map::new()),
        },
        Step::Index(_) => match obj {
            Value::Array(_) => obj.clone(),
            _ => Value::array(Vec::new()),
        },
    }
}

/// Write `target` at a concrete path, cloning only along the path.
///
/// Returns the original `obj` by the same pointer when nothing changed, so the
/// no-op identity propagates to the root. A `target` of `Undefined` removes the
/// addressed key or index.
fn set_value(obj: &Value, path: &[Step], target: &Value) -> Value {
    let Some((step, rest)) = path.split_first() else {
        return target.clone();
    };

    let value = child(obj, step);

    if step_is_reserved(step) {
        return obj.clone();
    }

    let next_obj = coerce(obj, step);
    let next_value = set_value(&value, rest, target);

    match step {
        Step::Index(i) => {
            if next_value.is_undefined() {
                if obj.is_undefined() {
                    return obj.clone();
                }
                let items = next_obj.as_array().expect("coerced to array");
                if *i < items.len() {
                    let mut cloned = items.to_vec();
                    cloned.remove(*i);
                    return Value::array(cloned);
                }
                next_obj
            } else if next_value.same_value(&value) {
                obj.clone()
            } else {
                let items = next_obj.as_array().expect("coerced to array");
                let mut cloned = items.to_vec();
                if *i >= cloned.len() {
                    // Fill the gap past the end with holes. A hole is
                    // `Undefined` because a JavaScript sparse slot reads back as
                    // `undefined`, distinct from a stored `Null`.
                    cloned.resize(*i + 1, Value::Undefined);
                }
                cloned[*i] = next_value;
                Value::array(cloned)
            }
        }
        Step::Key(key) => {
            if next_value.is_undefined() {
                if obj.is_undefined() {
                    return obj.clone();
                }
                let map = next_obj.as_object().expect("coerced to map");
                if map.contains_key(key) {
                    let mut cloned = map.clone();
                    cloned.remove(key);
                    return Value::object(cloned);
                }
                next_obj
            } else if next_value.same_value(&value) {
                obj.clone()
            } else {
                let map = next_obj.as_object().expect("coerced to map");
                let mut cloned = map.clone();
                cloned.insert(key.clone(), next_value);
                Value::object(cloned)
            }
        }
    }
}

/// Run one update.
///
/// Resolves the path, then folds each concrete write into the working value.
/// Reads the leaf from the latest value each step, which matters when predicate
/// branches share an ancestor.
pub fn update_in(obj: Value, path: &[Accessor], updater: Option<&dyn Fn(Value) -> Value>) -> Value {
    let Some(paths) = get_paths(&obj, path) else {
        return obj;
    };

    if paths.iter().any(|p| p.iter().any(step_is_reserved)) {
        return obj;
    }

    let mut current = obj;
    for p in paths {
        let target = match updater {
            Some(f) => f(get_value(&current, &p)),
            None => Value::Undefined,
        };
        current = set_value(&current, &p, &target);
    }
    current
}