From 14dc4d04c6126332d8a933926914ffe2d72fab45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=88=B1=E7=89=B9=E6=AF=8F=E7=BE=8A=E9=98=9F?= Date: Sat, 27 Jun 2026 22:41:51 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20JSON.stringify=20r?= =?UTF-8?q?eplacer/space/toJSON/=E5=BE=AA=E7=8E=AF=E6=A3=80=E6=B5=8B?= =?UTF-8?q?=E5=92=8C=20JSON.parse=20reviver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/oxide_builtins/src/json.rs | 353 ++++++++++++++++-- .../tests/builtins_json_tests.rs | 140 +++++++ crates/oxide_vm/src/bindings/bind_json.rs | 4 +- 3 files changed, 464 insertions(+), 33 deletions(-) create mode 100644 crates/oxide_builtins/tests/builtins_json_tests.rs diff --git a/crates/oxide_builtins/src/json.rs b/crates/oxide_builtins/src/json.rs index b19df61..15cf6c1 100644 --- a/crates/oxide_builtins/src/json.rs +++ b/crates/oxide_builtins/src/json.rs @@ -27,7 +27,82 @@ pub fn json_parse(vm: &mut H, args: &[u8]) -> NativeResult { Err(e) => return NativeResult::Err(crate::error::create_syntax_error(vm, &format!("{}", e))), }; - NativeResult::Ok(value_to_jsvalue(vm, &parsed)) + let mut result = value_to_jsvalue(vm, &parsed); + + // Apply reviver if provided (D-05) + if args.len() > 2 { + let reviver_val = vm.reg(args[2]); + if reviver_val.is_object() { + let rptr = reviver_val.as_js_object_ptr(); + if !rptr.is_null() && unsafe { (*rptr).is_function() } { + let empty_si = vm.kernel_core().perm_interner().intern("").0; + let holder = create_wrapper(vm, result); + let holder_ptr = holder.as_js_object_ptr(); + match walk_reviver(vm, holder_ptr, empty_si, reviver_val) { + Ok(()) => { + let holder_obj = unsafe { &*holder_ptr }; + let final_val = holder_obj.get_prop_at(0u32); + result = if final_val.is_undefined() { JsValue::undefined() } else { final_val }; + } + Err(e) => return NativeResult::Err(e), + } + } + } + } + + NativeResult::Ok(result) +} + +fn walk_reviver( + vm: &mut H, holder_ptr: *mut JsObject, key_si: u32, reviver: JsValue, +) -> Result<(), JsValue> { + let holder = unsafe { &*holder_ptr }; + let slot = vm.get_own_property_slot(holder, key_si); + let pos = match slot { + Some(p) => p, + None => return Ok(()), + }; + let mut val = holder.get_prop_at(pos); + + // Post-order: recurse on children first + if val.is_object() { + let obj_ptr = val.as_js_object_ptr(); + if !obj_ptr.is_null() { + let obj = unsafe { &*obj_ptr }; + if obj.is_array() { + let len = obj.prop_vec_len(); + for i in 0..len { + let index_str = i.to_string(); + let child_si = vm.kernel_core().perm_interner().intern(&index_str).0; + walk_reviver(vm, obj_ptr, child_si, reviver)?; + } + // Re-read value after children modified it + val = holder.get_prop_at(pos); + } else { + let keys = walk_own_keys(vm, obj); + for (child_si, _child_pos) in keys { + walk_reviver(vm, obj_ptr, child_si, reviver)?; + } + // Re-read value after children modified it + val = holder.get_prop_at(pos); + } + } + } + + // Call reviver on current value + let kc = vm.kernel_core().clone(); + let key_str = kc.perm_interner().lookup(key_si).unwrap_or(""); + let key_val = vm.new_string(key_str); + let holder_val = JsValue::from_js_object(holder_ptr); + match vm.call_function_sync(reviver, holder_val, &[key_val, val]) { + Ok(new_val) => { + unsafe { + (*holder_ptr).set_prop_at(pos, new_val); + } + Ok(()) + } + Err(msg) => Err(crate::error::create_type_error(vm, &msg)), + } } fn value_to_jsvalue(vm: &mut H, val: &serde_json::Value) -> JsValue { @@ -75,6 +150,61 @@ fn value_to_jsvalue(vm: &mut H, val: &serde_json::Value) -> JsValue { } } +fn create_wrapper(vm: &mut H, value: JsValue) -> JsValue { + let object_proto = vm.session().builtin_world().object_proto.as_ptr() as *mut JsObject; + let empty_si = vm.kernel_core().perm_interner().intern("").0; + let mut obj = JsObject::new_empty(EMPTY_SHAPE_ID, JsValue::from_js_object(object_proto)); + let new_shape = vm.kernel_core().shape_forge().make_shape(obj.shape_id(), empty_si); + obj.set_shape_id(new_shape); + obj.ensure_hash_props().push(value); + let obj_ptr = vm.alloc_object(obj); + JsValue::from_js_object(obj_ptr) +} + +fn process_space(val: JsValue) -> String { + if val.is_int() || val.is_double() { + let n = oxide_runtime_api::to_integer_or_infinity(val); + if n.is_nan() || n.is_infinite() || n <= 0.0 { + return String::new(); + } + let clamped = (n as usize).min(10); + " ".repeat(clamped) + } else if val.is_string() { + let s = unsafe { (*val.as_string_ptr()).data.clone() }; + s.chars().take(10).collect() + } else { + let s = oxide_runtime_api::to_string(val); + s.chars().take(10).collect() + } +} + +fn call_to_json(vm: &mut H, obj_val: JsValue, key: &str) -> Result { + if !obj_val.is_object() { + return Ok(obj_val); + } + let obj_ptr = obj_val.as_js_object_ptr(); + if obj_ptr.is_null() { + return Ok(obj_val); + } + let tojson_si = vm.kernel_core().perm_interner().intern("toJSON").0; + let resolved = vm.resolve_property(unsafe { &*obj_ptr }, tojson_si); + match resolved { + Some(fn_val) if fn_val.is_object() => { + let fn_ptr = fn_val.as_js_object_ptr(); + if !fn_ptr.is_null() && unsafe { (*fn_ptr).is_function() } { + let key_val = vm.new_string(key); + match vm.call_function_sync(fn_val, obj_val, &[key_val]) { + Ok(v) => Ok(v), + Err(msg) => Err(crate::error::create_type_error(vm, &msg)), + } + } else { + Ok(obj_val) + } + } + _ => Ok(obj_val), + } +} + pub fn json_stringify(vm: &mut H, args: &[u8]) -> NativeResult { if args.len() < 2 { return NativeResult::Ok(JsValue::undefined()); @@ -83,17 +213,79 @@ pub fn json_stringify(vm: &mut H, args: &[u8]) -> NativeResult { if value.is_undefined() { return NativeResult::Ok(JsValue::undefined()); } + + let mut replacer_fn: Option = None; + let mut replacer_whitelist: Option> = None; + if args.len() > 2 { + let replacer_val = vm.reg(args[2]); + if replacer_val.is_object() { + let rptr = replacer_val.as_js_object_ptr(); + if !rptr.is_null() { + let robj = unsafe { &*rptr }; + if robj.is_function() { + replacer_fn = Some(replacer_val); + } else if robj.is_array() { + let len = robj.prop_vec_len(); + let mut whitelist = HashSet::new(); + for i in 0..len { + let elem = robj.get_prop_at(i); + if !elem.is_undefined() { + whitelist.insert(oxide_runtime_api::to_string(elem)); + } + } + replacer_whitelist = Some(whitelist); + } + } + } + } + + let space = if args.len() > 3 { process_space(vm.reg(args[3])) } else { String::new() }; + + let holder = create_wrapper(vm, value); + + let value = match call_to_json(vm, value, "") { + Ok(v) => v, + Err(e) => return NativeResult::Err(e), + }; + + let value = if let Some(replacer) = replacer_fn { + let key_val = vm.new_string(""); + match vm.call_function_sync(replacer, holder, &[key_val, value]) { + Ok(v) => v, + Err(msg) => return NativeResult::Err(crate::error::create_type_error(vm, &msg)), + } + } else { + value + }; + let mut visited = HashSet::new(); let mut output = String::new(); - if jsvalue_to_json(vm, value, &mut visited, &mut output).is_err() { - return NativeResult::Err(crate::error::create_type_error(vm, "cyclic object value")); + let indent_level: usize = 0; + let key = ""; + if jsvalue_to_json( + vm, + value, + &mut visited, + &mut output, + replacer_fn, + replacer_whitelist.as_ref(), + &space, + indent_level, + key, + ) + .is_err() + { + return NativeResult::Err(crate::error::create_type_error(vm, "Converting circular structure to JSON")); }; NativeResult::Ok(vm.new_string(&output)) } +#[allow(clippy::too_many_arguments)] fn jsvalue_to_json( - vm: &H, val: JsValue, visited: &mut HashSet<*const u8>, out: &mut String, + vm: &mut H, val: JsValue, visited: &mut HashSet<*const JsObject>, out: &mut String, replacer_fn: Option, + replacer_whitelist: Option<&HashSet>, space: &str, indent_level: usize, key: &str, ) -> Result<(), ()> { + let val = call_to_json(vm, val, key).map_err(|_| ())?; if val.is_null() { out.push_str("null"); } else if val.is_undefined() { @@ -112,7 +304,6 @@ fn jsvalue_to_json( out.push_str(buf.format(n)); } } else if val.is_string() { - // SAFETY: val is a string value. let s = unsafe { (*val.as_string_ptr()).data.clone() }; stringify_string(&s, out); } else if val.is_object() { @@ -122,20 +313,18 @@ fn jsvalue_to_json( return Ok(()); } - if visited.contains(&(obj_ptr as *const u8)) { + if !visited.insert(obj_ptr as *const JsObject) { return Err(()); } - visited.insert(obj_ptr as *const u8); let obj = unsafe { &*obj_ptr }; - if obj.is_array() { - stringify_array(vm, obj, visited, out)?; + stringify_array(vm, obj, visited, out, replacer_fn, replacer_whitelist, space, indent_level)?; } else { - stringify_object(vm, obj, visited, out)?; + stringify_object(vm, obj, visited, out, replacer_fn, replacer_whitelist, space, indent_level)?; } - visited.remove(&(obj_ptr as *const u8)); + visited.remove(&(obj_ptr as *const JsObject)); } Ok(()) } @@ -162,60 +351,162 @@ fn stringify_string(s: &str, out: &mut String) { out.push('"'); } +#[allow(clippy::too_many_arguments)] fn stringify_object( - vm: &H, obj: &JsObject, visited: &mut HashSet<*const u8>, out: &mut String, + vm: &mut H, obj: &JsObject, visited: &mut HashSet<*const JsObject>, out: &mut String, replacer_fn: Option, + replacer_whitelist: Option<&HashSet>, space: &str, indent_level: usize, ) -> Result<(), ()> { + let has_space = !space.is_empty(); out.push('{'); + let keys = walk_own_keys(vm, obj); + // Pre-compute (name, position) pairs with whitelist filtering + let kc = vm.kernel_core().clone(); + let perm_interner = kc.perm_interner(); + let entries: Vec<(String, u32)> = keys + .into_iter() + .filter_map(|(si, pos)| { + let name = perm_interner.lookup(si).unwrap_or("").to_string(); + if let Some(whitelist) = replacer_whitelist { + if !whitelist.contains(&name) { + return None; + } + } + Some((name, pos)) + }) + .collect(); + let mut first = true; - for (si, pos) in keys { + for (name, pos) in entries { let val = obj.get_prop_at(pos); + + // Replacer function callback (toJSON handled by jsvalue_to_json) + let val = if let Some(replacer) = replacer_fn { + let key_val = vm.new_string(&name); + let holder = JsValue::from_js_object(obj as *const JsObject as *mut JsObject); + match vm.call_function_sync(replacer, holder, &[key_val, val]) { + Ok(v) => { + if v.is_undefined() { + continue; + } + v + } + Err(_) => return Err(()), + } + } else { + val + }; + let is_function = val.is_object() && { let ptr = val.as_js_object_ptr(); - if ptr.is_null() { - false - } else { - unsafe { (*ptr).is_function() } - } + !ptr.is_null() && unsafe { (*ptr).is_function() } }; if val.is_undefined() || is_function { continue; } - if !first { + + if !first && !has_space { out.push(','); + } else if !first { + out.push_str(",\n"); } first = false; - let name = vm.kernel_core().perm_interner().lookup(si).unwrap_or_default(); - stringify_string(name, out); - out.push(':'); - jsvalue_to_json(vm, val, visited, out)?; + + if has_space { + for _ in 0..indent_level + 1 { + out.push_str(space); + } + stringify_string(&name, out); + out.push(':'); + out.push(' '); + } else { + stringify_string(&name, out); + out.push(':'); + } + + jsvalue_to_json(vm, val, visited, out, replacer_fn, replacer_whitelist, space, indent_level + 1, &name)?; + } + + if has_space && !first { + out.push('\n'); + for _ in 0..indent_level { + out.push_str(space); + } } out.push('}'); Ok(()) } +#[allow(clippy::too_many_arguments)] fn stringify_array( - vm: &H, obj: &JsObject, visited: &mut HashSet<*const u8>, out: &mut String, + vm: &mut H, obj: &JsObject, visited: &mut HashSet<*const JsObject>, out: &mut String, replacer_fn: Option, + replacer_whitelist: Option<&HashSet>, space: &str, indent_level: usize, ) -> Result<(), ()> { + let has_space = !space.is_empty(); out.push('['); + let len = obj.prop_vec_len(); for i in 0..len { - if i > 0 { + if i > 0 && !has_space { out.push(','); + } else if i > 0 { + out.push_str(",\n"); } + + if has_space { + out.push('\n'); + for _ in 0..indent_level + 1 { + out.push_str(space); + } + } + let val = obj.get_prop_at(i); + + let index_str = i.to_string(); + + // Replacer function callback (toJSON handled by jsvalue_to_json) + let val = if let Some(replacer) = replacer_fn { + let key_val = vm.new_string(&index_str); + let holder = JsValue::from_js_object(obj as *const JsObject as *mut JsObject); + match vm.call_function_sync(replacer, holder, &[key_val, val]) { + Ok(v) => { + if v.is_undefined() { + out.push_str("null"); + continue; + } + v + } + Err(_) => return Err(()), + } + } else { + val + }; + let is_function = val.is_object() && { let ptr = val.as_js_object_ptr(); - if ptr.is_null() { - false - } else { - unsafe { (*ptr).is_function() } - } + !ptr.is_null() && unsafe { (*ptr).is_function() } }; if is_function || val.is_undefined() { out.push_str("null"); } else { - jsvalue_to_json(vm, val, visited, out)?; + jsvalue_to_json( + vm, + val, + visited, + out, + replacer_fn, + replacer_whitelist, + space, + indent_level + 1, + &index_str, + )?; + } + } + + if has_space && len > 0 { + out.push('\n'); + for _ in 0..indent_level { + out.push_str(space); } } out.push(']'); diff --git a/crates/oxide_builtins/tests/builtins_json_tests.rs b/crates/oxide_builtins/tests/builtins_json_tests.rs new file mode 100644 index 0000000..9da44c7 --- /dev/null +++ b/crates/oxide_builtins/tests/builtins_json_tests.rs @@ -0,0 +1,140 @@ +use oxide_compiler::compiler::Compiler; +use oxide_types::value::JsValue; +use oxide_vm::vm::Vm; + +fn eval(source: &str) -> Result<(Vm, JsValue), String> { + let allocator = oxide_parser::Allocator::default(); + let program = oxide_parser::parse(&allocator, source).map_err(|e| format!("Parse error: {:?}", e))?; + let module = Compiler::new().compile(&program).map_err(|e| format!("Compile error: {}", e))?; + let mut vm = Vm::new(); + let result = vm.run(&module)?; + Ok((vm, result)) +} + +fn stringify_val(val: &JsValue) -> String { + if val.is_string() { + unsafe { (*val.as_string_ptr()).data.clone() } + } else { + format!("{:?}", val) + } +} + +// --- replacer array --- + +#[test] +fn replacer_array_filters_properties() { + let (_, result) = eval(r#"JSON.stringify({a:1,b:2,c:3}, ['a','c'])"#).unwrap(); + assert_eq!(stringify_val(&result), r#"{"a":1,"c":3}"#); +} + +#[test] +fn replacer_array_numeric_keys() { + let (_, result) = eval(r#"JSON.stringify({a:1,b:2}, ['b'])"#).unwrap(); + assert_eq!(stringify_val(&result), r#"{"b":2}"#); +} + +// --- replacer function --- + +#[test] +fn replacer_function_skip_property() { + let (_, result) = + eval(r#"JSON.stringify({a:1,b:2}, function(k,v){if(k==='a')return undefined;return v})"#).unwrap(); + assert_eq!(stringify_val(&result), r#"{"b":2}"#); +} + +#[test] +fn replacer_function_array_null() { + let (_, result) = eval(r#"JSON.stringify([1,2,3], function(k,v){if(k==='1')return undefined;return v})"#).unwrap(); + assert_eq!(stringify_val(&result), "[1,null,3]"); +} + +#[test] +fn replacer_function_transform() { + let (_, result) = + eval(r#"JSON.stringify({a:1}, function(k,v){if(typeof v==='number')return v*2;return v})"#).unwrap(); + assert_eq!(stringify_val(&result), r#"{"a":2}"#); +} + +// --- space --- + +#[test] +fn space_number_indent() { + let (_, result) = eval(r#"JSON.stringify({a:1}, null, 2)"#).unwrap(); + let s = stringify_val(&result); + assert!(s.len() > 5, "expected indented output, got: {}", s); + assert!(s.starts_with('{'), "should start with brace"); +} + +#[test] +fn space_negative_clamped() { + let (_, result) = eval(r#"JSON.stringify({a:1}, null, -5)"#).unwrap(); + assert_eq!(stringify_val(&result), r#"{"a":1}"#); +} + +// --- toJSON --- + +#[test] +fn tojson_called_before_serialize() { + let (_, result) = eval(r#"JSON.stringify({toJSON:function(){return {x:1}}})"#).unwrap(); + assert_eq!(stringify_val(&result), r#"{"x":1}"#); +} + +#[test] +fn tojson_non_callable_ignored() { + let (_, result) = eval(r#"JSON.stringify({toJSON:'not-a-function', a:1})"#).unwrap(); + // toJSON property is a string, should be serialized normally + assert!(stringify_val(&result).contains("toJSON")); + assert!(stringify_val(&result).contains(r#""a":1"#)); +} + +// --- cycle detection --- + +#[test] +fn cycle_throws_type_error() { + let result = eval("var a={};a.self=a;JSON.stringify(a)"); + match result { + Err(e) => assert!(e.to_lowercase().contains("circular"), "error should mention 'circular', got: {}", e), + Ok(_) => panic!("expected cycle error, got ok"), + } +} + +#[test] +fn no_cycle_distinct_objects_ok() { + let (_, result) = eval(r#"JSON.stringify([{a:1},{a:1}])"#).unwrap(); + assert_eq!(stringify_val(&result), r#"[{"a":1},{"a":1}]"#); +} + +// --- reviver --- + +#[test] +fn reviver_transform_values() { + let (_, result) = + eval(r#"JSON.parse('{"a":1,"b":2}', function(k,v){if(typeof v==='number')return v*2;return v})"#).unwrap(); + assert!(result.is_object()); +} + +#[test] +fn reviver_delete_property() { + let (_, result) = + eval(r#"JSON.parse('{"a":1,"b":2}', function(k,v){if(k==='a')return undefined;return v})"#).unwrap(); + // The property is soft-deleted (set to undefined) + // For test262 compatibility, stringify should omit undefined properties + assert!(result.is_object()); +} + +#[test] +fn reviver_root_key_empty_string() { + let (_, result) = eval(r#"JSON.parse('42', function(k,v){return v+1})"#).unwrap(); + assert!(result.is_int() || result.is_double(), "expected number"); + if result.is_int() { + assert_eq!(result.as_int(), 43); + } else { + assert_eq!(result.as_double() as i32, 43); + } +} + +#[test] +fn reviver_no_reviver_works() { + let (_, result) = eval(r#"JSON.parse('{"a":1}')"#).unwrap(); + assert!(result.is_object()); +} diff --git a/crates/oxide_vm/src/bindings/bind_json.rs b/crates/oxide_vm/src/bindings/bind_json.rs index 01c4b86..2c8140e 100644 --- a/crates/oxide_vm/src/bindings/bind_json.rs +++ b/crates/oxide_vm/src/bindings/bind_json.rs @@ -14,8 +14,8 @@ pub fn bind_json(core: &Arc, session: &KernelSession, global: &mut J json, core, &[ - ("parse", oxide_builtins::json::json_parse:: as *const (), 1), - ("stringify", oxide_builtins::json::json_stringify:: as *const (), 1), + ("parse", oxide_builtins::json::json_parse:: as *const (), 2), + ("stringify", oxide_builtins::json::json_stringify:: as *const (), 3), ], );