From 9ad23e4384fc1c517d3e77743192db00fe07ef3f Mon Sep 17 00:00:00 2001 From: Lars Mueller Date: Thu, 24 Apr 2025 21:12:12 +0200 Subject: [PATCH] Revamp `dump` --- builtin/common/misc_helpers.lua | 162 +++++++++++++++------ builtin/common/tests/misc_helpers_spec.lua | 120 ++++++++++++++- doc/lua_api.md | 13 +- 3 files changed, 240 insertions(+), 55 deletions(-) diff --git a/builtin/common/misc_helpers.lua b/builtin/common/misc_helpers.lua index 54b24fe52..77847eb91 100644 --- a/builtin/common/misc_helpers.lua +++ b/builtin/common/misc_helpers.lua @@ -108,65 +108,133 @@ function dump2(o, name, dumped) return string.format("%s = {}\n%s", name, table.concat(t)) end --------------------------------------------------------------------------------- --- This dumps values in a one-statement format. + +-- This dumps values in a human-readable expression format. +-- If possible, the resulting string should evaluate to an equivalent value if loaded and executed. -- For example, {test = {"Testing..."}} becomes: -- [[{ -- test = { -- "Testing..." -- } -- }]] --- This supports tables as keys, but not circular references. --- It performs poorly with multiple references as it writes out the full --- table each time. --- The indent field specifies a indentation string, it defaults to a tab. --- Use the empty string to disable indentation. --- The dumped and level arguments are internal-only. - -function dump(o, indent, nested, level) - local t = type(o) - if not level and t == "userdata" then - -- when userdata (e.g. player) is passed directly, print its metatable: - return "userdata metatable: " .. dump(getmetatable(o)) - end - if t ~= "table" then - return basic_dump(o) - end - - -- Contains table -> true/nil of currently nested tables - nested = nested or {} - if nested[o] then - return "" - end - nested[o] = true +function dump(value, indent) indent = indent or "\t" - level = level or 1 + local newline = indent == "" and "" or "\n" - local ret = {} - local dumped_indexes = {} - for i, v in ipairs(o) do - ret[#ret + 1] = dump(v, indent, nested, level + 1) - dumped_indexes[i] = true + local rope = {} + local function write(str) + table.insert(rope, str) end - for k, v in pairs(o) do - if not dumped_indexes[k] then - if type(k) ~= "string" or not is_valid_identifier(k) then - k = "["..dump(k, indent, nested, level + 1).."]" - end - v = dump(v, indent, nested, level + 1) - ret[#ret + 1] = k.." = "..v + + local n_refs = {} + local function count_refs(val) + if type(val) ~= "table" then + return + end + local tbl = val + if n_refs[tbl] then + n_refs[tbl] = n_refs[tbl] + 1 + return + end + n_refs[tbl] = 1 + for k, v in pairs(tbl) do + count_refs(k) + count_refs(v) end end - nested[o] = nil - if indent ~= "" then - local indent_str = "\n"..string.rep(indent, level) - local end_indent_str = "\n"..string.rep(indent, level - 1) - return string.format("{%s%s%s}", - indent_str, - table.concat(ret, ","..indent_str), - end_indent_str) + count_refs(value) + + local refs = {} + local cur_ref = 1 + local function write_value(val, level) + if type(val) ~= "table" then + write(basic_dump(val)) + return + end + + local tbl = val + if refs[tbl] then + write(refs[tbl]) + return + end + + if n_refs[val] > 1 then + refs[val] = ("getref(%d)"):format(cur_ref) + write(("setref(%d)"):format(cur_ref)) + cur_ref = cur_ref + 1 + end + write("{") + if next(tbl) == nil then + write("}") + return + end + write(newline) + + local function write_entry(k, v) + write(indent:rep(level)) + write("[") + write_value(k, level + 1) + write("] = ") + write_value(v, level + 1) + write(",") + write(newline) + end + + local keys = {string = {}, number = {}} + for k in pairs(tbl) do + local t = type(k) + if keys[t] then + table.insert(keys[t], k) + end + end + + -- Write string-keyed entries + table.sort(keys.string) + for _, k in ipairs(keys.string) do + local v = val[k] + if is_valid_identifier(k) then + write(indent:rep(level)) + write(k) + write(" = ") + write_value(v, level + 1) + write(",") + write(newline) + else + write_entry(k, v) + end + end + + -- Write number-keyed entries + local len = 0 + for i in ipairs(tbl) do + len = i + end + if #keys.number == len then -- table is a list + for _, v in ipairs(tbl) do + write(indent:rep(level)) + write_value(v, level + 1) + write(",") + write(newline) + end + else -- table harbors arbitrary number keys + table.sort(keys.number) + for _, k in ipairs(keys.number) do + write_entry(k, tbl[k]) + end + end + + -- Write all remaining entries + for k, v in pairs(val) do + if not keys[type(k)] then + write_entry(k, v) + end + end + + write(indent:rep(level - 1)) + write("}") end - return "{"..table.concat(ret, ", ").."}" + write_value(value, 1) + return table.concat(rope) end -------------------------------------------------------------------------------- diff --git a/builtin/common/tests/misc_helpers_spec.lua b/builtin/common/tests/misc_helpers_spec.lua index 43639ccbb..59eb4ec5f 100644 --- a/builtin/common/tests/misc_helpers_spec.lua +++ b/builtin/common/tests/misc_helpers_spec.lua @@ -232,8 +232,122 @@ describe("math", function() end) describe("dump", function() - it("avoids misleading rounding of floating point numbers", function() - assert.equal("0.3", dump(0.3)) - assert.equal("0.30000000000000004", dump(0.1 + 0.2)) + local function test_expression(expr) + local chunk = assert(loadstring("return " .. expr)) + local refs = {} + setfenv(chunk, { + setref = function(id) + refs[id] = {} + return function(fields) + for k, v in pairs(fields) do + refs[id][k] = v + end + return refs[id] + end + end, + getref = function(id) + return assert(refs[id]) + end, + }) + assert.equal(expr, dump(chunk())) + end + + it("nil", function() + test_expression("nil") + end) + + it("booleans", function() + test_expression("false") + test_expression("true") + end) + + describe("numbers", function() + it("formats integers nicely", function() + test_expression("42") + end) + it("avoids misleading rounding", function() + test_expression("0.3") + assert.equal("0.30000000000000004", dump(0.1 + 0.2)) + end) + end) + + it("strings", function() + test_expression('"hello world"') + test_expression([["hello \"world\""]]) + end) + + describe("tables", function() + it("empty", function() + test_expression("{}") + end) + + it("lists", function() + test_expression([[ +{ + false, + true, + "foo", + 1, + 2, +}]]) + end) + + it("number keys", function() +test_expression([[ +{ + [0.5] = false, + [1.5] = true, + [2.5] = "foo", +}]]) + end) + + it("dicts", function() + test_expression([[{ + a = 1, + b = 2, + c = 3, +}]]) + end) + + it("mixed", function() + test_expression([[{ + a = 1, + b = 2, + c = 3, + ["d e"] = true, + "foo", + "bar", +}]]) + end) + + it("nested", function() +test_expression([[{ + a = { + 1, + {}, + }, + b = "foo", + c = { + [0.5] = 0.1, + [1.5] = 0.2, + }, +}]]) + end) + + it("circular references", function() +test_expression([[setref(1){ + child = { + parent = getref(1), + }, + other_child = { + parent = getref(1), + }, +}]]) + end) + + it("supports variable indent", function() + assert.equal('{1,2,3,{foo = "bar",},}', dump({1, 2, 3, {foo = "bar"}}, "")) + assert.equal('{\n "x",\n "y",\n}', dump({"x", "y"}, " ")) + end) end) end) diff --git a/doc/lua_api.md b/doc/lua_api.md index b492f5be5..afb44d489 100644 --- a/doc/lua_api.md +++ b/doc/lua_api.md @@ -4162,9 +4162,11 @@ Helper functions * `obj`: arbitrary variable * `name`: string, default: `"_"` * `dumped`: table, default: `{}` -* `dump(obj, dumped)`: returns a string which makes `obj` human-readable - * `obj`: arbitrary variable - * `dumped`: table, default: `{}` +* `dump(value, indent)`: returns a string which makes `value` human-readable + * `value`: arbitrary value + * Circular references are supported. Every table is dumped only once. + * `indent`: string to use for indentation, default: `"\t"` + * `""` disables indentation & line breaks (compact output) * `math.hypot(x, y)` * Get the hypotenuse of a triangle with legs x and y. Useful for distance calculation. @@ -7611,9 +7613,10 @@ Misc. * Example: `write_json({10, {a = false}})`, returns `'[10, {"a": false}]'` * `core.serialize(table)`: returns a string - * Convert a table containing tables, strings, numbers, booleans and `nil`s - into string form readable by `core.deserialize` + * Convert a value into string form readable by `core.deserialize`. + * Supports tables, strings, numbers, booleans and `nil`. * Support for dumping function bytecode is **deprecated**. + * Note: To obtain a human-readable representation of a value, use `dump` instead. * Example: `serialize({foo="bar"})`, returns `'return { ["foo"] = "bar" }'` * `core.deserialize(string[, safe])`: returns a table * Convert a string returned by `core.serialize` into a table