From fdd1d145cf2389af6e4378d68f98019d1e715aab Mon Sep 17 00:00:00 2001 From: Joe Date: Thu, 22 Jan 2026 22:19:00 -0600 Subject: [PATCH] json5 work --- Cargo.lock | 44 +- plugin/Packages/json5/JSON5Decoder.lua | 847 ++++++++++++++++++ plugin/Packages/json5/JSONEncoder.lua | 565 ++++++++++++ plugin/http/Response.lua | 9 +- plugin/http/init.lua | 28 +- plugin/rbx_dom_lua/EncodedValue.lua | 7 +- ...end_to_end__tests__build__csv_bug_145.snap | 11 +- ...end_to_end__tests__build__csv_bug_147.snap | 58 +- ...d_to_end__tests__build__csv_in_folder.snap | 11 +- ..._tests__build__init_csv_with_children.snap | 15 +- src/cli/fmt_project.rs | 7 +- src/cli/sourcemap.rs | 5 +- src/json.rs | 369 +++++++- src/project.rs | 19 +- src/snapshot_middleware/csv.rs | 2 +- ...t_middleware__csv__test__csv_from_vfs.snap | 2 +- ...pshot_middleware__csv__test__csv_init.snap | 2 +- ...leware__csv__test__csv_init_with_meta.snap | 2 +- ..._middleware__csv__test__csv_with_meta.snap | 2 +- src/web/api.rs | 39 +- src/web/util.rs | 12 +- tests/rojo_test/serve_util.rs | 11 +- 22 files changed, 1977 insertions(+), 90 deletions(-) create mode 100644 plugin/Packages/json5/JSON5Decoder.lua create mode 100644 plugin/Packages/json5/JSONEncoder.lua diff --git a/Cargo.lock b/Cargo.lock index 9d5fe38c..5e102c5b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -305,7 +305,7 @@ dependencies = [ "heck 0.4.1", "proc-macro-error", "proc-macro2 1.0.106", - "quote 1.0.43", + "quote 1.0.44", "syn 1.0.109", ] @@ -581,7 +581,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2 1.0.106", - "quote 1.0.43", + "quote 1.0.44", "syn 2.0.114", ] @@ -800,7 +800,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2 1.0.106", - "quote 1.0.43", + "quote 1.0.44", "syn 2.0.114", ] @@ -1686,7 +1686,7 @@ dependencies = [ "pest", "pest_meta", "proc-macro2 1.0.106", - "quote 1.0.43", + "quote 1.0.44", "syn 2.0.114", ] @@ -1782,7 +1782,7 @@ checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" dependencies = [ "proc-macro-error-attr", "proc-macro2 1.0.106", - "quote 1.0.43", + "quote 1.0.44", "syn 1.0.109", "version_check", ] @@ -1794,7 +1794,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" dependencies = [ "proc-macro2 1.0.106", - "quote 1.0.43", + "quote 1.0.44", "version_check", ] @@ -1844,7 +1844,7 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" dependencies = [ - "quote 1.0.43", + "quote 1.0.44", "syn 2.0.114", ] @@ -1859,9 +1859,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.43" +version = "1.0.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" dependencies = [ "proc-macro2 1.0.106", ] @@ -2384,7 +2384,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2 1.0.106", - "quote 1.0.43", + "quote 1.0.44", "syn 2.0.114", ] @@ -2538,7 +2538,7 @@ checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ "heck 0.5.0", "proc-macro2 1.0.106", - "quote 1.0.43", + "quote 1.0.44", "syn 2.0.114", ] @@ -2549,7 +2549,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2 1.0.106", - "quote 1.0.43", + "quote 1.0.44", "unicode-ident", ] @@ -2560,7 +2560,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" dependencies = [ "proc-macro2 1.0.106", - "quote 1.0.43", + "quote 1.0.44", "unicode-ident", ] @@ -2577,7 +2577,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2 1.0.106", - "quote 1.0.43", + "quote 1.0.44", "syn 2.0.114", ] @@ -2655,7 +2655,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2 1.0.106", - "quote 1.0.43", + "quote 1.0.44", "syn 2.0.114", ] @@ -2710,7 +2710,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2 1.0.106", - "quote 1.0.43", + "quote 1.0.44", "syn 2.0.114", ] @@ -3049,7 +3049,7 @@ version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" dependencies = [ - "quote 1.0.43", + "quote 1.0.44", "wasm-bindgen-macro-support", ] @@ -3061,7 +3061,7 @@ checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" dependencies = [ "bumpalo", "proc-macro2 1.0.106", - "quote 1.0.43", + "quote 1.0.44", "syn 2.0.114", "wasm-bindgen-shared", ] @@ -3480,7 +3480,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2 1.0.106", - "quote 1.0.43", + "quote 1.0.44", "syn 2.0.114", "synstructure", ] @@ -3501,7 +3501,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" dependencies = [ "proc-macro2 1.0.106", - "quote 1.0.43", + "quote 1.0.44", "syn 2.0.114", ] @@ -3521,7 +3521,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2 1.0.106", - "quote 1.0.43", + "quote 1.0.44", "syn 2.0.114", "synstructure", ] @@ -3555,7 +3555,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2 1.0.106", - "quote 1.0.43", + "quote 1.0.44", "syn 2.0.114", ] diff --git a/plugin/Packages/json5/JSON5Decoder.lua b/plugin/Packages/json5/JSON5Decoder.lua new file mode 100644 index 00000000..eaa5fc0b --- /dev/null +++ b/plugin/Packages/json5/JSON5Decoder.lua @@ -0,0 +1,847 @@ +--!strict +--!native +--!optimize 2 + +-- json5.lua: JSON5 parser written in pure Lua. +-- +-- Copyright (c) 2024 Miku AuahDark +-- +-- Permission is hereby granted, free of charge, to any person obtaining a copy of +-- this software and associated documentation files (the "Software"), to deal in +-- the Software without restriction, including without limitation the rights to +-- use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +-- of the Software, and to permit persons to whom the Software is furnished to do +-- so, subject to the following conditions: +-- +-- The above copyright notice and this permission notice shall be included in all +-- copies or substantial portions of the Software. +-- +-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +-- SOFTWARE. + +-- @see https://github.com/MikuAuahDark/json5.lua + + +---@type string[]|table +local whitespaceList = { + "\225\154\128", + "\226\128\128", + "\226\128\129", + "\226\128\130", + "\226\128\131", + "\226\128\132", + "\226\128\133", + "\226\128\134", + "\226\128\135", + "\226\128\136", + "\226\128\137", + "\226\128\138", + "\226\128\168", + "\226\128\169", + "\226\128\175", + "\226\129\159", + "\227\128\128", + "\194\160", --   + "\r\n", -- CRLF + "\9", -- Tab + "\10", -- Newline + "\11", -- Vert. tab + "\12", -- Form feed + "\13", -- CR + " ", +} + +---@type string[]|table +local newlineCharsList = { + "\226\128\168", + "\226\128\169", + "\r\n", -- CRLF + "\r", -- CR + "\n", -- LF +} + +local function MAKE_LOOKUP(t: {string}): {[string]: boolean} + local kv = {} + for i, v in ipairs(t) do + kv[v] = true + end + return kv +end + +local whitespace = MAKE_LOOKUP(whitespaceList) +local newlineChars = MAKE_LOOKUP(newlineCharsList) + +local escaper = { + ["0"] = "\0", + ["'"] = "'", + ["\""] = "\"", + ["\\"] = "\\", + b = "\8", + f = "\12", + n = "\n", + r = "\r", + t = "\t", + v = "\11" +} + +local function Q(obj: any): string + return (string.format("%q", obj):gsub("\r", "\\r"):gsub("\n", "\\n")) +end + +---@param message string +---@param rowcol {[1]:integer,[2]:integer} +local function formatError( + message: string, + rowcol: {number} +): string + return string.format("%s at line %d col %d", message, rowcol[1], rowcol[2]) +end + +---@param ws string +---@param rowcol {[1]:integer,[2]:integer} +local function advanceNewline( + ws: string, + rowcol: {number} +): boolean + if newlineChars[ws] then + rowcol[1] = rowcol[1] + 1 + rowcol[2] = 1 + return true + end + + return false +end + +---@param text string +---@param rowcol {[1]:integer,[2]:integer} +local function getNewline( + text: string, + rowcol: {number} +): string? + for _, nl in ipairs(newlineCharsList) do + if text:sub(1, #nl) == nl and advanceNewline(nl, rowcol) then + return nl + end + end + + return nil +end + +---@param text string +---@param rowcol {[1]:integer,[2]:integer} +local function getWhitespace( + text: string, + rowcol: {number} +): string? + if #text == 0 then + return nil + end + + for _, w in ipairs(whitespaceList) do + if text:sub(1, #w) == w then + rowcol[2] = rowcol[2] + #w + advanceNewline(w, rowcol) + return w + end + end + + return nil +end + +---@param text string +---@param rowcol {[1]:integer,[2]:integer} +local function stripWhitespace( + text: string, + rowcol: {number} +): string + -- This is quite expensive, O(n^2) + while true do + local ws = getWhitespace(text, rowcol) + + if ws == nil then + break + end + + text = text:sub(#ws + 1) + end + + return text +end + +---@param text string +---@param rowcol {[1]:integer,[2]:integer} +local function stripInlineComments( + text: string, + rowcol: {number} +): string + -- Find newline + while true do + if #text == 0 then + -- EOF + break + end + + local nl = getNewline(text, rowcol) + if nl then + text = text:sub(#nl + 1) + break + end + + text = text:sub(2) + rowcol[2] = rowcol[2] + 1 + end + + return text +end + +---@param text string +---@param rowcol {[1]:integer,[2]:integer} +---@return string +local function stripBlockComments( + text: string, + rowcol: {number} +): string + -- This is bit expensive but it's necessary for proper row column support + while #text > 0 do + -- Found block comment close tag + if text:sub(1, 2) == "*/" then + rowcol[2] = rowcol[2] + 2 + return text:sub(3) + end + + local nl = getNewline(text, rowcol) + if nl then + text = text:sub(#nl + 1) + else + rowcol[2] = rowcol[2] + 1 + text = text:sub(2) + end + end + + error(formatError("missing multiline comment close tag", rowcol)) + return "" +end + +---Copied and modified slightly from rxi/json +---@param n integer +local function codepointToutf8( + n: number +): (string?, string?) + -- http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=iws-appendixa + if n <= 0x7f then + return string.char(n) + elseif n <= 0x7ff then + return string.char(math.floor(n / 64) + 192, n % 64 + 128) + elseif n <= 0xffff then + return string.char(math.floor(n / 4096) + 224, + math.floor(n % 4096 / 64) + 128, n % 64 + 128) + elseif n <= 0x10ffff then + return string.char( + math.floor(n / 262144) + 240, + math.floor(n % 262144 / 4096) + 128, + math.floor(n % 4096 / 64) + 128, + n % 64 + 128 + ) + end + + return nil, string.format("invalid unicode codepoint '%x'", n) +end + +---@param text string includes the "\u" +---@param rowcol {[1]:integer,[2]:integer} +local function parseUnicodeImpl( + text: string, + rowcol: {number} +): (number, string) + local hexcode = text:match("^\\u(%x%x%x%x)") + if not hexcode then + error(formatError("invalid unicode hex escape sequence", rowcol)) + end + + local utf16code = tonumber(hexcode, 16) + if not utf16code then + error(formatError("invalid unicode hex escape sequence", rowcol)) + end + + rowcol[2] = rowcol[2] + 6 + return utf16code, text:sub(7) +end + +---@param low integer +---@param high integer +local function getSurrogatePair(low: number, high: number): number + return (high - 0xd800) * 0x400 + (low - 0xdc00) + 0x10000 +end + +---@param text string includes the "\u" +---@param rowcol {[1]:integer,[2]:integer} +local function parseUnicode( + text: string, + rowcol: {number} +): (string, string) + local row, col = rowcol[1], rowcol[2] -- for codepointToUTF8 + local num + num, text = parseUnicodeImpl(text, rowcol) + + -- Is it surrogate pair? + if num >= 0xd800 and num < 0xdc00 then + -- High surrogate pair. Need low surrogate pair. + local lownum + lownum, text = parseUnicodeImpl(text, rowcol) + if lownum and lownum >= 0xdc00 and lownum <= 0xdfff then + num = getSurrogatePair(lownum, num) + end + -- TODO(low): Should we error in case of invalid pairs? + end + + local utf8text, errmsg = codepointToutf8(num) + if not utf8text then + ---@cast errmsg -nil + error(formatError(assert(errmsg), {row, col})) + end + + ---@cast utf8text -nil + return utf8text, text +end + +---@param text string +---@param stop fun(text:string):boolean +---@param identifierMode boolean +---@param rowcol {[1]:integer,[2]:integer} +local function parseStringImpl( + text: string, + stop: (string) -> boolean, + identifierMode: boolean, + rowcol: {number} +): (string, string) + local result = {} + + while true do + if stop(text) then + break + end + + local char = text:sub(1, 1) + + -- Escape sequence? + if char == "\\" then + local what = text:sub(2, 2) + rowcol[2] = rowcol[2] + 1 + + if escaper[what] then + rowcol[2] = rowcol[2] + 1 + + if identifierMode then + error(formatError("escape sequence not allowed", rowcol)) + end + + result[#result+1] = escaper[what] + text = text:sub(3) + elseif what == "u" then + -- Unicode escape + local unicode + unicode, text = parseUnicode(text, rowcol) + result[#result+1] = unicode + elseif what == "x" then + if identifierMode then + error(formatError("hex escape sequence not allowed", rowcol)) + end + + rowcol[2] = rowcol[2] + 2 + + local hexstr = text:sub(2, 3) + local hexnum = tonumber(hexstr, 16) + if not hexnum then + error(formatError("invalid hex escape sequence", rowcol)) + end + + result[#result+1] = string.char(hexnum) + rowcol[2] = rowcol[2] + 2 + text = text:sub(5) -- "\xHH" + else + if identifierMode then + error(formatError("invalid escape sequence", rowcol)) + end + + local nl = getNewline(text:sub(2), rowcol) + local ignore = 2 + if nl then + -- JSON5 allows string spanning multiple lines by escaping newline + ignore = #nl + 2 + end + + -- Ignore + text = text:sub(ignore) + end + elseif char:byte(1, 1) < 32 then + -- Accept control characters (e.g. from json5 pretty-print) + result[#result+1] = char + text = text:sub(2) + if char == "\n" then + rowcol[1] = rowcol[1] + 1 + rowcol[2] = 1 + else + rowcol[2] = rowcol[2] + 1 + end + else + result[#result+1] = char + text = text:sub(2) + rowcol[2] = rowcol[2] + 1 + end + end + + return table.concat(result), text +end + +---@param text string including the delimiter +---@param rowcol {[1]:integer,[2]:integer} +local function parseString( + text: string, + rowcol: {number} +): (string, string) + local stop, value = text:sub(1, 1), nil + + ---@param txt string + ---@return boolean + local function stopCriterion(txt) + return txt:sub(1, 1) == stop + end + + value, text = parseStringImpl(text:sub(2), stopCriterion, false, rowcol) + rowcol[2] = rowcol[2] + 1 + return value, text:sub(2) +end + +local NaN = 0/0 + +---@param text string +---@param rowcol {[1]:integer,[2]:integer} +local function parseNumber( + text: string, + rowcol: {number} +): (number, string) + local sign = 1 + local signchar = text:sub(1, 1) + local row, col = rowcol[1], rowcol[2] -- for tonumber parsing + + if signchar == "+" then + sign = 1 + text = text:sub(2) + rowcol[2] = rowcol[2] + 1 + elseif signchar == "-" then + sign = -1 + text = text:sub(2) + rowcol[2] = rowcol[2] + 1 + end + + if text:sub(1, 3) == "NaN" then + rowcol[2] = rowcol[2] + 3 + return NaN, text:sub(4) + end + + local infText = text:find("Infinity", 1, true) + if infText == 1 then + rowcol[2] = rowcol[2] + 8 + return math.huge * sign, text:sub(9) + end + + -- TODO(low): Bring our own number parsing for Lua 5.1? + local potentialNum = 0 + local lookText = text + + while true do + if getWhitespace(lookText, rowcol) then + break + end + + local next = lookText:sub(1, 1) + if next == "" or next == "," or next == "]" or next == "}" then + break + end + + potentialNum = potentialNum + 1 + lookText = lookText:sub(2) + rowcol[2] = rowcol[2] + 1 + end + + local numval = text:sub(1, potentialNum) + local num: number? = nil + + if numval:sub(1, 1) == "0" and numval:sub(2):find("^%d+$") then + -- Octal is not allowed + num = nil + else + -- Parse normal + num = tonumber(numval) + end + if num == nil then + error(formatError("invalid number sequence "..Q(numval), {row, col})) + end + + rowcol[2] = rowcol[2] + potentialNum + ---@cast num -nil + return num * sign, text:sub(potentialNum + 1) +end + +---@param text string +---@param rowcol {[1]:integer,[2]:integer} +---@param nullval any +local function parseNull( + text: string, + rowcol: {number}, + nullval: any? +): (any?, string) + if text:sub(1, 4) ~= "null" then + error(formatError("invalid null literal", rowcol)) + end + + rowcol[2] = rowcol[2] + 1 + return nullval, text:sub(5) +end + +---@param text string +---@param rowcol {[1]:integer,[2]:integer} +local function parseBoolean( + text: string, + rowcol: {number} +): boolean + if text:sub(1, 4) == "true" then + rowcol[2] = rowcol[2] + 4 + return true, text:sub(5) + elseif text:sub(1, 5) == "false" then + rowcol[2] = rowcol[2] + 5 + return false, text:sub(6) + else + error(formatError("invalid boolean literal", rowcol)) + end +end + +---@param text string +---@param rowcol {[1]:integer,[2]:integer} +local function stripComments( + text: string, + rowcol: {number} +): string + local s = text:sub(1, 2) + if s == "//" then + rowcol[2] = rowcol[2] + 2 + return stripInlineComments(text:sub(3), rowcol) + elseif s == "/*" then + rowcol[2] = rowcol[2] + 2 + return stripBlockComments(text:sub(3), rowcol) + end + + return text +end + +---@param text string +---@param rowcol {[1]:integer,[2]:integer} +local function stripWhitespaceAndComments( + text: string, + rowcol: {number} +): string + while true do + local txt = stripWhitespace(text, rowcol) + txt = stripComments(txt, rowcol) + + if txt == text then + break + end + + text = txt + end + + return text +end + +---@alias json5.Value nil|number|boolean|string|json5.Array|json5.Object +---@alias json5.Array json5.Value[] +---@alias json5.Object table + +local parseValue + +---@param text string +---@param rowcol {[1]:integer,[2]:integer} +---@param nullval any +---@return json5.Array,string +local function parseArray( + text: string, + rowcol: {number}, + nullval: any? +): ({any}, string) + text = text:sub(2) + rowcol[2] = rowcol[2] + 1 + + local result = {} + + while true do + text = stripWhitespaceAndComments(text, rowcol) + + if text:sub(1, 1) == "]" then + -- Finish + rowcol[2] = rowcol[2] + 1 + text = text:sub(2) + break + end + + local value + value, text = parseValue(text, rowcol, nullval) + text = stripWhitespaceAndComments(text, rowcol) + + -- Insert + result[#result+1] = value + + -- Continue or finish? + local lastOrNext = text:sub(1, 1) + text = text:sub(2) + + if lastOrNext == "]" then + -- Finish + rowcol[2] = rowcol[2] + 1 + break + elseif lastOrNext ~= "," then + error(formatError("expected comma got "..Q(lastOrNext), rowcol)) + end + + rowcol[2] = rowcol[2] + 1 + end + + return result, text +end + +---@param identifier string +local function testIdentifier( + identifier: string +): boolean + local firstID = identifier:byte(1, 1) + if firstID >= 48 and firstID <= 57 then + return false + end + + for i = 1, #identifier do + local char = identifier:byte(i, i) + if char < 36 then + return false + end + + if char >= 37 and char <= 47 then + return false + end + + if char >= 58 and char <= 64 then + return false + end + + if char >= 91 and char <= 94 then + return false + end + + if char == 96 then + return false + end + + if char >= 123 and char <= 128 then + return false + end + end + + return true +end + +---@param text string +local function stopIdentifier( + text: string +): boolean + return text:sub(1, 1) == ":" or getWhitespace(text, {0, 0}) ~= nil +end + +---@param text string +---@param rowcol {[1]:integer,[2]:integer} +local function parseIdentifier( + text: string, + rowcol: {number} +): (string, string) + local first = text:sub(1, 1) + local identifier + + if first == "'" or first == "\"" then + -- Quoted identifier + identifier, text = parseString(text, rowcol) + else + local row, col = rowcol[1], rowcol[2] + -- Unquoted identifier + identifier, text = parseStringImpl(text, stopIdentifier, true, rowcol) + + -- Test identifier validity + if not testIdentifier(identifier) then + error(formatError("invalid identifier "..Q(identifier), {row, col})) + end + end + + return identifier, text +end + +---@param text string +---@param rowcol {[1]:integer,[2]:integer} +---@param nullval any +---@return json5.Object,string +local function parseObject( + text: string, + rowcol: {number}, + nullval: any? +): ({[string]: any}, string) + rowcol[2] = rowcol[2] + 1 + text = text:sub(2) + + local result = {} + + while true do + text = stripWhitespaceAndComments(text, rowcol) + + if text:sub(1, 1) == "}" then + -- Finish + rowcol[2] = rowcol[2] + 1 + text = text:sub(2) + break + end + + -- Identifier + local identifier + identifier, text = parseIdentifier(text, rowcol) + text = stripWhitespaceAndComments(text, rowcol) + + -- Colon + if text:sub(1, 1) ~= ":" then + error(formatError( + "expected colon after identifier, got ".. Q(text:sub(1, 1)), + rowcol) + ) + end + + rowcol[2] = rowcol[2] + 1 + text = text:sub(2) + + -- Value + text = stripWhitespaceAndComments(text, rowcol) + + local value + value, text = parseValue(text, rowcol, nullval) + + text = stripWhitespaceAndComments(text, rowcol) + + -- Insert + result[identifier] = value + + -- Continue or finish? + local lastOrNext = text:sub(1, 1) + text = text:sub(2) + + if lastOrNext == "}" then + -- Finish + rowcol[2] = rowcol[2] + 1 + break + elseif lastOrNext ~= "," then + error(formatError("expected comma got "..Q(lastOrNext), rowcol)) + end + + rowcol[2] = rowcol[2] + 1 + end + + return result, text +end + +---@param text string +---@param rowcol {[1]:integer,[2]:integer} +local function catchEOF(text: string, rowcol: {number}) + error(formatError("unexpected eof", rowcol)) +end + +local valueTest = { + ["-"] = parseNumber, + ["+"] = parseNumber, + ["."] = parseNumber, + ["0"] = parseNumber, + ["1"] = parseNumber, + ["2"] = parseNumber, + ["3"] = parseNumber, + ["4"] = parseNumber, + ["5"] = parseNumber, + ["6"] = parseNumber, + ["7"] = parseNumber, + ["8"] = parseNumber, + ["9"] = parseNumber, + ["N"] = parseNumber, -- for NaN + ["I"] = parseNumber, -- for Infinity + ["n"] = parseNull, + ["t"] = parseBoolean, + ["f"] = parseBoolean, + ["'"] = parseString, + ["\""] = parseString, + ["["] = parseArray, + ["{"] = parseObject, + [""] = catchEOF, +} + +---@param text string +---@param rowcol {[1]:integer,[2]:integer} +---@param nullval any +---@return json5.Value,string +parseValue = function( + text: string, + rowcol: {number}, + nullval: any? +): any + text = stripWhitespaceAndComments(text, rowcol) + + local first = text:sub(1, 1) + local func = valueTest[first] + + if not func then + error(formatError("invalid value literal "..Q(first), rowcol)) + end + + return func(text, rowcol, nullval) +end + + + +local module = {} + +---A value that denote "null" value in JSON, if the user need to preserve keys. +---@type any +module.Null = newproxy(false) + +---@class json5.opts +---@field public null any Null value substitute. +-- Default is `nil` which means "null" will not preserve key with "null" value +-- and potentially leave holes in an array. To preserve "null", +-- use `json5.null` or any other value. + +---Decode JSON5 string to Lua value. +---@param text string JSON5 string. +---@param nullValue Optional null value, see module.Null +---@return json5.Value +function module.Decode( + text: string, + nullValue: any? +): any + local rowcol = {1, 1} + text = stripWhitespaceAndComments(text, rowcol) + + local value + value, text = parseValue(text, rowcol, nullValue) + text = stripWhitespaceAndComments(text, rowcol) + + if #text > 0 then + error(formatError("trailing garbage", rowcol)) + end + + return value +end + +return table.freeze(module) diff --git a/plugin/Packages/json5/JSONEncoder.lua b/plugin/Packages/json5/JSONEncoder.lua new file mode 100644 index 00000000..3e2d02c6 --- /dev/null +++ b/plugin/Packages/json5/JSONEncoder.lua @@ -0,0 +1,565 @@ +--!strict +--!native +--!optimize 2 +--[[ + Copyright 2025 Joseph Melsha + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +]] + +-- Negative codes mean \u00XX +local StringCodes = { + [0x00] = -0x3030, + [0x01] = -0x3130, + [0x02] = -0x3230, + [0x03] = -0x3330, + [0x04] = -0x3430, + [0x05] = -0x3530, + [0x06] = -0x3630, + [0x07] = -0x3730, + [0x08] = 0x625C, -- \b + [0x09] = 0x745C, -- \t + [0x0A] = 0x6E5C, -- \n + [0x0B] = -0x6230, + [0x0C] = 0x665C, -- \f + [0x0D] = 0x725C, -- \r + [0x0E] = -0x6530, + [0x0F] = -0x6630, + [0x10] = -0x3031, + [0x11] = -0x3131, + [0x12] = -0x3231, + [0x13] = -0x3331, + [0x14] = -0x3431, + [0x15] = -0x3531, + [0x16] = -0x3631, + [0x17] = -0x3731, + [0x18] = -0x3831, + [0x19] = -0x3931, + [0x1A] = -0x6131, + [0x1B] = -0x6231, + [0x1C] = -0x6331, + [0x1D] = -0x6431, + [0x1E] = -0x6531, + [0x1F] = -0x6631, +} + +local IdentifierChar1 = { + [36] = true, -- $ + [95] = true, -- _ +} +local IdentifierChar2 = { + [36] = true, -- $ + [95] = true, -- _ +} +-- A–Z +for b=65,90 do + IdentifierChar1[b] = true + IdentifierChar2[b] = true +end +-- a–z +for b=97,122 do + IdentifierChar1[b] = true + IdentifierChar2[b] = true +end +-- 0–9 (only valid in subsequent positions) +for b=48,57 do + IdentifierChar2[b] = true +end + +type Stream = { + Buf: buffer, + Pos: number, + Cap: number, + Indent: number, + Pretty: boolean, + Encoders: {[string]: (out: Stream, v: any) -> ()}, + Null: any?, + QuoteChar: number, + UnquoteIdent: boolean, +} + +local function Stream(buf: buffer): Stream + return { + Buf = buf, + Pos = 0, + Cap = buffer.len(buf), + Indent = 0, + Pretty = false, + Encoders = {}, + Null = nil, + QuoteChar = 0x22, + UnquoteIdent = false, + } +end + +local function ToString(out: Stream): string + return buffer.readstring(out.Buf, 0, out.Pos) +end + +local function AllocSize( + n: number +): number + return math.max(n, 2 ^ math.ceil(math.log(n, 2))) +end + +local function Reserve( + out: Stream, + n: number +): number + local pos = out.Pos + local newPos = pos + n + if out.Cap < newPos then + local newCap = AllocSize(newPos) + local newBuf = buffer.create(newCap) + buffer.copy(newBuf, 0, out.Buf, 0, pos) + out.Buf, out.Cap = newBuf, newCap + end + out.Pos = newPos + return pos +end + +local function WriteString(out: Stream, s: string) + local len = string.len(s) + local pos = Reserve(out, len) + buffer.writestring(out.Buf, pos, s, len) +end + +local function WriteIndent(out: Stream) + local len = out.Indent * 2 + local pos = Reserve(out, len) + local buf = out.Buf + for i=0,len-1 do + buffer.writeu16(buf, pos + i * 2, 0x2020) + end +end + +local function IncrementIndent(out: Stream, n: number) + out.Indent += n +end + +local function WriteU8(out: Stream, v: number) + local pos = Reserve(out, 1) + buffer.writeu8(out.Buf, pos, v) +end + +local function WriteU16(out: Stream, v: number) + local pos = Reserve(out, 2) + buffer.writeu16(out.Buf, pos, v) +end + +local function WriteU32(out: Stream, v: number) + local pos = Reserve(out, 4) + buffer.writeu32(out.Buf, pos, v) +end + +local function EncodeNull(out: Stream, v: nil) + WriteU32(out, 0x6C6C756E) -- null +end + +local function EncodeBoolean(out: Stream, v: boolean) + if v then + WriteU32(out, 0x65757274) -- true + else + local pos = Reserve(out, 5) + local buf = out.Buf + buffer.writeu32(buf, pos, 0x736C6166) -- fals + buffer.writeu8(buf, pos + 4, 0x65) -- e + end +end + +local function EncodeNumber(out: Stream, n: number) + if n ~= n then + local pos = Reserve(out, 3) + local buf = out.Buf + buffer.writeu16(buf, pos, 0x614E) -- Na + buffer.writeu8(buf, pos + 2, 0x4E) -- N + elseif n == math.huge then + local pos = Reserve(out, 8) + local buf = out.Buf + buffer.writeu32(buf, pos, 0x69666E49) -- Infi + buffer.writeu32(buf, pos + 4, 0x7974696E) -- 'nity + elseif n == -math.huge then + local pos = Reserve(out, 9) + local buf = out.Buf + buffer.writeu8(buf, pos, 0x2D) -- '-' + buffer.writeu32(buf, pos + 1, 0x69666E49) -- Infi + buffer.writeu32(buf, pos + 5, 0x7974696E) -- 'nity + else + local s = tostring(n) + local i = string.find(s, "e%+") + if i then + s = string.sub(s, 1, i) .. string.sub(s, i + 2) + end + WriteString(out, s) + end +end + +local function IsIdentifierName( + out: Stream, + s: string +): boolean + local len = string.len(s) + if len == 0 then + return false + end + local first = string.byte(s, 1) + if not IdentifierChar1[first] then + return false + end + local identifierChar2 = IdentifierChar2 + for i=2,len do + if not identifierChar2[string.byte(s, i)] then + return false + end + end + return true +end + +local function EncodeString( + out: Stream, + s: string, + unquoteIdent: boolean? +) + if unquoteIdent and not IsIdentifierName(out, s) then + unquoteIdent = false + end + + local len = string.len(s) + local pos = Reserve(out, 2 + len * 6) + local buf = out.Buf + + local quoteCode = out.QuoteChar + + if not unquoteIdent then + buffer.writeu8(buf, pos, quoteCode) -- " or ' + pos += 1 + end + + for i=1,len do + local b = string.byte(s, i) + if b > 31 then + if b ~= quoteCode and b ~= 0x5C then + buffer.writeu8(buf, pos, b) + pos += 1 + else + buffer.writeu16(buf, pos, 0x5C + bit32.lshift(b, 8)) -- \" or \' or \\ + pos += 2 + end + else + local code = StringCodes[b] + if code < 0 then + code = -code + buffer.writeu32(buf, pos, 0x3030755C) -- \u00 + pos += 4 + end + buffer.writeu16(buf, pos, code) + pos += 2 + end + end + + if not unquoteIdent then + buffer.writeu8(buf, pos, quoteCode) -- " or ' + pos += 1 + end + + out.Pos = pos +end + +local function EncodeBuffer(out: Stream, data: buffer) + local len = buffer.len(data) + local pos = Reserve(out, 2 + len * 6) + local buf = out.Buf + + local quoteCode = out.QuoteChar + + buffer.writeu8(buf, pos, quoteCode) -- " or ' + pos += 1 + + for i=0,len-1 do + local b = buffer.readu8(data, i) + if b > 31 then + if b ~= quoteCode and b ~= 0x5C then + buffer.writeu8(buf, pos, b) + pos += 1 + else + buffer.writeu16(buf, pos, 0x5C + bit32.lshift(b, 8)) -- \" or \' or \\ + pos += 2 + end + else + local code = StringCodes[b] + if code < 0 then + code = -code + buffer.writeu32(buf, pos, 0x3030755C) -- \u00 + pos += 4 + end + buffer.writeu16(buf, pos, code) + pos += 2 + end + end + + buffer.writeu8(buf, pos, quoteCode) -- " or ' + pos += 1 + + out.Pos = pos +end + +local function EncodeAny(out: Stream, v: any) + if v == out.Null then + v = nil + end + out.Encoders[typeof(v)](out, v) +end + +local function EncodeArray(out: Stream, o: {any}) + local pretty = out.Pretty + WriteU8(out, 0x5B) -- [ + if pretty then + IncrementIndent(out, 1) + end + for i, v in ipairs(o) do + if i > 1 then + WriteU8(out, 0x2C) -- , + end + if pretty then + WriteU8(out, 0x0A) -- \n + WriteIndent(out) + end + EncodeAny(out, v) + end + if pretty then + IncrementIndent(out, -1) + if #o > 0 then + WriteU8(out, 0x0A) -- \n + WriteIndent(out) + end + end + WriteU8(out, 0x5D) -- ] +end + +-- IDEA: encode keys as packed JSON, add to tuples, sort, encode +local function EncodeMap(out: Stream, o: {[string]: any}) + local keys = {} + for k, _ in pairs(o) do + assert(type(k) == "string") + table.insert(keys, k) + end + table.sort(keys) + local pretty = out.Pretty + local unquoteIdent = out.UnquoteIdent + WriteU8(out, 0x7B) -- { + if pretty then + IncrementIndent(out, 1) + end + for i, k in ipairs(keys) do + if i > 1 then + WriteU8(out, 0x2C) -- , + end + if pretty then + WriteU8(out, 0x0A) -- \n + WriteIndent(out) + end + EncodeString(out, k, unquoteIdent) + if pretty then + WriteU16(out, 0x203A) -- ': ' + else + WriteU8(out, 0x3A) -- : + end + EncodeAny(out, o[k]) + end + if pretty then + IncrementIndent(out, -1) + if #keys > 0 then + WriteU8(out, 0x0A) -- \n + WriteIndent(out) + end + end + WriteU8(out, 0x7D) -- } +end + +local function EncodeTable(out: Stream, o: any) + -- assert(getmetatable(o) == nil) + if #o > 0 or next(o) == nil then + EncodeArray(out, o) + else + EncodeMap(out, o) + end +end + +local function EncodeVector2(out: Stream, v: Vector2) + EncodeArray(out, { v.X, v.Y }) +end + +local function EncodeVector3(out: Stream, v: Vector3) + EncodeArray(out, { v.X, v.Y, v.Z }) +end + +local function EncodeVector2int16(out: Stream, v: Vector2int16) + EncodeArray(out, { v.X, v.Y }) +end + +local function EncodeVector3int16(out: Stream, v: Vector3int16) + EncodeArray(out, { v.X, v.Y, v.Z }) +end + +local function EncodeRegion3(out: Stream, v: Region3) + local min = v.CFrame * (v.Size * -0.5) + local max = v.CFrame * (v.Size * 0.5) + EncodeArray(out, { min.X, min.Y, min.Z, max.X, max.Y, max.Z }) +end + +local function EncodeRegion3int16(out: Stream, v: Region3int16) + EncodeArray(out, { v.Min.X, v.Min.Y, v.Min.Z, v.Max.X, v.Max.Y, v.Max.Z }) +end + +local function EncodeUDim(out: Stream, v: UDim) + EncodeArray(out, { v.Scale, v.Offset }) +end + +local function EncodeUDim2(out: Stream, v: UDim2) + EncodeArray(out, { v.X.Scale, v.X.Offset, v.Y.Scale, v.Y.Offset }) +end + +local function EncodeCFrame(out: Stream, v: CFrame) + EncodeArray(out, { v:GetComponents() }) +end + +local function EncodeColor3(out: Stream, v: Color3) + EncodeArray(out, { + math.round(v.R*255), + math.round(v.G*255), + math.round(v.B*255), + }) +end + +local function EncodeNumberRange(out: Stream, v: NumberRange) + EncodeArray(out, { v.Min, v.Max }) +end + +local function EncodeRect(out: Stream, v: Rect) + EncodeArray(out, { v.Min.X, v.Min.Y, v.Max.X, v.Max.Y }) +end + +local function EncodeEnumItem(out: Stream, v: EnumItem) + EncodeNumber(out, v.Value) +end + +-- Encoders +local Encoders = {} +Encoders["nil"] = EncodeNull +Encoders["boolean"] = EncodeBoolean +Encoders["number"] = EncodeNumber +Encoders["string"] = EncodeString +Encoders["buffer"] = EncodeBuffer +Encoders["table"] = EncodeTable + +-- Roblox Extensions +local EncodersExt = table.clone(Encoders) +EncodersExt["Vector2"] = EncodeVector2 +EncodersExt["Vector3"] = EncodeVector3 +EncodersExt["Vector2int16"] = EncodeVector2int16 +EncodersExt["Vector3int16"] = EncodeVector3int16 +EncodersExt["Region3"] = EncodeRegion3 +EncodersExt["Region3int16"] = EncodeRegion3int16 +EncodersExt["UDim"] = EncodeUDim +EncodersExt["UDim2"] = EncodeUDim2 +EncodersExt["CFrame"] = EncodeCFrame +EncodersExt["Color3"] = EncodeColor3 +EncodersExt["NumberRange"] = EncodeNumberRange +EncodersExt["Rect"] = EncodeRect +EncodersExt["EnumItem"] = EncodeEnumItem + +local sharedBuffer = buffer.create(0x1000) -- 4k + +local compactStream = Stream(sharedBuffer) +compactStream.Encoders = Encoders +local function Compact(v: any, nullValue: any?): string + local stream = compactStream + stream.Pos = 0 + stream.Null = nullValue + EncodeAny(stream, v) + return ToString(stream) +end + +local prettyStream = Stream(sharedBuffer) +prettyStream.Encoders = Encoders +prettyStream.Pretty = true +local function Pretty(v: any, nullValue: any?): string + local stream = prettyStream + stream.Pos = 0 + stream.Indent = 0 + stream.Null = nullValue + EncodeAny(stream, v) + return ToString(stream) +end + +local compactExtStream = Stream(sharedBuffer) +compactExtStream.Encoders = EncodersExt +local function CompactExt(v: any, nullValue: any?): string + local stream = compactExtStream + stream.Pos = 0 + stream.Null = nullValue + EncodeAny(stream, v) + return ToString(stream) +end + +local prettyExtStream = Stream(sharedBuffer) +prettyExtStream.Encoders = EncodersExt +prettyExtStream.Pretty = true +local function PrettyExt(v: any, nullValue: any?): string + local stream = prettyExtStream + stream.Pos = 0 + stream.Indent = 0 + stream.Null = nullValue + EncodeAny(stream, v) + return ToString(stream) +end + +-- JSON5 +local compact5Stream = Stream(sharedBuffer) +compact5Stream.Encoders = Encoders +compact5Stream.UnquoteIdent = true +compact5Stream.QuoteChar = 0x27 +local function Compact5(v: any, nullValue: any?): string + local stream = compact5Stream + stream.Pos = 0 + stream.Null = nullValue + EncodeAny(stream, v) + return ToString(stream) +end + +local pretty5Stream = Stream(sharedBuffer) +pretty5Stream.Encoders = Encoders +pretty5Stream.Pretty = true +pretty5Stream.UnquoteIdent = true +pretty5Stream.QuoteChar = 0x27 +local function Pretty5(v: any, nullValue: any?): string + local stream = pretty5Stream + stream.Pos = 0 + stream.Indent = 0 + stream.Null = nullValue + EncodeAny(stream, v) + return ToString(stream) +end + + +local module = { + Compact = Compact, + Pretty = Pretty, + CompactExt = CompactExt, + PrettyExt = PrettyExt, + + Compact5 = Compact5, + Pretty5 = Pretty5, +} + +return table.freeze(module) diff --git a/plugin/http/Response.lua b/plugin/http/Response.lua index 1230a1c9..249a7bf6 100644 --- a/plugin/http/Response.lua +++ b/plugin/http/Response.lua @@ -1,4 +1,5 @@ local HttpService = game:GetService("HttpService") +local JSON5Decoder = require(script.Parent.Parent.json5.JSON5Decoder) local stringTemplate = [[ Http.Response { @@ -28,7 +29,13 @@ function Response:isSuccess() end function Response:json() - return HttpService:JSONDecode(self.body) + -- Try fast native decode first, fallback to JSON5 for NaN/Infinity + local success, result = pcall(HttpService.JSONDecode, HttpService, self.body) + if success then + return result + end + print("[HTTP Response:json()] Native decode failed, using JSON5 for size:", #self.body) + return JSON5Decoder.Decode(self.body) end return Response diff --git a/plugin/http/init.lua b/plugin/http/init.lua index cac0ebc4..8c6b215f 100644 --- a/plugin/http/init.lua +++ b/plugin/http/init.lua @@ -2,6 +2,8 @@ local HttpService = game:GetService("HttpService") local Promise = require(script.Parent.Promise) local Log = require(script.Parent.Log) +local JSON5Decoder = require(script.Parent.json5.JSON5Decoder) +local JSONEncoder = require(script.Parent.json5.JSONEncoder) local HttpError = require(script.Error) local HttpResponse = require(script.Response) @@ -29,15 +31,15 @@ local function performRequest(requestParams) return HttpService:RequestAsync(requestParams) end) - if success then - Log.trace("Request {} success, response {:#?}", requestId, response) - local httpResponse = HttpResponse.fromRobloxResponse(response) - if httpResponse:isSuccess() then - resolve(httpResponse) - else - reject(HttpError.fromResponse(httpResponse)) - end + if success then + Log.trace("Request {} success, response {:#?}", requestId, response) + local httpResponse = HttpResponse.fromRobloxResponse(response) + if httpResponse:isSuccess() then + resolve(httpResponse) else + reject(HttpError.fromResponse(httpResponse)) + end + else Log.trace("Request {} failure: {:?}", requestId, response) reject(HttpError.fromRobloxErrorString(response)) end @@ -61,11 +63,17 @@ function Http.post(url, body) end function Http.jsonEncode(object) - return HttpService:JSONEncode(object) + return JSONEncoder.Compact5(object) end function Http.jsonDecode(source) - return HttpService:JSONDecode(source) + -- Try fast native decode first, fallback to JSON5 for NaN/Infinity + local success, result = pcall(HttpService.JSONDecode, HttpService, source) + if success then + return result + end + print("[HTTP Response:json()] Native decode failed, using JSON5 for size:", #self.body) + return JSON5Decoder.Decode(source) end return Http diff --git a/plugin/rbx_dom_lua/EncodedValue.lua b/plugin/rbx_dom_lua/EncodedValue.lua index cddff6a3..0c89fcd5 100644 --- a/plugin/rbx_dom_lua/EncodedValue.lua +++ b/plugin/rbx_dom_lua/EncodedValue.lua @@ -11,12 +11,7 @@ local function unpackDecoder(f) end local function serializeFloat(value) - -- TODO: Figure out a better way to serialize infinity and NaN, neither of - -- which fit into JSON. - if value == math.huge or value == -math.huge then - return 999999999 * math.sign(value) - end - + -- JSON5 supports Infinity and NaN natively return value end diff --git a/rojo-test/build-test-snapshots/end_to_end__tests__build__csv_bug_145.snap b/rojo-test/build-test-snapshots/end_to_end__tests__build__csv_bug_145.snap index ab7470b3..cc22752c 100644 --- a/rojo-test/build-test-snapshots/end_to_end__tests__build__csv_bug_145.snap +++ b/rojo-test/build-test-snapshots/end_to_end__tests__build__csv_bug_145.snap @@ -10,7 +10,16 @@ expression: contents normal - [{"key":"Count","example":"A number demonstrating issue 145","source":"3","values":{"es":"7"}}] + [ + { + key: "Count", + example: "A number demonstrating issue 145", + source: "3", + values: { + es: "7", + }, + }, +] diff --git a/rojo-test/build-test-snapshots/end_to_end__tests__build__csv_bug_147.snap b/rojo-test/build-test-snapshots/end_to_end__tests__build__csv_bug_147.snap index d16de20f..a3393816 100644 --- a/rojo-test/build-test-snapshots/end_to_end__tests__build__csv_bug_147.snap +++ b/rojo-test/build-test-snapshots/end_to_end__tests__build__csv_bug_147.snap @@ -10,7 +10,63 @@ expression: contents normal - [{"key":"Language.Name","source":"English","values":{}},{"key":"Language.Region","source":"United States","values":{}},{"key":"Label.Thickness","source":"Thickness","values":{}},{"key":"Label.Opacity","source":"Opacity","values":{}},{"key":"Toolbar.Undo","source":"Undo","values":{}},{"key":"Toolbar.Redo","source":"Redo","values":{}},{"key":"Toolbar.Camera","source":"Top-down camera","values":{}},{"key":"Toolbar.Saves","source":"Saved drawings","values":{}},{"key":"Toolbar.Preferences","source":"Settings","values":{}},{"key":"Toolbar.Mode.Vector","source":"Vector mode","values":{}},{"key":"Toolbar.Mode.Pixel","source":"Pixel mode","values":{}}] + [ + { + key: "Language.Name", + source: "English", + values: {}, + }, + { + key: "Language.Region", + source: "United States", + values: {}, + }, + { + key: "Label.Thickness", + source: "Thickness", + values: {}, + }, + { + key: "Label.Opacity", + source: "Opacity", + values: {}, + }, + { + key: "Toolbar.Undo", + source: "Undo", + values: {}, + }, + { + key: "Toolbar.Redo", + source: "Redo", + values: {}, + }, + { + key: "Toolbar.Camera", + source: "Top-down camera", + values: {}, + }, + { + key: "Toolbar.Saves", + source: "Saved drawings", + values: {}, + }, + { + key: "Toolbar.Preferences", + source: "Settings", + values: {}, + }, + { + key: "Toolbar.Mode.Vector", + source: "Vector mode", + values: {}, + }, + { + key: "Toolbar.Mode.Pixel", + source: "Pixel mode", + values: {}, + }, +] diff --git a/rojo-test/build-test-snapshots/end_to_end__tests__build__csv_in_folder.snap b/rojo-test/build-test-snapshots/end_to_end__tests__build__csv_in_folder.snap index d60fca02..055d4dbe 100644 --- a/rojo-test/build-test-snapshots/end_to_end__tests__build__csv_in_folder.snap +++ b/rojo-test/build-test-snapshots/end_to_end__tests__build__csv_in_folder.snap @@ -10,7 +10,16 @@ expression: contents normal - [{"key":"Ack","example":"An exclamation of despair","source":"Ack!","values":{"es":"¡Ay!"}}] + [ + { + key: "Ack", + example: "An exclamation of despair", + source: "Ack!", + values: { + es: "¡Ay!", + }, + }, +] diff --git a/rojo-test/build-test-snapshots/end_to_end__tests__build__init_csv_with_children.snap b/rojo-test/build-test-snapshots/end_to_end__tests__build__init_csv_with_children.snap index c089ebb2..6068f867 100644 --- a/rojo-test/build-test-snapshots/end_to_end__tests__build__init_csv_with_children.snap +++ b/rojo-test/build-test-snapshots/end_to_end__tests__build__init_csv_with_children.snap @@ -1,18 +1,27 @@ --- source: tests/tests/build.rs -assertion_line: 100 expression: contents --- init_csv_with_children - [{"key":"init.csv","values":{}}] + [ + { + key: "init.csv", + values: {}, + }, +] other - [{"key":"other.csv","values":{}}] + [ + { + key: "other.csv", + values: {}, + }, +] diff --git a/src/cli/fmt_project.rs b/src/cli/fmt_project.rs index 1c165974..d536b261 100644 --- a/src/cli/fmt_project.rs +++ b/src/cli/fmt_project.rs @@ -25,8 +25,11 @@ impl FmtProjectCommand { let project = Project::load_fuzzy(&vfs, &base_path)? .context("A project file is required to run 'rojo fmt-project'")?; - let serialized = serde_json::to_string_pretty(&project) - .context("could not re-encode project file as JSON")?; + let serialized = String::from_utf8( + crate::json::to_vec_pretty_sorted(&project) + .context("could not re-encode project file as JSON5")?, + ) + .context("JSON5 output was not valid UTF-8")?; fs_err::write(&project.file_location, serialized) .context("could not write back to project file")?; diff --git a/src/cli/sourcemap.rs b/src/cli/sourcemap.rs index 95fe6631..88a1ce25 100644 --- a/src/cli/sourcemap.rs +++ b/src/cli/sourcemap.rs @@ -239,12 +239,13 @@ fn write_sourcemap( if let Some(output_path) = output { let mut file = BufWriter::new(File::create(output_path)?); - serde_json::to_writer(&mut file, &root_node)?; + let json_output = json5::to_string(&root_node)?; + file.write_all(json_output.as_bytes())?; file.flush()?; println!("Created sourcemap at {}", output_path.display()); } else { - let output = serde_json::to_string(&root_node)?; + let output = json5::to_string(&root_node)?; println!("{}", output); } diff --git a/src/json.rs b/src/json.rs index 8d77b73b..4891c7ba 100644 --- a/src/json.rs +++ b/src/json.rs @@ -138,6 +138,368 @@ enum Json5Value { Object(std::collections::BTreeMap), } +/// Error type for compact JSON5 serialization. +#[derive(Debug)] +struct CompactJson5Error(String); + +impl std::fmt::Display for CompactJson5Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl std::error::Error for CompactJson5Error {} + +impl serde::ser::Error for CompactJson5Error { + fn custom(msg: T) -> Self { + CompactJson5Error(msg.to_string()) + } +} + +/// Serialize a value to a compact JSON5 string (for HTTP transport). +/// +/// Supports NaN/Infinity. No whitespace between elements. +/// +/// # Errors +/// +/// Returns an error if the value cannot be serialized to JSON5. +pub fn to_string_compact(value: &T) -> anyhow::Result { + let mut output = String::new(); + let mut serializer = CompactJson5Serializer { output: &mut output }; + value.serialize(&mut serializer).map_err(|e| anyhow::anyhow!("{}", e))?; + Ok(output) +} + +struct CompactJson5Serializer<'a> { + output: &'a mut String, +} + +impl<'a> serde::Serializer for &mut CompactJson5Serializer<'a> { + type Ok = (); + type Error = CompactJson5Error; + type SerializeSeq = Self; + type SerializeTuple = Self; + type SerializeTupleStruct = Self; + type SerializeTupleVariant = Self; + type SerializeMap = Self; + type SerializeStruct = Self; + type SerializeStructVariant = Self; + + fn serialize_bool(self, v: bool) -> Result<(), Self::Error> { + self.output.push_str(if v { "true" } else { "false" }); + Ok(()) + } + + fn serialize_i8(self, v: i8) -> Result<(), Self::Error> { + self.output.push_str(&v.to_string()); + Ok(()) + } + + fn serialize_i16(self, v: i16) -> Result<(), Self::Error> { + self.output.push_str(&v.to_string()); + Ok(()) + } + + fn serialize_i32(self, v: i32) -> Result<(), Self::Error> { + self.output.push_str(&v.to_string()); + Ok(()) + } + + fn serialize_i64(self, v: i64) -> Result<(), Self::Error> { + self.output.push_str(&v.to_string()); + Ok(()) + } + + fn serialize_u8(self, v: u8) -> Result<(), Self::Error> { + self.output.push_str(&v.to_string()); + Ok(()) + } + + fn serialize_u16(self, v: u16) -> Result<(), Self::Error> { + self.output.push_str(&v.to_string()); + Ok(()) + } + + fn serialize_u32(self, v: u32) -> Result<(), Self::Error> { + self.output.push_str(&v.to_string()); + Ok(()) + } + + fn serialize_u64(self, v: u64) -> Result<(), Self::Error> { + self.output.push_str(&v.to_string()); + Ok(()) + } + + fn serialize_f32(self, v: f32) -> Result<(), Self::Error> { + self.serialize_f64(v as f64) + } + + fn serialize_f64(self, v: f64) -> Result<(), Self::Error> { + if v.is_nan() { + self.output.push_str("NaN"); + } else if v.is_infinite() { + if v.is_sign_positive() { + self.output.push_str("Infinity"); + } else { + self.output.push_str("-Infinity"); + } + } else { + self.output.push_str(&v.to_string()); + } + Ok(()) + } + + fn serialize_char(self, v: char) -> Result<(), Self::Error> { + self.serialize_str(&v.to_string()) + } + + fn serialize_str(self, v: &str) -> Result<(), Self::Error> { + self.output.push('"'); + for c in v.chars() { + match c { + '"' => self.output.push_str("\\\""), + '\\' => self.output.push_str("\\\\"), + '\n' => self.output.push_str("\\n"), + '\r' => self.output.push_str("\\r"), + '\t' => self.output.push_str("\\t"), + c if c < ' ' => self.output.push_str(&format!("\\u{:04x}", c as u32)), + c => self.output.push(c), + } + } + self.output.push('"'); + Ok(()) + } + + fn serialize_bytes(self, v: &[u8]) -> Result<(), Self::Error> { + use serde::ser::SerializeSeq; + let mut seq = self.serialize_seq(Some(v.len()))?; + for b in v { + seq.serialize_element(b)?; + } + seq.end() + } + + fn serialize_none(self) -> Result<(), Self::Error> { + self.serialize_unit() + } + + fn serialize_some(self, value: &T) -> Result<(), Self::Error> { + value.serialize(self) + } + + fn serialize_unit(self) -> Result<(), Self::Error> { + self.output.push_str("null"); + Ok(()) + } + + fn serialize_unit_struct(self, _name: &'static str) -> Result<(), Self::Error> { + self.serialize_unit() + } + + fn serialize_unit_variant( + self, + _name: &'static str, + _variant_index: u32, + variant: &'static str, + ) -> Result<(), Self::Error> { + self.serialize_str(variant) + } + + fn serialize_newtype_struct( + self, + _name: &'static str, + value: &T, + ) -> Result<(), Self::Error> { + value.serialize(self) + } + + fn serialize_newtype_variant( + self, + _name: &'static str, + _variant_index: u32, + variant: &'static str, + value: &T, + ) -> Result<(), Self::Error> { + self.output.push('{'); + self.serialize_str(variant)?; + self.output.push(':'); + value.serialize(&mut *self)?; + self.output.push('}'); + Ok(()) + } + + fn serialize_seq(self, _len: Option) -> Result { + self.output.push('['); + Ok(self) + } + + fn serialize_tuple(self, len: usize) -> Result { + self.serialize_seq(Some(len)) + } + + fn serialize_tuple_struct( + self, + _name: &'static str, + len: usize, + ) -> Result { + self.serialize_seq(Some(len)) + } + + fn serialize_tuple_variant( + self, + _name: &'static str, + _variant_index: u32, + variant: &'static str, + _len: usize, + ) -> Result { + self.output.push('{'); + self.serialize_str(variant)?; + self.output.push_str(":["); + Ok(self) + } + + fn serialize_map(self, _len: Option) -> Result { + self.output.push('{'); + Ok(self) + } + + fn serialize_struct( + self, + _name: &'static str, + len: usize, + ) -> Result { + self.serialize_map(Some(len)) + } + + fn serialize_struct_variant( + self, + _name: &'static str, + _variant_index: u32, + variant: &'static str, + _len: usize, + ) -> Result { + self.output.push('{'); + self.serialize_str(variant)?; + self.output.push_str(":{"); + Ok(self) + } +} + +impl<'a> serde::ser::SerializeSeq for &mut CompactJson5Serializer<'a> { + type Ok = (); + type Error = CompactJson5Error; + + fn serialize_element(&mut self, value: &T) -> Result<(), Self::Error> { + if !self.output.ends_with('[') { + self.output.push(','); + } + value.serialize(&mut **self) + } + + fn end(self) -> Result<(), Self::Error> { + self.output.push(']'); + Ok(()) + } +} + +impl<'a> serde::ser::SerializeTuple for &mut CompactJson5Serializer<'a> { + type Ok = (); + type Error = CompactJson5Error; + + fn serialize_element(&mut self, value: &T) -> Result<(), Self::Error> { + serde::ser::SerializeSeq::serialize_element(self, value) + } + + fn end(self) -> Result<(), Self::Error> { + serde::ser::SerializeSeq::end(self) + } +} + +impl<'a> serde::ser::SerializeTupleStruct for &mut CompactJson5Serializer<'a> { + type Ok = (); + type Error = CompactJson5Error; + + fn serialize_field(&mut self, value: &T) -> Result<(), Self::Error> { + serde::ser::SerializeSeq::serialize_element(self, value) + } + + fn end(self) -> Result<(), Self::Error> { + serde::ser::SerializeSeq::end(self) + } +} + +impl<'a> serde::ser::SerializeTupleVariant for &mut CompactJson5Serializer<'a> { + type Ok = (); + type Error = CompactJson5Error; + + fn serialize_field(&mut self, value: &T) -> Result<(), Self::Error> { + serde::ser::SerializeSeq::serialize_element(self, value) + } + + fn end(self) -> Result<(), Self::Error> { + self.output.push_str("]}"); + Ok(()) + } +} + +impl<'a> serde::ser::SerializeMap for &mut CompactJson5Serializer<'a> { + type Ok = (); + type Error = CompactJson5Error; + + fn serialize_key(&mut self, key: &T) -> Result<(), Self::Error> { + if !self.output.ends_with('{') { + self.output.push(','); + } + key.serialize(&mut **self) + } + + fn serialize_value(&mut self, value: &T) -> Result<(), Self::Error> { + self.output.push(':'); + value.serialize(&mut **self) + } + + fn end(self) -> Result<(), Self::Error> { + self.output.push('}'); + Ok(()) + } +} + +impl<'a> serde::ser::SerializeStruct for &mut CompactJson5Serializer<'a> { + type Ok = (); + type Error = CompactJson5Error; + + fn serialize_field( + &mut self, + key: &'static str, + value: &T, + ) -> Result<(), Self::Error> { + serde::ser::SerializeMap::serialize_key(self, key)?; + serde::ser::SerializeMap::serialize_value(self, value) + } + + fn end(self) -> Result<(), Self::Error> { + serde::ser::SerializeMap::end(self) + } +} + +impl<'a> serde::ser::SerializeStructVariant for &mut CompactJson5Serializer<'a> { + type Ok = (); + type Error = CompactJson5Error; + + fn serialize_field( + &mut self, + key: &'static str, + value: &T, + ) -> Result<(), Self::Error> { + serde::ser::SerializeStruct::serialize_field(self, key, value) + } + + fn end(self) -> Result<(), Self::Error> { + self.output.push_str("}}"); + Ok(()) + } +} + /// Serialize a value to a JSON5 byte vector with sorted keys. /// /// Supports NaN/Infinity and sorts object keys alphabetically. @@ -148,10 +510,11 @@ enum Json5Value { pub fn to_vec_pretty_sorted(value: &T) -> anyhow::Result> { // Serialize to json5 string (preserves NaN/Infinity) let json5_str = json5::to_string(value).context("Failed to serialize to JSON5")?; - + // Parse into Json5Value which uses BTreeMap (sorted keys) - let sorted: Json5Value = json5::from_str(&json5_str).context("Failed to parse JSON5 for sorting")?; - + let sorted: Json5Value = + json5::from_str(&json5_str).context("Failed to parse JSON5 for sorting")?; + // Serialize back to json5 (now sorted) let output = json5::to_string(&sorted).context("Failed to serialize sorted JSON5")?; Ok(output.into_bytes()) diff --git a/src/project.rs b/src/project.rs index 23d578b2..ba7538c7 100644 --- a/src/project.rs +++ b/src/project.rs @@ -516,8 +516,11 @@ mod test { ) .unwrap(); - let serialized = serde_json::to_string(&project_node).unwrap(); - assert_eq!(serialized, r#"{"$path":{"optional":"../src"}}"#); + let serialized = json5::to_string(&project_node).unwrap(); + assert_eq!( + serialized, + "{\n $path: {\n optional: \"../src\",\n },\n}" + ); } #[test] @@ -529,8 +532,11 @@ mod test { ) .unwrap(); - let serialized = serde_json::to_string(&project_node).unwrap(); - assert_eq!(serialized, r#"{"$path":{"optional":"../src"}}"#); + let serialized = json5::to_string(&project_node).unwrap(); + assert_eq!( + serialized, + "{\n $path: {\n optional: \"../src\",\n },\n}" + ); } #[test] @@ -542,8 +548,9 @@ mod test { ) .unwrap(); - let serialized = serde_json::to_string(&project_node).unwrap(); - assert_eq!(serialized, r#"{"$path":"../src"}"#); + let serialized = json5::to_string(&project_node).unwrap(); + // Backslashes normalized to forward slashes + assert_eq!(serialized, "{\n $path: \"../src\",\n}"); } #[test] diff --git a/src/snapshot_middleware/csv.rs b/src/snapshot_middleware/csv.rs index 55fd06c5..54e51179 100644 --- a/src/snapshot_middleware/csv.rs +++ b/src/snapshot_middleware/csv.rs @@ -243,7 +243,7 @@ fn convert_localization_csv(contents: &[u8]) -> Result { } let encoded = - serde_json::to_string(&entries).expect("Could not encode JSON for localization table"); + json5::to_string(&entries).expect("Could not encode JSON5 for localization table"); Ok(encoded) } diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_from_vfs.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_from_vfs.snap index 45f5cbc3..9364e46f 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_from_vfs.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_from_vfs.snap @@ -22,5 +22,5 @@ name: foo class_name: LocalizationTable properties: Contents: - String: "[{\"key\":\"Ack\",\"example\":\"An exclamation of despair\",\"source\":\"Ack!\",\"values\":{\"es\":\"¡Ay!\"}}]" + String: "[\n {\n key: \"Ack\",\n example: \"An exclamation of despair\",\n source: \"Ack!\",\n values: {\n es: \"¡Ay!\",\n },\n },\n]" children: [] diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_init.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_init.snap index a2c6608e..1ed84fa2 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_init.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_init.snap @@ -29,5 +29,5 @@ name: root class_name: LocalizationTable properties: Contents: - String: "[{\"key\":\"Ack\",\"example\":\"An exclamation of despair\",\"source\":\"Ack!\",\"values\":{\"es\":\"¡Ay!\"}}]" + String: "[\n {\n key: \"Ack\",\n example: \"An exclamation of despair\",\n source: \"Ack!\",\n values: {\n es: \"¡Ay!\",\n },\n },\n]" children: [] diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_init_with_meta.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_init_with_meta.snap index 3b4d23b3..4f8d795b 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_init_with_meta.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_init_with_meta.snap @@ -29,5 +29,5 @@ name: root class_name: LocalizationTable properties: Contents: - String: "[{\"key\":\"Ack\",\"example\":\"An exclamation of despair\",\"source\":\"Ack!\",\"values\":{\"es\":\"¡Ay!\"}}]" + String: "[\n {\n key: \"Ack\",\n example: \"An exclamation of despair\",\n source: \"Ack!\",\n values: {\n es: \"¡Ay!\",\n },\n },\n]" children: [] diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_with_meta.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_with_meta.snap index 2f7f70c5..0d57b0c5 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_with_meta.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_with_meta.snap @@ -22,5 +22,5 @@ name: foo class_name: LocalizationTable properties: Contents: - String: "[{\"key\":\"Ack\",\"example\":\"An exclamation of despair\",\"source\":\"Ack!\",\"values\":{\"es\":\"¡Ay!\"}}]" + String: "[\n {\n key: \"Ack\",\n example: \"An exclamation of despair\",\n source: \"Ack!\",\n values: {\n es: \"¡Ay!\",\n },\n },\n]" children: [] diff --git a/src/web/api.rs b/src/web/api.rs index 2e32135d..d3bf2ddd 100644 --- a/src/web/api.rs +++ b/src/web/api.rs @@ -28,7 +28,7 @@ use crate::{ ServerInfoResponse, SocketPacket, SocketPacketBody, SocketPacketType, SubscribeMessage, WriteRequest, WriteResponse, PROTOCOL_VERSION, SERVER_VERSION, }, - util::{json, json_ok}, + util::{json_response, json_ok}, }, web_api::{ BufferEncode, InstanceUpdate, RefPatchRequest, RefPatchResponse, SerializeRequest, @@ -48,7 +48,7 @@ pub async fn call(serve_session: Arc, mut request: Request) if is_upgrade_request(&request) { service.handle_api_socket(&mut request).await } else { - json( + json_response( ErrorResponse::bad_request( "/api/socket must be called as a websocket upgrade request", ), @@ -64,7 +64,7 @@ pub async fn call(serve_session: Arc, mut request: Request) } (&Method::POST, "/api/write") => service.handle_api_write(request).await, - (_method, path) => json( + (_method, path) => json_response( ErrorResponse::not_found(format!("Route not found: {}", path)), StatusCode::NOT_FOUND, ), @@ -104,7 +104,7 @@ impl ApiService { let input_cursor: u32 = match argument.parse() { Ok(v) => v, Err(err) => { - return json( + return json_response( ErrorResponse::bad_request(format!("Malformed message cursor: {}", err)), StatusCode::BAD_REQUEST, ); @@ -115,7 +115,7 @@ impl ApiService { let (response, websocket) = match upgrade(request, None) { Ok(result) => result, Err(err) => { - return json( + return json_response( ErrorResponse::internal_error(format!("WebSocket upgrade failed: {}", err)), StatusCode::INTERNAL_SERVER_ERROR, ); @@ -145,7 +145,7 @@ impl ApiService { let request: WriteRequest = match json::from_slice(&body) { Ok(request) => request, Err(err) => { - return json( + return json_response( ErrorResponse::bad_request(format!("Invalid body: {}", err)), StatusCode::BAD_REQUEST, ); @@ -153,7 +153,7 @@ impl ApiService { }; if request.session_id != session_id { - return json( + return json_response( ErrorResponse::bad_request("Wrong session ID"), StatusCode::BAD_REQUEST, ); @@ -189,7 +189,7 @@ impl ApiService { let requested_ids = match requested_ids { Ok(ids) => ids, Err(_) => { - return json( + return json_response( ErrorResponse::bad_request("Malformed ID list"), StatusCode::BAD_REQUEST, ); @@ -274,7 +274,7 @@ impl ApiService { let request: SerializeRequest = match json::from_slice(&body) { Ok(request) => request, Err(err) => { - return json( + return json_response( ErrorResponse::bad_request(format!("Invalid body: {}", err)), StatusCode::BAD_REQUEST, ); @@ -282,7 +282,7 @@ impl ApiService { }; if request.session_id != session_id { - return json( + return json_response( ErrorResponse::bad_request("Wrong session ID"), StatusCode::BAD_REQUEST, ); @@ -315,7 +315,7 @@ impl ApiService { response_dom.transfer_within(child_ref, object_value); } else { - json( + json_response( ErrorResponse::bad_request(format!("provided id {id} is not in the tree")), StatusCode::BAD_REQUEST, ); @@ -343,7 +343,7 @@ impl ApiService { let request: RefPatchRequest = match json::from_slice(&body) { Ok(request) => request, Err(err) => { - return json( + return json_response( ErrorResponse::bad_request(format!("Invalid body: {}", err)), StatusCode::BAD_REQUEST, ); @@ -351,7 +351,7 @@ impl ApiService { }; if request.session_id != session_id { - return json( + return json_response( ErrorResponse::bad_request("Wrong session ID"), StatusCode::BAD_REQUEST, ); @@ -400,7 +400,7 @@ impl ApiService { let requested_id = match Ref::from_str(argument) { Ok(id) => id, Err(_) => { - return json( + return json_response( ErrorResponse::bad_request("Invalid instance ID"), StatusCode::BAD_REQUEST, ); @@ -412,7 +412,7 @@ impl ApiService { let instance = match tree.get_instance(requested_id) { Some(instance) => instance, None => { - return json( + return json_response( ErrorResponse::bad_request("Instance not found"), StatusCode::NOT_FOUND, ); @@ -422,7 +422,7 @@ impl ApiService { let script_path = match pick_script_path(instance) { Some(path) => path, None => { - return json( + return json_response( ErrorResponse::bad_request( "No appropriate file could be found to open this script", ), @@ -435,7 +435,7 @@ impl ApiService { Ok(()) => {} Err(error) => match error { OpenError::Io(io_error) => { - return json( + return json_response( ErrorResponse::internal_error(format!( "Attempting to open {} failed because of the following io error: {}", script_path.display(), @@ -449,7 +449,7 @@ impl ApiService { status, stderr, } => { - return json( + return json_response( ErrorResponse::internal_error(format!( r#"The command '{}' to open '{}' failed with the error code '{}'. Error logs: @@ -554,7 +554,8 @@ async fn handle_websocket_subscription( }), }; - serde_json::to_string(&response)? + // Use json5 to preserve NaN/Infinity values + json5::to_string(&response)? }; log::debug!("Sending batch of messages over WebSocket subscription"); diff --git a/src/web/util.rs b/src/web/util.rs index 2b6882e5..c5eb93b4 100644 --- a/src/web/util.rs +++ b/src/web/util.rs @@ -1,12 +1,18 @@ use hyper::{header::CONTENT_TYPE, Body, Response, StatusCode}; use serde::Serialize; +use crate::json as json_module; + pub fn json_ok(value: T) -> Response { - json(value, StatusCode::OK) + json_response(value, StatusCode::OK) } -pub fn json(value: T, code: StatusCode) -> Response { - let serialized = match serde_json::to_string(&value) { +/// Alias for backwards compatibility +pub use json_response as json; + +pub fn json_response(value: T, code: StatusCode) -> Response { + // Use compact JSON5 for transport efficiency, preserves NaN/Infinity + let serialized = match json_module::to_string_compact(&value) { Ok(v) => v, Err(err) => { return Response::builder() diff --git a/tests/rojo_test/serve_util.rs b/tests/rojo_test/serve_util.rs index 673f4848..bd7d833a 100644 --- a/tests/rojo_test/serve_util.rs +++ b/tests/rojo_test/serve_util.rs @@ -163,14 +163,14 @@ impl TestServeSession { let url = format!("http://localhost:{}/api/rojo", self.port); let body = reqwest::blocking::get(url)?.text()?; - Ok(serde_json::from_str(&body).expect("Server returned malformed response")) + Ok(json5::from_str(&body).expect("Server returned malformed response")) } pub fn get_api_read(&self, id: Ref) -> Result, reqwest::Error> { let url = format!("http://localhost:{}/api/read/{}", self.port, id); let body = reqwest::blocking::get(url)?.text()?; - Ok(serde_json::from_str(&body).expect("Server returned malformed response")) + Ok(json5::from_str(&body).expect("Server returned malformed response")) } pub fn get_api_socket_packet( @@ -193,7 +193,7 @@ impl TestServeSession { match socket.read() { Ok(Message::Text(text)) => { - let packet: SocketPacket = serde_json::from_str(&text)?; + let packet: SocketPacket = json5::from_str(&text)?; if packet.packet_type != packet_type { continue; } @@ -230,12 +230,13 @@ impl TestServeSession { ) -> Result { let client = reqwest::blocking::Client::new(); let url = format!("http://localhost:{}/api/serialize", self.port); - let body = serde_json::to_string(&SerializeRequest { + let body = json5::to_string(&SerializeRequest { session_id, ids: ids.to_vec(), }); - client.post(url).body((body).unwrap()).send()?.json() + let response_body = client.post(url).body((body).unwrap()).send()?.text()?; + Ok(json5::from_str(&response_body).expect("Server returned malformed response")) } }