diff --git a/README.md b/README.md index 521a648..1b86af3 100644 --- a/README.md +++ b/README.md @@ -45,19 +45,20 @@ luarocks install mods -| Module | Description | -| -------------- | -------------------------------------------------------------- | -| [`is`] | Type predicates for Lua values and filesystem path kinds. | -| [`keyword`] | Lua keyword helpers for reserved-word checks. | -| [`List`] | Python-style list helpers for mapping, filtering, and slicing. | -| [`operator`] | Operator helpers as functions. | -| [`Set`] | Set operations and helpers for unique values. | -| [`str`] | String utility helpers modeled after Python's `str`. | -| [`stringcase`] | String case conversion helpers. | -| [`tbl`] | Utility functions for plain Lua tables. | -| [`template`] | Simple template rendering with value replacement. | -| [`utils`] | Common utility helpers. | -| [`validate`] | Validation helpers for Lua values. | +| Module | Description | +| -------------- | --------------------------------------------------------------- | +| [`is`] | Type predicates for Lua values and filesystem path kinds. | +| [`keyword`] | Lua keyword helpers for reserved-word checks. | +| [`List`] | Python-style list helpers for mapping, filtering, and slicing. | +| [`operator`] | Operator helpers as functions. | +| [`repr`] | Readable Lua value rendering with deterministic table ordering. | +| [`Set`] | Set operations and helpers for unique values. | +| [`str`] | String utility helpers modeled after Python's `str`. | +| [`stringcase`] | String case conversion helpers. | +| [`tbl`] | Utility functions for plain Lua tables. | +| [`template`] | Simple template rendering with value replacement. | +| [`utils`] | Common utility helpers. | +| [`validate`] | Validation helpers for Lua values. | > [!NOTE] > @@ -76,6 +77,7 @@ Thanks to these Lua ecosystem projects: [`keyword`]: https://luamod.github.io/mods/modules/keyword [`List`]: https://luamod.github.io/mods/modules/list [`operator`]: https://luamod.github.io/mods/modules/operator +[`repr`]: https://luamod.github.io/mods/modules/repr [`Set`]: https://luamod.github.io/mods/modules/set [`str`]: https://luamod.github.io/mods/modules/str [`stringcase`]: https://luamod.github.io/mods/modules/stringcase diff --git a/docs/src/modules/index.md b/docs/src/modules/index.md index 979510f..3f1a7b2 100644 --- a/docs/src/modules/index.md +++ b/docs/src/modules/index.md @@ -5,16 +5,17 @@ description: Overview of available Mods modules and their purpose. -| Module | Description | -| ----------------------------------- | -------------------------------------------------------------- | -| [`is`](/modules/is) | Type predicates for Lua values and filesystem path kinds. | -| [`keyword`](/modules/keyword) | Lua keyword helpers for reserved-word checks. | -| [`List`](/modules/list) | Python-style list helpers for mapping, filtering, and slicing. | -| [`operator`](/modules/operator) | Operator helpers as functions. | -| [`Set`](/modules/set) | Set operations and helpers for unique values. | -| [`str`](/modules/str) | String utility helpers modeled after Python's `str`. | -| [`stringcase`](/modules/stringcase) | String case conversion helpers. | -| [`tbl`](/modules/tbl) | Utility functions for plain Lua tables. | -| [`template`](/modules/template) | Simple template rendering with value replacement. | -| [`utils`](/modules/utils) | Common utility helpers. | -| [`validate`](/modules/validate) | Validation helpers for Lua values. | +| Module | Description | +| ----------------------------------- | --------------------------------------------------------------- | +| [`is`](/modules/is) | Type predicates for Lua values and filesystem path kinds. | +| [`keyword`](/modules/keyword) | Lua keyword helpers for reserved-word checks. | +| [`List`](/modules/list) | Python-style list helpers for mapping, filtering, and slicing. | +| [`operator`](/modules/operator) | Operator helpers as functions. | +| [`repr`](/modules/repr) | Readable Lua value rendering with deterministic table ordering. | +| [`Set`](/modules/set) | Set operations and helpers for unique values. | +| [`str`](/modules/str) | String utility helpers modeled after Python's `str`. | +| [`stringcase`](/modules/stringcase) | String case conversion helpers. | +| [`tbl`](/modules/tbl) | Utility functions for plain Lua tables. | +| [`template`](/modules/template) | Simple template rendering with value replacement. | +| [`utils`](/modules/utils) | Common utility helpers. | +| [`validate`](/modules/validate) | Validation helpers for Lua values. | diff --git a/docs/src/modules/repr.md b/docs/src/modules/repr.md new file mode 100644 index 0000000..309f4b9 --- /dev/null +++ b/docs/src/modules/repr.md @@ -0,0 +1,49 @@ +--- +editLinkTarget: types/repr.lua +description: Fast, readable string rendering for Lua values and nested tables. +--- + +# `repr` + +Render any Lua value as a readable string. + +## Import + +```lua +local mods = require("mods.repr") +``` + +## Usage + +```lua +local out = repr({ + user = { name = "Ada", role = "Engineer" }, + count = 3, + msg = 'He said "hi"', +}) +-- result: +-- { +-- count = 3, +-- msg = 'He said "hi"', +-- user = { +-- name = "Ada", +-- role = "Engineer" +-- } +-- } + +out = repr({ + user = { + name = "Ada", + meta = { role = "Engineer" }, + }, +}) +-- result: +-- { +-- user = { +-- meta = { +-- role = "Engineer" +-- }, +-- name = "Ada" +-- } +-- } +``` diff --git a/mods.rockspec.template b/mods.rockspec.template index 50aba6a..2421049 100644 --- a/mods.rockspec.template +++ b/mods.rockspec.template @@ -13,7 +13,9 @@ description = { license = "MIT", summary = "Pure Lua modules", detailed = [[ -Mods provides small, focused Lua modules: List, Set, is, keyword, operator, str, stringcase, tbl, template, and validate. +Mods provides small, focused Lua modules: +List, Set, is, keyword, operator, repr, str, stringcase, tbl, template, and +validate. ]], } @@ -29,6 +31,7 @@ build = { ["mods.keyword"] = "src/mods/keyword.lua", ["mods.List"] = "src/mods/List.lua", ["mods.operator"] = "src/mods/operator.lua", + ["mods.repr"] = "src/mods/repr.lua", ["mods.Set"] = "src/mods/Set.lua", ["mods.str"] = "src/mods/str.lua", ["mods.stringcase"] = "src/mods/stringcase.lua", diff --git a/spec/repr_spec.lua b/spec/repr_spec.lua new file mode 100644 index 0000000..15f3564 --- /dev/null +++ b/spec/repr_spec.lua @@ -0,0 +1,101 @@ +local mods = require("mods") + +local repr = mods.repr +local fmt = string.format + +describe("mods.repr", function() + local fn = function() end + local co = coroutine.create(fn) + local keywords = mods.keyword.kwlist() + + -- stylua: ignore + local tests = { + ---------input--------|---------------------expected--------------------- + { nil , "nil" }, + { true , "true" }, + { false , "false" }, + { 42 , "42" }, + { 'He said "hi"' , [['He said "hi"']] }, + { { hello = "world" } , '{\n hello = "world"\n}' }, + { { "a", "b", "c" } , '{\n [1] = "a",\n [2] = "b",\n [3] = "c"\n}' }, + { {} , '{}' }, + { { { {} } } , '{\n [1] = {\n [1] = {}\n }\n}' }, + { fn , tostring(fn) }, + { co , tostring(co) }, + } + + for i = 1, #tests do + local input, expected = unpack(tests[i], 1, 2) + it(fmt("repr (%s)", inspect(input)), function() + local res = repr(input) + assert.are.equal(expected, res) + end) + end + + for _, v in ipairs(keywords) do + it(fmt("repr(%q) brackets reserved keys", v), function() + local expected = '{\n ["' .. v .. '"] = true\n}' + assert.are_equal(expected, repr({ [v] = true })) + end) + end + + it("renders complex nested tables with shared refs and cycles", function() + local root = { title = "root" } + local child = { name = "child" } + local leaf = { value = 99 } + root.child = child + root.self = root + root.shared_a = leaf + root.shared_b = leaf + root.list = { child, { back = root } } + child.parent = root + child.link = leaf + leaf.owner = child + + local expected = [[ +{ + child = { + link = { + owner = , + value = 99 + }, + name = "child", + parent = + }, + list = { + [1] = { + link = { + owner = , + value = 99 + }, + name = "child", + parent = + }, + [2] = { + back = + } + }, + self = , + shared_a = { + owner = { + link = , + name = "child", + parent = + }, + value = 99 + }, + shared_b = { + owner = { + link = , + name = "child", + parent = + }, + value = 99 + }, + title = "root" +}]] + + local res = repr(root) + assert.are_equal(expected, res) + end) +end) diff --git a/src/mods/init.lua b/src/mods/init.lua index 0917ab3..9308031 100644 --- a/src/mods/init.lua +++ b/src/mods/init.lua @@ -1,7 +1,7 @@ local mods = {} -([[is keyword List operator Set str stringcase - tbl template utils validate]]):gsub("%S+", function(name) +([[ is keyword List operator repr Set + str stringcase tbl template utils validate ]]):gsub("%S+", function(name) mods[name] = "mods." .. name end) diff --git a/src/mods/repr.lua b/src/mods/repr.lua new file mode 100644 index 0000000..be6246d --- /dev/null +++ b/src/mods/repr.lua @@ -0,0 +1,109 @@ +local mods = require("mods") + +local type = type +local next = next +local concat = table.concat +local sort = table.sort +local rep = string.rep +local tostring = tostring +local gmatch = string.gmatch +local isidentifier = mods.keyword.isidentifier +local quote = mods.utils.quote + +local INDENT = " " +local TYPE_RANK = { n = 0 } +for type_name in gmatch("number string boolean table function userdata thread", "%S+") do + TYPE_RANK.n = TYPE_RANK.n + 1 + TYPE_RANK[type_name] = TYPE_RANK.n +end + +-- Compare keys for deterministic mixed-type ordering. +local function key_less(a, b) + local ta = type(a) + local tb = type(b) + if ta ~= tb then + return TYPE_RANK[ta] < TYPE_RANK[tb] + end + + if ta == "number" or ta == "string" then + return a < b + elseif ta == "boolean" then + return (not a) and b + end + + return tostring(a) < tostring(b) +end + +-- Sort entry records in place by their keys. +local function sort_entries(t) + sort(t, function(a, b) + return key_less(a.key, b.key) + end) +end + +-- Format a table key using identifier or bracket notation. +local function render_key(k) + if type(k) == "string" then + if isidentifier(k) then + return k + end + return "[" .. quote(k) .. "]" + end + return "[" .. tostring(k) .. "]" +end + +-- Recursively render a Lua value with cycle detection. +local function render(value, depth, seen) + local vt = type(value) + if vt == "string" then + return quote(value) + elseif vt ~= "table" then + return tostring(value) + end + + if seen[value] then + return "" + end + seen[value] = true + + if next(value) == nil then + seen[value] = nil + return "{}" + end + + local indent = rep(INDENT, depth - 1) + local out = {} + local pad = indent .. INDENT + local first = true + local entries = {} + for k, v in next, value do + entries[#entries + 1] = { key = k, value = v } + end + sort_entries(entries) + out[#out + 1] = "{\n" + + for i = 1, #entries do + local entry = entries[i] + local k = entry.key + local v = entry.value + if first then + first = false + else + out[#out + 1] = ",\n" + end + out[#out + 1] = pad .. render_key(k) .. " = " .. render(v, depth + 1, seen) + end + + out[#out + 1] = "\n" + out[#out + 1] = indent + out[#out + 1] = "}" + seen[value] = nil + + return concat(out) +end + +local function repr(v) + return render(v, 1, {}) +end + +return repr diff --git a/types/mods.lua b/types/mods.lua index 30dfce8..70edc98 100644 --- a/types/mods.lua +++ b/types/mods.lua @@ -5,6 +5,7 @@ ---@field keyword mods.keyword ---@field List mods.List ---@field operator mods.operator +---@field repr mods.repr ---@field Set mods.Set ---@field str mods.str ---@field stringcase mods.stringcase diff --git a/types/repr.lua b/types/repr.lua new file mode 100644 index 0000000..137c755 --- /dev/null +++ b/types/repr.lua @@ -0,0 +1,18 @@ +---@meta mods.repr + +---Render any Lua value as a readable string. +--- +---```lua +---repr({ a = 1, msg = 'He said "hi"' }) +----- result: +----- { +----- a = 1, +----- msg = 'He said "hi"' +----- } +---``` +---@alias mods.repr fun(v:any):string + +---@type mods.repr +local repr + +return repr