From c5360db1856ab59fda50c5db161cdaca7f0d16c2 Mon Sep 17 00:00:00 2001 From: Stephen Chung Date: Tue, 18 Aug 2020 23:07:17 +0800 Subject: [PATCH] Handle #{ in Engine::parse_json, restrict to object hashes only. --- RELEASES.md | 1 + doc/src/language/json.md | 42 ++++++++++++++++++++++++++++++++------ src/api.rs | 44 ++++++++++++++++++++++++++++++++-------- tests/maps.rs | 10 ++++++++- 4 files changed, 82 insertions(+), 15 deletions(-) diff --git a/RELEASES.md b/RELEASES.md index b753bba3..89b05c36 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -9,6 +9,7 @@ New features * Adds `Engine::register_get_result`, `Engine::register_set_result`, `Engine::register_indexer_get_result`, `Engine::register_indexer_set_result` API. * Adds `Module::combine` to combine two modules. +* `Engine::parse_json` now also accepts a JSON object starting with `#{`. Version 0.18.1 diff --git a/doc/src/language/json.md b/doc/src/language/json.md index a5aaba2a..d76877c9 100644 --- a/doc/src/language/json.md +++ b/doc/src/language/json.md @@ -3,11 +3,14 @@ Parse an Object Map from JSON {{#include ../links.md}} -The syntax for an [object map] is extremely similar to JSON, with the exception of `null` values which can -technically be mapped to [`()`]. A valid JSON string does not start with a hash character `#` while a -Rhai [object map] does - that's the major difference! +The syntax for an [object map] is extremely similar to the JSON representation of a object hash, +with the exception of `null` values which can technically be mapped to [`()`]. -Use the `Engine::parse_json` method to parse a piece of JSON into an object map: +A valid JSON string does not start with a hash character `#` while a Rhai [object map] does - that's the major difference! + +Use the `Engine::parse_json` method to parse a piece of JSON into an object map. +The JSON text must represent a single object hash (i.e. must be wrapped within "`{ .. }`") +otherwise it returns a syntax error. ```rust // JSON string - notice that JSON property names are always quoted @@ -26,7 +29,7 @@ let json = r#"{ // Set the second boolean parameter to true in order to map 'null' to '()' let map = engine.parse_json(json, true)?; -map.len() == 6; // 'map' contains all properties in the JSON string +map.len() == 6; // 'map' contains all properties in the JSON string // Put the object map into a 'Scope' let mut scope = Scope::new(); @@ -34,7 +37,7 @@ scope.push("map", map); let result = engine.eval_with_scope::(r#"map["^^^!!!"].len()"#)?; -result == 3; // the object map is successfully used in the script +result == 3; // the object map is successfully used in the script ``` Representation of Numbers @@ -45,3 +48,30 @@ the [`no_float`] feature is not used. Most common generators of JSON data disti integer and floating-point values by always serializing a floating-point number with a decimal point (i.e. `123.0` instead of `123` which is assumed to be an integer). This style can be used successfully with Rhai [object maps]. + + +Parse JSON with Sub-Objects +-------------------------- + +`Engine::parse_json` depends on the fact that the [object map] literal syntax in Rhai is _almost_ +the same as a JSON object. However, it is _almost_ because the syntax for a sub-object in JSON +(i.e. "`{ ... }`") is different from a Rhai [object map] literal (i.e. "`#{ ... }`"). + +When `Engine::parse_json` encounters JSON with sub-objects, it fails with a syntax error. + +If it is certain that no text string in the JSON will ever contain the character '`{`', +then it is possible to parse it by first replacing all occupance of '`{`' with "`#{`". + +A JSON object hash starting with `#{` is handled transparently by `Engine::parse_json`. + +```rust +// JSON with sub-object 'b'. +let json = r#"{"a":1, "b":{"x":true, "y":false}}"#; + +let new_json = json.replace("{" "#{"); + +// The leading '{' will also be replaced to '#{', but parse_json can handle this. +let map = engine.parse_json(&new_json, false)?; + +map.len() == 2; // 'map' contains two properties: 'a' and 'b' +``` diff --git a/src/api.rs b/src/api.rs index 8487370f..8b91cd2a 100644 --- a/src/api.rs +++ b/src/api.rs @@ -2,7 +2,7 @@ use crate::any::{Dynamic, Variant}; use crate::engine::{Engine, Imports, State}; -use crate::error::ParseError; +use crate::error::{ParseError, ParseErrorType}; use crate::fn_native::{IteratorFn, SendSync}; use crate::module::{FuncReturn, Module}; use crate::optimize::OptimizationLevel; @@ -895,24 +895,39 @@ impl Engine { /// Parse a JSON string into a map. /// + /// The JSON string must be an object hash. It cannot be a simple JavaScript primitive. + /// /// Set `has_null` to `true` in order to map `null` values to `()`. /// Setting it to `false` will cause a _variable not found_ error during parsing. /// + /// # JSON With Sub-Objects + /// + /// This method assumes no sub-objects in the JSON string. That is because the syntax + /// of a JSON sub-object (or object hash), `{ .. }`, is different from Rhai's syntax, `#{ .. }`. + /// Parsing a JSON string with sub-objects will cause a syntax error. + /// + /// If it is certain that the character `{` never appears in any text string within the JSON object, + /// then globally replace `{` with `#{` before calling this method. + /// /// # Example /// /// ``` /// # fn main() -> Result<(), Box> { - /// use rhai::Engine; + /// use rhai::{Engine, Map}; /// /// let engine = Engine::new(); /// - /// let map = engine.parse_json(r#"{"a":123, "b":42, "c":false, "d":null}"#, true)?; + /// let map = engine.parse_json( + /// r#"{"a":123, "b":42, "c":{"x":false, "y":true}, "d":null}"# + /// .replace("{", "#{").as_str(), true)?; /// /// assert_eq!(map.len(), 4); - /// assert_eq!(map.get("a").cloned().unwrap().cast::(), 123); - /// assert_eq!(map.get("b").cloned().unwrap().cast::(), 42); - /// assert_eq!(map.get("c").cloned().unwrap().cast::(), false); - /// assert_eq!(map.get("d").cloned().unwrap().cast::<()>(), ()); + /// assert_eq!(map["a"].as_int().unwrap(), 123); + /// assert_eq!(map["b"].as_int().unwrap(), 42); + /// assert!(map["d"].is::<()>()); + /// + /// let c = map["c"].read_lock::().unwrap(); + /// assert_eq!(c["x"].as_bool().unwrap(), false); /// # Ok(()) /// # } /// ``` @@ -921,7 +936,20 @@ impl Engine { let mut scope = Scope::new(); // Trims the JSON string and add a '#' in front - let scripts = ["#", json.trim()]; + let json_text = json.trim_start(); + let scripts = if json_text.starts_with(Token::MapStart.syntax().as_ref()) { + [json_text, ""] + } else if json_text.starts_with(Token::LeftBrace.syntax().as_ref()) { + ["#", json_text] + } else { + return Err(ParseErrorType::MissingToken( + Token::LeftBrace.syntax().to_string(), + "to start a JSON object hash".to_string(), + ) + .into_err(Position::new(1, (json.len() - json_text.len() + 1) as u16)) + .into()); + }; + let stream = lex( &scripts, if has_null { diff --git a/tests/maps.rs b/tests/maps.rs index c57deaa6..988c51a3 100644 --- a/tests/maps.rs +++ b/tests/maps.rs @@ -1,6 +1,6 @@ #![cfg(not(feature = "no_object"))] -use rhai::{Engine, EvalAltResult, Map, Scope, INT}; +use rhai::{Engine, EvalAltResult, Map, ParseErrorType, Scope, INT}; #[test] fn test_map_indexing() -> Result<(), Box> { @@ -182,6 +182,14 @@ fn test_map_json() -> Result<(), Box> { ); } + engine.parse_json(&format!("#{}", json), true)?; + + assert!(matches!( + *engine.parse_json(" 123", true).expect_err("should error"), + EvalAltResult::ErrorParsing(ParseErrorType::MissingToken(token, _), pos) + if token == "{" && pos.position() == Some(4) + )); + Ok(()) }