runmat_runtime/builtins/io/json/
jsonencode.rs

1//! MATLAB-compatible `jsonencode` builtin for serialising RunMat values to JSON text.
2
3use std::collections::BTreeMap;
4use std::fmt::Write as FmtWrite;
5
6use runmat_builtins::{
7    CellArray, CharArray, ComplexTensor, IntValue, LogicalArray, ObjectInstance, StringArray,
8    StructValue, Tensor, Value,
9};
10use runmat_macros::runtime_builtin;
11
12use crate::builtins::common::spec::{
13    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
14    ReductionNaN, ResidencyPolicy, ShapeRequirements,
15};
16use crate::{gather_if_needed, register_builtin_fusion_spec, register_builtin_gpu_spec};
17
18#[cfg(feature = "doc_export")]
19use crate::register_builtin_doc_text;
20
21const OPTION_NAME_ERROR: &str = "jsonencode: option names must be character vectors or strings";
22const OPTION_VALUE_ERROR: &str = "jsonencode: option value must be scalar logical or numeric";
23const INF_NAN_ERROR: &str = "jsonencode: ConvertInfAndNaN must be true to encode NaN or Inf values";
24const UNSUPPORTED_TYPE_ERROR: &str =
25    "jsonencode: unsupported input type; expected numeric, logical, string, struct, cell, or object data";
26
27#[cfg(feature = "doc_export")]
28#[allow(clippy::too_many_lines)]
29pub const DOC_MD: &str = r#"---
30title: "jsonencode"
31category: "io/json"
32keywords: ["jsonencode", "json", "serialization", "struct to json", "pretty print json", "gpu gather"]
33summary: "Serialize MATLAB values to UTF-8 JSON text with MATLAB-compatible defaults."
34references:
35  - https://www.mathworks.com/help/matlab/ref/jsonencode.html
36gpu_support:
37  elementwise: false
38  reduction: false
39  precisions: []
40  broadcasting: "none"
41  notes: "jsonencode gathers GPU-resident data to host memory before serialisation and executes entirely on the CPU."
42fusion:
43  elementwise: false
44  reduction: false
45  max_inputs: 2
46  constants: "inline"
47requires_feature: null
48tested:
49  unit: "builtins::io::json::jsonencode::tests"
50  integration:
51    - "builtins::io::json::jsonencode::tests::jsonencode_struct_round_trip"
52    - "builtins::io::json::jsonencode::tests::jsonencode_gpu_tensor_gathers_host_data"
53    - "builtins::io::json::jsonencode::tests::jsonencode_struct_options_enable_pretty_print"
54    - "builtins::io::json::jsonencode::tests::jsonencode_convert_inf_and_nan_controls_null_output"
55    - "builtins::io::json::jsonencode::tests::jsonencode_char_array_zero_rows_is_empty_array"
56    - "builtins::io::json::jsonencode::tests::jsonencode_gpu_tensor_wgpu_gathers_host_data"
57---
58
59# What does the `jsonencode` function do in MATLAB / RunMat?
60`jsonencode` converts MATLAB values into UTF-8 JSON text. The builtin mirrors MATLAB defaults:
61scalars become numbers or strings, matrices turn into JSON arrays, structs map to JSON objects,
62and `NaN`/`Inf` values encode as `null` unless you disable the conversion.
63
64## How does the `jsonencode` function behave in MATLAB / RunMat?
65- Returns a 1×N character array containing UTF-8 encoded JSON text.
66- Numeric and logical arrays become JSON arrays, preserving MATLAB column-major ordering.
67- Scalars encode as bare numbers/strings rather than single-element arrays.
68- Struct scalars become JSON objects; struct arrays become JSON arrays of objects.
69- Cell arrays map to JSON arrays, with nested arrays when the cell is 2-D.
70- String arrays and char arrays become JSON strings (1 element) or arrays of strings (multiple rows).
71- By default, `NaN`, `Inf`, and `-Inf` values encode as `null`. Set `'ConvertInfAndNaN'` to `false`
72  to raise an error instead.
73- Pretty printing is disabled by default; enable it with the `'PrettyPrint'` option.
74- Inputs that reside on the GPU are gathered back to host memory automatically.
75
76## jsonencode Options
77| Name | Type | Default | Description |
78| ---- | ---- | ------- | ----------- |
79| `PrettyPrint` | logical | `false` | Emit indented, multi-line JSON output. |
80| `ConvertInfAndNaN` | logical | `true` | Convert `NaN`, `Inf`, `-Inf` to `null`. Set `false` to raise an error when these values are encountered. |
81
82## Examples of using the `jsonencode` function in MATLAB / RunMat
83
84### Converting a MATLAB struct to JSON
85```matlab
86person = struct('name', 'Ada', 'age', 37);
87encoded = jsonencode(person);
88```
89Expected output:
90```matlab
91encoded = '{"age":37,"name":"Ada"}'
92```
93
94### Serialising a matrix with pretty printing
95```matlab
96A = magic(3);
97encoded = jsonencode(A, 'PrettyPrint', true);
98```
99Expected output:
100```matlab
101encoded =
102'[
103    [8,1,6],
104    [3,5,7],
105    [4,9,2]
106]'
107```
108
109### Encoding nested cell arrays
110```matlab
111C = {struct('task','encode','ok',true), {'nested', 42}};
112encoded = jsonencode(C);
113```
114Expected output:
115```matlab
116encoded = '[{"ok":true,"task":"encode"},["nested",42]]'
117```
118
119### Handling NaN and Inf values
120```matlab
121data = [1 NaN Inf];
122encoded = jsonencode(data);
123```
124Expected output:
125```matlab
126encoded = '[1,null,null]'
127```
128
129### Rejecting NaN when ConvertInfAndNaN is false
130```matlab
131try
132    jsonencode(data, 'ConvertInfAndNaN', false);
133catch err
134    disp(err.message);
135end
136```
137Expected output:
138```matlab
139jsonencode: ConvertInfAndNaN must be true to encode NaN or Inf values
140```
141
142### Serialising GPU-resident tensors
143```matlab
144G = gpuArray(eye(2));
145encoded = jsonencode(G);
146```
147Expected output:
148```matlab
149encoded = '[[1,0],[0,1]]'
150```
151
152## `jsonencode` Function GPU Execution Behaviour
153`jsonencode` never launches GPU kernels. When the input contains `gpuArray` data, RunMat gathers
154those values back to host memory via the active acceleration provider and then serialises on the CPU.
155If no provider is registered, the builtin propagates the same gather error used by other residency
156sinks (`gather: no acceleration provider registered`).
157
158## GPU residency in RunMat (Do I need `gpuArray`?)
159For most workflows you do not need to call `gpuArray` explicitly before using `jsonencode`. The
160auto-offload planner and fusion system keep track of residency, so any GPU-backed tensors that flow
161into `jsonencode` are gathered automatically as part of this sink operation. If you prefer to control
162residency manually—or need MATLAB parity—you can still wrap data with `gpuArray` and call `gather`
163explicitly before serialising.
164
165## FAQ
166
167### What MATLAB types does `jsonencode` support?
168Numeric, logical, string, char, struct, cell, and table-like structs are supported. Unsupported types
169such as function handles or opaque objects raise an error.
170
171### Why are my field names sorted alphabetically?
172RunMat sorts struct field names to produce deterministic JSON (matching MATLAB when fields are stored
173as scalar structs).
174
175### How are complex numbers encoded?
176Complex scalars become objects with `real` and `imag` fields. Complex arrays become arrays of those
177objects, mirroring MATLAB.
178
179### Does `jsonencode` return a character array or string?
180It returns a row character array (`char`) for MATLAB compatibility. Use `string(jsonencode(x))` if
181you prefer string scalars.
182
183### Can I pretty-print nested structures?
184Yes. Pass `'PrettyPrint', true` to `jsonencode`. Indentation uses four spaces per nesting level, just
185like MATLAB's pretty-print mode.
186
187### How are empty arrays encoded?
188Empty numeric, logical, char, and string arrays become `[]`. Empty structs become `{}` if scalar, or
189`[]` if they are empty arrays of structs.
190
191### Does `jsonencode` preserve MATLAB column-major ordering?
192Yes. Arrays are emitted in MATLAB's logical row/column order, so reshaping on decode reproduces the
193original layout.
194
195### What happens when ConvertInfAndNaN is false?
196Encountering `NaN`, `Inf`, or `-Inf` raises `jsonencode: ConvertInfAndNaN must be true to encode NaN or Inf values`.
197
198### How do I control the newline style?
199`jsonencode` always emits `\n` (LF) line endings when `PrettyPrint` is enabled, regardless of platform,
200matching MATLAB's behaviour.
201
202### Are Unicode characters escaped?
203Printable Unicode characters are emitted verbatim. Control characters and quotes are escaped using
204standard JSON escape sequences.
205
206## See Also
207[jsondecode](./jsondecode), [gpuArray](../../acceleration/gpu/gpuArray), [gather](../../acceleration/gpu/gather)
208
209## Source & Feedback
210- The full source code for the implementation of the `jsonencode` function is available at: [`crates/runmat-runtime/src/builtins/io/json/jsonencode.rs`](https://github.com/runmat-org/runmat/blob/main/crates/runmat-runtime/src/builtins/io/json/jsonencode.rs)
211- Found a bug or behavioural difference? Please [open an issue](https://github.com/runmat-org/runmat/issues/new/choose) with details and a minimal repro.
212"#;
213
214pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
215    name: "jsonencode",
216    op_kind: GpuOpKind::Custom("serialization"),
217    supported_precisions: &[],
218    broadcast: BroadcastSemantics::None,
219    provider_hooks: &[],
220    constant_strategy: ConstantStrategy::InlineLiteral,
221    residency: ResidencyPolicy::GatherImmediately,
222    nan_mode: ReductionNaN::Include,
223    two_pass_threshold: None,
224    workgroup_size: None,
225    accepts_nan_mode: false,
226    notes:
227        "Serialization sink that gathers GPU data to host memory before emitting UTF-8 JSON text.",
228};
229
230pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
231    name: "jsonencode",
232    shape: ShapeRequirements::Any,
233    constant_strategy: ConstantStrategy::InlineLiteral,
234    elementwise: None,
235    reduction: None,
236    emits_nan: false,
237    notes: "jsonencode is a residency sink and never participates in fusion planning.",
238};
239
240register_builtin_gpu_spec!(GPU_SPEC);
241register_builtin_fusion_spec!(FUSION_SPEC);
242
243#[cfg(feature = "doc_export")]
244register_builtin_doc_text!("jsonencode", DOC_MD);
245
246#[derive(Debug, Clone)]
247struct JsonEncodeOptions {
248    pretty_print: bool,
249    convert_inf_and_nan: bool,
250}
251
252impl Default for JsonEncodeOptions {
253    fn default() -> Self {
254        Self {
255            pretty_print: false,
256            convert_inf_and_nan: true,
257        }
258    }
259}
260
261#[derive(Debug, Clone)]
262enum JsonValue {
263    Null,
264    Bool(bool),
265    Number(JsonNumber),
266    String(String),
267    Array(Vec<JsonValue>),
268    Object(Vec<(String, JsonValue)>),
269}
270
271#[derive(Debug, Clone)]
272enum JsonNumber {
273    Float(f64),
274    I64(i64),
275    U64(u64),
276}
277
278#[runtime_builtin(
279    name = "jsonencode",
280    category = "io/json",
281    summary = "Serialize MATLAB values to UTF-8 JSON text.",
282    keywords = "jsonencode,json,serialization,struct,gpu",
283    accel = "cpu"
284)]
285fn jsonencode_builtin(value: Value, rest: Vec<Value>) -> Result<Value, String> {
286    let host_value = gather_if_needed(&value)?;
287    let gathered_args: Vec<Value> = rest
288        .iter()
289        .map(gather_if_needed)
290        .collect::<Result<_, _>>()?;
291
292    let options = parse_options(&gathered_args)?;
293    let json_value = value_to_json(&host_value, &options)?;
294    let json_string = render_json(&json_value, &options);
295
296    Ok(Value::CharArray(CharArray::new_row(&json_string)))
297}
298
299fn parse_options(args: &[Value]) -> Result<JsonEncodeOptions, String> {
300    let mut options = JsonEncodeOptions::default();
301    if args.is_empty() {
302        return Ok(options);
303    }
304
305    if args.len() == 1 {
306        if let Value::Struct(struct_value) = &args[0] {
307            apply_struct_options(struct_value, &mut options)?;
308            return Ok(options);
309        }
310        return Err("jsonencode: expected name/value pairs or options struct".to_string());
311    }
312
313    if !args.len().is_multiple_of(2) {
314        return Err("jsonencode: name/value pairs must come in pairs".to_string());
315    }
316
317    let mut idx = 0usize;
318    while idx < args.len() {
319        let name = option_name(&args[idx])?;
320        let value = &args[idx + 1];
321        apply_option(&name, value, &mut options)?;
322        idx += 2;
323    }
324
325    Ok(options)
326}
327
328fn apply_struct_options(
329    struct_value: &StructValue,
330    options: &mut JsonEncodeOptions,
331) -> Result<(), String> {
332    for (key, value) in &struct_value.fields {
333        apply_option(key, value, options)?;
334    }
335    Ok(())
336}
337
338fn option_name(value: &Value) -> Result<String, String> {
339    match value {
340        Value::String(s) => Ok(s.clone()),
341        Value::CharArray(ca) if ca.rows == 1 => Ok(ca.data.iter().collect()),
342        Value::StringArray(sa) if sa.data.len() == 1 => Ok(sa.data[0].clone()),
343        _ => Err(OPTION_NAME_ERROR.to_string()),
344    }
345}
346
347fn apply_option(
348    raw_name: &str,
349    value: &Value,
350    options: &mut JsonEncodeOptions,
351) -> Result<(), String> {
352    let lowered = raw_name.to_ascii_lowercase();
353    match lowered.as_str() {
354        "prettyprint" => {
355            options.pretty_print = coerce_bool(value)?;
356            Ok(())
357        }
358        "convertinfandnan" => {
359            options.convert_inf_and_nan = coerce_bool(value)?;
360            Ok(())
361        }
362        other => Err(format!("jsonencode: unknown option '{}'", other)),
363    }
364}
365
366fn coerce_bool(value: &Value) -> Result<bool, String> {
367    match value {
368        Value::Bool(b) => Ok(*b),
369        Value::Int(i) => Ok(i.to_i64() != 0),
370        Value::Num(n) => bool_from_f64(*n),
371        Value::Tensor(t) => {
372            if t.data.len() == 1 {
373                bool_from_f64(t.data[0])
374            } else {
375                Err(OPTION_VALUE_ERROR.to_string())
376            }
377        }
378        Value::LogicalArray(la) => match la.data.len() {
379            1 => Ok(la.data[0] != 0),
380            _ => Err(OPTION_VALUE_ERROR.to_string()),
381        },
382        Value::CharArray(ca) if ca.rows == 1 => {
383            parse_bool_string(&ca.data.iter().collect::<String>())
384        }
385        Value::String(s) => parse_bool_string(s),
386        Value::StringArray(sa) if sa.data.len() == 1 => parse_bool_string(&sa.data[0]),
387        _ => Err(OPTION_VALUE_ERROR.to_string()),
388    }
389}
390
391fn bool_from_f64(value: f64) -> Result<bool, String> {
392    if value.is_finite() {
393        Ok(value != 0.0)
394    } else {
395        Err(OPTION_VALUE_ERROR.to_string())
396    }
397}
398
399fn parse_bool_string(text: &str) -> Result<bool, String> {
400    match text.trim().to_ascii_lowercase().as_str() {
401        "true" | "on" | "yes" | "1" => Ok(true),
402        "false" | "off" | "no" | "0" => Ok(false),
403        _ => Err(OPTION_VALUE_ERROR.to_string()),
404    }
405}
406
407fn value_to_json(value: &Value, options: &JsonEncodeOptions) -> Result<JsonValue, String> {
408    match value {
409        Value::Num(n) => number_to_json(*n, options),
410        Value::Int(i) => Ok(JsonValue::Number(int_to_number(i))),
411        Value::Bool(b) => Ok(JsonValue::Bool(*b)),
412        Value::LogicalArray(logical) => logical_array_to_json(logical, options),
413        Value::Tensor(tensor) => tensor_to_json(tensor, options),
414        Value::Complex(re, im) => complex_scalar_to_json(*re, *im, options),
415        Value::ComplexTensor(ct) => complex_tensor_to_json(ct, options),
416        Value::String(s) => Ok(JsonValue::String(s.clone())),
417        Value::StringArray(sa) => string_array_to_json(sa, options),
418        Value::CharArray(ca) => char_array_to_json(ca, options),
419        Value::Struct(sv) => struct_to_json(sv, options),
420        Value::Cell(ca) => cell_array_to_json(ca, options),
421        Value::Object(obj) => object_to_json(obj, options),
422        Value::GpuTensor(_) => {
423            Err("jsonencode: unexpected gpuArray handle after gather pass".to_string())
424        }
425        Value::HandleObject(_)
426        | Value::Listener(_)
427        | Value::FunctionHandle(_)
428        | Value::Closure(_)
429        | Value::ClassRef(_)
430        | Value::MException(_) => Err(UNSUPPORTED_TYPE_ERROR.to_string()),
431    }
432}
433
434fn int_to_number(value: &IntValue) -> JsonNumber {
435    match value {
436        IntValue::I8(v) => JsonNumber::I64(*v as i64),
437        IntValue::I16(v) => JsonNumber::I64(*v as i64),
438        IntValue::I32(v) => JsonNumber::I64(*v as i64),
439        IntValue::I64(v) => JsonNumber::I64(*v),
440        IntValue::U8(v) => JsonNumber::U64(*v as u64),
441        IntValue::U16(v) => JsonNumber::U64(*v as u64),
442        IntValue::U32(v) => JsonNumber::U64(*v as u64),
443        IntValue::U64(v) => JsonNumber::U64(*v),
444    }
445}
446
447fn number_to_json(value: f64, options: &JsonEncodeOptions) -> Result<JsonValue, String> {
448    if !value.is_finite() {
449        if options.convert_inf_and_nan {
450            return Ok(JsonValue::Null);
451        }
452        return Err(INF_NAN_ERROR.to_string());
453    }
454    Ok(JsonValue::Number(JsonNumber::Float(value)))
455}
456
457fn logical_array_to_json(
458    logical: &LogicalArray,
459    _options: &JsonEncodeOptions,
460) -> Result<JsonValue, String> {
461    let keep_dims = compute_keep_dims(&logical.shape, true);
462    if logical.shape.is_empty() || logical.data.is_empty() {
463        return Ok(JsonValue::Array(Vec::new()));
464    }
465    if keep_dims.is_empty() {
466        let first = logical.data.first().copied().unwrap_or(0) != 0;
467        return Ok(JsonValue::Bool(first));
468    }
469    build_strided_array(&logical.shape, &keep_dims, |offset| {
470        Ok(JsonValue::Bool(logical.data[offset] != 0))
471    })
472}
473
474fn tensor_to_json(tensor: &Tensor, options: &JsonEncodeOptions) -> Result<JsonValue, String> {
475    if tensor.data.is_empty() {
476        return Ok(JsonValue::Array(Vec::new()));
477    }
478    let keep_dims = compute_keep_dims(&tensor.shape, true);
479    if keep_dims.is_empty() {
480        return number_to_json(tensor.data[0], options);
481    }
482    build_strided_array(&tensor.shape, &keep_dims, |offset| {
483        number_to_json(tensor.data[offset], options)
484    })
485}
486
487fn complex_scalar_to_json(
488    real: f64,
489    imag: f64,
490    options: &JsonEncodeOptions,
491) -> Result<JsonValue, String> {
492    let real_json = number_to_json(real, options)?;
493    let imag_json = number_to_json(imag, options)?;
494    Ok(JsonValue::Object(vec![
495        ("real".to_string(), real_json),
496        ("imag".to_string(), imag_json),
497    ]))
498}
499
500fn complex_tensor_to_json(
501    ct: &ComplexTensor,
502    options: &JsonEncodeOptions,
503) -> Result<JsonValue, String> {
504    if ct.data.is_empty() {
505        return Ok(JsonValue::Array(Vec::new()));
506    }
507    let keep_dims = compute_keep_dims(&ct.shape, true);
508    if keep_dims.is_empty() {
509        let (re, im) = ct.data[0];
510        return complex_scalar_to_json(re, im, options);
511    }
512    build_strided_array(&ct.shape, &keep_dims, |offset| {
513        let (re, im) = ct.data[offset];
514        complex_scalar_to_json(re, im, options)
515    })
516}
517
518fn string_array_to_json(
519    sa: &StringArray,
520    _options: &JsonEncodeOptions,
521) -> Result<JsonValue, String> {
522    if sa.data.is_empty() {
523        return Ok(JsonValue::Array(Vec::new()));
524    }
525    let keep_dims = compute_keep_dims(&sa.shape, true);
526    if keep_dims.is_empty() {
527        return Ok(JsonValue::String(sa.data[0].clone()));
528    }
529    build_strided_array(&sa.shape, &keep_dims, |offset| {
530        Ok(JsonValue::String(sa.data[offset].clone()))
531    })
532}
533
534fn char_array_to_json(ca: &CharArray, _options: &JsonEncodeOptions) -> Result<JsonValue, String> {
535    if ca.rows == 0 {
536        return Ok(JsonValue::Array(Vec::new()));
537    }
538
539    if ca.cols == 0 {
540        if ca.rows == 1 {
541            return Ok(JsonValue::String(String::new()));
542        }
543        let mut rows = Vec::with_capacity(ca.rows);
544        for _ in 0..ca.rows {
545            rows.push(JsonValue::String(String::new()));
546        }
547        return Ok(JsonValue::Array(rows));
548    }
549
550    if ca.rows == 1 {
551        return Ok(JsonValue::String(ca.data.iter().collect()));
552    }
553
554    let mut rows = Vec::with_capacity(ca.rows);
555    for r in 0..ca.rows {
556        let mut row_string = String::with_capacity(ca.cols);
557        for c in 0..ca.cols {
558            row_string.push(ca.data[r * ca.cols + c]);
559        }
560        rows.push(JsonValue::String(row_string));
561    }
562    Ok(JsonValue::Array(rows))
563}
564
565fn struct_to_json(sv: &StructValue, options: &JsonEncodeOptions) -> Result<JsonValue, String> {
566    if sv.fields.is_empty() {
567        return Ok(JsonValue::Object(Vec::new()));
568    }
569    let mut map = BTreeMap::new();
570    for (key, value) in &sv.fields {
571        map.insert(key.clone(), value_to_json(value, options)?);
572    }
573    Ok(JsonValue::Object(map.into_iter().collect()))
574}
575
576fn object_to_json(obj: &ObjectInstance, options: &JsonEncodeOptions) -> Result<JsonValue, String> {
577    let mut map = BTreeMap::new();
578    for (key, value) in &obj.properties {
579        map.insert(key.clone(), value_to_json(value, options)?);
580    }
581    Ok(JsonValue::Object(map.into_iter().collect()))
582}
583
584fn cell_array_to_json(ca: &CellArray, options: &JsonEncodeOptions) -> Result<JsonValue, String> {
585    if ca.rows == 0 || ca.cols == 0 {
586        return Ok(JsonValue::Array(Vec::new()));
587    }
588
589    if ca.rows == 1 && ca.cols == 1 {
590        let value = ca.get(0, 0).map_err(|e| format!("jsonencode: {e}"))?;
591        return Ok(JsonValue::Array(vec![value_to_json(&value, options)?]));
592    }
593
594    if ca.rows == 1 {
595        let mut row = Vec::with_capacity(ca.cols);
596        for c in 0..ca.cols {
597            let element = ca.get(0, c).map_err(|e| format!("jsonencode: {e}"))?;
598            row.push(value_to_json(&element, options)?);
599        }
600        return Ok(JsonValue::Array(row));
601    }
602
603    if ca.cols == 1 {
604        let mut column = Vec::with_capacity(ca.rows);
605        for r in 0..ca.rows {
606            let element = ca.get(r, 0).map_err(|e| format!("jsonencode: {e}"))?;
607            column.push(value_to_json(&element, options)?);
608        }
609        return Ok(JsonValue::Array(column));
610    }
611
612    let mut rows = Vec::with_capacity(ca.rows);
613    for r in 0..ca.rows {
614        let mut row = Vec::with_capacity(ca.cols);
615        for c in 0..ca.cols {
616            let element = ca.get(r, c).map_err(|e| format!("jsonencode: {e}"))?;
617            row.push(value_to_json(&element, options)?);
618        }
619        rows.push(JsonValue::Array(row));
620    }
621    Ok(JsonValue::Array(rows))
622}
623
624fn compute_keep_dims(shape: &[usize], drop_singletons: bool) -> Vec<usize> {
625    let mut keep = Vec::new();
626    for (idx, &size) in shape.iter().enumerate() {
627        if size != 1 || !drop_singletons {
628            keep.push(idx);
629        }
630    }
631    keep
632}
633
634fn compute_strides(shape: &[usize]) -> Vec<usize> {
635    let mut strides = Vec::with_capacity(shape.len());
636    let mut acc = 1usize;
637    for &size in shape {
638        strides.push(acc);
639        acc = acc.saturating_mul(size.max(1));
640    }
641    strides
642}
643
644fn build_strided_array<F>(
645    shape: &[usize],
646    keep_dims: &[usize],
647    mut fetch: F,
648) -> Result<JsonValue, String>
649where
650    F: FnMut(usize) -> Result<JsonValue, String>,
651{
652    if keep_dims.is_empty() {
653        return fetch(0);
654    }
655    if keep_dims.iter().any(|&idx| shape[idx] == 0) {
656        return Ok(JsonValue::Array(Vec::new()));
657    }
658    let strides = compute_strides(shape);
659    let dims: Vec<usize> = keep_dims.iter().map(|&idx| shape[idx]).collect();
660    build_nd_array(&dims, |indices| {
661        let mut offset = 0usize;
662        for (value, dim_idx) in indices.iter().zip(keep_dims.iter()) {
663            offset += value * strides[*dim_idx];
664        }
665        fetch(offset)
666    })
667}
668
669fn build_nd_array<F>(dims: &[usize], mut fetch: F) -> Result<JsonValue, String>
670where
671    F: FnMut(&[usize]) -> Result<JsonValue, String>,
672{
673    if dims.is_empty() {
674        return fetch(&[]);
675    }
676    if dims[0] == 0 {
677        return Ok(JsonValue::Array(Vec::new()));
678    }
679    let mut indices = vec![0usize; dims.len()];
680    build_nd_array_recursive(dims, 0, &mut indices, &mut fetch)
681}
682
683fn build_nd_array_recursive<F>(
684    dims: &[usize],
685    level: usize,
686    indices: &mut [usize],
687    fetch: &mut F,
688) -> Result<JsonValue, String>
689where
690    F: FnMut(&[usize]) -> Result<JsonValue, String>,
691{
692    let size = dims[level];
693    if size == 0 {
694        return Ok(JsonValue::Array(Vec::new()));
695    }
696    if level + 1 == dims.len() {
697        let mut items = Vec::with_capacity(size);
698        for i in 0..size {
699            indices[level] = i;
700            items.push(fetch(indices)?);
701        }
702        return Ok(JsonValue::Array(items));
703    }
704    let mut items = Vec::with_capacity(size);
705    for i in 0..size {
706        indices[level] = i;
707        items.push(build_nd_array_recursive(dims, level + 1, indices, fetch)?);
708    }
709    Ok(JsonValue::Array(items))
710}
711
712fn render_json(value: &JsonValue, options: &JsonEncodeOptions) -> String {
713    let mut writer = JsonWriter::new(options.pretty_print);
714    writer.write_value(value);
715    writer.finish()
716}
717
718struct JsonWriter {
719    output: String,
720    pretty: bool,
721    indent: usize,
722}
723
724impl JsonWriter {
725    fn new(pretty: bool) -> Self {
726        Self {
727            output: String::new(),
728            pretty,
729            indent: 0,
730        }
731    }
732
733    fn finish(self) -> String {
734        self.output
735    }
736
737    fn write_value(&mut self, value: &JsonValue) {
738        match value {
739            JsonValue::Null => self.output.push_str("null"),
740            JsonValue::Bool(true) => self.output.push_str("true"),
741            JsonValue::Bool(false) => self.output.push_str("false"),
742            JsonValue::Number(number) => self.write_number(number),
743            JsonValue::String(text) => {
744                self.output.push('"');
745                self.output.push_str(&escape_json_string(text));
746                self.output.push('"');
747            }
748            JsonValue::Array(items) => self.write_array(items),
749            JsonValue::Object(fields) => self.write_object(fields),
750        }
751    }
752
753    fn write_number(&mut self, number: &JsonNumber) {
754        match number {
755            JsonNumber::Float(f) => {
756                if f.is_nan() || !f.is_finite() {
757                    self.output.push_str("null");
758                } else {
759                    self.output.push_str(&format_number(*f));
760                }
761            }
762            JsonNumber::I64(i) => {
763                let _ = write!(self.output, "{i}");
764            }
765            JsonNumber::U64(u) => {
766                let _ = write!(self.output, "{u}");
767            }
768        }
769    }
770
771    fn write_array(&mut self, items: &[JsonValue]) {
772        if items.is_empty() {
773            self.output.push_str("[]");
774            return;
775        }
776        let inline = if self.pretty {
777            items.iter().all(|item| {
778                matches!(
779                    item,
780                    JsonValue::Null
781                        | JsonValue::Bool(_)
782                        | JsonValue::Number(_)
783                        | JsonValue::String(_)
784                )
785            })
786        } else {
787            false
788        };
789        if inline {
790            self.output.push('[');
791            for (index, item) in items.iter().enumerate() {
792                self.write_value(item);
793                if index + 1 < items.len() {
794                    self.output.push(',');
795                }
796            }
797            self.output.push(']');
798            return;
799        }
800        self.output.push('[');
801        if self.pretty {
802            self.output.push('\n');
803            self.indent += 1;
804        }
805        for (index, item) in items.iter().enumerate() {
806            if self.pretty {
807                self.write_indent();
808            }
809            self.write_value(item);
810            if index + 1 < items.len() {
811                if self.pretty {
812                    self.output.push_str(",\n");
813                } else {
814                    self.output.push(',');
815                }
816            }
817        }
818        if self.pretty {
819            self.output.push('\n');
820            if self.indent > 0 {
821                self.indent -= 1;
822            }
823            self.write_indent();
824        }
825        self.output.push(']');
826    }
827
828    fn write_object(&mut self, fields: &[(String, JsonValue)]) {
829        if fields.is_empty() {
830            self.output.push_str("{}");
831            return;
832        }
833        self.output.push('{');
834        if self.pretty {
835            self.output.push('\n');
836            self.indent += 1;
837        }
838        for (index, (key, value)) in fields.iter().enumerate() {
839            if self.pretty {
840                self.write_indent();
841            }
842            self.output.push('"');
843            self.output.push_str(&escape_json_string(key));
844            self.output.push('"');
845            if self.pretty {
846                self.output.push_str(": ");
847            } else {
848                self.output.push(':');
849            }
850            self.write_value(value);
851            if index + 1 < fields.len() {
852                if self.pretty {
853                    self.output.push_str(",\n");
854                } else {
855                    self.output.push(',');
856                }
857            }
858        }
859        if self.pretty {
860            self.output.push('\n');
861            if self.indent > 0 {
862                self.indent -= 1;
863            }
864            self.write_indent();
865        }
866        self.output.push('}');
867    }
868
869    fn write_indent(&mut self) {
870        if self.pretty {
871            for _ in 0..self.indent {
872                self.output.push_str("    ");
873            }
874        }
875    }
876}
877
878fn escape_json_string(value: &str) -> String {
879    let mut escaped = String::with_capacity(value.len());
880    for ch in value.chars() {
881        match ch {
882            '"' => escaped.push_str("\\\""),
883            '\\' => escaped.push_str("\\\\"),
884            '\u{08}' => escaped.push_str("\\b"),
885            '\u{0C}' => escaped.push_str("\\f"),
886            '\n' => escaped.push_str("\\n"),
887            '\r' => escaped.push_str("\\r"),
888            '\t' => escaped.push_str("\\t"),
889            c if (c as u32) < 0x20 => {
890                let _ = write!(escaped, "\\u{:04X}", c as u32);
891            }
892            _ => escaped.push(ch),
893        }
894    }
895    escaped
896}
897
898fn format_number(value: f64) -> String {
899    if value.fract() == 0.0 {
900        // Display integer-like doubles without decimal point
901        format!("{:.0}", value)
902    } else {
903        format!("{}", value)
904    }
905}
906
907#[cfg(test)]
908mod tests {
909    use super::*;
910    use crate::builtins::common::test_support;
911    use runmat_builtins::{
912        CellArray, CharArray, ComplexTensor, LogicalArray, StringArray, StructValue, Tensor,
913    };
914
915    fn as_string(value: Value) -> String {
916        match value {
917            Value::CharArray(ca) => ca.data.iter().collect(),
918            Value::String(s) => s,
919            other => panic!("expected char array, got {:?}", other),
920        }
921    }
922
923    #[test]
924    fn jsonencode_scalar_double() {
925        let encoded = jsonencode_builtin(Value::Num(5.0), Vec::new()).expect("jsonencode");
926        assert_eq!(as_string(encoded), "5");
927    }
928
929    #[test]
930    fn jsonencode_matrix_pretty_print() {
931        let tensor = Tensor::new(vec![1.0, 4.0, 2.0, 5.0, 3.0, 6.0], vec![2, 3]).expect("tensor");
932        let args = vec![Value::from("PrettyPrint"), Value::Bool(true)];
933        let encoded = jsonencode_builtin(Value::Tensor(tensor), args).expect("jsonencode");
934        let expected = "[\n    [1,2,3],\n    [4,5,6]\n]";
935        assert_eq!(as_string(encoded), expected);
936    }
937
938    #[test]
939    fn jsonencode_struct_round_trip() {
940        let mut fields = StructValue::new();
941        fields
942            .fields
943            .insert("name".to_string(), Value::from("RunMat"));
944        fields
945            .fields
946            .insert("year".to_string(), Value::Int(IntValue::I32(2025)));
947        let encoded = jsonencode_builtin(Value::Struct(fields), Vec::new()).expect("jsonencode");
948        assert_eq!(as_string(encoded), "{\"name\":\"RunMat\",\"year\":2025}");
949    }
950
951    #[test]
952    fn jsonencode_struct_options_enable_pretty_print() {
953        let tensor = Tensor::new(vec![1.0, 4.0, 2.0, 5.0], vec![2, 2]).expect("tensor");
954        let mut opts = StructValue::new();
955        opts.fields
956            .insert("PrettyPrint".to_string(), Value::Bool(true));
957        let encoded = jsonencode_builtin(Value::Tensor(tensor), vec![Value::Struct(opts)])
958            .expect("jsonencode");
959        let expected = "[\n    [1,2],\n    [4,5]\n]";
960        assert_eq!(as_string(encoded), expected);
961    }
962
963    #[test]
964    fn jsonencode_options_accept_scalar_tensor_bool() {
965        let tensor_value = Tensor::new(vec![1.0], vec![1, 1]).expect("tensor");
966        let args = vec![Value::from("PrettyPrint"), Value::Tensor(tensor_value)];
967        let encoded = jsonencode_builtin(Value::Num(42.0), args).expect("jsonencode");
968        assert_eq!(as_string(encoded), "42");
969    }
970
971    #[test]
972    fn jsonencode_options_reject_non_scalar_tensor_bool() {
973        let tensor = Tensor::new(vec![1.0, 0.0], vec![1, 2]).expect("tensor");
974        let err = jsonencode_builtin(
975            Value::Num(1.0),
976            vec![Value::from("PrettyPrint"), Value::Tensor(tensor)],
977        )
978        .expect_err("expected failure");
979        assert_eq!(err, OPTION_VALUE_ERROR);
980    }
981
982    #[test]
983    fn jsonencode_options_accept_scalar_logical_array() {
984        let logical = LogicalArray::new(vec![1], vec![1]).expect("logical");
985        let args = vec![Value::from("PrettyPrint"), Value::LogicalArray(logical)];
986        let encoded = jsonencode_builtin(Value::Num(7.0), args).expect("jsonencode");
987        assert_eq!(as_string(encoded), "7");
988    }
989
990    #[test]
991    fn jsonencode_convert_inf_and_nan_controls_null_output() {
992        let tensor = Tensor::new(vec![1.0, f64::NAN], vec![1, 2]).expect("tensor");
993        let encoded =
994            jsonencode_builtin(Value::Tensor(tensor.clone()), Vec::new()).expect("jsonencode");
995        assert_eq!(as_string(encoded), "[1,null]");
996
997        let err = jsonencode_builtin(
998            Value::Tensor(tensor),
999            vec![Value::from("ConvertInfAndNaN"), Value::Bool(false)],
1000        )
1001        .expect_err("expected failure");
1002        assert_eq!(err, INF_NAN_ERROR);
1003    }
1004
1005    #[test]
1006    fn jsonencode_cell_array() {
1007        let elements = vec![Value::from(1.0), Value::from("two")];
1008        let cell = CellArray::new(elements, 1, 2).expect("cell");
1009        let encoded = jsonencode_builtin(Value::Cell(cell), Vec::new()).expect("jsonencode");
1010        assert_eq!(as_string(encoded), "[1,\"two\"]");
1011    }
1012
1013    #[test]
1014    fn jsonencode_char_array_zero_rows_is_empty_array() {
1015        let chars = CharArray::new(Vec::new(), 0, 3).expect("char array");
1016        let encoded = jsonencode_builtin(Value::CharArray(chars), Vec::new()).expect("jsonencode");
1017        assert_eq!(as_string(encoded), "[]");
1018    }
1019
1020    #[test]
1021    fn jsonencode_char_array_empty_strings_per_row() {
1022        let chars = CharArray::new(Vec::new(), 2, 0).expect("char array");
1023        let encoded = jsonencode_builtin(Value::CharArray(chars), Vec::new()).expect("jsonencode");
1024        let encoded_str = as_string(encoded);
1025        assert_eq!(encoded_str, "[\"\",\"\"]");
1026    }
1027
1028    #[test]
1029    fn jsonencode_string_array_matrix() {
1030        let sa = StringArray::new(vec!["alpha".to_string(), "beta".to_string()], vec![2, 1])
1031            .expect("string array");
1032        let encoded = jsonencode_builtin(Value::StringArray(sa), Vec::new()).expect("jsonencode");
1033        assert_eq!(as_string(encoded), "[\"alpha\",\"beta\"]");
1034    }
1035
1036    #[test]
1037    fn jsonencode_complex_tensor_outputs_objects() {
1038        let ct = ComplexTensor::new(vec![(1.0, 2.0), (3.5, -4.0)], vec![2, 1]).expect("complex");
1039        let encoded = jsonencode_builtin(Value::ComplexTensor(ct), Vec::new()).expect("jsonencode");
1040        assert_eq!(
1041            as_string(encoded),
1042            "[{\"real\":1,\"imag\":2},{\"real\":3.5,\"imag\":-4}]"
1043        );
1044    }
1045
1046    #[test]
1047    fn jsonencode_gpu_tensor_gathers_host_data() {
1048        test_support::with_test_provider(|provider| {
1049            let tensor = Tensor::new(vec![1.0, 0.0, 0.0, 1.0], vec![2, 2]).expect("tensor");
1050            let view = runmat_accelerate_api::HostTensorView {
1051                data: &tensor.data,
1052                shape: &tensor.shape,
1053            };
1054            let handle = provider.upload(&view).expect("upload");
1055            let encoded =
1056                jsonencode_builtin(Value::GpuTensor(handle), Vec::new()).expect("jsonencode");
1057            assert_eq!(as_string(encoded), "[[1,0],[0,1]]");
1058        });
1059    }
1060
1061    #[test]
1062    #[cfg(feature = "wgpu")]
1063    fn jsonencode_gpu_tensor_wgpu_gathers_host_data() {
1064        let ensure = runmat_accelerate::backend::wgpu::provider::ensure_wgpu_provider();
1065        let Some(_) = ensure.ok().flatten() else {
1066            // No WGPU device available on this host; skip.
1067            return;
1068        };
1069        let provider = runmat_accelerate_api::provider().expect("wgpu provider");
1070        let tensor = Tensor::new(vec![1.0, 2.0, 3.0], vec![3, 1]).expect("tensor");
1071        let view = runmat_accelerate_api::HostTensorView {
1072            data: &tensor.data,
1073            shape: &tensor.shape,
1074        };
1075        let handle = provider.upload(&view).expect("upload");
1076        let encoded = jsonencode_builtin(Value::GpuTensor(handle), Vec::new()).expect("jsonencode");
1077        assert_eq!(as_string(encoded), "[1,2,3]");
1078    }
1079
1080    #[test]
1081    #[cfg(feature = "doc_export")]
1082    fn doc_examples_present() {
1083        let examples = test_support::doc_examples(DOC_MD);
1084        assert!(!examples.is_empty());
1085    }
1086}