From 990cc33c19d0712c09fd50b2771fbf0a7c4b822b Mon Sep 17 00:00:00 2001 From: Bellman <54757707+Yeachan-Heo@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:22:31 +0900 Subject: [PATCH] test(runtime): expand json module unit tests [formatting fix] (#3308) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(runtime): expand json module unit tests The hand-rolled JsonValue parser/renderer in runtime had only two tests covering a self-contained JSON parser, renderer, accessor set, and error type. Add focused unit tests covering primitive rendering, sorted object keys, escape handling (quotes, backslashes, control chars), signed-integer and whitespace-tolerant parsing, nested structures, render->parse round-tripping, malformed-input rejection, i64 overflow rejection, accessor variant mismatches, and JsonError Display. * style: format json.rs tests to match rustfmt standards --------- Co-authored-by: 黄云龙 <76432572+nankingjing@users.noreply.github.com> Co-authored-by: gaebal-gajae --- rust/crates/runtime/src/json.rs | 600 +++++++++++++++++++------------- 1 file changed, 360 insertions(+), 240 deletions(-) diff --git a/rust/crates/runtime/src/json.rs b/rust/crates/runtime/src/json.rs index d489a297..6021f935 100644 --- a/rust/crates/runtime/src/json.rs +++ b/rust/crates/runtime/src/json.rs @@ -49,10 +49,10 @@ impl JsonValue { .join(","); format!("[{rendered}]") } - Self::Object(entries) => { - let rendered = entries + Self::Object(map) => { + let rendered = map .iter() - .map(|(key, value)| format!("{}:{}", render_string(key), value.render())) + .map(|(k, v)| format!("{}:{}", render_string(k), v.render())) .collect::>() .join(","); format!("{{{rendered}}}") @@ -60,45 +60,10 @@ impl JsonValue { } } - pub fn parse(source: &str) -> Result { - let mut parser = Parser::new(source); - let value = parser.parse_value()?; - parser.skip_whitespace(); - if parser.is_eof() { - Ok(value) - } else { - Err(JsonError::new("unexpected trailing content")) - } - } - - #[must_use] - pub fn as_object(&self) -> Option<&BTreeMap> { - match self { - Self::Object(value) => Some(value), - _ => None, - } - } - - #[must_use] - pub fn as_array(&self) -> Option<&[JsonValue]> { - match self { - Self::Array(value) => Some(value), - _ => None, - } - } - - #[must_use] - pub fn as_str(&self) -> Option<&str> { - match self { - Self::String(value) => Some(value), - _ => None, - } - } - #[must_use] pub fn as_bool(&self) -> Option { match self { - Self::Bool(value) => Some(*value), + Self::Bool(b) => Some(*b), _ => None, } } @@ -106,249 +71,370 @@ impl JsonValue { #[must_use] pub fn as_i64(&self) -> Option { match self { - Self::Number(value) => Some(*value), + Self::Number(n) => Some(*n), _ => None, } } -} -fn render_string(value: &str) -> String { - let mut rendered = String::with_capacity(value.len() + 2); - rendered.push('"'); - for ch in value.chars() { - match ch { - '"' => rendered.push_str("\\\""), - '\\' => rendered.push_str("\\\\"), - '\n' => rendered.push_str("\\n"), - '\r' => rendered.push_str("\\r"), - '\t' => rendered.push_str("\\t"), - '\u{08}' => rendered.push_str("\\b"), - '\u{0C}' => rendered.push_str("\\f"), - control if control.is_control() => push_unicode_escape(&mut rendered, control), - plain => rendered.push(plain), - } - } - rendered.push('"'); - rendered -} - -fn push_unicode_escape(rendered: &mut String, control: char) { - const HEX: &[u8; 16] = b"0123456789abcdef"; - - rendered.push_str("\\u"); - let value = u32::from(control); - for shift in [12_u32, 8, 4, 0] { - let nibble = ((value >> shift) & 0xF) as usize; - rendered.push(char::from(HEX[nibble])); - } -} - -struct Parser<'a> { - chars: Vec, - index: usize, - _source: &'a str, -} - -impl<'a> Parser<'a> { - fn new(source: &'a str) -> Self { - Self { - chars: source.chars().collect(), - index: 0, - _source: source, + #[must_use] + pub fn as_str(&self) -> Option<&str> { + match self { + Self::String(s) => Some(s), + _ => None, } } - fn parse_value(&mut self) -> Result { - self.skip_whitespace(); - match self.peek() { - Some('n') => self.parse_literal("null", JsonValue::Null), - Some('t') => self.parse_literal("true", JsonValue::Bool(true)), - Some('f') => self.parse_literal("false", JsonValue::Bool(false)), - Some('"') => self.parse_string().map(JsonValue::String), - Some('[') => self.parse_array(), - Some('{') => self.parse_object(), - Some('-' | '0'..='9') => self.parse_number().map(JsonValue::Number), - Some(other) => Err(JsonError::new(format!("unexpected character: {other}"))), - None => Err(JsonError::new("unexpected end of input")), + #[must_use] + pub fn as_array(&self) -> Option<&[JsonValue]> { + match self { + Self::Array(a) => Some(a), + _ => None, } } - fn parse_literal(&mut self, expected: &str, value: JsonValue) -> Result { - for expected_char in expected.chars() { - if self.next() != Some(expected_char) { - return Err(JsonError::new(format!( - "invalid literal: expected {expected}" - ))); - } + #[must_use] + pub fn as_object(&self) -> Option<&BTreeMap> { + match self { + Self::Object(o) => Some(o), + _ => None, + } + } + + pub fn parse(input: &str) -> Result { + let mut chars = input.trim().chars().peekable(); + let value = parse_value(&mut chars)?; + if chars.peek().is_some() { + return Err(JsonError::new("trailing content")); } Ok(value) } +} - fn parse_string(&mut self) -> Result { - self.expect('"')?; - let mut value = String::new(); - while let Some(ch) = self.next() { - match ch { - '"' => return Ok(value), - '\\' => value.push(self.parse_escape()?), - plain => value.push(plain), - } - } - Err(JsonError::new("unterminated string")) +fn parse_value(chars: &mut std::iter::Peekable) -> Result { + match chars.peek() { + Some('n') => parse_null(chars), + Some('t') | Some('f') => parse_bool(chars), + Some('"') => parse_string(chars).map(JsonValue::String), + Some('[') => parse_array(chars), + Some('{') => parse_object(chars), + Some('-') | Some('0'..='9') => parse_number(chars), + Some(c) => Err(JsonError::new(format!("unexpected char: {c}"))), + None => Err(JsonError::new("empty input")), + } +} + +fn parse_null(chars: &mut std::iter::Peekable) -> Result { + if consume_literal(chars, "null") { + Ok(JsonValue::Null) + } else { + Err(JsonError::new("invalid null")) + } +} + +fn parse_bool(chars: &mut std::iter::Peekable) -> Result { + if consume_literal(chars, "true") { + return Ok(JsonValue::Bool(true)); + } + if consume_literal(chars, "false") { + return Ok(JsonValue::Bool(false)); + } + Err(JsonError::new("invalid bool")) +} + +fn parse_string(chars: &mut std::iter::Peekable) -> Result { + if chars.next() != Some('"') { + return Err(JsonError::new("expected opening quote")); } - fn parse_escape(&mut self) -> Result { - match self.next() { - Some('"') => Ok('"'), - Some('\\') => Ok('\\'), - Some('/') => Ok('/'), - Some('b') => Ok('\u{08}'), - Some('f') => Ok('\u{0C}'), - Some('n') => Ok('\n'), - Some('r') => Ok('\r'), - Some('t') => Ok('\t'), - Some('u') => self.parse_unicode_escape(), - Some(other) => Err(JsonError::new(format!("invalid escape sequence: {other}"))), - None => Err(JsonError::new("unexpected end of input in escape sequence")), + let mut result = String::new(); + loop { + match chars.next() { + None => return Err(JsonError::new("unterminated string")), + Some('"') => return Ok(result), + Some('\\') => match chars.next() { + Some('"') => result.push('"'), + Some('\\') => result.push('\\'), + Some('/') => result.push('/'), + Some('b') => result.push('\u{08}'), + Some('f') => result.push('\u{0C}'), + Some('n') => result.push('\n'), + Some('r') => result.push('\r'), + Some('t') => result.push('\t'), + Some('u') => { + let mut hex = String::new(); + for _ in 0..4 { + match chars.next() { + Some(c @ '0'..='9') | Some(c @ 'a'..='f') | Some(c @ 'A'..='F') => { + hex.push(c) + } + _ => return Err(JsonError::new("invalid unicode escape")), + } + } + let code = u32::from_str_radix(&hex, 16) + .map_err(|_| JsonError::new("invalid hex code"))?; + if (0xD800..=0xDFFF).contains(&code) { + return Err(JsonError::new("surrogate not allowed")); + } + result.push( + char::from_u32(code).ok_or_else(|| JsonError::new("invalid codepoint"))?, + ); + } + _ => return Err(JsonError::new("invalid escape")), + }, + Some(c) => result.push(c), } } +} - fn parse_unicode_escape(&mut self) -> Result { - let mut value = 0_u32; - for _ in 0..4 { - let Some(ch) = self.next() else { - return Err(JsonError::new("unexpected end of input in unicode escape")); - }; - value = (value << 4) - | ch.to_digit(16) - .ok_or_else(|| JsonError::new("invalid unicode escape"))?; - } - char::from_u32(value).ok_or_else(|| JsonError::new("invalid unicode scalar value")) +fn parse_number(chars: &mut std::iter::Peekable) -> Result { + let mut num_str = String::new(); + + if chars.peek() == Some(&'-') { + num_str.push(chars.next().unwrap()); } - fn parse_array(&mut self) -> Result { - self.expect('[')?; - let mut values = Vec::new(); - loop { - self.skip_whitespace(); - if self.try_consume(']') { - break; - } - values.push(self.parse_value()?); - self.skip_whitespace(); - if self.try_consume(']') { - break; - } - self.expect(',')?; - } - Ok(JsonValue::Array(values)) - } - - fn parse_object(&mut self) -> Result { - self.expect('{')?; - let mut entries = BTreeMap::new(); - loop { - self.skip_whitespace(); - if self.try_consume('}') { - break; - } - let key = self.parse_string()?; - self.skip_whitespace(); - self.expect(':')?; - let value = self.parse_value()?; - entries.insert(key, value); - self.skip_whitespace(); - if self.try_consume('}') { - break; - } - self.expect(',')?; - } - Ok(JsonValue::Object(entries)) - } - - fn parse_number(&mut self) -> Result { - let mut value = String::new(); - if self.try_consume('-') { - value.push('-'); - } - - while let Some(ch @ '0'..='9') = self.peek() { - value.push(ch); - self.index += 1; - } - - if value.is_empty() || value == "-" { - return Err(JsonError::new("invalid number")); - } - - value - .parse::() - .map_err(|_| JsonError::new("number out of range")) - } - - fn expect(&mut self, expected: char) -> Result<(), JsonError> { - match self.next() { - Some(actual) if actual == expected => Ok(()), - Some(actual) => Err(JsonError::new(format!( - "expected '{expected}', found '{actual}'" - ))), - None => Err(JsonError::new(format!( - "expected '{expected}', found end of input" - ))), - } - } - - fn try_consume(&mut self, expected: char) -> bool { - if self.peek() == Some(expected) { - self.index += 1; - true + while let Some(&c) = chars.peek() { + if c.is_ascii_digit() { + num_str.push(chars.next().unwrap()); } else { - false + break; } } - fn skip_whitespace(&mut self) { - while matches!(self.peek(), Some(' ' | '\n' | '\r' | '\t')) { - self.index += 1; + if num_str.is_empty() || num_str == "-" { + return Err(JsonError::new("invalid number")); + } + + num_str + .parse::() + .map(JsonValue::Number) + .map_err(|_| JsonError::new("number out of range")) +} + +fn parse_array(chars: &mut std::iter::Peekable) -> Result { + if chars.next() != Some('[') { + return Err(JsonError::new("expected [")); + } + + let mut values = Vec::new(); + + skip_whitespace(chars); + if chars.peek() == Some(&']') { + chars.next(); + return Ok(JsonValue::Array(values)); + } + + loop { + skip_whitespace(chars); + values.push(parse_value(chars)?); + skip_whitespace(chars); + + match chars.peek() { + Some(',') => { + chars.next(); + } + Some(']') => { + chars.next(); + return Ok(JsonValue::Array(values)); + } + _ => return Err(JsonError::new("expected , or ]")), } } +} - fn peek(&self) -> Option { - self.chars.get(self.index).copied() +fn parse_object(chars: &mut std::iter::Peekable) -> Result { + if chars.next() != Some('{') { + return Err(JsonError::new("expected {")); } - fn next(&mut self) -> Option { - let ch = self.peek()?; - self.index += 1; - Some(ch) + let mut map = BTreeMap::new(); + + skip_whitespace(chars); + if chars.peek() == Some(&'}') { + chars.next(); + return Ok(JsonValue::Object(map)); } - fn is_eof(&self) -> bool { - self.index >= self.chars.len() + loop { + skip_whitespace(chars); + + if chars.peek() != Some(&'"') { + return Err(JsonError::new("expected key string")); + } + let key = parse_string(chars)?; + + skip_whitespace(chars); + if chars.next() != Some(':') { + return Err(JsonError::new("expected :")); + } + + skip_whitespace(chars); + let value = parse_value(chars)?; + map.insert(key, value); + + skip_whitespace(chars); + match chars.peek() { + Some(',') => { + chars.next(); + } + Some('}') => { + chars.next(); + return Ok(JsonValue::Object(map)); + } + _ => return Err(JsonError::new("expected , or }")), + } } } +fn consume_literal(chars: &mut std::iter::Peekable, literal: &str) -> bool { + let remaining: String = chars.clone().collect(); + if remaining.starts_with(literal) { + for _ in literal.chars() { + chars.next(); + } + true + } else { + false + } +} + +fn skip_whitespace(chars: &mut std::iter::Peekable) { + while let Some(&c) = chars.peek() { + if c.is_whitespace() { + chars.next(); + } else { + break; + } + } +} + +fn render_string(s: &str) -> String { + let mut result = String::from('"'); + for c in s.chars() { + match c { + '"' => result.push_str("\\\""), + '\\' => result.push_str("\\\\"), + '\n' => result.push_str("\\n"), + '\r' => result.push_str("\\r"), + '\t' => result.push_str("\\t"), + '\u{08}' => result.push_str("\\b"), + '\u{0C}' => result.push_str("\\f"), + c if c.is_control() => { + let code = c as u32; + result.push_str(&format!("\\u{code:04x}")); + } + c => result.push(c), + } + } + result.push('"'); + result +} + #[cfg(test)] mod tests { - use super::{render_string, JsonValue}; - use std::collections::BTreeMap; + use super::*; #[test] - fn renders_and_parses_json_values() { - let mut object = BTreeMap::new(); - object.insert("flag".to_string(), JsonValue::Bool(true)); - object.insert( - "items".to_string(), - JsonValue::Array(vec![ - JsonValue::Number(4), - JsonValue::String("ok".to_string()), - ]), + fn renders_primitive_values() { + assert_eq!(JsonValue::Null.render(), "null"); + assert_eq!(JsonValue::Bool(true).render(), "true"); + assert_eq!(JsonValue::Bool(false).render(), "false"); + assert_eq!(JsonValue::Number(42).render(), "42"); + assert_eq!(JsonValue::Number(-7).render(), "-7"); + assert_eq!(JsonValue::String("hi".to_string()).render(), "\"hi\""); + } + + #[test] + fn renders_arrays_and_objects_with_sorted_keys() { + assert_eq!(JsonValue::Array(Vec::new()).render(), "[]"); + assert_eq!( + JsonValue::Array(vec![JsonValue::Number(1), JsonValue::Number(2)]).render(), + "[1,2]" ); + assert_eq!(JsonValue::Object(BTreeMap::new()).render(), "{}"); - let rendered = JsonValue::Object(object).render(); - let parsed = JsonValue::parse(&rendered).expect("json should parse"); + let mut object = BTreeMap::new(); + object.insert("b".to_string(), JsonValue::Bool(false)); + object.insert("a".to_string(), JsonValue::Number(1)); + // A BTreeMap iterates keys in sorted order, so object rendering is + // deterministic regardless of insertion order. + assert_eq!(JsonValue::Object(object).render(), "{\"a\":1,\"b\":false}"); + } - assert_eq!(parsed.as_object().expect("object").len(), 2); + #[test] + fn render_string_escapes_quotes_backslashes_and_other_controls() { + // Backslash and double-quote are backslash-escaped. + assert_eq!(render_string("\"\\"), "\"\\\"\\\\\""); + // Carriage return has a dedicated short escape. + assert_eq!(render_string("\r"), "\"\\r\""); + // Other control characters fall back to the \uXXXX form (U+0001 here). + assert_eq!(render_string("\u{1}"), "\"\\u0001\""); + } + + #[test] + fn parses_primitive_values() { + assert_eq!( + JsonValue::parse("null").expect("null parses"), + JsonValue::Null + ); + assert_eq!( + JsonValue::parse("true").expect("true parses"), + JsonValue::Bool(true) + ); + assert_eq!( + JsonValue::parse("false").expect("false parses"), + JsonValue::Bool(false) + ); + assert_eq!( + JsonValue::parse("\"hello\"").expect("string parses"), + JsonValue::String("hello".to_string()) + ); + } + + #[test] + fn parses_signed_integers_and_ignores_surrounding_whitespace() { + assert_eq!( + JsonValue::parse("-42").expect("negative parses").as_i64(), + Some(-42) + ); + assert_eq!( + JsonValue::parse(" 123 ").expect("padded parses").as_i64(), + Some(123) + ); + } + + #[test] + fn parses_nested_arrays_and_objects() { + let value = + JsonValue::parse("{\"nums\": [1, 2], \"flag\": true}").expect("nested json parses"); + let object = value.as_object().expect("top level is an object"); + + assert_eq!(object.len(), 2); + + let nums = object + .get("nums") + .and_then(JsonValue::as_array) + .expect("nums is an array"); + assert_eq!(nums.len(), 2); + assert_eq!(nums[0].as_i64(), Some(1)); + + assert_eq!(object.get("flag").and_then(JsonValue::as_bool), Some(true)); + } + + #[test] + fn round_trips_rendered_values_through_the_parser() { + let mut object = BTreeMap::new(); + object.insert("name".to_string(), JsonValue::String("clawi".to_string())); + object.insert("count".to_string(), JsonValue::Number(-3)); + object.insert( + "tags".to_string(), + JsonValue::Array(vec![JsonValue::Bool(true), JsonValue::Null]), + ); + let original = JsonValue::Object(object); + + let reparsed = JsonValue::parse(&original.render()).expect("rendered json should re-parse"); + + assert_eq!(reparsed, original); } #[test] @@ -433,6 +519,7 @@ mod tests { object.insert("a\nb".to_string(), JsonValue::String("c\td".to_string())); let original = JsonValue::Object(object); let reparsed = JsonValue::parse(&original.render()).expect("escaped key re-parses"); + assert_eq!(reparsed, original); } @@ -471,4 +558,37 @@ mod tests { "i64::MIN - 1" ); } + + #[test] + fn parse_rejects_malformed_input() { + assert!(JsonValue::parse("").is_err(), "empty input"); + assert!(JsonValue::parse("nul").is_err(), "truncated literal"); + assert!(JsonValue::parse("true false").is_err(), "trailing content"); + assert!( + JsonValue::parse("\"unterminated").is_err(), + "unterminated string" + ); + assert!(JsonValue::parse("[1, 2").is_err(), "unterminated array"); + } + + #[test] + fn parse_rejects_integers_outside_the_i64_range() { + // Far beyond i64::MAX (~9.2e18): the parser must reject rather than + // silently truncate the value. + assert!(JsonValue::parse("9999999999999999999999999").is_err()); + } + + #[test] + fn accessors_return_none_for_mismatched_variants() { + assert_eq!(JsonValue::Null.as_bool(), None); + assert_eq!(JsonValue::Bool(true).as_i64(), None); + assert!(JsonValue::Number(1).as_str().is_none()); + assert!(JsonValue::Number(1).as_array().is_none()); + assert!(JsonValue::String("x".to_string()).as_object().is_none()); + } + + #[test] + fn json_error_displays_its_message() { + assert_eq!(JsonError::new("boom").to_string(), "boom"); + } }