feat(plugins): add JSON parsing/serialization (#6420)
Reviewed-by: Mm2PL <mm2pl+gh@kotmisia.pl> Reviewed-by: pajlada <rasmus.karlsson@pajlada.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
Vendored
+20
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<decltype(args)>(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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<QString, std::unique_ptr<Plugin>> plugins_;
|
||||
WebSocketPool webSocketPool_;
|
||||
|
||||
std::vector<
|
||||
std::pair<std::string, std::function<sol::object(sol::state_view)>>>
|
||||
loaders_;
|
||||
|
||||
// This is for tests, pay no attention
|
||||
friend class PluginControllerAccess;
|
||||
};
|
||||
|
||||
@@ -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 <sol/sol.hpp>
|
||||
|
||||
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<char, 2 + 16 + 1> 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<void *>(nullptr) //
|
||||
);
|
||||
}
|
||||
|
||||
} // namespace chatterino::lua::api
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,16 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef CHATTERINO_HAVE_PLUGINS
|
||||
# include <sol/forward.hpp>
|
||||
|
||||
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
|
||||
@@ -0,0 +1,416 @@
|
||||
#include "controllers/plugins/api/JSONParse.hpp"
|
||||
|
||||
#ifdef CHATTERINO_HAVE_PLUGINS
|
||||
|
||||
# include "controllers/plugins/LuaUtilities.hpp"
|
||||
|
||||
# include <boost/json/basic_parser.hpp>
|
||||
# include <boost/json/serialize.hpp>
|
||||
# include <QVarLengthArray>
|
||||
# include <sol/sol.hpp>
|
||||
|
||||
# include <limits>
|
||||
|
||||
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<ParseError>(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<int>(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<lua_Integer>(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<uint64_t>(std::numeric_limits<lua_Integer>::max()))
|
||||
{
|
||||
lua_pushinteger(this->state, static_cast<lua_Integer>(i));
|
||||
}
|
||||
else
|
||||
{
|
||||
lua_pushnumber(this->state, static_cast<lua_Number>(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<int>(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<Element, 16> 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<int>(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<SaxHandler> 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<sol::table>(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 <boost/json/src.hpp>
|
||||
|
||||
#endif // CHATTERINO_HAVE_PLUGINS
|
||||
@@ -0,0 +1,17 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef CHATTERINO_HAVE_PLUGINS
|
||||
# include <sol/forward.hpp>
|
||||
|
||||
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
|
||||
@@ -0,0 +1,284 @@
|
||||
#include "controllers/plugins/api/JSONStringify.hpp"
|
||||
|
||||
#ifdef CHATTERINO_HAVE_PLUGINS
|
||||
|
||||
# include "controllers/plugins/LuaUtilities.hpp"
|
||||
|
||||
# include <rapidjson/prettywriter.h>
|
||||
# include <rapidjson/writer.h>
|
||||
# include <sol/sol.hpp>
|
||||
|
||||
// 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<size_t> 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<size_t>(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<lua_Integer>(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<sol::table>(L);
|
||||
if (tbl)
|
||||
{
|
||||
pretty = tbl->get_or("pretty", pretty);
|
||||
indentChar = tbl->get_or<char>("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<rapidjson::CrtAllocator *>(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
|
||||
@@ -0,0 +1,17 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef CHATTERINO_HAVE_PLUGINS
|
||||
# include <sol/forward.hpp>
|
||||
|
||||
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
|
||||
@@ -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 })
|
||||
@@ -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' })
|
||||
+58
-1
@@ -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<QString, std::unique_ptr<Plugin>> &plugins()
|
||||
@@ -1061,6 +1085,39 @@ INSTANTIATE_TEST_SUITE_P(
|
||||
PluginMessageConstruction, PluginMessageConstructionTest,
|
||||
testing::ValuesIn(testlib::Snapshot::discover("PluginMessageCtor")));
|
||||
|
||||
class PluginJsonTest : public PluginTest,
|
||||
public ::testing::WithParamInterface<QString>
|
||||
{
|
||||
};
|
||||
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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user