hedl_json/
to_json.rs

1// Dweve HEDL - Hierarchical Entity Data Language
2//
3// Copyright (c) 2025 Dweve IP B.V. and individual contributors.
4//
5// SPDX-License-Identifier: Apache-2.0
6//
7// Licensed under the Apache License, Version 2.0 (the "License");
8// you may not use this file except in compliance with the License.
9// You may obtain a copy of the License in the LICENSE file at the
10// root of this repository or at: http://www.apache.org/licenses/LICENSE-2.0
11//
12// Unless required by applicable law or agreed to in writing, software
13// distributed under the License is distributed on an "AS IS" BASIS,
14// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15// See the License for the specific language governing permissions and
16// limitations under the License.
17
18//! HEDL to JSON conversion
19
20use hedl_core::{Document, Item, MatrixList, Node, Value};
21use hedl_core::lex::Tensor;
22use serde_json::{json, Map, Number, Value as JsonValue};
23use std::collections::BTreeMap;
24
25/// Configuration for JSON output
26#[derive(Debug, Clone)]
27pub struct ToJsonConfig {
28    /// Include HEDL metadata (__type__, __schema__)
29    pub include_metadata: bool,
30    /// Flatten matrix lists to plain arrays
31    pub flatten_lists: bool,
32    /// Include children as nested arrays (default: true)
33    pub include_children: bool,
34}
35
36impl Default for ToJsonConfig {
37    fn default() -> Self {
38        Self {
39            include_metadata: false,
40            flatten_lists: false,
41            include_children: true, // Children should be included by default
42        }
43    }
44}
45
46impl hedl_core::convert::ExportConfig for ToJsonConfig {
47    fn include_metadata(&self) -> bool {
48        self.include_metadata
49    }
50
51    fn pretty(&self) -> bool {
52        // JSON always uses pretty printing in to_json
53        true
54    }
55}
56
57/// Convert Document to JSON string
58pub fn to_json(doc: &Document, config: &ToJsonConfig) -> Result<String, String> {
59    let value = to_json_value(doc, config)?;
60    serde_json::to_string_pretty(&value).map_err(|e| format!("JSON serialization error: {}", e))
61}
62
63/// Convert Document to serde_json::Value
64pub fn to_json_value(doc: &Document, config: &ToJsonConfig) -> Result<JsonValue, String> {
65    root_to_json(&doc.root, doc, config)
66}
67
68fn root_to_json(
69    root: &BTreeMap<String, Item>,
70    doc: &Document,
71    config: &ToJsonConfig,
72) -> Result<JsonValue, String> {
73    // P1 OPTIMIZATION: Pre-allocate map capacity (1.05-1.1x speedup)
74    let mut map = Map::with_capacity(root.len());
75
76    for (key, item) in root {
77        let json_value = item_to_json(item, doc, config)?;
78        map.insert(key.clone(), json_value);
79    }
80
81    Ok(JsonValue::Object(map))
82}
83
84fn item_to_json(item: &Item, doc: &Document, config: &ToJsonConfig) -> Result<JsonValue, String> {
85    match item {
86        Item::Scalar(value) => Ok(value_to_json(value)),
87        Item::Object(obj) => object_to_json(obj, doc, config),
88        Item::List(list) => matrix_list_to_json(list, doc, config),
89    }
90}
91
92fn object_to_json(
93    obj: &BTreeMap<String, Item>,
94    doc: &Document,
95    config: &ToJsonConfig,
96) -> Result<JsonValue, String> {
97    // P1 OPTIMIZATION: Pre-allocate map capacity
98    let mut map = Map::with_capacity(obj.len());
99
100    for (key, item) in obj {
101        let json_value = item_to_json(item, doc, config)?;
102        map.insert(key.clone(), json_value);
103    }
104
105    Ok(JsonValue::Object(map))
106}
107
108fn value_to_json(value: &Value) -> JsonValue {
109    match value {
110        Value::Null => JsonValue::Null,
111        Value::Bool(b) => JsonValue::Bool(*b),
112        Value::Int(n) => JsonValue::Number(Number::from(*n)),
113        Value::Float(f) => Number::from_f64(*f)
114            .map(JsonValue::Number)
115            .unwrap_or(JsonValue::Null),
116        Value::String(s) => JsonValue::String(s.clone()),
117        Value::Tensor(t) => tensor_to_json(t),
118        Value::Reference(r) => {
119            // Represent references as objects with special key
120            json!({ "@ref": r.to_ref_string() })
121        }
122        Value::Expression(e) => {
123            // Represent expressions as strings with $() wrapper
124            JsonValue::String(format!("$({})", e))
125        }
126    }
127}
128
129fn tensor_to_json(tensor: &Tensor) -> JsonValue {
130    // Convert tensor to nested arrays recursively
131    match tensor {
132        Tensor::Scalar(n) => Number::from_f64(*n)
133            .map(JsonValue::Number)
134            .unwrap_or(JsonValue::Null),
135        Tensor::Array(items) => {
136            // OPTIMIZATION: Pre-allocate array with exact capacity
137            // Reduces reallocations during recursive tensor serialization
138            let mut arr = Vec::with_capacity(items.len());
139            for item in items {
140                arr.push(tensor_to_json(item));
141            }
142            JsonValue::Array(arr)
143        }
144    }
145}
146
147fn matrix_list_to_json(
148    list: &MatrixList,
149    doc: &Document,
150    config: &ToJsonConfig,
151) -> Result<JsonValue, String> {
152    // P1 OPTIMIZATION: Pre-allocate array capacity
153    let mut array = Vec::with_capacity(list.rows.len());
154
155    for row in &list.rows {
156        // P1 OPTIMIZATION: Pre-allocate row object capacity
157        let mut row_obj = Map::with_capacity(list.schema.len() + 2); // +2 for metadata fields
158
159        // Add field values according to schema
160        // Per SPEC.md: Node.fields contains ALL values including ID (first column)
161        // MatrixList.schema includes all column names with ID first
162        for (i, col_name) in list.schema.iter().enumerate() {
163            if let Some(field_value) = row.fields.get(i) {
164                row_obj.insert(col_name.clone(), value_to_json(field_value));
165            }
166        }
167
168        // Add metadata if configured
169        if config.include_metadata {
170            row_obj.insert(
171                "__type__".to_string(),
172                JsonValue::String(list.type_name.clone()),
173            );
174        }
175
176        // Add children if configured and present
177        if config.include_children && !row.children.is_empty() {
178            for (child_type, child_nodes) in &row.children {
179                let child_json = nodes_to_json(child_type, child_nodes, doc, config)?;
180                row_obj.insert(child_type.clone(), child_json);
181            }
182        }
183
184        array.push(JsonValue::Object(row_obj));
185    }
186
187    // Wrap with metadata if configured
188    if config.include_metadata && !config.flatten_lists {
189        let mut metadata = json!({
190            "__type__": list.type_name,
191            "__schema__": list.schema,
192            "items": array
193        });
194
195        // Include count_hint if present
196        if let Some(count) = list.count_hint {
197            if let Some(obj) = metadata.as_object_mut() {
198                obj.insert("__count_hint__".to_string(), JsonValue::Number(count.into()));
199            }
200        }
201
202        Ok(metadata)
203    } else {
204        Ok(JsonValue::Array(array))
205    }
206}
207
208fn nodes_to_json(
209    type_name: &str,
210    nodes: &[Node],
211    doc: &Document,
212    config: &ToJsonConfig,
213) -> Result<JsonValue, String> {
214    // OPTIMIZATION: Pre-allocate array with exact capacity
215    // Reduces reallocation during node processing
216    let mut array = Vec::with_capacity(nodes.len());
217
218    // Look up the schema for this type from the document
219    let schema = doc.get_schema(type_name);
220
221    for node in nodes {
222        // OPTIMIZATION: Pre-allocate map capacity based on schema size + metadata + children
223        let capacity = if let Some(field_names) = schema {
224            field_names.len() + if config.include_metadata { 1 } else { 0 } + node.children.len()
225        } else {
226            node.fields.len() + if config.include_metadata { 1 } else { 0 } + node.children.len()
227        };
228        let mut obj = Map::with_capacity(capacity);
229
230        // Add fields according to schema if available
231        if let Some(field_names) = schema {
232            for (i, col_name) in field_names.iter().enumerate() {
233                if let Some(field_value) = node.fields.get(i) {
234                    obj.insert(col_name.clone(), value_to_json(field_value));
235                }
236            }
237        } else {
238            // Fallback: use id + field_N naming when schema not available
239            obj.insert("id".to_string(), JsonValue::String(node.id.clone()));
240            for (i, value) in node.fields.iter().enumerate() {
241                obj.insert(format!("field_{}", i), value_to_json(value));
242            }
243        }
244
245        // Add metadata if configured
246        if config.include_metadata {
247            obj.insert(
248                "__type__".to_string(),
249                JsonValue::String(type_name.to_string()),
250            );
251        }
252
253        // Add children if configured
254        if config.include_children && !node.children.is_empty() {
255            for (child_type, child_nodes) in &node.children {
256                let child_json = nodes_to_json(child_type, child_nodes, doc, config)?;
257                obj.insert(child_type.clone(), child_json);
258            }
259        }
260
261        array.push(JsonValue::Object(obj));
262    }
263
264    Ok(JsonValue::Array(array))
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use hedl_core::{Expression, Reference};
271
272    // ==================== ToJsonConfig tests ====================
273
274    #[test]
275    fn test_to_json_config_default() {
276        let config = ToJsonConfig::default();
277        assert!(!config.include_metadata);
278        assert!(!config.flatten_lists);
279        assert!(config.include_children);
280    }
281
282    #[test]
283    fn test_to_json_config_debug() {
284        let config = ToJsonConfig::default();
285        let debug = format!("{:?}", config);
286        assert!(debug.contains("ToJsonConfig"));
287        assert!(debug.contains("include_metadata"));
288        assert!(debug.contains("flatten_lists"));
289        assert!(debug.contains("include_children"));
290    }
291
292    #[test]
293    fn test_to_json_config_clone() {
294        let config = ToJsonConfig {
295            include_metadata: true,
296            flatten_lists: true,
297            include_children: false,
298        };
299        let cloned = config.clone();
300        assert!(cloned.include_metadata);
301        assert!(cloned.flatten_lists);
302        assert!(!cloned.include_children);
303    }
304
305    // ==================== value_to_json tests ====================
306
307    #[test]
308    fn test_value_to_json() {
309        assert_eq!(value_to_json(&Value::Null), JsonValue::Null);
310        assert_eq!(value_to_json(&Value::Bool(true)), JsonValue::Bool(true));
311        assert_eq!(value_to_json(&Value::Int(42)), json!(42));
312        assert_eq!(
313            value_to_json(&Value::String("hello".into())),
314            json!("hello")
315        );
316    }
317
318    #[test]
319    fn test_value_to_json_null() {
320        assert_eq!(value_to_json(&Value::Null), JsonValue::Null);
321    }
322
323    #[test]
324    fn test_value_to_json_bool() {
325        assert_eq!(value_to_json(&Value::Bool(true)), json!(true));
326        assert_eq!(value_to_json(&Value::Bool(false)), json!(false));
327    }
328
329    #[test]
330    fn test_value_to_json_int() {
331        assert_eq!(value_to_json(&Value::Int(0)), json!(0));
332        assert_eq!(value_to_json(&Value::Int(-42)), json!(-42));
333        assert_eq!(value_to_json(&Value::Int(i64::MAX)), json!(i64::MAX));
334    }
335
336    #[test]
337    fn test_value_to_json_float() {
338        assert_eq!(value_to_json(&Value::Float(3.5)), json!(3.5));
339        assert_eq!(value_to_json(&Value::Float(0.0)), json!(0.0));
340        assert_eq!(value_to_json(&Value::Float(-1.5)), json!(-1.5));
341    }
342
343    #[test]
344    fn test_value_to_json_float_nan() {
345        // NaN cannot be represented in JSON, becomes null
346        assert_eq!(value_to_json(&Value::Float(f64::NAN)), JsonValue::Null);
347    }
348
349    #[test]
350    fn test_value_to_json_float_infinity() {
351        // Infinity cannot be represented in JSON, becomes null
352        assert_eq!(value_to_json(&Value::Float(f64::INFINITY)), JsonValue::Null);
353        assert_eq!(
354            value_to_json(&Value::Float(f64::NEG_INFINITY)),
355            JsonValue::Null
356        );
357    }
358
359    #[test]
360    fn test_value_to_json_string() {
361        assert_eq!(value_to_json(&Value::String("".into())), json!(""));
362        assert_eq!(
363            value_to_json(&Value::String("hello world".into())),
364            json!("hello world")
365        );
366        assert_eq!(
367            value_to_json(&Value::String("with\nnewline".into())),
368            json!("with\nnewline")
369        );
370    }
371
372    #[test]
373    fn test_value_to_json_string_unicode() {
374        assert_eq!(
375            value_to_json(&Value::String("héllo 世界".into())),
376            json!("héllo 世界")
377        );
378    }
379
380    #[test]
381    fn test_value_to_json_reference() {
382        let reference = Reference::qualified("User", "123");
383        let json = value_to_json(&Value::Reference(reference));
384        assert_eq!(json, json!({"@ref": "@User:123"}));
385    }
386
387    #[test]
388    fn test_value_to_json_reference_local() {
389        let reference = Reference::local("123");
390        let json = value_to_json(&Value::Reference(reference));
391        assert_eq!(json, json!({"@ref": "@123"}));
392    }
393
394    #[test]
395    fn test_value_to_json_expression() {
396        use hedl_core::lex::Span;
397        let expr = Expression::Identifier {
398            name: "foo".to_string(),
399            span: Span::default(),
400        };
401        let json = value_to_json(&Value::Expression(expr));
402        assert_eq!(json, json!("$(foo)"));
403    }
404
405    // ==================== tensor_to_json tests ====================
406
407    #[test]
408    fn test_tensor_to_json_scalar() {
409        assert_eq!(tensor_to_json(&Tensor::Scalar(1.0)), json!(1.0));
410        assert_eq!(tensor_to_json(&Tensor::Scalar(3.5)), json!(3.5));
411    }
412
413    #[test]
414    fn test_tensor_to_json_1d() {
415        let tensor = Tensor::Array(vec![
416            Tensor::Scalar(1.0),
417            Tensor::Scalar(2.0),
418            Tensor::Scalar(3.0),
419        ]);
420        assert_eq!(tensor_to_json(&tensor), json!([1.0, 2.0, 3.0]));
421    }
422
423    #[test]
424    fn test_tensor_to_json_2d() {
425        let tensor = Tensor::Array(vec![
426            Tensor::Array(vec![Tensor::Scalar(1.0), Tensor::Scalar(2.0)]),
427            Tensor::Array(vec![Tensor::Scalar(3.0), Tensor::Scalar(4.0)]),
428        ]);
429        assert_eq!(tensor_to_json(&tensor), json!([[1.0, 2.0], [3.0, 4.0]]));
430    }
431
432    #[test]
433    fn test_tensor_to_json_empty() {
434        let tensor = Tensor::Array(vec![]);
435        assert_eq!(tensor_to_json(&tensor), json!([]));
436    }
437
438    #[test]
439    fn test_tensor_to_json_nan_becomes_null() {
440        let tensor = Tensor::Scalar(f64::NAN);
441        assert_eq!(tensor_to_json(&tensor), JsonValue::Null);
442    }
443
444    // ==================== item_to_json tests ====================
445
446    #[test]
447    fn test_item_to_json_scalar() {
448        let doc = Document::new((1, 0));
449        let config = ToJsonConfig::default();
450        let item = Item::Scalar(Value::Int(42));
451        let result = item_to_json(&item, &doc, &config).unwrap();
452        assert_eq!(result, json!(42));
453    }
454
455    #[test]
456    fn test_item_to_json_object() {
457        let doc = Document::new((1, 0));
458        let config = ToJsonConfig::default();
459        let mut obj = BTreeMap::new();
460        obj.insert(
461            "key".to_string(),
462            Item::Scalar(Value::String("value".into())),
463        );
464        let item = Item::Object(obj);
465        let result = item_to_json(&item, &doc, &config).unwrap();
466        assert_eq!(result, json!({"key": "value"}));
467    }
468
469    // ==================== object_to_json tests ====================
470
471    #[test]
472    fn test_object_to_json_empty() {
473        let doc = Document::new((1, 0));
474        let config = ToJsonConfig::default();
475        let obj = BTreeMap::new();
476        let result = object_to_json(&obj, &doc, &config).unwrap();
477        assert_eq!(result, json!({}));
478    }
479
480    #[test]
481    fn test_object_to_json_nested() {
482        let doc = Document::new((1, 0));
483        let config = ToJsonConfig::default();
484        let mut inner = BTreeMap::new();
485        inner.insert("nested".to_string(), Item::Scalar(Value::Bool(true)));
486        let mut outer = BTreeMap::new();
487        outer.insert("inner".to_string(), Item::Object(inner));
488        let result = object_to_json(&outer, &doc, &config).unwrap();
489        assert_eq!(result, json!({"inner": {"nested": true}}));
490    }
491
492    // ==================== root_to_json tests ====================
493
494    #[test]
495    fn test_root_to_json_empty() {
496        let doc = Document::new((1, 0));
497        let config = ToJsonConfig::default();
498        let root = BTreeMap::new();
499        let result = root_to_json(&root, &doc, &config).unwrap();
500        assert_eq!(result, json!({}));
501    }
502
503    #[test]
504    fn test_root_to_json_with_items() {
505        let doc = Document::new((1, 0));
506        let config = ToJsonConfig::default();
507        let mut root = BTreeMap::new();
508        root.insert(
509            "name".to_string(),
510            Item::Scalar(Value::String("test".into())),
511        );
512        root.insert("count".to_string(), Item::Scalar(Value::Int(42)));
513        let result = root_to_json(&root, &doc, &config).unwrap();
514        assert_eq!(result, json!({"name": "test", "count": 42}));
515    }
516
517    // ==================== to_json tests ====================
518
519    #[test]
520    fn test_to_json_empty_document() {
521        let doc = Document {
522            version: (1, 0),
523            aliases: BTreeMap::new(),
524            structs: BTreeMap::new(),
525            nests: BTreeMap::new(),
526            root: BTreeMap::new(),
527        };
528        let config = ToJsonConfig::default();
529        let result = to_json(&doc, &config).unwrap();
530        assert_eq!(result.trim(), "{}");
531    }
532
533    #[test]
534    fn test_to_json_with_scalars() {
535        let mut root = BTreeMap::new();
536        root.insert(
537            "name".to_string(),
538            Item::Scalar(Value::String("test".into())),
539        );
540        root.insert("active".to_string(), Item::Scalar(Value::Bool(true)));
541        let doc = Document {
542            version: (1, 0),
543            aliases: BTreeMap::new(),
544            structs: BTreeMap::new(),
545            nests: BTreeMap::new(),
546            root,
547        };
548        let config = ToJsonConfig::default();
549        let result = to_json(&doc, &config).unwrap();
550        let parsed: JsonValue = serde_json::from_str(&result).unwrap();
551        assert_eq!(parsed["name"], json!("test"));
552        assert_eq!(parsed["active"], json!(true));
553    }
554
555    // ==================== to_json_value tests ====================
556
557    #[test]
558    fn test_to_json_value_simple() {
559        let mut root = BTreeMap::new();
560        root.insert("key".to_string(), Item::Scalar(Value::Int(42)));
561        let doc = Document {
562            version: (1, 0),
563            aliases: BTreeMap::new(),
564            structs: BTreeMap::new(),
565            nests: BTreeMap::new(),
566            root,
567        };
568        let config = ToJsonConfig::default();
569        let result = to_json_value(&doc, &config).unwrap();
570        assert_eq!(result, json!({"key": 42}));
571    }
572
573    // ==================== matrix_list_to_json tests ====================
574
575    #[test]
576    fn test_matrix_list_to_json_simple() {
577        let doc = Document::new((1, 0));
578        let config = ToJsonConfig::default();
579        let list = MatrixList {
580            type_name: "User".to_string(),
581            schema: vec!["id".to_string(), "name".to_string()],
582            rows: vec![Node {
583                type_name: "User".to_string(),
584                id: "1".to_string(),
585                fields: vec![Value::String("1".into()), Value::String("Alice".into())],
586                children: BTreeMap::new(),
587                child_count: None,
588            }],
589            count_hint: None,
590        };
591        let result = matrix_list_to_json(&list, &doc, &config).unwrap();
592        assert_eq!(result, json!([{"id": "1", "name": "Alice"}]));
593    }
594
595    #[test]
596    fn test_matrix_list_to_json_with_metadata() {
597        let doc = Document::new((1, 0));
598        let config = ToJsonConfig {
599            include_metadata: true,
600            flatten_lists: false,
601            include_children: true,
602        };
603        let list = MatrixList {
604            type_name: "User".to_string(),
605            schema: vec!["id".to_string()],
606            rows: vec![Node {
607                type_name: "User".to_string(),
608                id: "1".to_string(),
609                fields: vec![Value::String("1".into())],
610                children: BTreeMap::new(),
611                child_count: None,
612            }],
613            count_hint: None,
614        };
615        let result = matrix_list_to_json(&list, &doc, &config).unwrap();
616        assert!(result["__type__"] == json!("User"));
617        assert!(result["__schema__"] == json!(["id"]));
618    }
619
620    #[test]
621    fn test_matrix_list_to_json_empty() {
622        let doc = Document::new((1, 0));
623        let config = ToJsonConfig::default();
624        let list = MatrixList {
625            type_name: "User".to_string(),
626            schema: vec!["id".to_string()],
627            rows: vec![],
628            count_hint: None,
629        };
630        let result = matrix_list_to_json(&list, &doc, &config).unwrap();
631        assert_eq!(result, json!([]));
632    }
633
634    #[test]
635    fn test_matrix_list_to_json_with_count_hint() {
636        let doc = Document::new((1, 0));
637        let config = ToJsonConfig {
638            include_metadata: true,
639            flatten_lists: false,
640            include_children: true,
641        };
642        let list = MatrixList {
643            type_name: "Team".to_string(),
644            schema: vec!["id".to_string(), "name".to_string()],
645            rows: vec![Node {
646                type_name: "Team".to_string(),
647                id: "1".to_string(),
648                fields: vec![Value::String("1".into()), Value::String("Alpha".into())],
649                children: BTreeMap::new(),
650                child_count: None,
651            }],
652            count_hint: Some(5),
653        };
654        let result = matrix_list_to_json(&list, &doc, &config).unwrap();
655
656        // Should include count_hint in metadata
657        assert_eq!(result["__count_hint__"], json!(5));
658        assert_eq!(result["__type__"], json!("Team"));
659        assert_eq!(result["__schema__"], json!(["id", "name"]));
660    }
661}