Skip to main content

quillmark_core/
value.rs

1//! Value type for unified representation of TOML/YAML/JSON values.
2//!
3//! [`QuillValue`] is an **annotated value tree**: every node carries a
4//! `fill` flag (the in-memory form of the `!must_fill` YAML tag) alongside
5//! its data. The tree is the authoritative representation. For the data
6//! API (`as_json`, `as_array`, `as_object`, `Deref`) a plain
7//! [`serde_json::Value`] projection is materialized lazily and cached; that
8//! projection is **fill-free** — it is a derived view of the data, not a
9//! second source of truth. Fill never reaches the JSON projection, so
10//! rendering and wire layers that consume `as_json()` are unaffected by it.
11
12use indexmap::IndexMap;
13use serde::{Deserialize, Deserializer, Serialize, Serializer};
14use serde_json::Value as JsonValue;
15use std::ops::Deref;
16use std::sync::OnceLock;
17
18/// Unified value type: an annotated tree of JSON-shaped data where every
19/// node additionally records whether it was tagged `!must_fill`.
20///
21/// Construction (`from_json`, `from_yaml_str`, the scalar constructors)
22/// produces nodes with `fill = false`; the `!must_fill` markers are applied
23/// by the document layer. `QuillValue` exposes no data-mutating methods —
24/// only the fill setter `set_fill_at`, which does not affect the JSON
25/// projection — so the cached projection never goes stale.
26pub struct QuillValue {
27    node: Node,
28    /// Lazily materialized, fill-free [`serde_json::Value`] view of `node`.
29    json: OnceLock<JsonValue>,
30}
31
32/// One node of the annotated tree: a `fill` flag plus the data.
33#[derive(Debug, Clone, PartialEq)]
34struct Node {
35    fill: bool,
36    kind: Kind,
37}
38
39#[derive(Debug, Clone, PartialEq)]
40enum Kind {
41    Null,
42    Bool(bool),
43    Number(serde_json::Number),
44    String(String),
45    Array(Vec<Node>),
46    Object(IndexMap<String, Node>),
47}
48
49/// One step of a path into a value tree: an object key or an array index.
50///
51/// This is the canonical path-segment type for the whole crate; the document
52/// layer aliases it as `CommentPathSegment` for nested-comment paths.
53#[derive(Debug, Clone, PartialEq, Eq, Hash)]
54pub enum PathSegment {
55    Key(String),
56    Index(usize),
57}
58
59fn collect_fill_paths(node: &Node, prefix: &mut Vec<PathSegment>, out: &mut Vec<Vec<PathSegment>>) {
60    if node.fill {
61        out.push(prefix.clone());
62    }
63    match &node.kind {
64        Kind::Array(items) => {
65            for (i, child) in items.iter().enumerate() {
66                prefix.push(PathSegment::Index(i));
67                collect_fill_paths(child, prefix, out);
68                prefix.pop();
69            }
70        }
71        Kind::Object(entries) => {
72            for (k, child) in entries {
73                prefix.push(PathSegment::Key(k.clone()));
74                collect_fill_paths(child, prefix, out);
75                prefix.pop();
76            }
77        }
78        _ => {}
79    }
80}
81
82fn node_at_mut<'a>(node: &'a mut Node, path: &[PathSegment]) -> Option<&'a mut Node> {
83    let mut cur = node;
84    for seg in path {
85        cur = match (&mut cur.kind, seg) {
86            (Kind::Object(entries), PathSegment::Key(k)) => entries.get_mut(k)?,
87            (Kind::Array(items), PathSegment::Index(i)) => items.get_mut(*i)?,
88            _ => return None,
89        };
90    }
91    Some(cur)
92}
93
94fn node_is_object(node: &Node, path: &[PathSegment]) -> bool {
95    fn at<'a>(node: &'a Node, path: &[PathSegment]) -> Option<&'a Node> {
96        let mut cur = node;
97        for seg in path {
98            cur = match (&cur.kind, seg) {
99                (Kind::Object(entries), PathSegment::Key(k)) => entries.get(k)?,
100                (Kind::Array(items), PathSegment::Index(i)) => items.get(*i)?,
101                _ => return None,
102            };
103        }
104        Some(cur)
105    }
106    matches!(at(node, path).map(|n| &n.kind), Some(Kind::Object(_)))
107}
108
109impl Node {
110    fn from_json(value: &JsonValue) -> Node {
111        let kind = match value {
112            JsonValue::Null => Kind::Null,
113            JsonValue::Bool(b) => Kind::Bool(*b),
114            JsonValue::Number(n) => Kind::Number(n.clone()),
115            JsonValue::String(s) => Kind::String(s.clone()),
116            JsonValue::Array(items) => Kind::Array(items.iter().map(Node::from_json).collect()),
117            JsonValue::Object(map) => Kind::Object(
118                map.iter()
119                    .map(|(k, v)| (k.clone(), Node::from_json(v)))
120                    .collect(),
121            ),
122        };
123        Node { fill: false, kind }
124    }
125
126    fn to_json(&self) -> JsonValue {
127        match &self.kind {
128            Kind::Null => JsonValue::Null,
129            Kind::Bool(b) => JsonValue::Bool(*b),
130            Kind::Number(n) => JsonValue::Number(n.clone()),
131            Kind::String(s) => JsonValue::String(s.clone()),
132            Kind::Array(items) => JsonValue::Array(items.iter().map(Node::to_json).collect()),
133            Kind::Object(entries) => JsonValue::Object(
134                entries
135                    .iter()
136                    .map(|(k, n)| (k.clone(), n.to_json()))
137                    .collect(),
138            ),
139        }
140    }
141}
142
143/// `true` when `value` nests deeper than `max_depth` container levels.
144///
145/// Every path that stores a value into a `Document` — markdown parse,
146/// DTO/wire deserialization, the typed mutators, the binding converters —
147/// bounds nesting at the spec §8 limit
148/// ([`crate::document::limits::MAX_YAML_DEPTH`]), which makes the recursive
149/// consumers (emit, plate-JSON serialization, DTO conversion) bounded by
150/// construction. The walk is iterative (explicit stack), so the check
151/// itself cannot overflow on adversarially deep input — the very condition
152/// it exists to detect.
153///
154/// The unit is **container levels**, not nodes: only arrays/objects are
155/// charged a level, and the scalar leaf at the bottom of a chain is never
156/// checked. So `max_depth` nested containers are accepted whether the deepest
157/// holds a scalar, is empty, or holds another container; `max_depth + 1` is
158/// rejected in every case. A container occupies a level whether or not it has
159/// contents — reaching an empty array/object at level `max_depth + 1` still
160/// cost the recursive consumers that many frames to get there — so an
161/// over-deep *empty* container is rejected exactly like a non-empty one.
162///
163/// The Python binding's `py_to_json_at` charges levels the same way (its guard
164/// fires only on container branches, never scalar leaves), so the two paths
165/// reject the identical shape; see [`crate::document::limits::MAX_YAML_DEPTH`]
166/// for the canonical definition.
167pub fn json_depth_exceeds(value: &serde_json::Value, max_depth: usize) -> bool {
168    use serde_json::Value;
169    // (value, depth) pairs; depth counts container levels entered.
170    let mut stack: Vec<(&Value, usize)> = vec![(value, 0)];
171    while let Some((v, depth)) = stack.pop() {
172        match v {
173            Value::Array(items) => {
174                if depth + 1 > max_depth {
175                    return true;
176                }
177                stack.extend(items.iter().map(|c| (c, depth + 1)));
178            }
179            Value::Object(map) => {
180                if depth + 1 > max_depth {
181                    return true;
182                }
183                stack.extend(map.values().map(|c| (c, depth + 1)));
184            }
185            _ => {}
186        }
187    }
188    false
189}
190
191/// Depth-bound an owned `$ext` / `$seed` map against
192/// [`MAX_YAML_DEPTH`](crate::document::limits::MAX_YAML_DEPTH), returning it
193/// unchanged when within bounds. On overflow, `on_too_deep` builds the caller's
194/// boundary error from the limit — each write surface (`EditError` /
195/// `StorageError` / `WireError`) keeps its own type and message while the
196/// wrap-check-rebuild dance lives here once.
197pub(crate) fn depth_check_meta_map<E>(
198    map: serde_json::Map<String, serde_json::Value>,
199    on_too_deep: impl FnOnce(usize) -> E,
200) -> Result<serde_json::Map<String, serde_json::Value>, E> {
201    let max = crate::document::limits::MAX_YAML_DEPTH;
202    let as_value = serde_json::Value::Object(map);
203    if json_depth_exceeds(&as_value, max) {
204        return Err(on_too_deep(max));
205    }
206    let serde_json::Value::Object(map) = as_value else {
207        unreachable!("constructed as Object above")
208    };
209    Ok(map)
210}
211
212impl QuillValue {
213    fn from_node(node: Node) -> Self {
214        QuillValue {
215            node,
216            json: OnceLock::new(),
217        }
218    }
219
220    /// Create a QuillValue from a YAML string
221    pub fn from_yaml_str(yaml_str: &str) -> Result<Self, serde_saphyr::Error> {
222        let json_val: serde_json::Value = serde_saphyr::from_str(yaml_str)?;
223        Ok(Self::from_json(json_val))
224    }
225
226    /// Get a reference to the value's JSON projection.
227    ///
228    /// The projection is materialized on first use and cached. It carries
229    /// the data only; `!must_fill` markers are not represented in JSON.
230    pub fn as_json(&self) -> &serde_json::Value {
231        self.json.get_or_init(|| self.node.to_json())
232    }
233
234    /// Convert into the underlying JSON value (fill markers are dropped).
235    pub fn into_json(self) -> serde_json::Value {
236        match self.json.into_inner() {
237            Some(json) => json,
238            None => self.node.to_json(),
239        }
240    }
241
242    /// Create a QuillValue from a JSON value, with every node `fill = false`.
243    pub fn from_json(json_val: serde_json::Value) -> Self {
244        let node = Node::from_json(&json_val);
245        let json = OnceLock::new();
246        // Seed the projection with the value we were handed so the common
247        // render path doesn't re-lower it. This trades memory for speed:
248        // until dropped, the data is held twice (the `node` tree plus the
249        // cached JSON). Acceptable for the render-hot path; leaving the cache
250        // empty here would halve memory at the cost of re-lowering on first
251        // `as_json`.
252        let _ = json.set(json_val);
253        QuillValue { node, json }
254    }
255
256    /// String value.
257    pub fn string(s: impl Into<String>) -> Self {
258        Self::from_json(serde_json::Value::String(s.into()))
259    }
260
261    /// Integer value.
262    pub fn integer(n: i64) -> Self {
263        Self::from_json(serde_json::Value::Number(n.into()))
264    }
265
266    /// Boolean value.
267    pub fn bool(b: bool) -> Self {
268        Self::from_json(serde_json::Value::Bool(b))
269    }
270
271    /// Null value.
272    pub fn null() -> Self {
273        Self::from_json(serde_json::Value::Null)
274    }
275
276    /// Whether this value's root node carries the `!must_fill` marker.
277    pub fn fill(&self) -> bool {
278        self.node.fill
279    }
280
281    /// Paths (relative to this value's root) of every node carrying the
282    /// `!must_fill` marker. The root, if filled, is reported as the empty
283    /// path. The JSON projection carries no fill, so this is the only way to
284    /// observe nested fill markers.
285    pub fn fill_paths(&self) -> Vec<Vec<PathSegment>> {
286        let mut out = Vec::new();
287        let mut prefix = Vec::new();
288        collect_fill_paths(&self.node, &mut prefix, &mut out);
289        out
290    }
291
292    /// Fill paths *nested inside* this value — every [`fill_paths`](Self::fill_paths)
293    /// entry except the empty (root) path. A root fill is carried separately as
294    /// the `fill` flag on the owning field, so the wire / storage DTO record
295    /// only the nested ones here.
296    pub fn nonroot_fill_paths(&self) -> impl Iterator<Item = Vec<PathSegment>> {
297        self.fill_paths().into_iter().filter(|p| !p.is_empty())
298    }
299
300    /// Set `fill = true` on the node at `path` (relative to the root).
301    /// Returns `false` if the path does not resolve to a node.
302    pub fn set_fill_at(&mut self, path: &[PathSegment]) -> bool {
303        match node_at_mut(&mut self.node, path) {
304            Some(n) => {
305                n.fill = true;
306                true
307            }
308            None => false,
309        }
310    }
311
312    /// Whether the node at `path` (relative to the root) is a mapping.
313    /// Used to reject `!must_fill` on object-valued nodes.
314    pub fn is_object_at(&self, path: &[PathSegment]) -> bool {
315        node_is_object(&self.node, path)
316    }
317}
318
319/// Scalar conversions mirror [`serde_json::Value`]'s and produce `fill =
320/// false` nodes (like [`QuillValue::from_json`]); a non-finite `f64` maps to
321/// null, matching serde_json. These back the `impl Into<QuillValue>` mutator
322/// parameters, so `card.store_field("qty", 3)` reads as written.
323macro_rules! impl_from_scalar {
324    ($($ty:ty),* $(,)?) => {$(
325        impl From<$ty> for QuillValue {
326            fn from(v: $ty) -> Self {
327                QuillValue::from_json(serde_json::Value::from(v))
328            }
329        }
330    )*};
331}
332impl_from_scalar!(&str, String, bool, i32, i64, u32, u64, f64);
333
334impl From<serde_json::Value> for QuillValue {
335    fn from(v: serde_json::Value) -> Self {
336        QuillValue::from_json(v)
337    }
338}
339
340impl Deref for QuillValue {
341    type Target = serde_json::Value;
342
343    fn deref(&self) -> &Self::Target {
344        self.as_json()
345    }
346}
347
348impl PartialEq for QuillValue {
349    /// Two values are equal when their annotated trees (data **and** fill)
350    /// are equal. The cached JSON projection is derived and not compared.
351    fn eq(&self, other: &Self) -> bool {
352        self.node == other.node
353    }
354}
355
356impl Clone for QuillValue {
357    fn clone(&self) -> Self {
358        let json = OnceLock::new();
359        if let Some(cached) = self.json.get() {
360            let _ = json.set(cached.clone());
361        }
362        QuillValue {
363            node: self.node.clone(),
364            json,
365        }
366    }
367}
368
369impl std::fmt::Debug for QuillValue {
370    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
371        if self.node.fill {
372            write!(f, "QuillValue(!must_fill {:?})", self.as_json())
373        } else {
374            write!(f, "QuillValue({:?})", self.as_json())
375        }
376    }
377}
378
379impl Serialize for QuillValue {
380    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
381        self.as_json().serialize(serializer)
382    }
383}
384
385impl<'de> Deserialize<'de> for QuillValue {
386    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
387        let json = serde_json::Value::deserialize(deserializer)?;
388        Ok(QuillValue::from_json(json))
389    }
390}
391
392// Common delegating accessors, projected through the JSON view so existing
393// consumers keep their serde_json-shaped API unchanged.
394impl QuillValue {
395    /// Check if the value is null
396    pub fn is_null(&self) -> bool {
397        self.as_json().is_null()
398    }
399
400    /// Get the value as a string reference
401    pub fn as_str(&self) -> Option<&str> {
402        self.as_json().as_str()
403    }
404
405    /// Get the value as a boolean
406    pub fn as_bool(&self) -> Option<bool> {
407        self.as_json().as_bool()
408    }
409
410    /// Get the value as an i64
411    pub fn as_i64(&self) -> Option<i64> {
412        self.as_json().as_i64()
413    }
414
415    /// Get the value as a u64
416    pub fn as_u64(&self) -> Option<u64> {
417        self.as_json().as_u64()
418    }
419
420    /// Get the value as an f64
421    pub fn as_f64(&self) -> Option<f64> {
422        self.as_json().as_f64()
423    }
424
425    /// Get the value as an array reference
426    pub fn as_array(&self) -> Option<&Vec<serde_json::Value>> {
427        self.as_json().as_array()
428    }
429
430    /// Get the value as an object reference
431    pub fn as_object(&self) -> Option<&serde_json::Map<String, serde_json::Value>> {
432        self.as_json().as_object()
433    }
434
435    /// Get a field from an object by key, preserving the child's fill markers.
436    pub fn get(&self, key: &str) -> Option<QuillValue> {
437        match &self.node.kind {
438            Kind::Object(entries) => entries.get(key).map(|n| QuillValue::from_node(n.clone())),
439            _ => None,
440        }
441    }
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    #[test]
449    fn test_from_yaml_str() {
450        let yaml_str = r#"
451            title: Test Document
452            author: John Doe
453            count: 42
454        "#;
455        let quill_val = QuillValue::from_yaml_str(yaml_str).unwrap();
456
457        assert_eq!(
458            quill_val.get("title").as_ref().and_then(|v| v.as_str()),
459            Some("Test Document")
460        );
461        assert_eq!(
462            quill_val.get("author").as_ref().and_then(|v| v.as_str()),
463            Some("John Doe")
464        );
465        assert_eq!(
466            quill_val.get("count").as_ref().and_then(|v| v.as_i64()),
467            Some(42)
468        );
469    }
470
471    #[test]
472    fn test_yaml_custom_tags_ignored_at_value_level() {
473        // At the raw `QuillValue::from_yaml_str` layer, custom YAML tags
474        // (including `!must_fill`) pass through serde_saphyr which drops the
475        // tag and returns the underlying scalar.  The tag is recovered at
476        // the `Document` layer by `document::prescan`: see
477        // `document::tests::lossiness_tests::custom_tags_lose_tag_but_keep_value`.
478        let yaml_str = "memo_from: !must_fill 2d lt example";
479        let quill_val = QuillValue::from_yaml_str(yaml_str).unwrap();
480
481        assert_eq!(
482            quill_val.get("memo_from").as_ref().and_then(|v| v.as_str()),
483            Some("2d lt example")
484        );
485    }
486
487    #[test]
488    fn json_round_trips_through_the_tree() {
489        // from_json → as_json must be identity, preserving object key order
490        // (serde_json `preserve_order`) and number kinds.
491        let original = serde_json::json!({
492            "z": 1,
493            "a": [true, "x", 3.5, null],
494            "nested": { "k": 42 }
495        });
496        let qv = QuillValue::from_json(original.clone());
497        assert_eq!(qv.as_json(), &original);
498
499        // A value re-lowered from the tree (not the seeded cache) also matches.
500        let relowered = QuillValue::from_node(qv.node.clone()).into_json();
501        assert_eq!(relowered, original);
502    }
503
504    #[test]
505    fn depth_check_counts_empty_containers() {
506        use serde_json::json;
507
508        // A container occupies a level even when empty. With `max_depth = 1`,
509        // a single empty container is at the limit (accepted); a nested empty
510        // container is one level past it (rejected) — same as if its innermost
511        // slot held a non-empty container.
512        assert!(!json_depth_exceeds(&json!([]), 1));
513        assert!(!json_depth_exceeds(&json!({}), 1));
514        assert!(json_depth_exceeds(&json!([[]]), 1));
515        assert!(json_depth_exceeds(&json!({ "a": {} }), 1));
516
517        // Regression: an empty container at the deepest level must not slip
518        // past the bound. Build `[[[…[]…]]]` nested `n` levels with the
519        // innermost array empty, iteratively so the test stays stack-safe.
520        let deep_empty = |levels: usize| {
521            let mut v = serde_json::Value::Array(Vec::new());
522            for _ in 1..levels {
523                v = serde_json::Value::Array(vec![v]);
524            }
525            v
526        };
527        // `levels == max_depth` is exactly at the limit (the empty array is the
528        // last allowed level); `levels == max_depth + 1` is one past it.
529        assert!(!json_depth_exceeds(&deep_empty(100), 100));
530        assert!(json_depth_exceeds(&deep_empty(101), 100));
531    }
532
533    #[test]
534    fn depth_check_counts_container_levels_not_the_scalar_leaf() {
535        // The cutoff is container levels: a scalar leaf at the bottom is never
536        // charged a level. `{"a":{"a":…{"a":1}}}` with exactly `max_depth`
537        // objects is at the limit; one more object is past it. The Python
538        // binding's `py_to_json_at` pins the same boundary (test
539        // `test_depth_bound_matches_core_container_levels`), so the two paths
540        // reject the identical shape.
541        let scalar_terminated = |levels: usize| {
542            let mut v = serde_json::json!(1);
543            for _ in 0..levels {
544                v = serde_json::json!({ "a": v });
545            }
546            v
547        };
548        assert!(!json_depth_exceeds(&scalar_terminated(100), 100));
549        assert!(json_depth_exceeds(&scalar_terminated(101), 100));
550
551        // A non-empty container leaf lands at the same boundary: the deepest
552        // container — not its contents — is what occupies the last level.
553        let container_terminated = |levels: usize| {
554            let mut v = serde_json::json!([1, 2, 3]);
555            for _ in 1..levels {
556                v = serde_json::json!({ "a": v });
557            }
558            v
559        };
560        assert!(!json_depth_exceeds(&container_terminated(100), 100));
561        assert!(json_depth_exceeds(&container_terminated(101), 100));
562    }
563
564    #[test]
565    fn fill_marker_rides_on_the_node_not_the_json() {
566        let filled = || {
567            let mut qv = QuillValue::string("draft");
568            assert!(qv.set_fill_at(&[]));
569            qv
570        };
571        let qv = filled();
572        assert!(qv.fill());
573        // Projection is fill-free and equal to the plain scalar.
574        assert_eq!(qv.as_json(), &serde_json::json!("draft"));
575        // Equality is fill-sensitive.
576        assert_ne!(qv, QuillValue::string("draft"));
577        assert_eq!(qv, filled());
578    }
579}