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