Skip to main content

soil_rt/json/
encode.rs

1//! The canonical encoder — this module owns every canonicality
2//! guarantee (impl plan §4 step 5). Nothing else in the system may
3//! produce value JSON.
4//!
5//! Canonical form (tr-grammar §7, micro-pins §8): no whitespace; record
6//! fields in declaration order with `ignored` fields omitted; map
7//! entries in comparator order; shortest-round-trip numerals via
8//! `ryu`/`itoa`; the escape set of §8.1; `BigInt` hybrid by range;
9//! `Bool` as JSON booleans; opaque values as `"<handle>"`; closures a
10//! hard error.
11
12use base64::Engine;
13
14use crate::descriptor::{Strategy, TypeBody};
15use crate::error::{PanicKind, SoilError};
16use crate::value::Value;
17use crate::Runtime;
18
19/// Largest integer magnitude a float-only JSON parser preserves;
20/// `BigInt` values beyond ±this encode as strings (design §9.2).
21const BIGINT_SAFE_MAX: i64 = (1 << 53) - 1;
22
23/// Encode a value into canonical JSON — the runtime half of `show`
24/// (design §3.7). Equal values produce byte-equal output.
25pub fn encode(runtime: &Runtime, value: &Value) -> Result<String, SoilError> {
26    let mut out = String::new();
27    encode_into(runtime, value, &mut out)?;
28    Ok(out)
29}
30
31fn encode_into(runtime: &Runtime, value: &Value, out: &mut String) -> Result<(), SoilError> {
32    match value {
33        Value::I64(n) => push_int(*n, out),
34        Value::U64(n) => push_int(*n, out),
35        Value::I32(n) => push_int(*n, out),
36        Value::U32(n) => push_int(*n, out),
37        Value::I16(n) => push_int(*n, out),
38        Value::U16(n) => push_int(*n, out),
39        Value::I8(n) => push_int(*n, out),
40        Value::U8(n) => push_int(*n, out),
41        Value::F64(x) => push_f64(*x, out),
42        Value::BigInt(b) => {
43            match i64::try_from(&**b) {
44                Ok(n) if n.abs() <= BIGINT_SAFE_MAX => push_int(n, out),
45                _ => {
46                    // Beyond ±(2^53−1): a decimal string, so the value
47                    // survives float-only host JSON parsers.
48                    out.push('"');
49                    out.push_str(&b.to_string());
50                    out.push('"');
51                }
52            }
53        }
54        Value::Utf8(s) => push_string(s, out),
55        Value::Bytes(b) => {
56            out.push('"');
57            out.push_str(&base64::engine::general_purpose::STANDARD.encode(&**b));
58            out.push('"');
59        }
60        Value::Unit => out.push_str("null"),
61        Value::Record(record) => {
62            let desc = runtime.registry.get(record.type_id)?;
63            if desc.strategy == Strategy::Opaque {
64                out.push_str("\"<handle>\"");
65                return Ok(());
66            }
67            let TypeBody::Record(fields) = &desc.body else {
68                return Err(SoilError::new(
69                    PanicKind::TypeError,
70                    format!("value is a record but type {} is not", desc.name),
71                ));
72            };
73            if fields.len() != record.fields.len() {
74                return Err(SoilError::new(
75                    PanicKind::TypeError,
76                    format!("record of type {} has wrong field count", desc.name),
77                ));
78            }
79            out.push('{');
80            let mut first = true;
81            for (field_desc, field_value) in fields.iter().zip(record.fields.iter()) {
82                if field_desc.ignored.is_some() {
83                    continue;
84                }
85                if !first {
86                    out.push(',');
87                }
88                first = false;
89                push_string(&field_desc.name, out);
90                out.push(':');
91                encode_into(runtime, field_value, out)?;
92            }
93            out.push('}');
94        }
95        Value::Sum(sum) => {
96            if sum.type_id == runtime.registry.bool_id() {
97                // The one special case in the sum encoding (tr-grammar §7).
98                out.push_str(if sum.variant == 0 { "true" } else { "false" });
99                return Ok(());
100            }
101            let desc = runtime.registry.get(sum.type_id)?;
102            if desc.strategy == Strategy::Opaque {
103                out.push_str("\"<handle>\"");
104                return Ok(());
105            }
106            let TypeBody::Sum(variants) = &desc.body else {
107                return Err(SoilError::new(
108                    PanicKind::TypeError,
109                    format!("value is a sum but type {} is not", desc.name),
110                ));
111            };
112            let variant = variants.get(sum.variant as usize).ok_or_else(|| {
113                SoilError::new(
114                    PanicKind::TypeError,
115                    format!("type {} has no variant index {}", desc.name, sum.variant),
116                )
117            })?;
118            out.push_str("{\"tag\":");
119            push_string(&variant.name, out);
120            match (&variant.payload, &sum.payload) {
121                (Some(_), Some(payload)) => {
122                    out.push_str(",\"value\":");
123                    encode_into(runtime, payload, out)?;
124                }
125                (None, None) => {}
126                _ => {
127                    return Err(SoilError::new(
128                        PanicKind::TypeError,
129                        format!("variant {}::{} payload mismatch", desc.name, variant.name),
130                    ));
131                }
132            }
133            out.push('}');
134        }
135        Value::List(items) => {
136            out.push('[');
137            for (i, item) in items.iter().enumerate() {
138                if i > 0 {
139                    out.push(',');
140                }
141                encode_into(runtime, item, out)?;
142            }
143            out.push(']');
144        }
145        Value::Map(map) => {
146            out.push('[');
147            for (i, (key, val)) in map.entries().iter().enumerate() {
148                if i > 0 {
149                    out.push(',');
150                }
151                out.push_str("{\"key\":");
152                encode_into(runtime, key, out)?;
153                out.push_str(",\"value\":");
154                encode_into(runtime, val, out)?;
155                out.push('}');
156            }
157            out.push(']');
158        }
159        Value::Closure(_) => {
160            return Err(SoilError::new(
161                PanicKind::DerivationError,
162                "functions have no JSON encoding",
163            ));
164        }
165        Value::Opaque(_) => out.push_str("\"<handle>\""),
166    }
167    Ok(())
168}
169
170fn push_int<T: itoa::Integer>(n: T, out: &mut String) {
171    let mut buffer = itoa::Buffer::new();
172    out.push_str(buffer.format(n));
173}
174
175fn push_f64(x: f64, out: &mut String) {
176    if x.is_nan() {
177        out.push_str("\"NaN\"");
178    } else if x == f64::INFINITY {
179        out.push_str("\"Inf\"");
180    } else if x == f64::NEG_INFINITY {
181        out.push_str("\"-Inf\"");
182    } else {
183        let mut buffer = ryu::Buffer::new();
184        out.push_str(buffer.format(x));
185    }
186}
187
188/// The canonical string escape set (micro-pin §8.1): `"`, `\`, and
189/// control characters U+0000–U+001F only — short forms where they
190/// exist, lowercase `\u00xx` otherwise; everything else is raw UTF-8.
191pub(crate) fn push_string(s: &str, out: &mut String) {
192    out.push('"');
193    for c in s.chars() {
194        match c {
195            '"' => out.push_str("\\\""),
196            '\\' => out.push_str("\\\\"),
197            '\n' => out.push_str("\\n"),
198            '\r' => out.push_str("\\r"),
199            '\t' => out.push_str("\\t"),
200            '\u{0008}' => out.push_str("\\b"),
201            '\u{000c}' => out.push_str("\\f"),
202            c if (c as u32) < 0x20 => {
203                out.push_str(&format!("\\u{:04x}", c as u32));
204            }
205            c => out.push(c),
206        }
207    }
208    out.push('"');
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use crate::descriptor::{FieldDesc, Strategy, TypeBody, TypeDesc, TypeShape, VariantDesc};
215    use crate::value::{Closure, MapOrder, MapVal, OpaqueVal, Record, Ref, SumVal};
216    use num_bigint::BigInt;
217
218    fn rt() -> Runtime {
219        Runtime::new()
220    }
221
222    fn enc(runtime: &Runtime, v: &Value) -> String {
223        encode(runtime, v).unwrap()
224    }
225
226    #[test]
227    fn scalars() {
228        let rt = rt();
229        assert_eq!(enc(&rt, &Value::I64(-42)), "-42");
230        assert_eq!(enc(&rt, &Value::U64(u64::MAX)), "18446744073709551615");
231        assert_eq!(enc(&rt, &Value::U8(255)), "255");
232        assert_eq!(enc(&rt, &Value::Unit), "null");
233    }
234
235    #[test]
236    fn floats() {
237        let rt = rt();
238        assert_eq!(enc(&rt, &Value::F64(1.0)), "1.0");
239        assert_eq!(enc(&rt, &Value::F64(-0.0)), "-0.0");
240        assert_eq!(enc(&rt, &Value::F64(0.0)), "0.0");
241        assert_eq!(enc(&rt, &Value::F64(1.5e300)), "1.5e300");
242        assert_eq!(enc(&rt, &Value::F64(f64::NAN)), "\"NaN\"");
243        assert_eq!(enc(&rt, &Value::F64(f64::INFINITY)), "\"Inf\"");
244        assert_eq!(enc(&rt, &Value::F64(f64::NEG_INFINITY)), "\"-Inf\"");
245    }
246
247    #[test]
248    fn bigint_hybrid_by_range() {
249        let rt = rt();
250        let safe = BigInt::from((1i64 << 53) - 1);
251        let beyond = BigInt::from(1i64 << 53);
252        assert_eq!(enc(&rt, &Value::BigInt(Ref::new(safe))), "9007199254740991");
253        assert_eq!(
254            enc(&rt, &Value::BigInt(Ref::new(beyond))),
255            "\"9007199254740992\""
256        );
257        let negative = BigInt::from(-(1i64 << 53));
258        assert_eq!(
259            enc(&rt, &Value::BigInt(Ref::new(negative))),
260            "\"-9007199254740992\""
261        );
262    }
263
264    #[test]
265    fn string_escapes() {
266        let rt = rt();
267        let s: Ref<str> = Ref::from("a\"b\\c\nd\te\u{1}f — ok");
268        assert_eq!(
269            enc(&rt, &Value::Utf8(s)),
270            "\"a\\\"b\\\\c\\nd\\te\\u0001f — ok\""
271        );
272    }
273
274    #[test]
275    fn bytes_base64() {
276        let rt = rt();
277        let b: Ref<[u8]> = Ref::from(&b"hello"[..]);
278        assert_eq!(enc(&rt, &Value::Bytes(b)), "\"aGVsbG8=\"");
279    }
280
281    #[test]
282    fn bool_is_json_booleans() {
283        let rt = rt();
284        let t = Value::Sum(Ref::new(SumVal {
285            type_id: rt.registry.bool_id(),
286            variant: 0,
287            payload: None,
288        }));
289        let f = Value::Sum(Ref::new(SumVal {
290            type_id: rt.registry.bool_id(),
291            variant: 1,
292            payload: None,
293        }));
294        assert_eq!(enc(&rt, &t), "true");
295        assert_eq!(enc(&rt, &f), "false");
296    }
297
298    #[test]
299    fn record_declaration_order_and_ignored_omitted() {
300        let mut rt = rt();
301        let id = rt
302            .registry
303            .register(TypeDesc {
304                name: "Doc".to_string(),
305                strategy: Strategy::Structural,
306                body: TypeBody::Record(vec![
307                    FieldDesc {
308                        name: "text".to_string(),
309                        shape: TypeShape::Utf8,
310                        ignored: None,
311                    },
312                    FieldDesc {
313                        name: "cached_word_count".to_string(),
314                        shape: TypeShape::U64,
315                        ignored: Some(crate::descriptor::IgnoredDefault::Native(Closure::native(
316                            |_| Ok(Value::U64(0)),
317                        ))),
318                    },
319                    FieldDesc {
320                        name: "id".to_string(),
321                        shape: TypeShape::I64,
322                        ignored: None,
323                    },
324                ]),
325            })
326            .unwrap();
327        let value = Value::Record(Ref::new(Record {
328            type_id: id,
329            fields: Box::new([Value::Utf8(Ref::from("hi")), Value::U64(2), Value::I64(7)]),
330        }));
331        assert_eq!(enc(&rt, &value), "{\"text\":\"hi\",\"id\":7}");
332    }
333
334    #[test]
335    fn sum_internally_tagged() {
336        let mut rt = rt();
337        let id = rt
338            .registry
339            .register(TypeDesc {
340                name: "Shape".to_string(),
341                strategy: Strategy::Structural,
342                body: TypeBody::Sum(vec![
343                    VariantDesc {
344                        name: "Point".to_string(),
345                        payload: None,
346                    },
347                    VariantDesc {
348                        name: "Circle".to_string(),
349                        payload: Some(TypeShape::F64),
350                    },
351                ]),
352            })
353            .unwrap();
354        let point = Value::Sum(Ref::new(SumVal {
355            type_id: id,
356            variant: 0,
357            payload: None,
358        }));
359        let circle = Value::Sum(Ref::new(SumVal {
360            type_id: id,
361            variant: 1,
362            payload: Some(Value::F64(2.5)),
363        }));
364        assert_eq!(enc(&rt, &point), "{\"tag\":\"Point\"}");
365        assert_eq!(enc(&rt, &circle), "{\"tag\":\"Circle\",\"value\":2.5}");
366    }
367
368    #[test]
369    fn list_and_map() {
370        let rt = rt();
371        let list = Value::List(Ref::new(vec![Value::I64(1), Value::I64(2)]));
372        assert_eq!(enc(&rt, &list), "[1,2]");
373
374        let mut map = MapVal::new(MapOrder::Structural);
375        map.insert(&rt, Value::I64(2), Value::Utf8(Ref::from("b")))
376            .unwrap();
377        map.insert(&rt, Value::I64(1), Value::Utf8(Ref::from("a")))
378            .unwrap();
379        assert_eq!(
380            enc(&rt, &Value::Map(Ref::new(map))),
381            "[{\"key\":1,\"value\":\"a\"},{\"key\":2,\"value\":\"b\"}]"
382        );
383    }
384
385    #[test]
386    fn opaque_and_closure() {
387        let mut rt = rt();
388        let id = rt
389            .registry
390            .register(TypeDesc {
391                name: "Handle".to_string(),
392                strategy: Strategy::Opaque,
393                body: TypeBody::Opaque,
394            })
395            .unwrap();
396        let opaque = Value::Opaque(Ref::new(OpaqueVal {
397            type_id: id,
398            payload: Box::new(()),
399        }));
400        assert_eq!(enc(&rt, &opaque), "\"<handle>\"");
401
402        let closure = Value::Closure(Ref::new(Closure::native(|_| Ok(Value::Unit))));
403        let err = encode(&rt, &closure).unwrap_err();
404        assert_eq!(err.kind, PanicKind::DerivationError);
405    }
406
407    #[test]
408    fn encoding_is_deterministic() {
409        let rt = rt();
410        let value = Value::List(Ref::new(vec![Value::F64(0.1), Value::Utf8(Ref::from("x"))]));
411        assert_eq!(enc(&rt, &value), enc(&rt, &value));
412    }
413}