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