diff --git a/.gitignore b/.gitignore index 948a9ca8..5052287c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ notes packages tags tests/utf8.dat +_build +.vscode \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt index ca263814..0bc3f726 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -3,11 +3,13 @@ # Unix: export LUA_DIR=/home/user/pkg # Windows: set LUA_DIR=c:\lua51 +cmake_minimum_required(VERSION 3.14) project(lua-cjson C) -cmake_minimum_required(VERSION 2.6) option(USE_INTERNAL_FPCONV "Use internal strtod() / g_fmt() code for performance") option(MULTIPLE_THREADS "Support multi-threaded apps with internal fpconv - recommended" ON) +option(USE_LUAU "Use Luau instead of standard Lua" OFF) +option(COMPILE_LUAU_TEST "Use Luau instead of standard Lua" OFF) if(NOT CMAKE_BUILD_TYPE) set(CMAKE_BUILD_TYPE Release CACHE STRING @@ -15,21 +17,54 @@ if(NOT CMAKE_BUILD_TYPE) FORCE) endif() -find_package(Lua51 REQUIRED) -include_directories(${LUA_INCLUDE_DIR}) +#add_compile_options(-fno-omit-frame-pointer -mno-omit-leaf-frame-pointer) + +#add_compile_options(-fsanitize=address) +#add_link_options(-fsanitize=address) + +if(USE_LUAU) + add_library(cjson) +else() + add_library(cjson MODULE) +endif() + +if(USE_LUAU) + include(FetchContent) + FetchContent_Declare( + luau + GIT_REPOSITORY https://github.com/luau-lang/luau.git + GIT_TAG origin/master + GIT_SHALLOW TRUE + GIT_PROGRESS TRUE + PATCH_COMMAND git apply ${CMAKE_CURRENT_SOURCE_DIR}/luau_extern_fix.patch + UPDATE_DISCONNECTED 1 + ) + set(LUAU_BUILD_CLI OFF CACHE BOOL "Build CLI" FORCE) + set(LUAU_BUILD_TESTS OFF CACHE BOOL "Build tests" FORCE) + set(LUAU_BUILD_WEB OFF CACHE BOOL "Build Web module" FORCE) + set(LUAU_WERROR OFF CACHE BOOL "Warnings as errors" FORCE) + set(LUAU_STATIC_CRT OFF CACHE BOOL "Link with the static CRT (/MT)" FORCE) + set(LUAU_EXTERN_C ON CACHE BOOL "Use extern C for all APIs" FORCE) + FetchContent_MakeAvailable(luau) + target_include_directories(cjson PRIVATE ${luau_SOURCE_DIR}/VM/include) + target_compile_definitions(cjson PUBLIC LUAU=1) +else () + find_package(Lua51 REQUIRED) + target_include_directories(cjson PRIVATE ${LUA_INCLUDE_DIR}) +endif() if(NOT USE_INTERNAL_FPCONV) # Use libc number conversion routines (strtod(), sprintf()) - set(FPCONV_SOURCES fpconv.c) + target_sources(cjson PRIVATE fpconv.c) else() # Use internal number conversion routines - add_definitions(-DUSE_INTERNAL_FPCONV) - set(FPCONV_SOURCES g_fmt.c dtoa.c) + target_compile_definitions(cjson PRIVATE USE_INTERNAL_FPCONV) + target_sources(cjson PRIVATE g_fmt.c dtoa.c) include(TestBigEndian) TEST_BIG_ENDIAN(IEEE_BIG_ENDIAN) if(IEEE_BIG_ENDIAN) - add_definitions(-DIEEE_BIG_ENDIAN) + target_compile_definitions(cjson PRIVATE IEEE_BIG_ENDIAN) endif() if(MULTIPLE_THREADS) @@ -39,7 +74,8 @@ else() message(FATAL_ERROR "Pthreads not found - required by MULTIPLE_THREADS option") endif() - add_definitions(-DMULTIPLE_THREADS) + target_compile_definitions(cjson PRIVATE MULTIPLE_THREADS) + target_link_libraries(cjson PRIVATE Threads::Threads) endif() endif() @@ -47,11 +83,18 @@ endif() include(CheckSymbolExists) CHECK_SYMBOL_EXISTS(isinf math.h HAVE_ISINF) if(NOT HAVE_ISINF) - add_definitions(-DUSE_INTERNAL_ISINF) + target_compile_definitions(cjson PRIVATE USE_INTERNAL_ISINF) endif() -set(_MODULE_LINK "${CMAKE_THREAD_LIBS_INIT}") -get_filename_component(_lua_lib_dir ${LUA_LIBRARY} PATH) +if(USE_LUAU) + target_link_libraries(cjson PRIVATE Luau.VM) + target_compile_definitions(cjson PRIVATE ENABLE_CJSON_GLOBAL) +else() + if(WIN32) + # Win32 modules need to be linked to the Lua library. + target_link_libraries(cjson PRIVATE ${LUA_LIBRARIES}) + endif() +endif() if(APPLE) set(CMAKE_SHARED_MODULE_CREATE_C_FLAGS @@ -59,25 +102,45 @@ if(APPLE) endif() if(WIN32) - # Win32 modules need to be linked to the Lua library. - set(_MODULE_LINK ${LUA_LIBRARY} ${_MODULE_LINK}) - set(_lua_module_dir "${_lua_lib_dir}") # Windows sprintf()/strtod() handle NaN/inf differently. Not supported. - add_definitions(-DDISABLE_INVALID_NUMBERS) -else() - set(_lua_module_dir "${_lua_lib_dir}/lua/5.1") + target_compile_definitions(cjson PRIVATE DISABLE_INVALID_NUMBERS) endif() if(MSVC) - add_definitions(-D_CRT_SECURE_NO_WARNINGS) - add_definitions(-Dinline=__inline) - add_definitions(-Dsnprintf=_snprintf) - add_definitions(-Dstrncasecmp=_strnicmp) + target_compile_definitions(cjson PRIVATE _CRT_SECURE_NO_WARNINGS) + target_compile_definitions(cjson PRIVATE inline=__inline) + target_compile_definitions(cjson PRIVATE snprintf=_snprintf) + target_compile_definitions(cjson PRIVATE strncasecmp=_strnicmp) endif() -add_library(cjson MODULE lua_cjson.c strbuf.c ${FPCONV_SOURCES}) +target_sources(cjson PRIVATE lua_cjson.c strbuf.c) set_target_properties(cjson PROPERTIES PREFIX "") -target_link_libraries(cjson ${_MODULE_LINK}) -install(TARGETS cjson DESTINATION "${_lua_module_dir}") +if(NOT USE_LUAU) + get_filename_component(_lua_lib_dir ${LUA_LIBRARY} PATH) + if(WIN32) + set(_lua_module_dir "${_lua_lib_dir}") + else() + set(_lua_module_dir "${_lua_lib_dir}/lua/5.1") + endif() + install(TARGETS cjson DESTINATION "${_lua_module_dir}") +endif() + +if(COMPILE_LUAU_TEST) + if(NOT USE_LUAU) + message(FATAL_ERROR "Can not compile Luau test if Luau is not used") + endif() + enable_language(CXX) + add_executable(luau_test tests/luau_test.cpp) + target_link_libraries(luau_test PRIVATE + Luau.Compiler + Luau.VM + cjson + ) + target_include_directories(luau_test PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${luau_SOURCE_DIR}/VM/include + ${luau_SOURCE_DIR}/Compiler/include + ) +endif() # vi:ai et sw=4 ts=4: diff --git a/lua_cjson.c b/lua_cjson.c index 3d1123e0..60d75e7c 100644 --- a/lua_cjson.c +++ b/lua_cjson.c @@ -36,12 +36,17 @@ * difficult to know object/array sizes ahead of time. */ +#include #include #include #include #include #include +#ifdef LUAU +#include "lualib.h" +#else #include +#endif #include "strbuf.h" #include "fpconv.h" @@ -50,6 +55,10 @@ #define CJSON_MODNAME "cjson" #endif +#ifndef CJSON_SAFE_MODNAME +#define CJSON_SAFE_MODNAME "cjson_safe" +#endif + #ifndef CJSON_VERSION #define CJSON_VERSION "2.1devel" #endif @@ -362,31 +371,29 @@ static int json_cfg_decode_invalid_numbers(lua_State *l) return 1; } +static void json_config_destructor(void* p) +{ + json_config_t *cfg; + cfg = (json_config_t *)p; + if (cfg && cfg->encode_keep_buffer) + strbuf_free(&cfg->encode_buf); +} + static int json_destroy_config(lua_State *l) { json_config_t *cfg; cfg = (json_config_t *)lua_touserdata(l, 1); - if (cfg) - strbuf_free(&cfg->encode_buf); + json_config_destructor(cfg); cfg = NULL; return 0; } -static void json_create_config(lua_State *l) +static void json_config_init(json_config_t *cfg) { - json_config_t *cfg; int i; - - cfg = (json_config_t *)lua_newuserdata(l, sizeof(*cfg)); - - /* Create GC method to clean up strbuf */ - lua_newtable(l); - lua_pushcfunction(l, json_destroy_config); - lua_setfield(l, -2, "__gc"); - lua_setmetatable(l, -2); - + cfg->encode_sparse_convert = DEFAULT_SPARSE_CONVERT; cfg->encode_sparse_ratio = DEFAULT_SPARSE_RATIO; cfg->encode_sparse_safe = DEFAULT_SPARSE_SAFE; @@ -447,6 +454,38 @@ static void json_create_config(lua_State *l) cfg->escape2char['u'] = 'u'; /* Unicode parsing required */ } +static int json_reset_config(lua_State *l) +{ + json_config_t *cfg = json_arg_init(l, 0); + + if (cfg){ + if (cfg->encode_keep_buffer) + strbuf_free(&cfg->encode_buf); + json_config_init(cfg); + } + + return 0; +} + +static void json_create_config(lua_State *l) +{ + json_config_t *cfg; + +#ifdef LUAU + cfg = (json_config_t *)lua_newuserdatadtor(l, sizeof(*cfg), json_config_destructor); +#else + cfg = (json_config_t *)lua_newuserdata(l, sizeof(*cfg)); + + /* Create GC method to clean up strbuf */ + lua_newtable(l); + lua_pushcfunction(l, json_destroy_config); + lua_setfield(l, -2, "__gc"); + lua_setmetatable(l, -2); +#endif + + json_config_init(cfg); +} + /* ===== ENCODING ===== */ static void json_encode_exception(lua_State *l, json_config_t *cfg, strbuf_t *json, int lindex, @@ -1140,8 +1179,8 @@ static void json_decode_descend(lua_State *l, json_parse_t *json, int slots) } strbuf_free(json->tmp); - luaL_error(l, "Found too many nested data structures (%d) at character %d", - json->current_depth, json->ptr - json->data); + luaL_error(l, "Found too many nested data structures (%d) at character %lld", + json->current_depth, (long long int)(json->ptr - json->data)); } static void json_parse_object_context(lua_State *l, json_parse_t *json) @@ -1317,7 +1356,11 @@ static void luaL_setfuncs (lua_State *l, const luaL_Reg *reg, int nup) for (; reg->name != NULL; reg++) { /* fill the table with given functions */ for (i = 0; i < nup; i++) /* copy upvalues to the top */ lua_pushvalue(l, -nup); + #ifdef LUAU + lua_pushcclosure(l, reg->func, reg->name, nup); + #else lua_pushcclosure(l, reg->func, nup); /* closure with those upvalues */ + #endif lua_setfield(l, -(nup + 2), reg->name); } lua_pop(l, nup); /* remove upvalues */ @@ -1349,7 +1392,12 @@ static int json_protect_conversion(lua_State *l) /* Since we are not using a custom error handler, the only remaining * errors are memory related */ + #ifdef LUAU + luaL_error(l, "Memory allocation error in CJSON protected call"); /* never returns */ + return 0; + #else return luaL_error(l, "Memory allocation error in CJSON protected call"); + #endif } /* Return cjson module table */ @@ -1365,7 +1413,10 @@ static int lua_cjson_new(lua_State *l) { "encode_keep_buffer", json_cfg_encode_keep_buffer }, { "encode_invalid_numbers", json_cfg_encode_invalid_numbers }, { "decode_invalid_numbers", json_cfg_decode_invalid_numbers }, +#ifndef ENABLE_CJSON_GLOBAL { "new", lua_cjson_new }, +#endif + { "reset", json_reset_config}, { NULL, NULL } }; @@ -1400,13 +1451,23 @@ static int lua_cjson_safe_new(lua_State *l) lua_cjson_new(l); +#ifndef ENABLE_CJSON_GLOBAL /* Fix new() method */ + #ifdef LUAU + lua_pushcfunction(l, lua_cjson_safe_new, "lua_cjson_safe_new"); + #else lua_pushcfunction(l, lua_cjson_safe_new); + #endif lua_setfield(l, -2, "new"); +#endif for (i = 0; func[i]; i++) { lua_getfield(l, -1, func[i]); + #ifdef LUAU + lua_pushcclosure(l, json_protect_conversion, func[i], 1); + #else lua_pushcclosure(l, json_protect_conversion, 1); + #endif lua_setfield(l, -2, func[i]); } @@ -1431,6 +1492,12 @@ CJSON_EXPORT int luaopen_cjson_safe(lua_State *l) { lua_cjson_safe_new(l); +#ifdef ENABLE_CJSON_GLOBAL + /* Register a global "cjson_safe" table. */ + lua_pushvalue(l, -1); + lua_setglobal(l, CJSON_SAFE_MODNAME); +#endif + /* Return cjson.safe table */ return 1; } diff --git a/luau_cjson.h b/luau_cjson.h new file mode 100644 index 00000000..f905074d --- /dev/null +++ b/luau_cjson.h @@ -0,0 +1,13 @@ +#ifndef LUA_CJSON_H +#define LUA_CJSON_H + +/* Only use this header when using Luau */ +#ifdef LUAU + +#include "lua.h" + +LUA_API int luaopen_cjson(lua_State *l); +LUA_API int luaopen_cjson_safe(lua_State *l); + +#endif /* LUAU */ +#endif /* LUA_CJSON_H */ \ No newline at end of file diff --git a/luau_extern_fix.patch b/luau_extern_fix.patch new file mode 100644 index 00000000..32ef5967 --- /dev/null +++ b/luau_extern_fix.patch @@ -0,0 +1,17 @@ +diff --git a/CMakeLists.txt b/CMakeLists.txt +index ff9dc00f..2e56518c 100644 +--- a/CMakeLists.txt ++++ b/CMakeLists.txt +@@ -172,9 +172,9 @@ if(LUAU_EXTERN_C) + # enable extern "C" for VM (lua.h, lualib.h) and Compiler (luacode.h) to make Luau friendlier to use from non-C++ languages + # note that we enable LUA_USE_LONGJMP=1 as well; otherwise functions like luaL_error will throw C++ exceptions, which can't be done from extern "C" functions + target_compile_definitions(Luau.VM PUBLIC LUA_USE_LONGJMP=1) +- target_compile_definitions(Luau.VM PUBLIC LUA_API=extern\"C\") +- target_compile_definitions(Luau.Compiler PUBLIC LUACODE_API=extern\"C\") +- target_compile_definitions(Luau.CodeGen PUBLIC LUACODEGEN_API=extern\"C\") ++ target_compile_definitions(Luau.VM PUBLIC $<$:LUA_API=extern\"C\">) ++ target_compile_definitions(Luau.Compiler PUBLIC $<$:LUACODE_API=extern\"C\">) ++ target_compile_definitions(Luau.CodeGen PUBLIC $<$:LUACODEGEN_API=extern\"C\">) + endif() + + if (CMAKE_CXX_COMPILER_ID STREQUAL "MSVC" AND MSVC_VERSION GREATER_EQUAL 1924) diff --git a/runtests_luau.sh b/runtests_luau.sh new file mode 100755 index 00000000..28de2c7e --- /dev/null +++ b/runtests_luau.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +set -eoux pipefail + +ARG1=${1:-} + +clear +if [[ $ARG1 == "--rst" ]]; then + rm -rf _build || true +fi +cmake -G Ninja -B _build -S . -DUSE_INTERNAL_FPCONV=ON -DUSE_LUAU=ON -DCOMPILE_LUAU_TEST=ON \ + -DCMAKE_BUILD_TYPE=RelWithDebInfo -DCMAKE_EXPORT_COMPILE_COMMANDS=ON +cmake --build _build --config RelWithDebInfo +cd tests +if [ ! -f utf8.dat ]; then + ./genutf8.pl +fi +../_build/luau_test luau_test.lua + diff --git a/tests/luau_test.cpp b/tests/luau_test.cpp new file mode 100644 index 00000000..eecdbfc6 --- /dev/null +++ b/tests/luau_test.cpp @@ -0,0 +1,158 @@ +#include +#include +#include +#include + +// luau +#include "luacode.h" +#include "lua.h" +#include "lualib.h" + +// lua-cjson +#include "luau_cjson.h" + +std::string load_file_to_string(std::string& filename) +{ + std::ifstream file(filename); + if (!file) + { + std::cerr << "Failed to open file: " << filename << std::endl; + return ""; + } + std::stringstream buffer; + buffer << file.rdbuf(); + return buffer.str(); +} + +int exec_luau_source(lua_State* L, std::string chunkname, std::string& source) +{ + lua_CompileOptions complile_options = {}; + //// debug + //complile_options.optimizationLevel = 0; + //complile_options.debugLevel = 2; + // default + complile_options.optimizationLevel = 1; + complile_options.debugLevel = 1; + //// performance + //complile_options.optimizationLevel = 2; + //complile_options.debugLevel = 0; + + size_t bytecodeSize = 0; + char * bytecode = luau_compile(source.c_str(), source.size(), &complile_options, &bytecodeSize); + int load_result = luau_load(L, chunkname.c_str(), bytecode, bytecodeSize, 0); + free(bytecode); + + // load bytecode into VM + if (load_result != 0) + { + std::cerr << "Load error: " << lua_tostring(L, -1) << std::endl; + lua_pop(L, 1); // remove error from stack + lua_close(L); + return 1; + } + + /* Stack: [ ... , chunk ] */ + + /* 2) push debug.traceback and remove the debug table (so stack becomes [ ... , chunk, traceback] ) */ + lua_getglobal(L, "debug"); /* pushes debug table */ + lua_getfield(L, -1, "traceback"); /* pushes debug.traceback */ + lua_remove(L, -2); /* remove debug table; stack: [ ... , chunk, traceback ] */ + + /* 3) move traceback *below* the chunk => stack becomes [ ... , traceback, chunk ] */ + lua_insert(L, -2); + + int errfunc = lua_gettop(L) - 1; + + // execute script + if (lua_pcall(L, 0, LUA_MULTRET, errfunc) != 0) + { + std::cerr << "Runtime error: " << lua_tostring(L, -1) << std::endl; + lua_pop(L, 1); // remove error from stack + lua_close(L); + return 1; + } + + lua_remove(L, errfunc); + + return 0; +} + +// Luau has no file access, we create a custom function here to make the tests work +int luau_file_load(lua_State* L) +{ + int narg = lua_gettop(L); + if (narg != 1) + luaL_error(L, "luau_file_load: expected 1 argument (filename : string), got %d arguments", narg); + const char *arg1 = lua_tostring(L, 1); + if (arg1 == NULL) + luaL_error(L, "luau_file_load: expected 1 argument (filename : string), argument not a string"); + FILE* f = fopen(arg1, "rb"); + if (f == NULL) + luaL_error(L, "luau_file_load: can not open file %s", arg1); + fseek(f, 0, SEEK_END); + size_t f_size = ftell(f); + fseek(f, 0, SEEK_SET); + char* f_data = (char*)malloc(f_size); + if (f_data == NULL) + luaL_error(L, "luau_file_load: can not allocate %llu bytes", (long long unsigned)f_size); + size_t f_read = fread(f_data, 1, f_size, f); + if (f_read != f_size) + luaL_error(L, "luau_file_load: can only read %llu bytes, wanted %llu bytes", (long long unsigned)f_read, (long long unsigned)f_size); + lua_pushlstring(L, f_data, f_size); + free(f_data); + fclose(f); + return 1; +} + +// Luau has no setlocale, we create a custom function here to make the tests work +int luau_setlocale(lua_State* L) +{ + int narg = lua_gettop(L); + if (narg != 1) + luaL_error(L, "luau_setlocale: expected 1 argument (locale : string), got %d arguments", narg); + const char *arg1 = lua_tostring(L, 1); + if (arg1 == NULL) + luaL_error(L, "luau_setlocale: expected 1 argument (locale : string), argument not a string"); + char* ret = setlocale(LC_ALL, arg1); + if (ret == NULL) + luaL_error(L, "luau_setlocale: can not set locale %s", arg1); + return 0; +} + +int main(int argc, char** argv) +{ + if (argc < 2) + { + std::cerr << "Usage: " << argv[0] << " script.luau" << std::endl; + return 1; + } + + // create a new Luau VM state + lua_State* L = luaL_newstate(); + luaL_openlibs(L); + + // load lua-cjson library + lua_pushcfunction(L, luaopen_cjson, "luaopen_cjson"); + lua_call(L, 0, 0); + lua_pushcfunction(L, luaopen_cjson_safe, "luaopen_cjson_safe"); + lua_call(L, 0, 0); + + // load user defined functions + lua_pushcfunction(L, luau_file_load, "luau_file_load"); + lua_setglobal(L, "luau_file_load"); + lua_pushcfunction(L, luau_setlocale, "luau_setlocale"); + lua_setglobal(L, "luau_setlocale"); + + // lock global state + luaL_sandbox(L); + + // load user lua code + { + std::string user_filename = std::string(argv[1]); + std::string user_src = load_file_to_string(user_filename); + if (exec_luau_source(L, user_filename, user_src)) return 1; + } + + lua_close(L); + return 0; +} \ No newline at end of file diff --git a/tests/luau_test.lua b/tests/luau_test.lua new file mode 100755 index 00000000..51b09a6d --- /dev/null +++ b/tests/luau_test.lua @@ -0,0 +1,691 @@ +-- Various common routines used by the Lua CJSON package +-- +-- Mark Pulford + +-- Determine with a Lua table can be treated as an array. +-- Explicitly returns "not an array" for very sparse arrays. +-- Returns: +-- -1 Not an array +-- 0 Empty table +-- >0 Highest index in the array + +-- Provide unpack for Lua 5.3+ built without LUA_COMPAT_UNPACK +local unpack = unpack +if table.unpack then unpack = table.unpack end + +local function is_array(table) + local max = 0 + local count = 0 + for k, v in pairs(table) do + if type(k) == "number" then + if k > max then max = k end + count = count + 1 + else + return -1 + end + end + if max > count * 2 then + return -1 + end + + return max +end + +local serialise_value + +local function serialise_table(value, indent, depth) + local spacing, spacing2, indent2 + if indent then + spacing = "\n" .. indent + spacing2 = spacing .. " " + indent2 = indent .. " " + else + spacing, spacing2, indent2 = " ", " ", false + end + depth = depth + 1 + if depth > 50 then + return "Cannot serialise any further: too many nested tables" + end + + local max = is_array(value) + + local comma = false + local fragment = { "{" .. spacing2 } + if max > 0 then + -- Serialise array + for i = 1, max do + if comma then + table.insert(fragment, "," .. spacing2) + end + table.insert(fragment, serialise_value(value[i], indent2, depth)) + comma = true + end + elseif max < 0 then + -- Serialise table + for k, v in pairs(value) do + if comma then + table.insert(fragment, "," .. spacing2) + end + table.insert(fragment, + ("[%s] = %s"):format(serialise_value(k, indent2, depth), + serialise_value(v, indent2, depth))) + comma = true + end + end + table.insert(fragment, spacing .. "}") + + return table.concat(fragment) +end + +function serialise_value(value, indent, depth) + if indent == nil then indent = "" end + if depth == nil then depth = 0 end + + if value == cjson.null then + return "json.null" + elseif type(value) == "string" then + return ("%q"):format(value) + elseif type(value) == "nil" or type(value) == "number" or + type(value) == "boolean" then + return tostring(value) + elseif type(value) == "table" then + return serialise_table(value, indent, depth) + else + return "\"<" .. type(value) .. ">\"" + end +end + +local function compare_values(val1, val2) + local type1 = type(val1) + local type2 = type(val2) + if type1 ~= type2 then + return false + end + + -- Check for NaN + if type1 == "number" and val1 ~= val1 and val2 ~= val2 then + return true + end + + if type1 ~= "table" then + return val1 == val2 + end + + -- check_keys stores all the keys that must be checked in val2 + local check_keys = {} + for k, _ in pairs(val1) do + check_keys[k] = true + end + + for k, v in pairs(val2) do + if not check_keys[k] then + return false + end + + if not compare_values(val1[k], val2[k]) then + return false + end + + check_keys[k] = nil + end + for k, _ in pairs(check_keys) do + -- Not the same if any keys from val1 were not found in val2 + return false + end + return true +end + +local test_count_pass = 0 +local test_count_total = 0 + +local function run_test_summary() + return test_count_pass, test_count_total +end + +local function run_test(testname, func, input, should_work, output) + + local function to_printable_str(str, max_len) + if #str > max_len then + return string.sub(str, 1, max_len) .. "... (" .. #str-max_len .. " bytes omitted)" + else + return str + end + end + + local function status_line(name, status, value, max_len) + local statusmap = { [true] = ":success", [false] = ":error" } + if status ~= nil then + name = name .. statusmap[status] + end + print(("[%s] %s"):format(name, to_printable_str(serialise_value(value, false), max_len))) + end + + local result = { pcall(func, unpack(input)) } + local success = table.remove(result, 1) + + local correct = false + if success == should_work and compare_values(result, output) then + correct = true + test_count_pass = test_count_pass + 1 + end + test_count_total = test_count_total + 1 + + local teststatus = { [true] = "PASS", [false] = "FAIL" } + print(("==> Test [%d] %s: %s"):format(test_count_total, testname, + teststatus[correct])) + + local max_len = 120 + + status_line("Input", nil, input, max_len) + if not correct then + status_line("Expected", should_work, output, max_len) + end + status_line("Received", success, result, max_len) + print() + + if not correct then + error("test failed") + end + + return correct, result +end + +local function run_test_group(tests) + local function run_helper(name, func, input) + if type(name) == "string" and #name > 0 then + print("==> " .. name) + end + -- Not a protected call, these functions should never generate errors. + func(unpack(input or {})) + print() + end + + for _, v in ipairs(tests) do + -- Run the helper if "should_work" is missing + if v[4] == nil then + run_helper(unpack(v)) + else + run_test(unpack(v)) + end + end +end + +-- Run a Lua script in a separate environment +local function run_script(script, env) + local env = env or {} + local func + + -- Use setfenv() if it exists, otherwise assume Lua 5.2 load() exists + if _G.setfenv then + func = loadstring(script) + if func then + setfenv(func, env) + end + else + func = load(script, nil, nil, env) + end + + if func == nil then + error("Invalid syntax.") + end + func() + + return env +end + +-- Export functions +local util = { + serialise_value = serialise_value, + file_load = luau_file_load, + compare_values = compare_values, + run_test_summary = run_test_summary, + run_test = run_test, + run_test_group = run_test_group, + run_script = run_script +} + +-- Luau CJSON tests +-- +-- Mark Pulford +-- +-- Note: The output of this script is easier to read with "less -S" + +local function json_encode_output_type(value) + local text = cjson.encode(value) + if string.match(text, "{.*}") then + return "object" + elseif string.match(text, "%[.*%]") then + return "array" + else + return "scalar" + end +end + +local function gen_raw_octets() + local chars = {} + for i = 0, 255 do chars[i + 1] = string.char(i) end + return table.concat(chars) +end + +-- Generate every UTF-16 codepoint, including supplementary codes +local function gen_utf16_escaped() + -- Create raw table escapes + local utf16_escaped = {} + local count = 0 + + local function append_escape(code) + local esc = ('\\u%04X'):format(code) + table.insert(utf16_escaped, esc) + end + + table.insert(utf16_escaped, '"') + for i = 0, 0xD7FF do + append_escape(i) + end + -- Skip 0xD800 - 0xDFFF since they are used to encode supplementary + -- codepoints + for i = 0xE000, 0xFFFF do + append_escape(i) + end + -- Append surrogate pair for each supplementary codepoint + for high = 0xD800, 0xDBFF do + for low = 0xDC00, 0xDFFF do + append_escape(high) + append_escape(low) + end + end + table.insert(utf16_escaped, '"') + + return table.concat(utf16_escaped) +end + +local function load_testdata() + local data = {} + + -- Data for 8bit raw <-> escaped octets tests + data.octets_raw = gen_raw_octets() + data.octets_escaped = util.file_load("octets-escaped.dat") + + -- Data for \uXXXX -> UTF-8 test + data.utf16_escaped = gen_utf16_escaped() + + -- Load matching data for utf16_escaped + local utf8_loaded + utf8_loaded, data.utf8_raw = pcall(util.file_load, "utf8.dat") + if not utf8_loaded then + data.utf8_raw = "Failed to load utf8.dat - please run genutf8.pl" + end + + data.table_cycle = {} + data.table_cycle[1] = data.table_cycle + + local big = {} + for i = 1, 1100 do + big = { { 10, false, true, cjson.null }, "string", a = big } + end + data.deeply_nested_data = big + + return data +end + +local function test_decode_cycle(filename) + local obj1 = cjson.decode(util.file_load(filename)) + local obj2 = cjson.decode(cjson.encode(obj1)) + return util.compare_values(obj1, obj2) +end + +-- Set up data used in tests +local Inf = math.huge; +local NaN = math.huge * 0; + +local testdata = load_testdata() + +local cjson_tests = { + -- Test API variables + { "Check module name, version", + function () return cjson._NAME, cjson._VERSION end, { }, + true, { "cjson", "2.1devel" } }, + + -- Test decoding simple types + { "Decode string", + cjson.decode, { '"test string"' }, true, { "test string" } }, + { "Decode numbers", + cjson.decode, { '[ 0.0, -5e3, -1, 0.3e-3, 1023.2, 0e10 ]' }, + true, { { 0.0, -5000, -1, 0.0003, 1023.2, 0 } } }, + { "Decode null", + cjson.decode, { 'null' }, true, { cjson.null } }, + { "Decode true", + cjson.decode, { 'true' }, true, { true } }, + { "Decode false", + cjson.decode, { 'false' }, true, { false } }, + { "Decode object with numeric keys", + cjson.decode, { '{ "1": "one", "3": "three" }' }, + true, { { ["1"] = "one", ["3"] = "three" } } }, + { "Decode object with string keys", + cjson.decode, { '{ "a": "a", "b": "b" }' }, + true, { { a = "a", b = "b" } } }, + { "Decode array", + cjson.decode, { '[ "one", null, "three" ]' }, + true, { { "one", cjson.null, "three" } } }, + + -- Test decoding errors + { "Decode UTF-16BE [throw error]", + cjson.decode, { '\0"\0"' }, + false, { "JSON parser does not support UTF-16 or UTF-32" } }, + { "Decode UTF-16LE [throw error]", + cjson.decode, { '"\0"\0' }, + false, { "JSON parser does not support UTF-16 or UTF-32" } }, + { "Decode UTF-32BE [throw error]", + cjson.decode, { '\0\0\0"' }, + false, { "JSON parser does not support UTF-16 or UTF-32" } }, + { "Decode UTF-32LE [throw error]", + cjson.decode, { '"\0\0\0' }, + false, { "JSON parser does not support UTF-16 or UTF-32" } }, + { "Decode partial JSON [throw error]", + cjson.decode, { '{ "unexpected eof": ' }, + false, { "Expected value but found T_END at character 21" } }, + { "Decode with extra comma [throw error]", + cjson.decode, { '{ "extra data": true }, false' }, + false, { "Expected the end but found T_COMMA at character 23" } }, + { "Decode invalid escape code [throw error]", + cjson.decode, { [[ { "bad escape \q code" } ]] }, + false, { "Expected object key string but found invalid escape code at character 16" } }, + { "Decode invalid unicode escape [throw error]", + cjson.decode, { [[ { "bad unicode \u0f6 escape" } ]] }, + false, { "Expected object key string but found invalid unicode escape code at character 17" } }, + { "Decode invalid keyword [throw error]", + cjson.decode, { ' [ "bad barewood", test ] ' }, + false, { "Expected value but found invalid token at character 20" } }, + { "Decode invalid number #1 [throw error]", + cjson.decode, { '[ -+12 ]' }, + false, { "Expected value but found invalid number at character 3" } }, + { "Decode invalid number #2 [throw error]", + cjson.decode, { '-v' }, + false, { "Expected value but found invalid number at character 1" } }, + { "Decode invalid number exponent [throw error]", + cjson.decode, { '[ 0.4eg10 ]' }, + false, { "Expected comma or array end but found invalid token at character 6" } }, + + -- Test decoding nested arrays / objects + { "Set decode_max_depth(5)", + cjson.decode_max_depth, { 5 }, true, { 5 } }, + { "Decode array at nested limit", + cjson.decode, { '[[[[[ "nested" ]]]]]' }, + true, { {{{{{ "nested" }}}}} } }, + { "Decode array over nested limit [throw error]", + cjson.decode, { '[[[[[[ "nested" ]]]]]]' }, + false, { "Found too many nested data structures (6) at character 6" } }, + { "Decode object at nested limit", + cjson.decode, { '{"a":{"b":{"c":{"d":{"e":"nested"}}}}}' }, + true, { {a={b={c={d={e="nested"}}}}} } }, + { "Decode object over nested limit [throw error]", + cjson.decode, { '{"a":{"b":{"c":{"d":{"e":{"f":"nested"}}}}}}' }, + false, { "Found too many nested data structures (6) at character 26" } }, + { "Set decode_max_depth(1000)", + cjson.decode_max_depth, { 1000 }, true, { 1000 } }, + { "Decode deeply nested array [throw error]", + cjson.decode, { string.rep("[", 1100) .. '1100' .. string.rep("]", 1100)}, + false, { "Found too many nested data structures (1001) at character 1001" } }, + + -- Test encoding nested tables + { "Set encode_max_depth(5)", + cjson.encode_max_depth, { 5 }, true, { 5 } }, + { "Encode nested table as array at nested limit", + cjson.encode, { {{{{{"nested"}}}}} }, true, { '[[[[["nested"]]]]]' } }, + { "Encode nested table as array after nested limit [throw error]", + cjson.encode, { { {{{{{"nested"}}}}} } }, + false, { "Cannot serialise, excessive nesting (6)" } }, + { "Encode nested table as object at nested limit", + cjson.encode, { {a={b={c={d={e="nested"}}}}} }, + true, { '{"a":{"b":{"c":{"d":{"e":"nested"}}}}}' } }, + { "Encode nested table as object over nested limit [throw error]", + cjson.encode, { {a={b={c={d={e={f="nested"}}}}}} }, + false, { "Cannot serialise, excessive nesting (6)" } }, + { "Encode table with cycle [throw error]", + cjson.encode, { testdata.table_cycle }, + false, { "Cannot serialise, excessive nesting (6)" } }, + { "Set encode_max_depth(1000)", + cjson.encode_max_depth, { 1000 }, true, { 1000 } }, + { "Encode deeply nested data [throw error]", + cjson.encode, { testdata.deeply_nested_data }, + false, { "Cannot serialise, excessive nesting (1001)" } }, + + -- Test encoding simple types + { "Encode null", + cjson.encode, { cjson.null }, true, { 'null' } }, + { "Encode true", + cjson.encode, { true }, true, { 'true' } }, + { "Encode false", + cjson.encode, { false }, true, { 'false' } }, + { "Encode empty object", + cjson.encode, { { } }, true, { '{}' } }, + { "Encode integer", + cjson.encode, { 10 }, true, { '10' } }, + { "Encode string", + cjson.encode, { "hello" }, true, { '"hello"' } }, + { "Encode Lua function [throw error]", + cjson.encode, { function () end }, + false, { "Cannot serialise function: type not supported" } }, + + -- Test decoding invalid numbers + { "Set decode_invalid_numbers(true)", + cjson.decode_invalid_numbers, { true }, true, { true } }, + { "Decode hexadecimal", + cjson.decode, { '0x6.ffp1' }, true, { 13.9921875 } }, + { "Decode numbers with leading zero", + cjson.decode, { '[ 0123, 00.33 ]' }, true, { { 123, 0.33 } } }, + { "Decode +-Inf", + cjson.decode, { '[ +Inf, Inf, -Inf ]' }, true, { { Inf, Inf, -Inf } } }, + { "Decode +-Infinity", + cjson.decode, { '[ +Infinity, Infinity, -Infinity ]' }, + true, { { Inf, Inf, -Inf } } }, + { "Decode +-NaN", + cjson.decode, { '[ +NaN, NaN, -NaN ]' }, true, { { NaN, NaN, NaN } } }, + { "Decode Infrared (not infinity) [throw error]", + cjson.decode, { 'Infrared' }, + false, { "Expected the end but found invalid token at character 4" } }, + { "Decode Noodle (not NaN) [throw error]", + cjson.decode, { 'Noodle' }, + false, { "Expected value but found invalid token at character 1" } }, + { "Set decode_invalid_numbers(false)", + cjson.decode_invalid_numbers, { false }, true, { false } }, + { "Decode hexadecimal [throw error]", + cjson.decode, { '0x6' }, + false, { "Expected value but found invalid number at character 1" } }, + { "Decode numbers with leading zero [throw error]", + cjson.decode, { '[ 0123, 00.33 ]' }, + false, { "Expected value but found invalid number at character 3" } }, + { "Decode +-Inf [throw error]", + cjson.decode, { '[ +Inf, Inf, -Inf ]' }, + false, { "Expected value but found invalid token at character 3" } }, + { "Decode +-Infinity [throw error]", + cjson.decode, { '[ +Infinity, Infinity, -Infinity ]' }, + false, { "Expected value but found invalid token at character 3" } }, + { "Decode +-NaN [throw error]", + cjson.decode, { '[ +NaN, NaN, -NaN ]' }, + false, { "Expected value but found invalid token at character 3" } }, + { 'Set decode_invalid_numbers("on")', + cjson.decode_invalid_numbers, { "on" }, true, { true } }, + + -- Test encoding invalid numbers + { "Set encode_invalid_numbers(false)", + cjson.encode_invalid_numbers, { false }, true, { false } }, + { "Encode NaN [throw error]", + cjson.encode, { NaN }, + false, { "Cannot serialise number: must not be NaN or Infinity" } }, + { "Encode Infinity [throw error]", + cjson.encode, { Inf }, + false, { "Cannot serialise number: must not be NaN or Infinity" } }, + { "Set encode_invalid_numbers(\"null\")", + cjson.encode_invalid_numbers, { "null" }, true, { "null" } }, + { "Encode NaN as null", + cjson.encode, { NaN }, true, { "null" } }, + { "Encode Infinity as null", + cjson.encode, { Inf }, true, { "null" } }, + { "Set encode_invalid_numbers(true)", + cjson.encode_invalid_numbers, { true }, true, { true } }, + { "Encode NaN", + cjson.encode, { NaN }, true, { "NaN" } }, + { "Encode +Infinity", + cjson.encode, { Inf }, true, { "Infinity" } }, + { "Encode -Infinity", + cjson.encode, { -Inf }, true, { "-Infinity" } }, + { 'Set encode_invalid_numbers("off")', + cjson.encode_invalid_numbers, { "off" }, true, { false } }, + + -- Test encoding tables + { "Set encode_sparse_array(true, 2, 3)", + cjson.encode_sparse_array, { true, 2, 3 }, true, { true, 2, 3 } }, + { "Encode sparse table as array #1", + cjson.encode, { { [3] = "sparse test" } }, + true, { '[null,null,"sparse test"]' } }, + { "Encode sparse table as array #2", + cjson.encode, { { [1] = "one", [4] = "sparse test" } }, + true, { '["one",null,null,"sparse test"]' } }, + { "Encode sparse array as object", + json_encode_output_type, { { [1] = "one", [5] = "sparse test" } }, + true, { 'object' } }, + { "Encode table with numeric string key as object", + cjson.encode, { { ["2"] = "numeric string key test" } }, + true, { '{"2":"numeric string key test"}' } }, + { "Set encode_sparse_array(false)", + cjson.encode_sparse_array, { false }, true, { false, 2, 3 } }, + { "Encode table with incompatible key [throw error]", + cjson.encode, { { [false] = "wrong" } }, + false, { "Cannot serialise boolean: table key must be a number or string" } }, + + -- Test escaping + { "Encode all octets (8-bit clean)", + cjson.encode, { testdata.octets_raw }, true, { testdata.octets_escaped } }, + { "Decode all escaped octets", + cjson.decode, { testdata.octets_escaped }, true, { testdata.octets_raw } }, + { "Decode single UTF-16 escape", + cjson.decode, { [["\uF800"]] }, true, { "\239\160\128" } }, + { "Decode all UTF-16 escapes (including surrogate combinations)", + cjson.decode, { testdata.utf16_escaped }, true, { testdata.utf8_raw } }, + { "Decode swapped surrogate pair [throw error]", + cjson.decode, { [["\uDC00\uD800"]] }, + false, { "Expected value but found invalid unicode escape code at character 2" } }, + { "Decode duplicate high surrogate [throw error]", + cjson.decode, { [["\uDB00\uDB00"]] }, + false, { "Expected value but found invalid unicode escape code at character 2" } }, + { "Decode duplicate low surrogate [throw error]", + cjson.decode, { [["\uDB00\uDB00"]] }, + false, { "Expected value but found invalid unicode escape code at character 2" } }, + { "Decode missing low surrogate [throw error]", + cjson.decode, { [["\uDB00"]] }, + false, { "Expected value but found invalid unicode escape code at character 2" } }, + { "Decode invalid low surrogate [throw error]", + cjson.decode, { [["\uDB00\uD"]] }, + false, { "Expected value but found invalid unicode escape code at character 2" } }, + + -- Test locale support + -- + -- The standard Lua interpreter is ANSI C online doesn't support locales + -- by default. Force a known problematic locale to test strtod()/sprintf(). + { "Set locale to en_DK.utf8 (comma separator)", function () + luau_setlocale("en_DK.utf8") + cjson.reset() + end }, + { "Encode number under comma locale", + cjson.encode, { 1.5 }, true, { '1.5' } }, + { "Decode number in array under comma locale", + cjson.decode, { '[ 10, "test" ]' }, true, { { 10, "test" } } }, + { "Revert locale to POSIX", function () + luau_setlocale("C") + cjson.reset() + end }, + + -- Test encode_keep_buffer() and enable_number_precision() + { "Set encode_keep_buffer(false)", + cjson.encode_keep_buffer, { false }, true, { false } }, + { "Set encode_number_precision(3)", + cjson.encode_number_precision, { 3 }, true, { 3 } }, + { "Encode number with precision 3", + cjson.encode, { 1/3 }, true, { "0.333" } }, + { "Set encode_number_precision(14)", + cjson.encode_number_precision, { 14 }, true, { 14 } }, + { "Set encode_keep_buffer(true)", + cjson.encode_keep_buffer, { true }, true, { true } }, + + -- Test config API errors + -- Function is listed as '?' due to pcall + { "Set encode_number_precision(0) [throw error]", + cjson.encode_number_precision, { 0 }, + false, { "invalid argument #1 to 'encode_number_precision' (expected integer between 1 and 14)" } }, + { "Set encode_number_precision(\"five\") [throw error]", + cjson.encode_number_precision, { "five" }, + false, { "invalid argument #1 to 'encode_number_precision' (number expected, got string)" } }, + { "Set encode_keep_buffer(nil, true) [throw error]", + cjson.encode_keep_buffer, { nil, true }, + false, { "invalid argument #2 to 'encode_keep_buffer' (found too many arguments)" } }, + { "Set encode_max_depth(\"wrong\") [throw error]", + cjson.encode_max_depth, { "wrong" }, + false, { "invalid argument #1 to 'encode_max_depth' (number expected, got string)" } }, + { "Set decode_max_depth(0) [throw error]", + cjson.decode_max_depth, { "0" }, + false, { "invalid argument #1 to 'decode_max_depth' (expected integer between 1 and 2147483647)" } }, + { "Set encode_invalid_numbers(-2) [throw error]", + cjson.encode_invalid_numbers, { -2 }, + false, { "invalid argument #1 to 'encode_invalid_numbers' (invalid option '-2')" } }, + { "Set decode_invalid_numbers(true, false) [throw error]", + cjson.decode_invalid_numbers, { true, false }, + false, { "invalid argument #2 to 'decode_invalid_numbers' (found too many arguments)" } }, + { "Set encode_sparse_array(\"not quite on\") [throw error]", + cjson.encode_sparse_array, { "not quite on" }, + false, { "invalid argument #1 to 'encode_sparse_array' (invalid option 'not quite on')" } }, + + { "Reset Lua CJSON configuration", function () cjson.reset() end }, + -- Wrap in a function to ensure the table returned by cjson.reset() is used + { "Check encode_sparse_array()", + function (...) return cjson.encode_sparse_array(...) end, { }, + true, { false, 2, 10 } }, + + { "Encode (safe) simple value", + cjson_safe.encode, { true }, + true, { "true" } }, + { "Encode (safe) argument validation [throw error]", + cjson_safe.encode, { "arg1", "arg2" }, + false, { "invalid argument #1 to 'encode' (expected 1 argument)" } }, + { "Decode (safe) error generation", + cjson_safe.decode, { "Oops" }, + true, { nil, "Expected value but found invalid token at character 1" } }, + { "Decode (safe) error generation after reset()", + function(...) + cjson_safe.reset() + return cjson_safe.decode(...) + end, { "Oops" }, + true, { nil, "Expected value but found invalid token at character 1" } }, +} + +print(("==> Testing Lua CJSON version %s\n"):format(cjson._VERSION)) + +util.run_test_group(cjson_tests) + +local decode_cycle_files = { + "example1.json", + "example2.json", + "example3.json", + "example4.json", + "example5.json", + "numbers.json", + "rfc-example1.json", + "rfc-example2.json", + "types.json" +} + +for _, filename in ipairs(decode_cycle_files) do + util.run_test("Decode cycle " .. filename, test_decode_cycle, { filename }, + true, { true }) +end + +local pass, total = util.run_test_summary() + +if pass == total then + print("==> Summary: all tests succeeded") +else + print(("==> Summary: %d/%d tests failed"):format(total - pass, total)) + error("tests failed") +end + +-- vi:ai et sw=4 ts=4: