diff --git a/addons/hashes/CfgFunctions.hpp b/addons/hashes/CfgFunctions.hpp index 66227fde1..111fd3c53 100644 --- a/addons/hashes/CfgFunctions.hpp +++ b/addons/hashes/CfgFunctions.hpp @@ -14,6 +14,8 @@ class CfgFunctions { PATHTO_FNC(parseYAML); PATHTO_FNC(serializeNamespace); PATHTO_FNC(deserializeNamespace); + PATHTO_FNC(encodeJSON); + PATHTO_FNC(parseJSON); }; }; }; diff --git a/addons/hashes/fnc_encodeJSON.sqf b/addons/hashes/fnc_encodeJSON.sqf new file mode 100644 index 000000000..d8a08a193 --- /dev/null +++ b/addons/hashes/fnc_encodeJSON.sqf @@ -0,0 +1,95 @@ +#include "script_component.hpp" +/* ---------------------------------------------------------------------------- +Function: CBA_fnc_encodeJSON + +Description: + Serializes input to a JSON string. Can handle + - ARRAY + - BOOL + - CONTROL + - GROUP + - LOCATION + - NAMESPACE + - NIL (ANY) + - NUMBER + - OBJECT + - STRING + - TASK + - TEAM_MEMBER + - Everything else will simply be stringified. + +Parameters: + _object - Object to serialize. + +Returns: + _json - JSON string containing serialized object. + +Examples: + (begin example) + private _settings = call CBA_fnc_createNamespace; + _settings setVariable ["enabled", true]; + private _json = [_settings] call CBA_fnc_encodeJSON; + (end) + +Author: + BaerMitUmlaut +---------------------------------------------------------------------------- */ +SCRIPT(encodeJSON); +params ["_object"]; + +if (isNil "_object") exitWith { "null" }; + +switch (typeName _object) do { + case "SCALAR"; + case "BOOL": { + str _object; + }; + + case "STRING": { + { + _object = [_object, _x#0, _x#1] call CBA_fnc_replace; + } forEach [ + ["\", "\\"], + ["""", "\"""], + [toString [8], "\b"], + [toString [12], "\f"], + [endl, "\n"], + [toString [10], "\n"], + [toString [13], "\r"], + [toString [9], "\t"] + ]; + // Stringify without escaping inter string quote marks. + """" + _object + """" + }; + + case "ARRAY": { + if ([_object] call CBA_fnc_isHash) then { + private _json = (([_object] call CBA_fnc_hashKeys) apply { + private _name = _x; + private _value = [_object, _name] call CBA_fnc_hashGet; + + format ["%1: %2", [_name] call CBA_fnc_encodeJSON, [_value] call CBA_fnc_encodeJSON] + }) joinString ", "; + "{" + _json + "}" + } else { + private _json = (_object apply {[_x] call CBA_fnc_encodeJSON}) joinString ", "; + "[" + _json + "]" + }; + }; + + default { + if !(typeName _object in (supportInfo "u:allVariables*" apply {_x splitString " " select 1})) exitWith { + [str _object] call CBA_fnc_encodeJSON + }; + + if (isNull _object) exitWith { "null" }; + + private _json = ((allVariables _object) apply { + private _name = _x; + private _value = _object getVariable _name; + + format ["%1: %2", [_name] call CBA_fnc_encodeJSON, [_value] call CBA_fnc_encodeJSON] + }) joinString ", "; + "{" + _json + "}" + }; +}; diff --git a/addons/hashes/fnc_parseJSON.sqf b/addons/hashes/fnc_parseJSON.sqf new file mode 100644 index 000000000..fee634f72 --- /dev/null +++ b/addons/hashes/fnc_parseJSON.sqf @@ -0,0 +1,255 @@ +#include "script_component.hpp" +/* ---------------------------------------------------------------------------- +Function: CBA_fnc_parseJSON + +Description: + Deserializes a JSON string. + +Parameters: + _json - String containing valid JSON. + _useHashes - Output CBA hashes instead of namespaces + (optional, default: false) + +Returns: + _object - The deserialized JSON object or nil if JSON is invalid. + + +Examples: + (begin example) + private _json = "{ ""enabled"": true }"; + private _settings = [_json] call CBA_fnc_parseJSON; + private _enabled = _settings getVariable "enabled"; + + loadFile "data\config.json" call CBA_fnc_parseJSON + [preprocessFile "data\config.json", true] call CBA_fnc_parseJSON + (end) + +Author: + BaerMitUmlaut +---------------------------------------------------------------------------- */ +SCRIPT(parseJSON); +params ["_json", ["_useHashes", false]]; + +// Wrappers for creating "objects" and setting values on them +private ["_objectSet", "_createObject"]; +if (_useHashes) then { + _createObject = CBA_fnc_hashCreate; + _objectSet = CBA_fnc_hashSet; +} else { + _createObject = CBA_fnc_createNamespace; + _objectSet = { + params ["_obj", "_key", "_val"]; + _obj setVariable [_key, _val]; + }; +}; + +// Handles escaped characters, except for unicode escapes (\uXXXX) +private _unescape = { + params ["_char"]; + + switch (_char) do { + case """": { """" }; + case "\": { "\" }; + case "/": { "/" }; + case "b": { toString [8] }; + case "f": { toString [12] }; + case "n": { endl }; + case "r": { toString [13] }; + case "t": { toString [9] }; + default { "" }; + }; +}; + +// Splits the input string into tokens +// Tokens can be numbers, strings, null, true, false and symbols +// Strings are prefixed with $ to distinguish them from symbols +private _tokenize = { + params ["_input"]; + + // Split string into chars, works with unicode unlike splitString + _input = toArray _input apply {toString [_x]}; + + private _tokens = []; + private _numeric = "+-.0123456789e" splitString ""; + private _symbols = "{}[]:," splitString ""; + private _consts = "tfn" splitString ""; + + while {count _input > 0} do { + private _c = _input deleteAt 0; + + switch (true) do { + // Symbols ({}[]:,) are passed directly into the tokens + case (_c in _symbols): { + _tokens pushBack _c; + }; + + // Number parsing + // This can fail with some invalid JSON numbers, like e10 + // Those would require some additional logic or regex + // Valid numbers are all parsed correctly though + case (_c in _numeric): { + private _numStr = _c; + while { _c = _input deleteAt 0; !isNil "_c" && {_c in _numeric} } do { + _numStr = _numStr + _c; + }; + _tokens pushBack parseNumber _numStr; + + if (!isNil "_c") then { + _input = [_c] + _input; + }; + }; + + // true, false and null + // Only check first char and assume JSON is valid + case (_c in _consts): { + switch (_c) do { + case "t": { + _input deleteRange [0, 3]; + _tokens pushBack true; + }; + case "f": { + _input deleteRange [0, 4]; + _tokens pushBack false; + }; + case "n": { + _input deleteRange [0, 3]; + _tokens pushBack objNull; + }; + }; + }; + + // String parsing + case (_c == """"): { + private _str = "$"; + + while {true} do { + _c = _input deleteAt 0; + + if (_c == """") exitWith {}; + + if (_c == "\") then { + _str = _str + ((_input deleteAt 0) call _unescape); + } else { + _str = _str + _c; + }; + }; + + _tokens pushBack _str; + }; + }; + }; + + _tokens +}; + +// Appends the next token to the parsing stack +// Returns true unless no more tokens left +private _shift = { + params ["_parseStack", "_tokens"]; + + if (count _tokens > 0) then { + _parseStack pushBack (_tokens deleteAt 0); + true + } else { + false + }; +}; + +// Tries to reduce the current parsing stack (collect arrays or objects) +// Returns true if parsing stack could be reduced +private _reduce = { + params ["_parseStack", "_tokens"]; + + // Nothing to reduce + if (count _parseStack == 0) exitWith { false }; + + // Check top of stack + switch (_parseStack#(count _parseStack - 1)) do { + + // Reached end of array, time to collect elements + case "]": { + private _array = []; + + // Empty arrays need special handling + if !(_parseStack#(count _parseStack - 2) isEqualTo "[") then { + // Get next token, if [ beginning is reached, otherwise assume + // valid JSON and that the token is a comma + while {_parseStack deleteAt (count _parseStack - 1) != "["} do { + private _element = _parseStack deleteAt (count _parseStack - 1); + + // Remove $ prefix from string + if (_element isEqualType "") then { + _element = _element select [1]; + }; + + _array pushBack _element; + }; + + reverse _array; + } else { + _parseStack resize (count _parseStack - 2); + }; + + _parseStack pushBack _array; + true + }; + + // Reached end of array, time to collect elements + // Works very similar to arrays + case "}": { + private _object = [] call _createObject; + + // Empty objects need special handling + if !(_parseStack#(count _parseStack - 2) isEqualTo "{") then { + // Get next token, if { beginning is reached, otherwise assume + // valid JSON and that token is comma + while {_parseStack deleteAt (count _parseStack - 1) != "{"} do { + private _value = _parseStack deleteAt (count _parseStack - 1); + private _colon = _parseStack deleteAt (count _parseStack - 1); + private _name = _parseStack deleteAt (count _parseStack - 1); + + // Remove $ prefix from strings + if (_value isEqualType "") then { + _value = _value select [1]; + }; + _name = _name select [1]; + + [_object, _name, _value] call _objectSet; + }; + } else { + _parseStack resize (count _parseStack - 2); + }; + + _parseStack pushBack _object; + true + }; + + default { + false + }; + }; +}; + +// Simple shift-reduce parser +private _parse = { + params ["_tokens"]; + private _parseStack = []; + private _params = [_parseStack, _tokens]; + + while { _params call _reduce || {_params call _shift} } do {}; + + if (count _parseStack != 1) then { + nil + } else { + private _object = _parseStack#0; + + // If JSON is just a string, remove $ prefix from it + if (_object isEqualType "") then { + _object = _object select [1]; + }; + + _object + }; +}; + +[_json call _tokenize] call _parse diff --git a/addons/hashes/test.sqf b/addons/hashes/test.sqf index 552dd9c4c..45db263d5 100644 --- a/addons/hashes/test.sqf +++ b/addons/hashes/test.sqf @@ -5,7 +5,7 @@ #define DEBUG_MODE_FULL #include "script_component.hpp" -#define TESTS ["hashEachPair", "hashes", "parseYaml", "hashFilter"] +#define TESTS ["hashEachPair", "hashes", "parseJSON", "parseYaml", "hashFilter"] SCRIPT(test-hashes); diff --git a/addons/hashes/test_parseJSON.sqf b/addons/hashes/test_parseJSON.sqf new file mode 100644 index 000000000..9c02c02a9 --- /dev/null +++ b/addons/hashes/test_parseJSON.sqf @@ -0,0 +1,132 @@ +// ---------------------------------------------------------------------------- +#define DEBUG_SYNCHRONOUS +#include "script_component.hpp" + +SCRIPT(test_parseJSON); + +// ---------------------------------------------------------------------------- + +private ["_expected", "_result", "_fn", "_data"]; + +_fn = "CBA_fnc_parseJSON"; +LOG("Testing " + _fn); + +TEST_DEFINED("CBA_fnc_parseJSON",_fn); + +// Namespace syntax +_data = [preprocessFile "\x\cba\addons\hashes\test_parseJSON_config.json"] call CBA_fnc_parseJSON; + +_result = [_data] call CBA_fnc_isHash; +TEST_FALSE(_result,_fn); + +_result = allVariables _data; +_result sort true; +_expected = ["address","age","companyname","firstname","lastname","newsubscription","phonenumber"]; //all lower case +TEST_OP(_result,isEqualTo,_expected,_fn); + +_result = _data getVariable "lastName"; +_expected = "Smith"; +TEST_OP(_result,==,_expected,_fn); + +_result = _data getVariable "age"; +_expected = 25; +TEST_OP(_result,==,_expected,_fn); + +_result = _data getVariable "address" getVariable "city"; +_expected = "New York"; +TEST_OP(_result,==,_expected,_fn); + +_result = _data getVariable "phoneNumber" select 0 getVariable "type"; +_expected = "home"; +TEST_OP(_result,==,_expected,_fn); + +_result = _data getVariable "phoneNumber" select 1 getVariable "type"; +_expected = "fax"; +TEST_OP(_result,==,_expected,_fn); + +_result = _data getVariable "newSubscription"; +TEST_FALSE(_result,_fn); + +_result = _data getVariable "companyName"; +TEST_TRUE(isNull _result,_fn); + +// Hash syntax +_data = [preprocessFile "\x\cba\addons\hashes\test_parseJSON_config.json", true] call CBA_fnc_parseJSON; + +_result = [_data] call CBA_fnc_isHash; +TEST_TRUE(_result,_fn); + +_result = [_data] call CBA_fnc_hashKeys; +_result sort true; +_expected = ["address","age","companyName","firstName","lastName","newSubscription","phoneNumber"]; //camel case +TEST_OP(_result,isEqualTo,_expected,_fn); + +_result = [_data, "lastName"] call CBA_fnc_hashGet; +_expected = "Smith"; +TEST_OP(_result,==,_expected,_fn); + +_result = [_data, "age"] call CBA_fnc_hashGet; +_expected = 25; +TEST_OP(_result,==,_expected,_fn); + +_result = [[_data, "address"] call CBA_fnc_hashGet, "city"] call CBA_fnc_hashGet; +_expected = "New York"; +TEST_OP(_result,==,_expected,_fn); + +_result = [[_data, "phoneNumber"] call CBA_fnc_hashGet select 0, "type"] call CBA_fnc_hashGet; +_expected = "home"; +TEST_OP(_result,==,_expected,_fn); + +_result = [[_data, "phoneNumber"] call CBA_fnc_hashGet select 1, "type"] call CBA_fnc_hashGet; +_expected = "fax"; +TEST_OP(_result,==,_expected,_fn); + +_result = [_data, "newSubscription"] call CBA_fnc_hashGet; +TEST_FALSE(_result,_fn); + +_result = [_data, "companyName"] call CBA_fnc_hashGet; +TEST_TRUE(isNull _result,_fn); + +// ---------------------------------------------------------------------------- + +_fn = "CBA_fnc_encodeJSON"; +LOG("Testing " + _fn); + +TEST_DEFINED("CBA_fnc_encodeJSON",_fn); + +// ---------------------------------------------------------------------------- + +private _testCases = [ + "null", + "true", + "1.2", + """Hello, World!""", + "[]", + "{}", + "[null, true, 1.2, ""Hello, World!""]", + "[{""nested"": [{""nested"": [{""nested"": [{""nested"": [{""nested"": [{""nested"": [{""nested"": []}]}]}]}]}]}]}]" +]; + +{ + private _useHashes = _x; + + { + diag_log _x; + private _input = _x; + private _object = [_x, _useHashes] call CBA_fnc_parseJSON; + private _output = [_object] call CBA_fnc_encodeJSON; + TEST_OP(_input,==,_output,_fn); + } forEach _testCases; +} forEach [true, false]; + +// Special test for complex object because properties are unordered +private _json = "{""OBJECT"": null, ""BOOL"": true, ""SCALAR"": 1.2, ""STRING"": ""Hello, World!"", ""ARRAY"": [], ""LOCATION"": {}}"; +private _object = [_json, false] call CBA_fnc_parseJSON; +private _properties = allVariables _object; +TEST_OP(count _properties,==,6,_fn); +{ + private _value = _object getVariable _x; + TEST_OP(typeName _value,==,_x,_fn); +} forEach _properties; + +nil diff --git a/addons/hashes/test_parseJSON_config.json b/addons/hashes/test_parseJSON_config.json new file mode 100644 index 000000000..1fa47277d --- /dev/null +++ b/addons/hashes/test_parseJSON_config.json @@ -0,0 +1,17 @@ +{ + "firstName": "Jason", + "lastName": "Smith", + "age": 25, + "address": { + "streetAddress": "21 2nd Street", + "city": "New York", + "state": "NY", + "postalCode": "10021" + }, + "phoneNumber": [ + { "type": "home", "number": "212 555-1234" }, + { "type": "fax", "number": "646 555-4567" } + ], + "newSubscription": false, + "companyName": null +}