Skip to main content

soil_rt/
descriptor_json.rs

1//! Descriptors as data (impl plan §4 step 7), in the tr-grammar §7
2//! conventions — internally tagged sums, records — as if descriptors
3//! were already Soil values. `Named` shapes serialize by *name* (ids
4//! are per-instance); loading is two-pass (declare all, then define
5//! all), which handles recursive types for free.
6//!
7//! This format feeds the fixtures corpus and the C ABI's registration
8//! entry point, and seeds plan 02's `--types env.json` contract — but
9//! it is not frozen here; plan 02 freezes it in `docs/soil0-cli.md`.
10//!
11//! ```json
12//! [{"name": "SummaryRow",
13//!   "strategy": {"tag": "Structural"},
14//!   "body": {"tag": "Record", "value": [
15//!     {"name": "count", "shape": {"tag": "U64"}, "ignored": null},
16//!     {"name": "mean",  "shape": {"tag": "F64"}, "ignored": null}]}}]
17//! ```
18//!
19//! Shapes: `{"tag": "I64"}` …, `{"tag": "List", "value": <shape>}`,
20//! `{"tag": "Map", "value": {"key": <shape>, "value": <shape>}}`,
21//! `{"tag": "Named", "value": "Tree"}`, `{"tag": "Closure"}`.
22//! Ignored defaults: `null`, `{"tag": "Const", "value": <scalar>}`, or
23//! `{"tag": "CopyField", "value": "field_name"}` (the serializable
24//! vocabulary; `Native` thunks cannot round-trip through data).
25
26use serde_json::Value as JsonValue;
27
28use crate::descriptor::{
29    FieldDesc, IgnoredDefault, Strategy, TypeBody, TypeDesc, TypeId, TypeShape, VariantDesc,
30};
31use crate::error::{PanicKind, SoilError};
32use crate::json;
33use crate::Runtime;
34
35fn err(message: impl std::fmt::Display) -> SoilError {
36    SoilError::new(
37        PanicKind::DecodeError,
38        format!("descriptor JSON: {message}"),
39    )
40}
41
42/// Load a JSON array of descriptors into the registry. Returns the new
43/// `TypeId`s in array order.
44///
45/// Not transactional: on error the registry may retain declarations
46/// from the failed load, so discard the runtime rather than retrying
47/// into it.
48pub fn load_descriptors(runtime: &mut Runtime, json: &str) -> Result<Vec<TypeId>, SoilError> {
49    let root: JsonValue =
50        serde_json::from_str(json).map_err(|e| err(format!("invalid JSON: {e}")))?;
51    let JsonValue::Array(entries) = root else {
52        return Err(err("expected a top-level array"));
53    };
54
55    // Pass 1: declare every name, so shapes may reference any of them.
56    let mut ids = Vec::with_capacity(entries.len());
57    for entry in &entries {
58        let name = get_str(entry, "name")?;
59        ids.push(runtime.registry.declare(name)?);
60    }
61
62    // Pass 2: build and define each descriptor.
63    for (entry, id) in entries.iter().zip(&ids) {
64        let desc = parse_desc(runtime, entry)?;
65        runtime.registry.define(*id, desc)?;
66    }
67    Ok(ids)
68}
69
70/// Serialize registered types back to the descriptor JSON format.
71/// Fails on `Native` ignored-defaults, which are code, not data.
72pub fn descriptors_to_json(runtime: &Runtime, ids: &[TypeId]) -> Result<String, SoilError> {
73    let mut out = String::from("[");
74    for (i, id) in ids.iter().enumerate() {
75        if i > 0 {
76            out.push(',');
77        }
78        let desc = runtime.registry.get(*id)?;
79        out.push_str("{\"name\":");
80        push_str(&desc.name, &mut out);
81        out.push_str(",\"strategy\":{\"tag\":");
82        push_str(
83            match desc.strategy {
84                Strategy::Structural => "Structural",
85                Strategy::Opaque => "Opaque",
86            },
87            &mut out,
88        );
89        out.push_str("},\"body\":");
90        match &desc.body {
91            TypeBody::Opaque => out.push_str("{\"tag\":\"Opaque\"}"),
92            TypeBody::Record(fields) => {
93                out.push_str("{\"tag\":\"Record\",\"value\":[");
94                for (j, field) in fields.iter().enumerate() {
95                    if j > 0 {
96                        out.push(',');
97                    }
98                    out.push_str("{\"name\":");
99                    push_str(&field.name, &mut out);
100                    out.push_str(",\"shape\":");
101                    push_shape(runtime, &field.shape, &mut out)?;
102                    out.push_str(",\"ignored\":");
103                    match &field.ignored {
104                        None => out.push_str("null"),
105                        Some(IgnoredDefault::Const(value)) => {
106                            out.push_str("{\"tag\":\"Const\",\"value\":");
107                            out.push_str(&json::encode(runtime, value)?);
108                            out.push('}');
109                        }
110                        Some(IgnoredDefault::CopyField { name, .. }) => {
111                            out.push_str("{\"tag\":\"CopyField\",\"value\":");
112                            push_str(name, &mut out);
113                            out.push('}');
114                        }
115                        Some(IgnoredDefault::Native(_)) => {
116                            return Err(err(format!(
117                                "field {} of {} has a native default, which is not serializable",
118                                field.name, desc.name
119                            )));
120                        }
121                    }
122                    out.push('}');
123                }
124                out.push_str("]}");
125            }
126            TypeBody::Sum(variants) => {
127                out.push_str("{\"tag\":\"Sum\",\"value\":[");
128                for (j, variant) in variants.iter().enumerate() {
129                    if j > 0 {
130                        out.push(',');
131                    }
132                    out.push_str("{\"name\":");
133                    push_str(&variant.name, &mut out);
134                    out.push_str(",\"payload\":");
135                    match &variant.payload {
136                        None => out.push_str("null"),
137                        Some(shape) => push_shape(runtime, shape, &mut out)?,
138                    }
139                    out.push('}');
140                }
141                out.push_str("]}");
142            }
143        }
144        out.push('}');
145    }
146    out.push(']');
147    Ok(out)
148}
149
150/// Parse one shape in the descriptor-JSON encoding (`{"tag": "I64"}`,
151/// `{"tag": "Named", "value": "Doc"}`, …) against the runtime's
152/// registry. The entry point used by the fixtures harness.
153pub fn parse_shape_json(runtime: &Runtime, json: &str) -> Result<TypeShape, SoilError> {
154    let tree: JsonValue =
155        serde_json::from_str(json).map_err(|e| err(format!("invalid JSON: {e}")))?;
156    parse_shape(runtime, &tree)
157}
158
159fn push_str(s: &str, out: &mut String) {
160    json::encode::push_string(s, out);
161}
162
163fn push_shape(runtime: &Runtime, shape: &TypeShape, out: &mut String) -> Result<(), SoilError> {
164    let scalar = |tag: &str, out: &mut String| {
165        out.push_str("{\"tag\":\"");
166        out.push_str(tag);
167        out.push_str("\"}");
168    };
169    match shape {
170        TypeShape::I64 => scalar("I64", out),
171        TypeShape::U64 => scalar("U64", out),
172        TypeShape::I32 => scalar("I32", out),
173        TypeShape::U32 => scalar("U32", out),
174        TypeShape::I16 => scalar("I16", out),
175        TypeShape::U16 => scalar("U16", out),
176        TypeShape::I8 => scalar("I8", out),
177        TypeShape::U8 => scalar("U8", out),
178        TypeShape::BigInt => scalar("BigInt", out),
179        TypeShape::F64 => scalar("F64", out),
180        TypeShape::Utf8 => scalar("Utf8", out),
181        TypeShape::Bytes => scalar("Bytes", out),
182        TypeShape::Unit => scalar("Unit", out),
183        TypeShape::Closure => scalar("Closure", out),
184        TypeShape::List(elem) => {
185            out.push_str("{\"tag\":\"List\",\"value\":");
186            push_shape(runtime, elem, out)?;
187            out.push('}');
188        }
189        TypeShape::Map(key, value) => {
190            out.push_str("{\"tag\":\"Map\",\"value\":{\"key\":");
191            push_shape(runtime, key, out)?;
192            out.push_str(",\"value\":");
193            push_shape(runtime, value, out)?;
194            out.push_str("}}");
195        }
196        TypeShape::Named(id) => {
197            // Serialized by name: ids are per-instance (impl plan §1).
198            let name = match runtime.registry.get(*id) {
199                Ok(desc) => desc.name.clone(),
200                Err(e) => return Err(e),
201            };
202            out.push_str("{\"tag\":\"Named\",\"value\":");
203            push_str(&name, out);
204            out.push('}');
205        }
206    }
207    Ok(())
208}
209
210fn get_str<'a>(value: &'a JsonValue, key: &str) -> Result<&'a str, SoilError> {
211    value
212        .get(key)
213        .and_then(JsonValue::as_str)
214        .ok_or_else(|| err(format!("missing string {key:?}")))
215}
216
217fn get_tag(value: &JsonValue) -> Result<&str, SoilError> {
218    value
219        .get("tag")
220        .and_then(JsonValue::as_str)
221        .ok_or_else(|| err("missing \"tag\""))
222}
223
224fn parse_desc(runtime: &Runtime, entry: &JsonValue) -> Result<TypeDesc, SoilError> {
225    let name = get_str(entry, "name")?.to_string();
226    let strategy = match get_tag(
227        entry
228            .get("strategy")
229            .ok_or_else(|| err("missing strategy"))?,
230    )? {
231        "Structural" => Strategy::Structural,
232        "Opaque" => Strategy::Opaque,
233        other => return Err(err(format!("unknown strategy {other:?}"))),
234    };
235    let body_json = entry.get("body").ok_or_else(|| err("missing body"))?;
236    let body = match get_tag(body_json)? {
237        "Opaque" => TypeBody::Opaque,
238        "Record" => {
239            let JsonValue::Array(field_entries) = body_json
240                .get("value")
241                .ok_or_else(|| err("record body needs a value"))?
242            else {
243                return Err(err("record body value must be an array"));
244            };
245            let mut fields = Vec::with_capacity(field_entries.len());
246            for field_entry in field_entries {
247                let field_name = get_str(field_entry, "name")?.to_string();
248                let shape = parse_shape(
249                    runtime,
250                    field_entry
251                        .get("shape")
252                        .ok_or_else(|| err(format!("field {field_name} needs a shape")))?,
253                )?;
254                let ignored = match field_entry.get("ignored") {
255                    None | Some(JsonValue::Null) => None,
256                    Some(spec) => Some(parse_ignored(runtime, spec, &field_name, &shape)?),
257                };
258                fields.push(FieldDesc {
259                    name: field_name,
260                    shape,
261                    ignored,
262                });
263            }
264            resolve_copy_indices(&mut fields)?;
265            TypeBody::Record(fields)
266        }
267        "Sum" => {
268            let JsonValue::Array(variant_entries) = body_json
269                .get("value")
270                .ok_or_else(|| err("sum body needs a value"))?
271            else {
272                return Err(err("sum body value must be an array"));
273            };
274            let mut variants = Vec::with_capacity(variant_entries.len());
275            for variant_entry in variant_entries {
276                let variant_name = get_str(variant_entry, "name")?.to_string();
277                let payload = match variant_entry.get("payload") {
278                    None | Some(JsonValue::Null) => None,
279                    Some(shape_json) => Some(parse_shape(runtime, shape_json)?),
280                };
281                variants.push(VariantDesc {
282                    name: variant_name,
283                    payload,
284                });
285            }
286            TypeBody::Sum(variants)
287        }
288        other => return Err(err(format!("unknown body tag {other:?}"))),
289    };
290    Ok(TypeDesc {
291        name,
292        strategy,
293        body,
294    })
295}
296
297fn parse_shape(runtime: &Runtime, shape_json: &JsonValue) -> Result<TypeShape, SoilError> {
298    let tag = get_tag(shape_json)?;
299    Ok(match tag {
300        "I64" => TypeShape::I64,
301        "U64" => TypeShape::U64,
302        "I32" => TypeShape::I32,
303        "U32" => TypeShape::U32,
304        "I16" => TypeShape::I16,
305        "U16" => TypeShape::U16,
306        "I8" => TypeShape::I8,
307        "U8" => TypeShape::U8,
308        "BigInt" => TypeShape::BigInt,
309        "F64" => TypeShape::F64,
310        "Utf8" => TypeShape::Utf8,
311        "Bytes" => TypeShape::Bytes,
312        "Unit" => TypeShape::Unit,
313        "Closure" => TypeShape::Closure,
314        "List" => TypeShape::List(Box::new(parse_shape(
315            runtime,
316            shape_json
317                .get("value")
318                .ok_or_else(|| err("List shape needs a value"))?,
319        )?)),
320        "Map" => {
321            let value = shape_json
322                .get("value")
323                .ok_or_else(|| err("Map shape needs a value"))?;
324            TypeShape::Map(
325                Box::new(parse_shape(
326                    runtime,
327                    value.get("key").ok_or_else(|| err("Map needs a key"))?,
328                )?),
329                Box::new(parse_shape(
330                    runtime,
331                    value.get("value").ok_or_else(|| err("Map needs a value"))?,
332                )?),
333            )
334        }
335        "Named" => {
336            let name = shape_json
337                .get("value")
338                .and_then(JsonValue::as_str)
339                .ok_or_else(|| err("Named shape needs a name"))?;
340            let id = runtime
341                .registry
342                .lookup(name)
343                .ok_or_else(|| err(format!("unknown type {name:?}")))?;
344            TypeShape::Named(id)
345        }
346        other => return Err(err(format!("unknown shape tag {other:?}"))),
347    })
348}
349
350fn parse_ignored(
351    runtime: &Runtime,
352    spec: &JsonValue,
353    field_name: &str,
354    shape: &TypeShape,
355) -> Result<IgnoredDefault, SoilError> {
356    match get_tag(spec)? {
357        "Const" => {
358            if !is_scalar(shape) {
359                return Err(err(format!(
360                    "field {field_name}: Const defaults are limited to scalar shapes"
361                )));
362            }
363            let raw = spec
364                .get("value")
365                .ok_or_else(|| err(format!("field {field_name}: Const needs a value")))?
366                .to_string();
367            let value = json::decode(runtime, shape, &raw)?;
368            Ok(IgnoredDefault::Const(value))
369        }
370        "CopyField" => {
371            let target = spec
372                .get("value")
373                .and_then(JsonValue::as_str)
374                .ok_or_else(|| err(format!("field {field_name}: CopyField needs a field name")))?;
375            Ok(IgnoredDefault::CopyField {
376                name: target.to_string(),
377                index: usize::MAX, // resolved by resolve_copy_indices
378            })
379        }
380        other => Err(err(format!(
381            "field {field_name}: unknown ignored-default tag {other:?}"
382        ))),
383    }
384}
385
386fn resolve_copy_indices(fields: &mut [FieldDesc]) -> Result<(), SoilError> {
387    let non_ignored: Vec<String> = fields
388        .iter()
389        .filter(|f| f.ignored.is_none())
390        .map(|f| f.name.clone())
391        .collect();
392    for field in fields.iter_mut() {
393        if let Some(IgnoredDefault::CopyField { name, index }) = &mut field.ignored {
394            *index = non_ignored.iter().position(|n| n == name).ok_or_else(|| {
395                err(format!(
396                    "CopyField target {name:?} is not a non-ignored field"
397                ))
398            })?;
399        }
400    }
401    Ok(())
402}
403
404fn is_scalar(shape: &TypeShape) -> bool {
405    !matches!(
406        shape,
407        TypeShape::List(_) | TypeShape::Map(..) | TypeShape::Named(_) | TypeShape::Closure
408    )
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414    use crate::json::{decode, encode};
415    use crate::ops;
416    use crate::value::Value;
417
418    const SAMPLE: &str = r#"[
419      {"name": "Tree",
420       "strategy": {"tag": "Structural"},
421       "body": {"tag": "Sum", "value": [
422         {"name": "Leaf", "payload": {"tag": "I64"}},
423         {"name": "Node", "payload": {"tag": "List", "value": {"tag": "Named", "value": "Tree"}}}]}},
424      {"name": "Doc",
425       "strategy": {"tag": "Structural"},
426       "body": {"tag": "Record", "value": [
427         {"name": "text", "shape": {"tag": "Utf8"}, "ignored": null},
428         {"name": "copy", "shape": {"tag": "Utf8"}, "ignored": {"tag": "CopyField", "value": "text"}},
429         {"name": "version", "shape": {"tag": "U64"}, "ignored": {"tag": "Const", "value": 1}}]}},
430      {"name": "PyHandle",
431       "strategy": {"tag": "Opaque"},
432       "body": {"tag": "Opaque"}}
433    ]"#;
434
435    #[test]
436    fn load_recursive_and_round_trip() {
437        let mut rt = Runtime::new();
438        let ids = load_descriptors(&mut rt, SAMPLE).unwrap();
439        assert_eq!(ids.len(), 3);
440        assert_eq!(rt.registry.get(ids[0]).unwrap().name, "Tree");
441
442        let serialized = descriptors_to_json(&rt, &ids).unwrap();
443        // Round trip: loading the serialized form into a fresh runtime
444        // reproduces the serialized form byte-for-byte.
445        let mut rt2 = Runtime::new();
446        let ids2 = load_descriptors(&mut rt2, &serialized).unwrap();
447        assert_eq!(descriptors_to_json(&rt2, &ids2).unwrap(), serialized);
448    }
449
450    #[test]
451    fn loaded_defaults_refill_on_decode() {
452        let mut rt = Runtime::new();
453        let ids = load_descriptors(&mut rt, SAMPLE).unwrap();
454        let doc_shape = TypeShape::Named(ids[1]);
455        let value = decode(&rt, &doc_shape, "{\"text\":\"hi\"}").unwrap();
456        let Value::Record(record) = &value else {
457            panic!()
458        };
459        // copy = CopyField("text"), version = Const(1)
460        assert!(ops::eq(&rt, &record.fields[1], &record.fields[0]).unwrap());
461        assert!(matches!(record.fields[2], Value::U64(1)));
462        assert_eq!(encode(&rt, &value).unwrap(), "{\"text\":\"hi\"}");
463    }
464
465    #[test]
466    fn unknown_named_type_rejected() {
467        let mut rt = Runtime::new();
468        let bad = r#"[{"name": "A", "strategy": {"tag": "Structural"},
469          "body": {"tag": "Record", "value": [
470            {"name": "x", "shape": {"tag": "Named", "value": "Missing"}, "ignored": null}]}}]"#;
471        assert!(load_descriptors(&mut rt, bad).is_err());
472    }
473
474    #[test]
475    fn const_defaults_must_be_scalar() {
476        let mut rt = Runtime::new();
477        let bad = r#"[{"name": "A", "strategy": {"tag": "Structural"},
478          "body": {"tag": "Record", "value": [
479            {"name": "xs", "shape": {"tag": "List", "value": {"tag": "I64"}},
480             "ignored": {"tag": "Const", "value": []}}]}}]"#;
481        assert!(load_descriptors(&mut rt, bad).is_err());
482    }
483
484    #[test]
485    fn native_defaults_do_not_serialize() {
486        let mut rt = Runtime::new();
487        let id = rt
488            .registry
489            .register(TypeDesc {
490                name: "N".to_string(),
491                strategy: Strategy::Structural,
492                body: TypeBody::Record(vec![FieldDesc {
493                    name: "x".to_string(),
494                    shape: TypeShape::U64,
495                    ignored: Some(IgnoredDefault::Native(crate::value::Closure::native(
496                        |_| Ok(Value::U64(0)),
497                    ))),
498                }]),
499            })
500            .unwrap();
501        assert!(descriptors_to_json(&rt, &[id]).is_err());
502    }
503}