diff --git a/README.md b/README.md index 37ec797..b5521d4 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,40 @@ -# serialize +serialize - A brainless Lua table serializer +============================================ -The brainless Lua table serialization library. \ No newline at end of file +**serialize** provides two functions: + +* `pack()` packs Lua tables to string. +* `unpack()` unpacks a string back to a Lua table. + +The implementation strives to be useful under the majority of reasonable use cases, +to be compact, understandable and sufficiently fast. +There is no pretense of complete generality, nor of absolute efficiency. +In case **serialize** does not meet exactly your requirements, the code +should be immediate enough to tweak to your needs. + +Documentation +============= + +Code is documented with [LDoc](https://github.com/lunarmodules/LDoc). + +Documentation may be generated running the command: + +```sh +ldoc init.lua +``` + +Test suite +========== + +The test suite uses [busted](https://olivinelabs.com/busted/). + +Tests may be run with the command: + +```sh +lua spec/serialize_spec.lua +``` + +License +======= + +See [LICENSE](LICENSE) for details. diff --git a/init.lua b/init.lua new file mode 100644 index 0000000..f47cebc --- /dev/null +++ b/init.lua @@ -0,0 +1,150 @@ +--- Brainless serialization library. +-- +-- Takes Lua tables and turns them into their string +-- representation. Also takes a Lua table string representation +-- and turns it back into its corresponding table. +-- That is all. +-- +-- @copyright 2022 The DoubleFourteen Code Forge +-- @author Lorenzo Cogotti + +local math = require 'math' + +local serialize = {} + + +local dopack -- forward declare for mutual recursion + +local function isfinite(x) + return x ~= math.huge and x ~= -math.huge and x == x +end + +local function keys(k, i) + local t = type(k) + + if t == 'boolean' then + return k and "[true]" or "[false]" + elseif t == 'string' then + local f = ("%q"):format(k) + if k:find(" ") or f ~= '"'..k..'"' then + return "["..f.."]" + else + return k + end + elseif t == 'number' then + if not isfinite(k) then + error("Can't serialize.pack() table with non-finite key `"..k.."'.") + end + + return "["..k.."]" + elseif t == 'table' then + return "["..dopack(k, i+1).."]" + else + error("Can't serialize.pack() table with key `"..tostring(k).."'.") + end +end + +local function vals(v, i) + local t = type(v) + + if t == 'boolean' then + return v and 'true' or 'false' + elseif t == 'string' then + return ("%q"):format(v) + elseif t == 'number' then + if not isfinite(v) then + error("Can't serialize.pack() table with non-finite value `"..v.."'.") + end + + return tostring(v) + elseif t == 'table' then + return dopack(v, i+1) + else + error("Can't serialize.pack() table with value `"..tostring(v).."'.") + end +end + +-- local +function dopack(o, i, mode) + local fields = {} + local seen = {} + local is = (" "):rep(i) + local lastis = (" "):rep(i-1) + + -- Attempt to encode as array. + for k,v in ipairs(o) do + if mode == 'skip-functions' and type(v) == 'function' then + goto skip + end + + fields[#fields + 1] = ("%s%s"):format(is, vals(v, i)) + + ::skip:: seen[k] = true + end + + -- Process leftover fields. + for k,v in pairs(o) do + if seen[k] or (mode == 'skip-functions' and type(v) == 'function') then + goto skip + end + + local f = ("%s%s = %s"):format(is, keys(k, i), vals(v, i)) + + fields[#fields + 1] = f + + ::skip:: + end + + return "{\n"..table.concat(fields, ",\n").."\n"..lastis.."}" +end + +--- Construct string recreating a Lua table. +-- +-- @param o (table) a Lua table. +-- @param indent (number|nil) optional initial indent. +-- @param mode (string|nil) one of two modes: 'strict' (default), where +-- attempt to serialize a function is an error, or +-- 'skip-functions', where functions are ignored. +-- +-- @return string recreating the table, use serialize.unpack() to do so. +function serialize.pack(o, indent, mode) + if type(o) ~= 'table' then + error("Can't serialize.pack() a `"..type(o).."'.") + end + + return dopack(o, indent or 1, mode) +end + +--- Reconstruct Lua table from string. +-- +-- @param s (string) Lua table in its string form. +-- @param chunk (string|nil) optional string providing a chunk's name +-- for better diagnostics, if left nil a default value +-- of "" is used. +-- +-- @return the reconstructed table and an error string, on success +-- the error value is nil, on failure the table is nil. +function serialize.unpack(s, chunk) + if type(s) ~= 'string' then + error("Can only serialize.unpack() strings.") + end + + chunk = chunk or "" + + local fun, res = load("return "..s, chunk, 't', {}) + if not fun then + return nil, res + end + + local ok, o = pcall(fun) + if not ok then + return nil, o -- o is now pcall()'s error message + end + if type(o) ~= 'table' then + return nil, "[string \""..chunk.."\"] resulted in a `"..type(o).."'." + end + + return o +end + +return serialize diff --git a/spec/serialize_spec.lua b/spec/serialize_spec.lua new file mode 100644 index 0000000..c999807 --- /dev/null +++ b/spec/serialize_spec.lua @@ -0,0 +1,202 @@ +require 'busted.runner'() + +describe("serialize", function() + setup(function() + serialize = require 'init' + math = require 'math' + end) + + it("converts tables to strings and back", function() + local tests = { + -- Empty table + ['{\n\n}'] = {}, + + -- Arrays + ['{\n true,\n true,\n false\n}'] = + { true, true, false }, + ['{\n 1,\n 2,\n 3\n}'] = + { 1, 2, 3 }, + ['{\n 1.0,\n 0.5,\n 0.25,\n 0.125,\n 0.0625\n}'] = + { 1.0, 0.5, 0.25, 0.125, 0.0625 }, + + -- Basic types + ['{\n key = "strings are double quoted"\n}'] = + { key = "strings are double quoted" }, + + ['{\n key = "\'string\'"\n}'] = + { key = "\'string\'" }, + ['{\n key = "\\"string\\""\n}'] = + { key = "\"string\"" }, + + ['{\n ikey = 10\n}'] = { ikey = 10 }, + ['{\n fkey = 0.9843\n}'] = { fkey = 0.9843 }, + ['{\n falsekey = false\n}'] = { falsekey = false }, + ['{\n truekey = true\n}'] = { truekey = true }, + + -- Mixed + ['{\n 3,\n 2,\n 1,\n what = "half array"\n}'] = + { 3, 2, 1, what = "half array" }, + } + + for k,v in pairs(tests) do + local s = assert.has_no.errors(function() + return serialize.pack(v) + end) + assert.are.equal(s, k) + + local o = assert(serialize.unpack(s)) + assert.are.same(v, o) + end + end) + + it("accepts tables with nil entries", function() + local tests = { + ['{ nil }'] = {}, + ['{ key = nil }'] = {}, + ['{ 1, 2, 3, 4, nil, 5 }'] = { 1, 2, 3, 4, nil, 5 }, + ['{ 1, 2, 3, 4, nil }'] = { 1, 2, 3, 4 }, + ['{ nil, 2, 3, 4 }'] = { nil, 2, 3, 4 }, + } + + for k,v in pairs(tests) do + local o = assert(serialize.unpack(k)) + assert.are.same(v, o) + end + end) + + it("errors on attempt to serialize functions in strict mode", function() + assert.has.error(function() + serialize.pack({ + boom = function() end + }) + end) + assert.has.error(function() + serialize.pack({ + function() end + }) + end) + assert.has.error(function() + serialize.pack({ + 1, 2, 3, + key = "a key", + boom = function() end + }) + end) + assert.has.error(function() + serialize.pack({ + 1, 2, function() end, + key = "a key", + val = 10.0e-4 + }) + end) + end) + + it("supports tables with complex keys", function() + local tablekey = { key = "table key", value = { nil, 1, 2, 3 }, booga = true } + + local test = { + ['key with spaces'] = 1, + ['key with \'single quotes\''] = 2, + ['key with "double quotes"'] = 3, + ['key with "both" \'quotes\''] = 4, + ['\b key \\ with \a escapes \t'] = 5, + ['key with embedded \0 zero'] = 6, + [tablekey] = 7, + [10] = 8, + [0.5] = 9, + [true] = 10, + [false] = 11, + [-1] = 12 + } + + local s = assert.has_no.errors(function() + return serialize.pack(test) + end) + + local o = assert(serialize.unpack(s)) + + -- Compare for equality (table key requires extra care). + for k,v in pairs(o) do + if type(k) == 'table' then + assert.are.same(tablekey, k) + assert.are.same(test[tablekey], v) + else + assert.are.same(test[k], v) + end + end + + local function countkeys(o) + local n = 0 + for _ in pairs(o) do n = n + 1 end + return n + end + + assert.are.equal(countkeys(test), countkeys(o)) + end) + + it("supports nested tables", function() + pending("to be tested...") + end) + + it("allows explicit skip of functions during pack()", function() + local expected = { + '{\n\n}', + '{\n\n}', + '{\n 1,\n 2,\n key = "a key"\n}', + '{\n 1,\n 2,\n 3,\n key = "a key"\n}' + } + local actual = {} + + assert.has_no.errors(function() + actual[#actual+1] = serialize.pack({ + boom = function() end + }, 1, 'skip-functions') + actual[#actual+1] = serialize.pack({ + function() end + }, 1, 'skip-functions') + actual[#actual+1] = serialize.pack({ + 1, 2, function() end, + key = "a key" + }, 1, 'skip-functions') + actual[#actual+1] = serialize.pack({ + 1, 2, 3, + key = "a key", + boom = function() end + }, 1, 'skip-functions') + end) + + assert.are.same(expected, actual) + end) + + it("errors on non-finite keys or values", function() + assert.has.error(function() serialize.pack({ math.huge }) end) + assert.has.error(function() serialize.pack({ -math.huge }) end) + assert.has.error(function() serialize.pack({ 0/0 }) end) + + assert.has.error(function() serialize.pack({ [-math.huge] = "-inf" }) end) + assert.has.error(function() serialize.pack({ [ math.huge] = "inf" }) end) + assert.has.error(function() serialize.pack({ [ 0/0] = "NaN" }) end) + + assert.has.error(function() serialize.pack({ inf = math.huge }) end) + assert.has.error(function() serialize.pack({ neginf = -math.huge }) end) + assert.has.error(function() serialize.pack({ nan = 0/0 }) end) + end) + + it("may only pack() tables", function() + assert.has.error(function() serialize.pack("meow") end) + assert.has.error(function() serialize.pack(true) end) + assert.has.error(function() serialize.pack(42) end) + assert.has.error(function() serialize.pack(25.12) end) + assert.has.error(function() serialize.pack(nil) end) + assert.has.error(function() serialize.pack(function() end) end) + end) + + it("may only unpack() strings", function() + assert.has.error(function() serialize.unpack({}) end) + assert.has.error(function() serialize.unpack(true) end) + assert.has.error(function() serialize.unpack(42) end) + assert.has.error(function() serialize.unpack(25.12) end) + assert.has.error(function() serialize.unpack(nil) end) + assert.has.error(function() serialize.unpack(function() end) end) + end) +end)