From b63739e792d977b43ab4be2007aa661d704c6b38 Mon Sep 17 00:00:00 2001 From: nerix Date: Fri, 21 Nov 2025 17:43:28 +0100 Subject: [PATCH] feat(plugins): add JSON parsing/serialization (#6420) Reviewed-by: Mm2PL Reviewed-by: pajlada --- CHANGELOG.md | 1 + docs/chatterino.d.ts | 20 + docs/lua-meta/json.lua | 28 ++ docs/wip-plugins.md | 77 ++++ src/CMakeLists.txt | 6 + src/controllers/plugins/LuaUtilities.hpp | 11 + src/controllers/plugins/PluginController.cpp | 12 +- src/controllers/plugins/PluginController.hpp | 8 +- src/controllers/plugins/api/JSON.cpp | 92 ++++ src/controllers/plugins/api/JSON.hpp | 16 + src/controllers/plugins/api/JSONParse.cpp | 416 ++++++++++++++++++ src/controllers/plugins/api/JSONParse.hpp | 17 + src/controllers/plugins/api/JSONStringify.cpp | 284 ++++++++++++ src/controllers/plugins/api/JSONStringify.hpp | 17 + tests/lua/json/parse.lua | 312 +++++++++++++ tests/lua/json/stringify.lua | 137 ++++++ tests/src/Plugins.cpp | 59 ++- 17 files changed, 1509 insertions(+), 4 deletions(-) create mode 100644 docs/lua-meta/json.lua create mode 100644 src/controllers/plugins/api/JSON.cpp create mode 100644 src/controllers/plugins/api/JSON.hpp create mode 100644 src/controllers/plugins/api/JSONParse.cpp create mode 100644 src/controllers/plugins/api/JSONParse.hpp create mode 100644 src/controllers/plugins/api/JSONStringify.cpp create mode 100644 src/controllers/plugins/api/JSONStringify.hpp create mode 100644 tests/lua/json/parse.lua create mode 100644 tests/lua/json/stringify.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index 62c0d8d0..bea8f974 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Minor: Added setting for character limit of deleted messages. (#6491) - Minor: Added link support to plugin message API. (#6386, #6527) - Minor: Added a description for the logging option under moderation tab. (#6514) +- Minor: Added a JSON API for plugins (`require('chatterino.json')`). (#6420) - Minor: Consolidate font picking into one dialog. (#6531) - Minor: Added a menu action to sort tabs alphabetically. (#6551) - Minor: Fixed "edit hotkey" dialog opening like a normal window. (#6540) diff --git a/docs/chatterino.d.ts b/docs/chatterino.d.ts index dd833df0..860f058e 100644 --- a/docs/chatterino.d.ts +++ b/docs/chatterino.d.ts @@ -377,3 +377,23 @@ declare namespace c2 { Repost, } } + +declare module "chatterino.json" { + class _Dummy {} + + function parse( + text: string, + opts?: { allow_comments?: boolean; allow_trailing_commas?: boolean } + ): any; + function stringify( + item: any, + opts?: { pretty?: boolean; indent_char?: string; indent_size?: number } + ): string; + + let exports: { + null: _Dummy; + parse: typeof parse; + stringify: typeof stringify; + }; + export = exports; +} diff --git a/docs/lua-meta/json.lua b/docs/lua-meta/json.lua new file mode 100644 index 00000000..54c341ad --- /dev/null +++ b/docs/lua-meta/json.lua @@ -0,0 +1,28 @@ +---@meta chatterino.json + +local json = {} + +--- Parse a string as JSON +--- +---@param input string The text to parse +---@param opts? {allow_comments?: boolean, allow_trailing_commas?: boolean} Additional options for parsing. `allow_comments` will allow C++ style comments (`[1] // text`). `allow_trailing_commas` will allow commas before an object/array ends (`[1, 2,]`). +---@return any +function json.parse(input, opts) end + +--- Stringify a Lua value as JSON. Only tables and scalars (strings/numbers/booleans) are supported. +--- Empty tables are stringified as objects. To get an empty array, use the following: `{ [0] = json.null }` (will produce `[]`). +--- Tables with `nil` values like `{ foo = nil }` will be stringified as `{}` (they are identical to the empty table). To get `null` there, +--- use `json.null`: `{ foo = json.null }` (produces `{"foo":null}`). +--- +---@param input any The value to stringify +---@param opts? {pretty?: boolean, indent_char?: string, indent_size?: number} Additional options for stringifying. The default pretty indent char is a space and the size is 4. +---@return string +function json.stringify(input, opts) end + +---Helper type to indicate a `null` value when serializing. +---This is useful if `nil` would hide the value (such as in tables). +---See `json.stringify` for more info. +---@type lightuserdata +json.null = {} + +return json diff --git a/docs/wip-plugins.md b/docs/wip-plugins.md index 75c4efe0..ebc0cf74 100644 --- a/docs/wip-plugins.md +++ b/docs/wip-plugins.md @@ -770,3 +770,80 @@ require("data.file") -- tried to load Plugins/name/data/file.lua and errors beca #### `print(Args...)` The `print` global function is equivalent to calling `c2.log(c2.LogLevel.Debug, Args...)` + +### JSON API + +Chatterino includes the `chatterino.json` module for parsing and serializing JSON: + +```lua +local json = require('chatterino.json') + +local parsed = json.parse('{"foo": 1}') +-- { foo = 1 } + +local str = json.stringify({ foo = 1 }) +-- '{"foo":1}' +``` + +#### `parse(string[, options])` + +Parse a string as JSON. Errors if the input was invalid JSON. Use [`pcall`](https://www.lua.org/pil/8.4.html) when parsing untrusted/user input. + +```lua +local json = require('chatterino.json') + +local parsed = json.parse('{"foo": 1}') +-- { foo = 1 } + +local ok, result = pcall(json.parse, 'invalid input') +-- ok = false, result = "Failed to parse JSON: syntax error..." + +local ok, result = pcall(json.parse, '{"foo": 1 /* foo */ }', { allow_comments = true }) +-- ok = true, result = { foo = 1 } +``` + +`options` can be an optional table with the following optional keys: + +- `allow_comments` (boolean): Allow C++ style comments (`/* foo */` and `// foo`) +- `allow_trailing_commas` (boolean): Allow trailing comments in objects and arrays (`[1, 2,]`) + +#### `stringify(value[, options])` + +Stringify a Lua value as JSON. Only tables and scalars (strings/numbers/booleans) are supported. +Empty tables are stringified as objects. To get an empty array, use the following: `{ [0] = json.null }` (will produce `[]`). +Tables with `nil` values like `{ foo = nil }` will be stringified as `{}` (they are identical to the empty table). +To get `null` there, use `json.null`. + +```lua +local json = require('chatterino.json') + +local str = json.stringify({ foo = 1, bar = nil, baz = json.null }) +-- '{"foo":1,"baz":null}' + +local str = json.stringify({ foo = 1 }, { pretty = true }) +-- { +-- "foo": 1 +-- } +``` + +`options` can be an optional table with the following optional keys: + +- `pretty` (boolean): Use newlines and indentation when stringifying +- `indent_char` (string, default: space): Character to use when indenting object/array items +- `indent_size` (number, default: 4): Amount of times `indent_char` is repeated per nesting-level + +#### `null` + +A sentinel to indicate a `null` value. +This is useful if `nil` would hide the value (such as in tables): + +```lua +local json = require('chatterino.json') + +local str = json.stringify({ foo = 1, bar = nil, baz = json.null }) +-- '{"foo":1,"baz":null}' + +local obj = json.parse(str) +assert(obj.baz == json.null) +assert(obj.baz ~= nil) +``` diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 9923391a..a477a597 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -252,6 +252,12 @@ set(SOURCE_FILES controllers/plugins/api/HTTPResponse.hpp controllers/plugins/api/IOWrapper.cpp controllers/plugins/api/IOWrapper.hpp + controllers/plugins/api/JSON.cpp + controllers/plugins/api/JSON.hpp + controllers/plugins/api/JSONParse.cpp + controllers/plugins/api/JSONParse.hpp + controllers/plugins/api/JSONStringify.cpp + controllers/plugins/api/JSONStringify.hpp controllers/plugins/api/Message.cpp controllers/plugins/api/Message.hpp controllers/plugins/api/WebSocket.cpp diff --git a/src/controllers/plugins/LuaUtilities.hpp b/src/controllers/plugins/LuaUtilities.hpp index 0fd0d476..6c417977 100644 --- a/src/controllers/plugins/LuaUtilities.hpp +++ b/src/controllers/plugins/LuaUtilities.hpp @@ -126,6 +126,17 @@ sol::table createEnumTable(sol::state_view &lua) return out; } +/// luaL_error but with [[noreturn]] +[[noreturn]] +void fail(lua_State *L, const char *fmt, auto &&...args) +{ + luaL_error(L, fmt, std::forward(args)...); + // luaL_error is not annotated with [[noreturn]] for backwards + // compatibility, but it will never return. If we ever get here, something + // is seriously wrong, so abort. + std::terminate(); +} + } // namespace chatterino::lua #endif diff --git a/src/controllers/plugins/PluginController.cpp b/src/controllers/plugins/PluginController.cpp index db52eb78..07ce82ca 100644 --- a/src/controllers/plugins/PluginController.cpp +++ b/src/controllers/plugins/PluginController.cpp @@ -11,6 +11,7 @@ # include "controllers/plugins/api/HTTPRequest.hpp" # include "controllers/plugins/api/HTTPResponse.hpp" # include "controllers/plugins/api/IOWrapper.hpp" +# include "controllers/plugins/api/JSON.hpp" # include "controllers/plugins/api/Message.hpp" # include "controllers/plugins/api/WebSocket.hpp" # include "controllers/plugins/LuaAPI.hpp" @@ -40,6 +41,7 @@ namespace chatterino { PluginController::PluginController(const Paths &paths_) : paths(paths_) { + this->loaders_.emplace_back("chatterino.json", &lua::api::loadJson); } void PluginController::initialize(Settings &settings) @@ -264,6 +266,14 @@ void PluginController::initSol(sol::state_view &lua, Plugin *plugin) sol::table package = g["package"]; package.set_function("loadlib", &lua::api::package_loadlib); + + for (const auto &[name, fn] : this->loaders_) + { + package["preload"][name] = [fn](sol::this_main_state state) { + sol::state_view sv(state); + return fn(sv); + }; + } } void PluginController::load(const QFileInfo &index, const QDir &pluginDir, @@ -282,7 +292,7 @@ void PluginController::load(const QFileInfo &index, const QDir &pluginDir, << " because safe mode is enabled."; return; } - PluginController::openLibrariesFor(temp); + this->openLibrariesFor(temp); if (!PluginController::isPluginEnabled(pluginName) || !getSettings()->pluginsEnabled) diff --git a/src/controllers/plugins/PluginController.hpp b/src/controllers/plugins/PluginController.hpp index 5095fdb9..f6760222 100644 --- a/src/controllers/plugins/PluginController.hpp +++ b/src/controllers/plugins/PluginController.hpp @@ -72,15 +72,19 @@ private: const PluginMeta &meta); // This function adds lua standard libraries into the state - static void openLibrariesFor(Plugin *plugin); + void openLibrariesFor(Plugin *plugin); - static void initSol(sol::state_view &lua, Plugin *plugin); + void initSol(sol::state_view &lua, Plugin *plugin); static void loadChatterinoLib(lua_State *l); bool tryLoadFromDir(const QDir &pluginDir); std::map> plugins_; WebSocketPool webSocketPool_; + std::vector< + std::pair>> + loaders_; + // This is for tests, pay no attention friend class PluginControllerAccess; }; diff --git a/src/controllers/plugins/api/JSON.cpp b/src/controllers/plugins/api/JSON.cpp new file mode 100644 index 00000000..9a88c859 --- /dev/null +++ b/src/controllers/plugins/api/JSON.cpp @@ -0,0 +1,92 @@ +#include "controllers/plugins/api/JSON.hpp" + +#ifdef CHATTERINO_HAVE_PLUGINS + +# include "controllers/plugins/api/JSONParse.hpp" +# include "controllers/plugins/api/JSONStringify.hpp" +# include "controllers/plugins/LuaUtilities.hpp" + +# include + +namespace { + +// NOLINTBEGIN(cppcoreguidelines-pro-type-vararg) + +/// `__tostring` implementation for lightuserdata +/// +/// If the value is `nullptr`, returns "null", otherwise the pointer value in +/// hex (like in '%p'). +int stringifyLightuserdata(lua_State *L) +{ + luaL_checktype(L, 1, LUA_TLIGHTUSERDATA); + void *ptr = lua_touserdata(L, 1); + if (ptr == nullptr) + { + lua_pushstring(L, "null"); + } + else + { + std::array buf{}; + // FIXME: use std::format once we require GCC 13/libstdc++ 13 + int written = std::snprintf(buf.data(), buf.size(), "%p", ptr); + if (written < 0 || std::ranges::find(buf, '\0') == buf.end()) + { + luaL_error(L, "Failed to format pointer"); + } + lua_pushstring(L, buf.data()); + } + return 1; +} +// NOLINTEND(cppcoreguidelines-pro-type-vararg) + +/// Sets the global metatable for lightuserdata +/// +/// `nullptr` is used as a sentinel for "null" values in both parsing and +/// stringifying. To allow users to get a proper string representation of +/// this value, we add a `__tostring` method on lightuserdata. As individual +/// lightuserdata values don't have metatables, this is done on the global +/// one. +void setLightuserdataMetatable(lua_State *L) +{ + chatterino::lua::StackGuard g(L); + + lua_checkstack(L, 4); + + lua_pushlightuserdata(L, nullptr); + lua_createtable(L, 0, 1); + lua_pushstring(L, "__tostring"); + lua_pushcfunction(L, &stringifyLightuserdata); + + // [-4] lightuserdata (nullptr) + // [-3] table {} + // [-2] "__tostring" + // [-1] &stringifyLightuserdata + lua_rawset(L, -3); // tbl.__tostring = fn + + // [-2] lightuserdata (nullptr) + // [-1] table { __tostring = fn } + lua_setmetatable(L, -2); // setmeta(lightuserdata) = tbl + + // [-1] lightuserdata (nullptr) + lua_pop(L, 1); +} + +} // namespace + +namespace chatterino::lua::api { + +sol::object loadJson(sol::state_view lua) +{ + setLightuserdataMetatable(lua.lua_state()); + + return lua.create_table_with( // + "parse", jsonParse, // + "stringify", jsonStringify, // + // pushed as lightuserdata + "null", static_cast(nullptr) // + ); +} + +} // namespace chatterino::lua::api + +#endif diff --git a/src/controllers/plugins/api/JSON.hpp b/src/controllers/plugins/api/JSON.hpp new file mode 100644 index 00000000..5b48676d --- /dev/null +++ b/src/controllers/plugins/api/JSON.hpp @@ -0,0 +1,16 @@ +#pragma once + +#ifdef CHATTERINO_HAVE_PLUGINS +# include + +namespace chatterino::lua::api { + +/// Loads the 'json' module as a table. +/// +/// Because `nullptr` is used as a sentinel for "null", this also adds a +/// `__tostring` method on lightuserdata. +sol::object loadJson(sol::state_view lua); + +} // namespace chatterino::lua::api + +#endif diff --git a/src/controllers/plugins/api/JSONParse.cpp b/src/controllers/plugins/api/JSONParse.cpp new file mode 100644 index 00000000..e75527e6 --- /dev/null +++ b/src/controllers/plugins/api/JSONParse.cpp @@ -0,0 +1,416 @@ +#include "controllers/plugins/api/JSONParse.hpp" + +#ifdef CHATTERINO_HAVE_PLUGINS + +# include "controllers/plugins/LuaUtilities.hpp" + +# include +# include +# include +# include + +# include + +namespace { + +using namespace chatterino::lua; + +// NOLINTNEXTLINE(performance-enum-size) -- we want `int` because that's what boost::system::error_code takes +enum class ParseError : int { + MoreThanOneTopLevel = 1, + TooMuchNesting, + NoElements, + StackExhausted, +}; + +class ParseErrorCategory final : public boost::system::error_category +{ +public: + consteval ParseErrorCategory() = default; + + const char *name() const noexcept override + { + return "JSON parse error"; + } + + std::string message(int ev) const override + { + switch (static_cast(ev)) + { + case ParseError::MoreThanOneTopLevel: + return "There was more than one top level element"; + case ParseError::TooMuchNesting: + return "Too much nesting"; + case ParseError::NoElements: + return "Invalid state: no elements on stack"; + case ParseError::StackExhausted: + return "Exhausted Lua stack (stack overflow)"; + } + return "Unknown error code"; + } +}; + +constexpr ParseErrorCategory CATEGORY; + +boost::system::error_code makeCode(ParseError kind) +{ + return {static_cast(kind), CATEGORY}; +} + +/// Reserve at least `size` slots on the Lua stack. +[[nodiscard]] bool reserveStack(lua_State *state, int size, + boost::system::error_code &ec) +{ + if (lua_checkstack(state, size) == 0) + { + ec = makeCode(ParseError::StackExhausted); + return false; + } + return true; +} + +// NOLINTBEGIN(cppcoreguidelines-pro-type-vararg) +// NOLINTBEGIN(readability-identifier-naming) +// NOLINTBEGIN(readability-convert-member-functions-to-static) +struct SaxHandler { + constexpr static std::size_t max_object_size = 1 << 20; // about 1 million + constexpr static std::size_t max_array_size = 1 << 28; // about 200 million + constexpr static std::size_t max_key_size = 1 << 29; // 512 MiB + constexpr static std::size_t max_string_size = 1 << 30; // 1 GiB + + constexpr static uint16_t MAX_NESTING = 256; + + SaxHandler(lua_State *state) + : state(state) + { + } + + bool on_document_begin(boost::system::error_code & /* ec */) + { + return true; + } + + bool on_document_end(boost::system::error_code & /* ec */) + { + return true; + } + + bool on_object_begin(boost::system::error_code &ec) + { + this->elements.append(Element{ + .index = 0, + .isObject = 1, + }); + if (this->elements.size() > MAX_NESTING) + { + ec = makeCode(ParseError::TooMuchNesting); + return false; + } + + // grow stack by 3 (table, key, value) + if (!reserveStack(this->state, 3, ec)) + { + return false; + } + lua_newtable(this->state); + + return true; + } + + bool on_object_end(size_t /* nElements */, boost::system::error_code &ec) + { + if (this->elements.empty()) + { + ec = makeCode(ParseError::NoElements); + assert(false && "No information about current on object on stack"); + return false; + } + assert(this->elements.back().isObject); + this->elements.pop_back(); + + return this->appendValue(ec); + } + + bool on_array_begin(boost::system::error_code &ec) + { + this->elements.append(Element{ + .index = 0, + .isObject = 0, + }); + if (this->elements.size() > MAX_NESTING) + { + ec = makeCode(ParseError::TooMuchNesting); + return false; + } + + // grow stack by 2 (table, value) + if (!reserveStack(this->state, 2, ec)) + { + return false; + } + lua_newtable(this->state); + + return true; + } + + bool on_array_end(size_t /* nElements */, boost::system::error_code &ec) + { + if (this->elements.empty()) + { + ec = makeCode(ParseError::NoElements); + assert(false && "No information about current array on stack"); + return false; + } + assert(!this->elements.back().isObject); + this->elements.pop_back(); + + return this->appendValue(ec); + } + + bool on_key_part(boost::json::string_view sv, std::size_t /* n */, + boost::system::error_code & /* ec */) + { + this->buffer.append(sv); + return true; // no push - we'll get a on_key call + } + + bool on_key(boost::json::string_view sv, size_t /* n */, + boost::system::error_code & /* ec */) + { + if (this->buffer.empty()) + { + lua_pushlstring(this->state, sv.data(), sv.size()); + } + else + { + this->buffer.append(sv); + lua_pushlstring(this->state, this->buffer.c_str(), + this->buffer.size()); + this->buffer.clear(); + } + + // no appendValue (wait for the value) + return true; + } + + // scalar values + bool on_string_part(boost::json::string_view sv, size_t /* n */, + boost::system::error_code & /* ec */) + { + this->buffer.append(sv); + return true; // no appendValue - we'll get a on_string call + } + + bool on_string(boost::json::string_view sv, size_t /* n */, + boost::system::error_code &ec) + { + if (this->buffer.empty()) + { + lua_pushlstring(this->state, sv.data(), sv.size()); + } + else + { + this->buffer.append(sv); + lua_pushlstring(this->state, this->buffer.c_str(), + this->buffer.size()); + this->buffer.clear(); + } + + return this->appendValue(ec); + } + + bool on_number_part(boost::json::string_view /* part */, + boost::system::error_code & /* ec */) + { + // unhandled + return true; + } + + bool on_int64(int64_t i, boost::json::string_view /* source */, + boost::system::error_code &ec) + { + lua_pushinteger(this->state, static_cast(i)); + return this->appendValue(ec); + } + + bool on_uint64(uint64_t i, boost::json::string_view /* source */, + boost::system::error_code &ec) + { + if (i <= static_cast(std::numeric_limits::max())) + { + lua_pushinteger(this->state, static_cast(i)); + } + else + { + lua_pushnumber(this->state, static_cast(i)); + } + return this->appendValue(ec); + } + + bool on_double(double d, boost::json::string_view /* source */, + boost::system::error_code &ec) + { + lua_pushnumber(this->state, d); + return this->appendValue(ec); + } + + bool on_bool(bool b, boost::system::error_code &ec) + { + lua_pushboolean(this->state, static_cast(b)); + return this->appendValue(ec); + } + + bool on_null(boost::system::error_code &ec) + { + lua_pushlightuserdata(this->state, nullptr); // json.null + return this->appendValue(ec); + } + + bool on_comment_part(boost::json::string_view /* part */, + boost::system::error_code & /* ec */) + { + // unhandled + return true; + } + + bool on_comment(boost::json::string_view /* comment */, + boost::system::error_code & /* ec */) + { + // unhandled + return true; + } + +private: + /// Append the value to the current container. + /// If we only got a scalar, do nothing but remember that we got some value + /// already. + bool appendValue(boost::system::error_code &ec) + { + if (this->elements.empty()) + { + if (this->hadElement) + { + ec = makeCode(ParseError::MoreThanOneTopLevel); + return false; + } + // The element is already at the top of the stack + this->hadElement = true; + return true; + } + + if (this->elements.back().isObject) + { + // [-3] table + // [-2] key + // [-1] value + lua_rawset(this->state, -3); // table[key] = value + } + else /* array */ + { + // [-2] table + // [-1] value + lua_rawseti(this->state, -2, ++this->elements.back().index); + } + + return true; + } + + struct Element { + uint16_t index : 15; // index of the last array element + uint16_t isObject : 1; + }; + + lua_State *state = nullptr; + + /// The stack of elements we're in. + /// + /// We need to keep track of this to issue the right call to Lua when we see + /// an element. + /// Usually this shouldn't be too deep, so allocate some inline space. + QVarLengthArray elements; + + /// Did we see any top-level element yet? + /// + /// This is only for safety. The JSON parser should guarantee that we don't + /// get two top-level elements. + bool hadElement = false; + + /// A buffer for partial strings + std::string buffer; + + friend class SaxParser; +}; + +class SaxParser +{ +public: + SaxParser(const boost::json::parse_options &opts, lua_State *state) + : parser(opts, state) + { + } + + void parse(std::string_view sv) + { + boost::system::error_code ec; + auto nRead = this->parser.write_some(false, sv.data(), sv.length(), ec); + if (ec) + { + fail(this->parser.handler().state, + "Failed to parse JSON: %s (at %s) pos: %d", + ec.message().c_str(), ec.location().to_string().c_str(), + static_cast(nRead)); + } + if (nRead != sv.size()) + { + fail(this->parser.handler().state, + "Failed to parse JSON: trailing junk", ec.message().c_str()); + } + } + +private: + boost::json::basic_parser parser; +}; + +// NOLINTEND(readability-convert-member-functions-to-static) +// NOLINTEND(readability-identifier-naming) +// NOLINTEND(cppcoreguidelines-pro-type-vararg) + +} // namespace + +namespace chatterino::lua::api { + +int jsonParse(lua_State *L) +{ + auto nArgs = lua_gettop(L); + luaL_argcheck(L, nArgs >= 1, 1, "expected at least one argument"); + + boost::json::parse_options opts; + opts.max_depth = SaxHandler::MAX_NESTING; + opts.allow_invalid_utf8 = true; +# if BOOST_VERSION >= 108700 // 1.87 + opts.allow_invalid_utf16 = true; +# endif + if (nArgs >= 2) + { + auto tbl = sol::stack::check_get(L, 2); + if (tbl) + { + opts.allow_comments = tbl->get_or("allow_comments", false); + opts.allow_trailing_commas = + tbl->get_or("allow_trailing_commas", false); + } + } + + size_t size = 0; + const char *str = luaL_checklstring(L, 1, &size); + std::string_view sv(str, size); + SaxParser parser(opts, L); + parser.parse(sv); + return 1; +} + +} // namespace chatterino::lua::api + +# include + +#endif // CHATTERINO_HAVE_PLUGINS diff --git a/src/controllers/plugins/api/JSONParse.hpp b/src/controllers/plugins/api/JSONParse.hpp new file mode 100644 index 00000000..ec468f09 --- /dev/null +++ b/src/controllers/plugins/api/JSONParse.hpp @@ -0,0 +1,17 @@ +#pragma once + +#ifdef CHATTERINO_HAVE_PLUGINS +# include + +namespace chatterino::lua::api { + +/// Parse JSON from a string into a Lua value +/// +/// Exposed in the 'json' package as `parse(string[, options]): any`. +/// It uses the C-Function signature, because we mostly use the raw Lua API in +/// the implementation. +int jsonParse(lua_State *L); + +} // namespace chatterino::lua::api + +#endif diff --git a/src/controllers/plugins/api/JSONStringify.cpp b/src/controllers/plugins/api/JSONStringify.cpp new file mode 100644 index 00000000..329a206e --- /dev/null +++ b/src/controllers/plugins/api/JSONStringify.cpp @@ -0,0 +1,284 @@ +#include "controllers/plugins/api/JSONStringify.hpp" + +#ifdef CHATTERINO_HAVE_PLUGINS + +# include "controllers/plugins/LuaUtilities.hpp" + +# include +# include +# include + +// NOLINTBEGIN(cppcoreguidelines-pro-type-vararg) -- luaL_error is a vararg function + +namespace { + +using namespace chatterino::lua; + +constexpr size_t ENCODE_MAX_TABLE_LENGTH = 1 << 20; // about 1 mil +constexpr uint16_t ENCODE_MAX_DEPTH = 256; + +/// Reserve at least `size` slots on the Lua stack. +void reserveStack(lua_State *L, int size) +{ + if (lua_checkstack(L, size) == 0) + { + fail(L, "Failed to reserve %d more stack slots (stack overflow)", size); + } +} + +/// Get the size of an "array" (table at index -1) +/// +/// Lua doesn't have a notion of arrays - everything is a table. We consider a +/// table an array if it only has positive non-zero integer keys. One notable +/// exception is an empty array. An empty table is considered an empty object. +/// If the user wants to specify an empty array, they specify a table with +/// `json.null` at item `0` (i.e. `{ [0] = json.null }`). `json.null` is a +/// `nullptr` lightuserdata. +/// +/// Arrays might have holes in them (e.g. `{1, [10]=2}` or `{1, nil, 2}`), so we +/// keep track of the maximum index we saw. +/// +/// @returns The size of the array if it is one - otherwise it's an object. +std::optional inferArraySize(lua_State *L) +{ + reserveStack(L, 3); // key + value + potential error + + size_t max = 0; + bool hasNull = false; + lua_pushnil(L); // first key + while (lua_next(L, -2) != 0) + { + // [-3] table + // [-2] key + // [-1] value + if (lua_isinteger(L, -2) == 0) + { + lua_pop(L, 2); + return std::nullopt; // not an array + } + + auto key = lua_tointeger(L, -2); + if (key <= 0) + { + // special empty table indicator if element at 0 is our nullptr lightuserdata + if (key == 0 && lua_islightuserdata(L, -1) && + lua_touserdata(L, -1) == nullptr) + { + hasNull = true; + } + else + { + fail(L, "Table keys can't be negative integers"); + } + } + else + { + max = std::max(max, static_cast(key)); + } + + lua_pop(L, 1); // value + } + + if (max > ENCODE_MAX_TABLE_LENGTH) + { + fail(L, "Table is too big"); + } + + if (max == 0 && !hasNull) + { + return std::nullopt; // empty tables are objects + } + + return max; +} + +void stringifyValue(lua_State *L, auto &writer, uint16_t depth); + +void stringifyArray(lua_State *L, auto &writer, uint16_t depth, size_t length) +{ + reserveStack(L, 2); // value + potential error + + if (!writer.StartArray()) + { + fail(L, "Failed to write array start"); + } + + for (size_t i = 0; i < length; i++) + { + lua_rawgeti(L, -1, static_cast(i) + 1); + stringifyValue(L, writer, depth); + lua_pop(L, 1); // pop value + } + + if (!writer.EndArray()) + { + fail(L, "Failed to write array end"); + } +} + +void stringifyObject(lua_State *L, auto &writer, uint16_t depth) +{ + reserveStack(L, 3); // key + value + potential error + + if (!writer.StartObject()) + { + fail(L, "Failed to write object start"); + } + + size_t items = 0; + lua_pushnil(L); + while (lua_next(L, -2) != 0) + { + items++; + if (items > ENCODE_MAX_TABLE_LENGTH) + { + fail(L, "Too many items in table"); + } + // Stack: + // [-3] table + // [-2] key + // [-1] value + auto keyType = lua_type(L, -2); + if (keyType != LUA_TSTRING) + { + fail(L, "Object key was not a string, got %s", + lua_typename(L, keyType)); + } + size_t len = 0; + const char *str = lua_tolstring(L, -2, &len); + + if (!writer.Key(str, len, /*copy=*/true)) + { + fail(L, "Failed to write object key"); + } + + stringifyValue(L, writer, depth); + lua_pop(L, 1); // pop value + } + + if (!writer.EndObject()) + { + fail(L, "Failed to write object end"); + } +} + +void stringifyValue(lua_State *L, auto &writer, uint16_t depth) +{ + auto typeId = lua_type(L, -1); + bool ok = true; + switch (typeId) + { + case LUA_TNIL: + ok = writer.Null(); + break; + case LUA_TBOOLEAN: + ok = writer.Bool(lua_toboolean(L, -1) != 0); + break; + case LUA_TNUMBER: { + if (lua_isinteger(L, -1)) + { + ok = writer.Int64(lua_tointeger(L, -1)); + } + else + { + ok = writer.Double(lua_tonumber(L, -1)); + } + } + break; + case LUA_TSTRING: { + size_t len = 0; + const char *str = lua_tolstring(L, -1, &len); + ok = writer.String(str, len, /*copy=*/true); + } + break; + case LUA_TTABLE: { + if (depth >= ENCODE_MAX_DEPTH) + { + fail(L, "Too deep"); + } + + auto arraySize = inferArraySize(L); + if (arraySize) + { + stringifyArray(L, writer, depth + 1, *arraySize); + } + else + { + stringifyObject(L, writer, depth + 1); + } + } + break; + case LUA_TLIGHTUSERDATA: { + auto *ptr = lua_touserdata(L, -1); + if (ptr == nullptr) + { + ok = writer.Null(); + } + else + { + fail(L, "Unsupported type: lightuserdata"); + } + } + break; + case LUA_TFUNCTION: + case LUA_TUSERDATA: + case LUA_TTHREAD: + default: + fail(L, "Unsupported type: %s", lua_typename(L, typeId)); + } + + if (!ok) + { + fail(L, "Failed to format %s", lua_typename(L, typeId)); + } +} + +} // namespace + +namespace chatterino::lua::api { + +int jsonStringify(lua_State *L) +{ + auto nArgs = lua_gettop(L); + luaL_argcheck(L, nArgs >= 1, 1, "expected at at least one argument"); + + bool pretty = false; + char indentChar = ' '; + unsigned int indentSize = 4; + if (nArgs >= 2) + { + auto tbl = sol::stack::check_get(L); + if (tbl) + { + pretty = tbl->get_or("pretty", pretty); + indentChar = tbl->get_or("indent_char", indentChar); + indentSize = tbl->get_or("indent_size", indentSize); + } + + // discard everything but the input + lua_pop(L, nArgs - 1); + } + + rapidjson::StringBuffer sb; + if (pretty) + { + rapidjson::PrettyWriter pw( + sb, static_cast(nullptr), 32); + pw.SetIndent(indentChar, indentSize); + stringifyValue(L, pw, 0); + } + else + { + rapidjson::Writer w(sb); + stringifyValue(L, w, 0); + } + + lua_pushlstring(L, sb.GetString(), sb.GetSize()); + return 1; +} + +} // namespace chatterino::lua::api + +// NOLINTEND(cppcoreguidelines-pro-type-vararg) + +#endif // CHATTERINO_HAVE_PLUGINS diff --git a/src/controllers/plugins/api/JSONStringify.hpp b/src/controllers/plugins/api/JSONStringify.hpp new file mode 100644 index 00000000..01e0a0d7 --- /dev/null +++ b/src/controllers/plugins/api/JSONStringify.hpp @@ -0,0 +1,17 @@ +#pragma once + +#ifdef CHATTERINO_HAVE_PLUGINS +# include + +namespace chatterino::lua::api { + +/// Serializes a Lua value to JSON. +/// +/// Exposed in the 'json' package as `stringify(value[, options]): string`. +/// It uses the C-Function signature, because we mostly use the raw Lua API in +/// the implementation. +int jsonStringify(lua_State *L); + +} // namespace chatterino::lua::api + +#endif diff --git a/tests/lua/json/parse.lua b/tests/lua/json/parse.lua new file mode 100644 index 00000000..3842ade9 --- /dev/null +++ b/tests/lua/json/parse.lua @@ -0,0 +1,312 @@ +local json = require('chatterino.json') + +local function compare_v(lhs, rhs) + if type(lhs) == "table" then + if type(rhs) ~= "table" then + print("lhs:", type(lhs), "rhs:", type(rhs)) + return false + end + for key_l, val_l in pairs(lhs) do + local val_r = rhs[key_l] + if not compare_v(val_l, val_r) then + print("[", key_l, "] failed") + return false + end + end + for key_r, _ in pairs(rhs) do + if lhs[key_r] == nil then + print(key_r, "was in rhs but not in lhs") + return false + end + end + return true + end + + if lhs ~= rhs then + print("lhs:", lhs, "rhs:", rhs) + return false + end + return true +end + +local function check_err(...) + local ok = pcall(json.parse, ...) + local arg0 = ... + if arg0 == nil then + arg0 = "(nil)" + end + assert(not ok, "parsing '" .. arg0 .. "' should've resulted in an error") +end + +---@param input string +---@param expected any +---@vararg {allow_comments?: boolean, allow_trailing_commas?: boolean}|any +local function check_ok(input, expected, ...) + local parsed = json.parse(input, ...) + assert(compare_v(parsed, expected), "parsing '" .. input .. "' didn't yield the expected value") +end + +-- empty +check_ok('{}', {}) +check_ok('[]', {}) +check_ok('{}', {}, { allow_comments = true }) +check_ok('{}', {}, { allow_trailing_commas = true }) +check_ok('{}', {}, { allow_comments = true, allow_trailing_commas = true }) + +-- spaces +check_ok(' {} ', {}) +check_ok(' {\n\t} ', {}) +check_ok(' {\r\n\t} ', {}) +check_ok(' {}\n\n \n', {}) + + +-- scalars +check_ok('""', "") +check_ok('"foo"', "foo") +check_ok('"foo\\nbar\\nbaz"', "foo\nbar\nbaz") +check_ok('true', true) +check_ok('false', false) +check_ok('0', 0) +check_ok('-0', 0) +check_ok('42', 42) +check_ok('-42', -42) +check_ok('1.23456', 1.23456) +check_ok('null', json.null) + +-- integer limits +check_ok('9223372036854775807', 9223372036854775807) -- max int (this has to be equal) +-- +/- 1 precision in 64 bit float +local v_1_shl_64 = json.parse('18446744073709551615') +assert(v_1_shl_64 >= 18446744073709551614 or v_1_shl_64 <= 18446744073709551616) + +check_ok('9223372036854775808', 9223372036854775808) -- double + +-- objects +check_ok('{"foo": 1}', { foo = 1 }) +check_ok('{"foo": [1, 2, {"bar": false}]}', { foo = { 1, 2, { bar = false } } }) +check_ok('{"foo": 1, "bar": "hey", "baz": {"obj": true}, "a": null}', + { foo = 1, bar = "hey", baz = { obj = true }, a = json.null }) +check_ok('{"esc\\naped": "a string"}', { ["esc\naped"] = "a string" }) +check_ok('{"": 1}', { [""] = 1 }) +check_ok('{"": {"": 0}, "": 0}', { [""] = 0 }) + +-- arrays +check_ok('[]', {}) +check_ok('[1]', { 1 }) +check_ok('["string"]', { "string" }) +check_ok('[null]', { json.null }) +check_ok('[null, 1, null]', { json.null, 1, json.null }) +check_ok('[{}]', { {} }) +check_ok('[{}, {}]', { {}, {} }) +check_ok('[{}, "foo", 1, false, true]', { {}, "foo", 1, false, true }) +check_ok('[[[[[[{"foo": 1}]]]]]]', { { { { { { { foo = 1 } } } } } } }) + +-- From Qt +check_ok([[ +[ + "json Test Pattern pass1", + {"object with 1 member":["array with 1 element"]}, + {}, + [], + -42, + true, + false, + null, + { + "integer": 1234567890, + "real": -9876.543210, + "e": 0.123456789e-2, + "E": 1.234567890E+3, + "": 2E66, + "zero": 0, + "one": 1, + "space": " ", + "quote": "\"", + "backslash": "\\", + "controls": "\b\f\n\r\t", + "slash": "/ & \/", + "alpha": "abcdefghijklmnopqrstuvwxyz", + "ALPHA": "ABCDEFGHIJKLMNOPQRSTUVWXYZ", + "digit": "0123456789", + "0123456789": "digit", + "special": "`1~!@#$%^&*()_+-={':[,]}|;.?", + "hex": "\u0123\u4567\u89AB\uCDEF\uabcd\uef4A", + "true": true, + "false": false, + "null": null, + "array":[ ], + "object":{ }, + "address": "50 St. James Street", + "url": "nix", + "comment": "// /* */": " ", + " s p a c e d " :[1,2 , 3 + +, + +4 , 5 , 6 ,7 ],"compact":[1,2,3,4,5,6,7], + "jsontext": "{\"object with 1 member\":[\"array with 1 element\"]}", + "quotes": "" \u0022 %22 0x22 034 "", + "\/\"\uCAFE\uBABE\uAB98\uFCDE\ubcda\uef4A\b\f\n\r\t`1~!@#$%^&*()_+-=[]{}|;:',./<>?" : "A key can be any string" + }, + 0.5 ,98.6 +, +99.44 +, + +1066, +1e1, +0.1e1, +1e-1, +1e00, +2e+00, +2e-00, +"rosebud", +{"foo": "bar"} +] + +]], { + "json Test Pattern pass1", + { ["object with 1 member"] = { "array with 1 element" } }, + {}, + {}, + -42, + true, + false, + json.null, + { + integer = 1234567890, + real = -9876.543210, + e = 0.123456789e-2, + E = 1.234567890E+3, + [""] = 2E66, + zero = 0, + one = 1, + space = " ", + quote = "\"", + backslash = "\\", + controls = "\b\f\n\r\t", + slash = "/ & /", + alpha = "abcdefghijklmnopqrstuvwxyz", + ALPHA = "ABCDEFGHIJKLMNOPQRSTUVWXYZ", + digit = "0123456789", + ["0123456789"] = "digit", + special = "`1~!@#$%^&*()_+-={\':[,]}|;.?", + hex = "\u{0123}\u{4567}\u{89AB}\u{CDEF}\u{abcd}\u{ef4A}", + ["true"] = true, + ["false"] = false, + null = json.null, + array = {}, + object = {}, + address = "50 St. James Street", + url = "nix", + comment = "// /* */"] = " ", + [" s p a c e d "] = { 1, 2, 3, 4, 5, 6, 7 }, + compact = { 1, 2, 3, 4, 5, 6, 7 }, + jsontext = "{\"object with 1 member\":[\"array with 1 element\"]}", + quotes = "" \u{0022} %22 0x22 034 "", + ["/\"\u{CAFE}\u{BABE}\u{AB98}\u{FCDE}\u{bcda}\u{ef4A}\b\f\n\r\t`1~!@#$%^&*()_+-=[]{}|;:',./<>?"] = + "A key can be any string" + }, + 0.5, + 98.6, + 99.44, + 1066, + 1e1, + 0.1e1, + 1e-1, + 1e00, + 2e+00, + 2e-00, + "rosebud", + { foo = "bar" } +}) + +-- limits +local big = string.rep('[', 256) .. string.rep(']', 256) +json.parse(big) + +local too_big = string.rep('[', 257) .. string.rep(']', 257) +check_err(too_big) + +big = string.rep('{"x":', 256) .. "1" .. string.rep('}', 256) +json.parse(big) + +too_big = string.rep('{"x":', 257) .. "1" .. string.rep('}', 257) +check_err(too_big) + +-- bad inputs +check_err('') +check_err('{} junk') +check_err('junk{}') +check_err('}') +check_err('{') +check_err('{"foo": 1') +check_err('{"foo": }') +check_err('{"foo": nul}') +check_err('"') +check_err('\\"foo"') +check_err() + +-- comments +local bcpl_comment = [[ + { + "my val": 42, // a comment + "other": false + } +]] +check_ok(bcpl_comment, { ["my val"] = 42, other = false }, { allow_comments = true }) +check_ok(bcpl_comment, { ["my val"] = 42, other = false }, { allow_comments = true, allow_trailing_commas = false }) +check_ok(bcpl_comment, { ["my val"] = 42, other = false }, { allow_comments = true, allow_trailing_commas = true }) +check_err(bcpl_comment) +check_err(bcpl_comment, { allow_comments = false }) +check_err(bcpl_comment, { allow_trailing_commas = false }) +check_err(bcpl_comment, { allow_trailing_commas = true }) +check_err(bcpl_comment, { allow_comments = false, allow_trailing_commas = true }) + +local c_comment = [[ + { + "my val": 42, /* a comment + */"other": false + } +]] +check_ok(c_comment, { ["my val"] = 42, other = false }, { allow_comments = true }) +check_ok(c_comment, { ["my val"] = 42, other = false }, { allow_comments = true, allow_trailing_commas = false }) +check_ok(c_comment, { ["my val"] = 42, other = false }, { allow_comments = true, allow_trailing_commas = true }) +check_err(c_comment) +check_err(c_comment, { allow_comments = false }) +check_err(c_comment, { allow_trailing_commas = false }) +check_err(c_comment, { allow_trailing_commas = true }) +check_err(c_comment, { allow_comments = false, allow_trailing_commas = true }) + +local trailing_comma = [[ + { + "my val": 42, + "other": false, + } +]] +check_ok(trailing_comma, { ["my val"] = 42, other = false }, { allow_trailing_commas = true }) +check_ok(trailing_comma, { ["my val"] = 42, other = false }, { allow_trailing_commas = true, allow_comments = false }) +check_ok(trailing_comma, { ["my val"] = 42, other = false }, { allow_trailing_commas = true, allow_comments = true }) +check_err(trailing_comma) +check_err(trailing_comma, { allow_trailing_commas = false }) +check_err(trailing_comma, { allow_comments = false }) +check_err(trailing_comma, { allow_comments = true }) +check_err(trailing_comma, { allow_trailing_commas = false, allow_comments = true }) + +local trailing_comma_comment = [[ + { + "my val": 42, // a comment + "other": false, /* another comment */ + } +]] +check_ok(trailing_comma_comment, { ["my val"] = 42, other = false }, + { allow_trailing_commas = true, allow_comments = true }) +check_err(trailing_comma_comment, { allow_trailing_commas = true }) +check_err(trailing_comma_comment, { allow_trailing_commas = true, allow_comments = false }) +check_err(trailing_comma_comment) +check_err(trailing_comma_comment, { allow_trailing_commas = false }) +check_err(trailing_comma_comment, { allow_comments = false }) +check_err(trailing_comma_comment, { allow_comments = true }) +check_err(trailing_comma_comment, { allow_trailing_commas = false, allow_comments = true }) diff --git a/tests/lua/json/stringify.lua b/tests/lua/json/stringify.lua new file mode 100644 index 00000000..e208be49 --- /dev/null +++ b/tests/lua/json/stringify.lua @@ -0,0 +1,137 @@ +local json = require('chatterino.json') + +local function check_err(...) + local ok = pcall(json.stringify, ...) + assert(not ok, "expected an error") +end + +---@param input any +---@param expected string|string[] +---@vararg { pretty: boolean, indent_char: string, indent_size: number }|any +local function check_ok(input, expected, ...) + local got = json.stringify(input, ...) + if type(expected) == "string" then + assert(got == expected, "Failed:\n\texpected: " .. expected .. "\n\tgot: " .. got .. "\n") + else + local ok = false + for _, it in ipairs(expected) do + if it == got then + ok = true + break + end + end + assert(ok, "Output did not match any of the expected values") + end +end + +assert(type(json.null) == "userdata") + +-- empty +check_ok({}, "{}") +check_ok({ [0] = json.null }, '[]') + +-- scalars +check_ok(1, "1") +check_ok("foo", '"foo"') +check_ok("foo\n", '"foo\\n"') +check_ok(true, "true") +check_ok(false, "false") +check_ok(nil, "null") +check_ok(json.null, "null") +check_ok(1.23, "1.23") + +-- arrays +check_ok({ 1, 2 }, "[1,2]") +check_ok({ 1, 2, nil }, "[1,2]") +check_ok({ 1, 2, json.null }, "[1,2,null]") +check_ok({ 1, nil, 2 }, "[1,null,2]") +check_ok({ 1, [10] = 2 }, "[1,null,null,null,null,null,null,null,null,2]") +check_ok({ [10 / 2] = 1 }, '[null,null,null,null,1]') +check_ok({ { { { { 1 } } } } }, "[[[[[1]]]]]") +check_ok({ { { { { 1 } } }, { foo = 1 } } }, '[[[[[1]]],{"foo":1}]]') + +-- objects +check_ok({ ok = { baz = "str", [""] = { 1, 2 } } }, { + '{"ok":{"baz":"str","":[1,2]}}', + '{"ok":{"":[1,2],"baz":"str"}}', +}) +check_ok({ ok = { 1, 2 } }, '{"ok":[1,2]}') +check_ok({ ['\n\tlol'] = { 1, 2 } }, '{"\\n\\tlol":[1,2]}') +check_ok({ foo = nil }, '{}') +check_ok({ foo = json.null }, '{"foo":null}') + +local meta_haver = {} +local my_meta = { + foo = 42, + __index = function() + return 1 + end +} +setmetatable(meta_haver, my_meta) +assert(meta_haver.xd == 1) +check_ok(meta_haver, '{}') +meta_haver.xd = 2 -- uses __newindex +check_ok(meta_haver, '{"xd":2}') + +-- limits +local big_obj = {} +; (function() + local it = big_obj + for _ = 1, 255 do + it.x = {} + it = it.x + end + it.x = "hey" +end)() +check_ok(big_obj, string.rep('{"x":', 256) .. '"hey"' .. string.rep('}', 256)) +check_err({ x = big_obj }) + +local big_arr = {} +; (function() + local it = big_arr + for _ = 1, 255 do + it[1] = {} + it = it[1] + end + it[1] = 1 +end)() +check_ok(big_arr, string.rep('[', 256) .. '1' .. string.rep(']', 256)) +check_err({ big_arr }) + +-- errors +check_err(c2.Message.new({})) +check_err(coroutine.create(function() end)) +check_err({ 1, 2, bar = "lol" }) +check_err({ [-1] = 3 }) +check_err({ [0] = 3 }) +check_err({ [0] = "42" }) +check_err({ [-1] = json.null }) +check_err({ [1.1] = 3 }) +check_err() +local recurse = {} +recurse.recurse = recurse +check_err(recurse) -- hits the depth limit + +check_err(math.huge) +check_err(-math.huge) +check_err(0/0) +check_err(-(0/0)) + +-- ✨ pretty ✨ + +check_ok(1, "1", { pretty = true }) +check_ok(false, "false", { pretty = true }) +check_ok(nil, "null", { pretty = true }) +check_ok("xd", '"xd"', { pretty = true }) +check_ok({ 1 }, '[\n 1\n]', { pretty = true }) +check_ok({ foo = "bar" }, '{\n "foo": "bar"\n}', { pretty = true }) +check_ok({ foo = "bar", baz = 1 }, { [[{ + "foo": "bar", + "baz": 1 +}]], [[{ + "baz": 1, + "foo": "bar" +}]] }, { pretty = true }) +check_ok({ foo = "bar" }, '{\n "foo": "bar"\n}', { pretty = true, indent_size = 1 }) +check_ok({ foo = "bar" }, '{\n\t"foo": "bar"\n}', { pretty = true, indent_size = 1, indent_char = '\t' }) +check_ok({ foo = "bar" }, '{"foo":"bar"}', { indent_size = 1, indent_char = '\tlol' }) diff --git a/tests/src/Plugins.cpp b/tests/src/Plugins.cpp index f4d579eb..5a84faf0 100644 --- a/tests/src/Plugins.cpp +++ b/tests/src/Plugins.cpp @@ -114,6 +114,30 @@ public: MockTwitch twitch; }; +QDir luaTestBaseDir(const QString &category) +{ + QDir snapshotDir(QStringLiteral(__FILE__)); + snapshotDir.cd("../../lua/"); + snapshotDir.cd(category); + return snapshotDir; +} + +QStringList discoverLuaTests(const QString &category) +{ + auto files = + luaTestBaseDir(category).entryList(QDir::NoDotAndDotDot | QDir::Files); + for (auto &file : files) + { + file.remove(".lua"); + } + return files; +} + +std::string luaTestPath(const QString &category, const QString &entry) +{ + return luaTestBaseDir(category).filePath(entry + ".lua").toStdString(); +} + } // namespace namespace chatterino { @@ -128,7 +152,7 @@ public: static void openLibrariesFor(Plugin *plugin) { - return PluginController::openLibrariesFor(plugin); + getApp()->getPlugins()->openLibrariesFor(plugin); } static std::map> &plugins() @@ -1061,6 +1085,39 @@ INSTANTIATE_TEST_SUITE_P( PluginMessageConstruction, PluginMessageConstructionTest, testing::ValuesIn(testlib::Snapshot::discover("PluginMessageCtor"))); +class PluginJsonTest : public PluginTest, + public ::testing::WithParamInterface +{ +}; +TEST_P(PluginJsonTest, Run) +{ + configure(); + auto reg = lua->registry().size(); + auto globals = lua->globals().size(); + + auto pfr = lua->safe_script_file(luaTestPath("json", GetParam())); + EXPECT_TRUE(pfr.valid()); + if (!pfr.valid()) + { + qDebug() << "Test" << GetParam() << "failed:"; + sol::error err = pfr; + qDebug() << err.what(); + return; + } + + for (size_t i = 0; i < 5; i++) + { + lua->collect_garbage(); + } + // make sure we don't leak anything to globals or the registry + // but give the registry some room of 1 slot + EXPECT_LE(lua->registry().size(), reg + 1); + EXPECT_EQ(lua->globals().size(), globals); +} + +INSTANTIATE_TEST_SUITE_P(PluginJson, PluginJsonTest, + testing::ValuesIn(discoverLuaTests("json"))); + // verify that all snapshots are included TEST(PluginMessageConstructionTest, Integrity) {