Module:Inspect
inspect.lua
This library transforms any Lua value into a human-readable representation. It is especially useful for debugging errors in tables.
The objective here is human understanding (i.e. for debugging), not serialization or compactness.
Examples of use
inspect
has the following declaration: local str = inspect(value, <options>)
.
value
can be any Lua value.
inspect
transforms simple types (like strings or numbers) into strings.
assert(inspect(1) == "1") assert(inspect("Hello") == '"Hello"')
Tables, on the other hand, are rendered in a way a human can read easily.
"Array-like" tables are rendered horizontally:
assert(inspect({1,2,3,4}) == "{ 1, 2, 3, 4 }")
"Dictionary-like" tables are rendered with one element per line:
assert(inspect({a=1,b=2}) == [[{ a = 1, b = 2 }]])
The keys will be sorted alphanumerically when possible.
"Hybrid" tables will have the array part on the first line, and the dictionary part just below them:
assert(inspect({1,2,3,b=2,a=1}) == [[{ 1, 2, 3, a = 1, b = 2 }]])
Subtables are indented with two spaces per level.
assert(inspect({a={b=2}}) == [[{ a = { b = 2 } }]])
Functions, userdata and any other custom types from Luajit are simply as <function x>
, <userdata x>
, etc.:
assert(inspect({ f = print, ud = some_user_data, thread = a_thread} ) == [[{ f = <function 1>, u = <userdata 1>, thread = <thread 1> }]])
If the table has a metatable, inspect will include it at the end, in a special field called <metatable>
:
assert(inspect(setmetatable({a=1}, {b=2}) == [[{ a = 1 <metatable> = { b = 2 } }]]))
inspect
can handle tables with loops inside them. It will print <id>
right before the table is printed out the first time, and replace the whole table with <table id>
from then on, preventing infinite loops.
local a = {1, 2} local b = {3, 4, a} a[3] = b -- a references b, and b references a assert(inspect(a) == "<1>{ 1, 2, { 3, 4, <table 1> } }")
Notice that since both a
appears more than once in the expression, it is prefixed by <1>
and replaced by <table 1>
every time it appears later on.
Options
inspect
has a second parameter, called options
. It is not mandatory, but when it is provided, it must be a table.
options.depth
options.depth
sets the maximum depth that will be printed out. When the max depth is reached, inspect
will stop parsing tables and just return {...}
:
local t5 = {a = {b = {c = {d = {e = 5}}}}} assert(inspect(t5, {depth = 4}) == [[{ a = { b = { c = { d = {...} } } } }]]) assert(inspect(t5, {depth = 2}) == [[{ a = { b = {...} } }]])
options.depth
defaults to infinite (math.huge
).
options.newline & options.indent
These are the strings used by inspect
to respectively add a newline and indent each level of a table.
By default, options.newline
is "\n"
and options.indent
is " "
(two spaces).
local t = {a={b=1}} assert(inspect(t) == [[{ a = { b = 1 } }]]) assert(inspect(t, {newline='@', indent="++"}), "{@++a = {@++++b = 1@++}@}"
options.process
options.process
is a function which allow altering the passed object before transforming it into a string. A typical way to use it would be to remove certain values so that they don't appear at all.
options.process
has the following signature:
local processed_item = function(item, path)
item
is either a key or a value on the table, or any of its subtablespath
is an array-like table built with all the keys that have been used to reachitem
, from the root.- For values, it is just a regular list of keys. For example, to reach the 1 in
{a = {b = 1}}
, thepath
will be{'a', 'b'}
- For keys, the special value
inspect.KEY
is inserted. For example, to reach thec
in{a = {b = {c = 1}}}
, the path will be{'a', 'b', 'c', inspect.KEY }
- For metatables, the special value
inspect.METATABLE
is inserted. For{a = {b = 1}}}
, the path{'a', {b = 1}, inspect.METATABLE}
means "the metatable of the table{b = 1}
". processed_item
is the value returned byoptions.process
. If it is equal toitem
, then the inspected table will look unchanged. If it is different, then the table will look different; most notably, if it'snil
, the item will dissapear on the inspected table.
Examples
Remove a particular metatable from the result:
local t = {1,2,3} local mt = {b = 2} setmetatable(t, mt) local remove_mt = function(item) if item ~= mt then return item end end -- mt does not appear assert(inspect(t, {process = remove_mt}) == "{ 1, 2, 3 }")
The previous exaple only works for a particular metatable. If you want to make all metatables, you can use the path
parameter to check wether the last element is inspect.METATABLE
, and return nil
instead of the item:
local t, mt = ... -- (defined as before) local remove_all_metatables = function(item, path) if path[#path] ~= inspect.METATABLE then return item end end assert(inspect(t, {process = remove_all_metatables}) == "{ 1, 2, 3 }")
Filter a value:
local anonymize_password = function(item, path) if path[#path] == 'password' then return "XXXX" end return item end local info = {user = 'peter', password = 'secret'} assert(inspect(info, {process = anonymize_password}) == [[{ password = "XXXX", user = "peter" }]])
Invoking for other modules
In order to use inspect.lua for other Module usage, you have to reference it locally on top of your module.
local inspect = require( 'Module:Inspect' ) local p = {} -- Rest of your module.
You can also use inspect
to return data for the invoking frame.
function p.main( frame ) return inspect( p.data ) end
local inspect ={
_VERSION = 'inspect.lua 3.1.0',
_URL = 'http://github.com/kikito/inspect.lua',
_DESCRIPTION = 'human-readable representations of tables',
_LICENSE = [[
MIT LICENSE
Copyright (c) 2013 Enrique García Cota
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.
]]
}
local tostring = tostring
inspect.KEY = setmetatable({}, {__tostring = function() return 'inspect.KEY' end})
inspect.METATABLE = setmetatable({}, {__tostring = function() return 'inspect.METATABLE' end})
-- Apostrophizes the string if it has quotes, but not aphostrophes
-- Otherwise, it returns a regular quoted string
local function smartQuote(str)
if str:match('"') and not str:match("'") then
return "'" .. str .. "'"
end
return '"' .. str:gsub('"', '\\"') .. '"'
end
-- \a => '\\a', \0 => '\\0', 31 => '\31'
local shortControlCharEscapes = {
["\a"] = "\\a", ["\b"] = "\\b", ["\f"] = "\\f", ["\n"] = "\\n",
["\r"] = "\\r", ["\t"] = "\\t", ["\v"] = "\\v"
}
local longControlCharEscapes = {} -- \a => nil, \0 => \000, 31 => \031
for i=0, 31 do
local ch = string.char(i)
if not shortControlCharEscapes[ch] then
shortControlCharEscapes[ch] = "\\"..i
longControlCharEscapes[ch] = string.format("\\%03d", i)
end
end
local function escape(str)
return (str:gsub("\\", "\\\\")
:gsub("(%c)%f[0-9]", longControlCharEscapes)
:gsub("%c", shortControlCharEscapes))
end
local function isIdentifier(str)
return type(str) == 'string' and str:match( "^[_%a][_%a%d]*$" )
end
local function isSequenceKey(k, sequenceLength)
return type(k) == 'number'
and 1 <= k
and k <= sequenceLength
and math.floor(k) == k
end
local defaultTypeOrders = {
['number'] = 1, ['boolean'] = 2, ['string'] = 3, ['table'] = 4,
['function'] = 5, ['userdata'] = 6, ['thread'] = 7
}
local function sortKeys(a, b)
local ta, tb = type(a), type(b)
-- strings and numbers are sorted numerically/alphabetically
if ta == tb and (ta == 'string' or ta == 'number') then return a < b end
local dta, dtb = defaultTypeOrders[ta], defaultTypeOrders[tb]
-- Two default types are compared according to the defaultTypeOrders table
if dta and dtb then return defaultTypeOrders[ta] < defaultTypeOrders[tb]
elseif dta then return true -- default types before custom ones
elseif dtb then return false -- custom types after default ones
end
-- custom types are sorted out alphabetically
return ta < tb
end
-- For implementation reasons, the behavior of rawlen & # is "undefined" when
-- tables aren't pure sequences. So we implement our own # operator.
local function getSequenceLength(t)
local len = 1
local v = rawget(t,len)
while v ~= nil do
len = len + 1
v = rawget(t,len)
end
return len - 1
end
local function getNonSequentialKeys(t)
local keys = {}
local sequenceLength = getSequenceLength(t)
for k,_ in pairs(t) do
if not isSequenceKey(k, sequenceLength) then table.insert(keys, k) end
end
table.sort(keys, sortKeys)
return keys, sequenceLength
end
local function getToStringResultSafely(t, mt)
local __tostring = type(mt) == 'table' and rawget(mt, '__tostring')
local str, ok
if type(__tostring) == 'function' then
ok, str = pcall(__tostring, t)
str = ok and str or 'error: ' .. tostring(str)
end
if type(str) == 'string' and #str > 0 then return str end
end
local function countTableAppearances(t, tableAppearances)
tableAppearances = tableAppearances or {}
if type(t) == 'table' then
if not tableAppearances[t] then
tableAppearances[t] = 1
for k,v in pairs(t) do
countTableAppearances(k, tableAppearances)
countTableAppearances(v, tableAppearances)
end
countTableAppearances(getmetatable(t), tableAppearances)
else
tableAppearances[t] = tableAppearances[t] + 1
end
end
return tableAppearances
end
local copySequence = function(s)
local copy, len = {}, #s
for i=1, len do copy[i] = s[i] end
return copy, len
end
local function makePath(path, ...)
local keys = {...}
local newPath, len = copySequence(path)
for i=1, #keys do
newPath[len + i] = keys[i]
end
return newPath
end
local function processRecursive(process, item, path, visited)
if item == nil then return nil end
if visited[item] then return visited[item] end
local processed = process(item, path)
if type(processed) == 'table' then
local processedCopy = {}
visited[item] = processedCopy
local processedKey
for k,v in pairs(processed) do
processedKey = processRecursive(process, k, makePath(path, k, inspect.KEY), visited)
if processedKey ~= nil then
processedCopy[processedKey] = processRecursive(process, v, makePath(path, processedKey), visited)
end
end
local mt = processRecursive(process, getmetatable(processed), makePath(path, inspect.METATABLE), visited)
setmetatable(processedCopy, mt)
processed = processedCopy
end
return processed
end
-------------------------------------------------------------------
local Inspector = {}
local Inspector_mt = {__index = Inspector}
function Inspector:puts(...)
local args = {...}
local buffer = self.buffer
local len = #buffer
for i=1, #args do
len = len + 1
buffer[len] = args[i]
end
end
function Inspector:down(f)
self.level = self.level + 1
f()
self.level = self.level - 1
end
function Inspector:tabify()
self:puts(self.newline, string.rep(self.indent, self.level))
end
function Inspector:alreadyVisited(v)
return self.ids[v] ~= nil
end
function Inspector:getId(v)
local id = self.ids[v]
if not id then
local tv = type(v)
id = (self.maxIds[tv] or 0) + 1
self.maxIds[tv] = id
self.ids[v] = id
end
return tostring(id)
end
function Inspector:putKey(k)
if isIdentifier(k) then return self:puts(k) end
self:puts("[")
self:putValue(k)
self:puts("]")
end
function Inspector:putTable(t)
if t == inspect.KEY or t == inspect.METATABLE then
self:puts(tostring(t))
elseif self:alreadyVisited(t) then
self:puts('<table ', self:getId(t), '>')
elseif self.level >= self.depth then
self:puts('{...}')
else
if self.tableAppearances[t] > 1 then self:puts('<', self:getId(t), '>') end
local nonSequentialKeys, sequenceLength = getNonSequentialKeys(t)
local mt = getmetatable(t)
local toStringResult = getToStringResultSafely(t, mt)
self:puts('{')
self:down(function()
if toStringResult then
self:puts(' -- ', escape(toStringResult))
if sequenceLength >= 1 then self:tabify() end
end
local count = 0
for i=1, sequenceLength do
if count > 0 then self:puts(',') end
self:puts(' ')
self:putValue(t[i])
count = count + 1
end
for _,k in ipairs(nonSequentialKeys) do
if count > 0 then self:puts(',') end
self:tabify()
self:putKey(k)
self:puts(' = ')
self:putValue(t[k])
count = count + 1
end
if mt then
if count > 0 then self:puts(',') end
self:tabify()
self:puts('<metatable> = ')
self:putValue(mt)
end
end)
if #nonSequentialKeys > 0 or mt then -- result is multi-lined. Justify closing }
self:tabify()
elseif sequenceLength > 0 then -- array tables have one extra space before closing }
self:puts(' ')
end
self:puts('}')
end
end
function Inspector:putValue(v)
local tv = type(v)
if tv == 'string' then
self:puts(smartQuote(escape(v)))
elseif tv == 'number' or tv == 'boolean' or tv == 'nil' then
self:puts(tostring(v))
elseif tv == 'table' then
self:putTable(v)
else
self:puts('<',tv,' ',self:getId(v),'>')
end
end
-------------------------------------------------------------------
function inspect.inspect(root, options)
options = options or {}
local depth = options.depth or math.huge
local newline = options.newline or '\n'
local indent = options.indent or ' '
local process = options.process
if process then
root = processRecursive(process, root, {}, {})
end
local inspector = setmetatable({
depth = depth,
level = 0,
buffer = {},
ids = {},
maxIds = {},
newline = newline,
indent = indent,
tableAppearances = countTableAppearances(root)
}, Inspector_mt)
inspector:putValue(root)
return table.concat(inspector.buffer)
end
setmetatable(inspect, { __call = function(_, ...) return inspect.inspect(...) end })
return inspect