Skip to main content

ferrijs_std/json/
stringify.rs

1// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2// SPDX-License-Identifier: Apache-2.0
3use std::{collections::HashSet, rc::Rc};
4
5use rquickjs::{
6    atom::PredefinedAtom, function::This, qjs, Ctx, Exception, Function, Object, Result, Type,
7    Value,
8};
9
10use crate::json::escape::escape_json_string;
11
12const CIRCULAR_REF_DETECTION_DEPTH: usize = 20;
13
14struct StringifyContext<'a, 'js> {
15    ctx: &'a Ctx<'js>,
16    result: &'a mut String,
17    value: &'a Value<'js>,
18    depth: usize,
19    indentation: Option<&'a str>,
20    key: Option<&'a str>,
21    index: Option<usize>,
22    parent: Option<&'a Object<'js>>,
23    ancestors: &'a mut Vec<(usize, Rc<str>)>,
24    replacer_fn: Option<&'a Function<'js>>,
25    include_keys_replacer: Option<&'a HashSet<String>>,
26    itoa_buffer: &'a mut itoa::Buffer,
27    ryu_buffer: &'a mut ryu::Buffer,
28}
29
30#[allow(dead_code)]
31pub fn json_stringify<'js>(ctx: &Ctx<'js>, value: Value<'js>) -> Result<Option<String>> {
32    json_stringify_replacer_space(ctx, value, None, None)
33}
34
35#[allow(dead_code)]
36pub fn json_stringify_replacer<'js>(
37    ctx: &Ctx<'js>,
38    value: Value<'js>,
39    replacer: Option<Value<'js>>,
40) -> Result<Option<String>> {
41    json_stringify_replacer_space(ctx, value, replacer, None)
42}
43
44pub fn json_stringify_replacer_space<'js>(
45    ctx: &Ctx<'js>,
46    value: Value<'js>,
47    replacer: Option<Value<'js>>,
48    indentation: Option<String>,
49) -> Result<Option<String>> {
50    let mut result = String::with_capacity(128);
51    let mut replacer_fn = None;
52    let mut include_keys_replacer = None;
53
54    let tmp_function;
55
56    let mut itoa_buffer = itoa::Buffer::new();
57    let mut ryu_buffer = ryu::Buffer::new();
58
59    if let Some(replacer) = replacer {
60        if let Some(function) = replacer.as_function() {
61            tmp_function = function.clone();
62            replacer_fn = Some(&tmp_function);
63        } else if let Some(array) = replacer.as_array() {
64            let mut filter = HashSet::with_capacity(array.len());
65            for value in array.clone().into_iter() {
66                let value = value?;
67                if let Some(string) = value.as_string() {
68                    filter.insert(string.to_string()?);
69                } else if let Some(number) = value.as_int() {
70                    filter.insert(itoa_buffer.format(number).to_string());
71                } else if let Some(number) = value.as_float() {
72                    filter.insert(ryu_buffer.format(number).to_string());
73                }
74            }
75            include_keys_replacer = Some(filter);
76        }
77    }
78
79    let indentation = indentation.as_deref();
80    let include_keys_replacer = include_keys_replacer.as_ref();
81
82    let mut ancestors = Vec::with_capacity(10);
83
84    let mut context = StringifyContext {
85        ctx,
86        result: &mut result,
87        value: &value,
88        depth: 0,
89        indentation: None,
90        key: None,
91        index: None,
92        parent: None,
93        ancestors: &mut ancestors,
94        replacer_fn,
95        include_keys_replacer,
96        itoa_buffer: &mut itoa_buffer,
97        ryu_buffer: &mut ryu_buffer,
98    };
99
100    match write_primitive(&mut context, false)? {
101        PrimitiveStatus::Written => {
102            return Ok(Some(result));
103        },
104        PrimitiveStatus::Ignored => {
105            return Ok(None);
106        },
107        _ => {},
108    }
109
110    context.depth += 1;
111    context.indentation = indentation;
112    iterate(&mut context, None)?;
113    Ok(Some(result))
114}
115
116#[inline(always)]
117#[cold]
118fn write_indentation(result: &mut String, indentation: Option<&str>, depth: usize) {
119    if let Some(indentation) = indentation {
120        result.push('\n');
121        result.push_str(&indentation.repeat(depth - 1));
122    }
123}
124
125#[inline(always)]
126#[cold]
127fn run_to_json<'js>(
128    context: &mut StringifyContext<'_, 'js>,
129    js_object: &Object<'js>,
130    to_json: &Function<'js>,
131) -> Result<()> {
132    let val: Value = to_json.call((This(js_object.clone()),))?;
133
134    //only preserve indentation if we're returning nested data
135    let indentation = context.indentation.and_then(|indentation| {
136        matches!(
137            val.type_of(),
138            Type::Object | Type::Array | Type::Exception | Type::Proxy
139        )
140        .then_some(indentation)
141    });
142
143    append_value(
144        &mut StringifyContext {
145            ctx: context.ctx,
146            result: context.result,
147            value: &val,
148            depth: context.depth,
149            indentation,
150            key: None,
151            index: None,
152            parent: Some(js_object),
153            ancestors: context.ancestors,
154            replacer_fn: context.replacer_fn,
155            include_keys_replacer: context.include_keys_replacer,
156            itoa_buffer: context.itoa_buffer,
157            ryu_buffer: context.ryu_buffer,
158        },
159        false,
160    )?;
161    Ok(())
162}
163
164#[derive(PartialEq)]
165enum PrimitiveStatus<'js> {
166    Written,
167    Ignored,
168    Iterate(Option<Value<'js>>),
169}
170
171#[inline(always)]
172#[cold]
173fn run_replacer<'js>(
174    context: &mut StringifyContext<'_, 'js>,
175    replacer_fn: &Function<'js>,
176    add_comma: bool,
177) -> Result<PrimitiveStatus<'js>> {
178    let key = context.key;
179    let index = context.index;
180    let value = context.value;
181    let parent = if let Some(parent) = context.parent {
182        parent.clone()
183    } else {
184        let parent = Object::new(context.ctx.clone())?;
185        parent.set("", value.clone())?;
186        parent
187    };
188    let new_value: Value = replacer_fn.call((
189        This(parent),
190        get_key_or_index(context.itoa_buffer, key, index),
191        value,
192    ))?;
193
194    write_primitive2(context, add_comma, Some(new_value))
195}
196
197fn write_primitive<'js>(
198    context: &mut StringifyContext<'_, 'js>,
199    add_comma: bool,
200) -> Result<PrimitiveStatus<'js>> {
201    if let Some(replacer_fn) = context.replacer_fn {
202        return run_replacer(context, replacer_fn, add_comma);
203    }
204
205    write_primitive2(context, add_comma, None)
206}
207
208fn write_primitive2<'js>(
209    context: &mut StringifyContext<'_, 'js>,
210    add_comma: bool,
211    new_value: Option<Value<'js>>,
212) -> Result<PrimitiveStatus<'js>> {
213    let key = context.key;
214    let include_keys_replacer = context.include_keys_replacer;
215    let indentation = context.indentation;
216    let depth = context.depth;
217
218    let value = new_value.as_ref().unwrap_or(context.value);
219
220    let type_of = value.type_of();
221
222    if context.index.is_none()
223        && matches!(
224            type_of,
225            Type::Symbol | Type::Undefined | Type::Function | Type::Constructor
226        )
227    {
228        return Ok(PrimitiveStatus::Ignored);
229    }
230
231    if matches!(type_of, Type::BigInt) {
232        return Err(Exception::throw_type(
233            context.ctx,
234            "Do not know how to serialize a BigInt",
235        ));
236    }
237
238    // The array form of `replacer` is the spec's PropertyList, and only
239    // SerializeJSONObject consults it: it names OBJECT KEYS. Matching it
240    // against the root, which has no key, dropped the whole document and
241    // returned undefined; matching it against an array element's index
242    // dropped every entry whose index was not itself in the list.
243    if let (Some(include_keys_replacer), Some(key)) = (include_keys_replacer, key) {
244        if !include_keys_replacer.contains(key) {
245            return Ok(PrimitiveStatus::Ignored);
246        }
247    }
248
249    if let Some(indentation) = indentation {
250        write_indented_separator(context.result, key, add_comma, indentation, depth);
251    } else {
252        write_sep(context.result, add_comma, false);
253        if let Some(key) = key {
254            write_key(context.result, key, false);
255        }
256    }
257
258    match type_of {
259        Type::Null | Type::Undefined => context.result.push_str("null"),
260        Type::Bool => {
261            let bool_str = if unsafe { value.as_bool().unwrap_unchecked() } {
262                "true"
263            } else {
264                "false"
265            };
266            context.result.push_str(bool_str);
267        },
268        Type::Int => context.result.push_str(
269            context
270                .itoa_buffer
271                .format(unsafe { value.as_int().unwrap_unchecked() }),
272        ),
273        Type::Float => {
274            let float_value = unsafe { value.as_float().unwrap_unchecked() };
275            const EXP_MASK: u64 = 0x7ff0000000000000;
276            let bits = float_value.to_bits();
277            if bits & EXP_MASK == EXP_MASK {
278                context.result.push_str("null");
279            } else {
280                let str = context.ryu_buffer.format_finite(float_value);
281
282                let bytes = str.as_bytes();
283                let len = bytes.len();
284
285                context.result.push_str(str);
286
287                if &bytes[len - 2..] == b".0" {
288                    let len = context.result.len();
289                    unsafe { context.result.as_mut_vec().set_len(len - 2) }
290                }
291            }
292        },
293        Type::String => {
294            let js_string = unsafe { value.as_string().unwrap_unchecked() }.clone();
295            write_string(context.result, js_string.to_cstring()?.as_str());
296        },
297        _ => return Ok(PrimitiveStatus::Iterate(new_value)),
298    }
299    Ok(PrimitiveStatus::Written)
300}
301
302#[inline(always)]
303#[cold]
304fn write_indented_separator(
305    result: &mut String,
306    key: Option<&str>,
307    add_comma: bool,
308    indentation: &str,
309    depth: usize,
310) {
311    write_sep(result, add_comma, true);
312    result.push_str(&indentation.repeat(depth));
313    if let Some(key) = key {
314        write_key(result, key, true);
315    }
316}
317
318#[cold]
319fn detect_circular_reference(
320    ctx: &Ctx<'_>,
321    value: &Object<'_>,
322    key: Option<&str>,
323    index: Option<usize>,
324    parent: Option<&Object<'_>>,
325    ancestors: &mut Vec<(usize, Rc<str>)>,
326    itoa_buffer: &mut itoa::Buffer,
327) -> Result<()> {
328    let parent_ptr = unsafe { qjs::JS_VALUE_GET_PTR(parent.unwrap_unchecked().as_raw()) as usize };
329    let current_ptr = unsafe { qjs::JS_VALUE_GET_PTR(value.as_raw()) as usize };
330
331    while !ancestors.is_empty()
332        && match ancestors.last() {
333            Some((ptr, _)) => ptr != &parent_ptr,
334            _ => false,
335        }
336    {
337        ancestors.pop();
338    }
339
340    if ancestors.iter().any(|(ptr, _)| ptr == &current_ptr) {
341        let mut iter = ancestors.iter_mut();
342
343        let first = &unsafe { iter.next().unwrap_unchecked() }.1;
344
345        let mut message = iter.rev().take(4).rev().fold(
346            String::from("Circular reference detected at: \".."),
347            |mut acc, (_, key)| {
348                if !key.starts_with('[') {
349                    acc.push('.');
350                }
351                acc.push_str(key);
352                acc
353            },
354        );
355
356        if !first.starts_with('[') {
357            message.push('.');
358        }
359
360        message.push_str(first);
361        message.push('"');
362
363        return Err(Exception::throw_type(ctx, &message));
364    }
365    ancestors.push((
366        current_ptr,
367        key.map(|k| k.into()).unwrap_or_else(|| {
368            ["[", itoa_buffer.format(index.unwrap_or_default()), "]"]
369                .concat()
370                .into()
371        }),
372    ));
373
374    Ok(())
375}
376
377#[inline(always)]
378fn append_value(context: &mut StringifyContext<'_, '_>, add_comma: bool) -> Result<bool> {
379    match write_primitive(context, add_comma)? {
380        PrimitiveStatus::Written => Ok(true),
381        PrimitiveStatus::Ignored => Ok(false),
382        PrimitiveStatus::Iterate(new_value) => {
383            context.depth += 1;
384            iterate(context, new_value)?;
385            Ok(true)
386        },
387    }
388}
389
390#[inline(always)]
391fn write_key(string: &mut String, key: &str, indent: bool) {
392    string.push('"');
393    escape_json_string(string, key.as_bytes());
394    string.push_str("\":");
395    if indent {
396        string.push(' ');
397    }
398}
399
400#[inline(always)]
401fn write_sep(result: &mut String, add_comma: bool, has_indentation: bool) {
402    if add_comma {
403        result.push(',');
404    }
405    if has_indentation {
406        result.push('\n');
407    }
408}
409
410#[inline(always)]
411fn write_string(string: &mut String, value: &str) {
412    string.push('"');
413    escape_json_string(string, value.as_bytes());
414    string.push('"');
415}
416
417#[inline(always)]
418fn get_key_or_index<'a>(
419    itoa_buffer: &'a mut itoa::Buffer,
420    key: Option<&'a str>,
421    index: Option<usize>,
422) -> &'a str {
423    key.unwrap_or_else(|| itoa_buffer.format(index.unwrap_or_default()))
424}
425
426fn iterate<'js>(
427    context: &mut StringifyContext<'_, 'js>,
428    new_value: Option<Value<'js>>,
429) -> Result<()> {
430    let mut add_comma;
431    let mut value_written;
432    let elem = new_value.as_ref().unwrap_or(context.value);
433    let depth = context.depth;
434    let ctx = context.ctx;
435    let indentation = context.indentation;
436    match elem.type_of() {
437        Type::Object | Type::Exception | Type::Proxy => {
438            let js_object = unsafe { elem.as_object().unwrap_unchecked() };
439            // `toJSON` is honoured only when it is CALLABLE. The spec's
440            // SerializeJSONProperty guards on IsCallable and serialises
441            // the object normally otherwise; taking mere presence as the
442            // signal and then converting to a `Function` turned
443            // `{toJSON: "x"}` into a TypeError instead of
444            // `{"toJSON":"x"}`. One `get` also replaces a `contains_key`
445            // followed by a second lookup.
446            let to_json: Value<'js> = js_object.get(PredefinedAtom::ToJSON)?;
447            if let Some(to_json) = to_json.as_function() {
448                return run_to_json(context, js_object, to_json);
449            }
450
451            //only start detect circular reference at this level
452            if depth > CIRCULAR_REF_DETECTION_DEPTH {
453                detect_circular_reference(
454                    ctx,
455                    js_object,
456                    context.key,
457                    context.index,
458                    context.parent,
459                    context.ancestors,
460                    context.itoa_buffer,
461                )?;
462            }
463
464            context.result.push('{');
465
466            value_written = false;
467
468            // Collect keys: js_object.keys() uses JS_GetOwnPropertyNames which can fail for
469            // Proxy objects. Fall back to Object.keys() in that case.
470            let keys: Vec<String> = {
471                let collected: Vec<String> = js_object.keys::<String>().flatten().collect();
472                if collected.is_empty() {
473                    // Clear any pending exception and try Object.keys() for Proxy support
474                    ctx.catch();
475                    ctx.globals()
476                        .get::<_, Object>("Object")
477                        .ok()
478                        .and_then(|o| o.get::<_, Function>("keys").ok())
479                        .and_then(|f| f.call::<_, Vec<String>>((js_object.clone(),)).ok())
480                        .unwrap_or_default()
481                } else {
482                    collected
483                }
484            };
485
486            for key in keys {
487                let val = js_object.get(&key)?;
488
489                add_comma = append_value(
490                    &mut StringifyContext {
491                        ctx,
492                        result: context.result,
493                        value: &val,
494                        depth,
495                        key: Some(&key),
496                        indentation,
497                        index: None,
498                        parent: Some(js_object),
499                        ancestors: context.ancestors,
500                        replacer_fn: context.replacer_fn,
501                        include_keys_replacer: context.include_keys_replacer,
502                        itoa_buffer: context.itoa_buffer,
503                        ryu_buffer: context.ryu_buffer,
504                    },
505                    value_written,
506                )?;
507                value_written = value_written || add_comma;
508            }
509
510            if value_written {
511                write_indentation(context.result, indentation, depth);
512            }
513            context.result.push('}');
514        },
515        Type::Array => {
516            context.result.push('[');
517            add_comma = false;
518            value_written = false;
519            let js_array = unsafe { elem.as_array().unwrap_unchecked() };
520            //only start detect circular reference at this level
521            if depth > CIRCULAR_REF_DETECTION_DEPTH {
522                detect_circular_reference(
523                    ctx,
524                    js_array.as_object(),
525                    context.key,
526                    context.index,
527                    context.parent,
528                    context.ancestors,
529                    context.itoa_buffer,
530                )?;
531            }
532            for (i, val) in js_array.iter::<Value>().enumerate() {
533                let val = val?;
534                add_comma = append_value(
535                    &mut StringifyContext {
536                        ctx,
537                        result: context.result,
538                        value: &val,
539                        depth,
540                        key: None,
541                        indentation,
542                        index: Some(i),
543                        parent: Some(js_array),
544                        ancestors: context.ancestors,
545                        replacer_fn: context.replacer_fn,
546                        include_keys_replacer: context.include_keys_replacer,
547                        itoa_buffer: context.itoa_buffer,
548                        ryu_buffer: context.ryu_buffer,
549                    },
550                    add_comma,
551                )?;
552                value_written = value_written || add_comma;
553            }
554            if value_written {
555                write_indentation(context.result, indentation, depth);
556            }
557            context.result.push(']');
558        },
559        _ => {},
560    }
561    Ok(())
562}