1local pprint = { VERSION = '0.1' }
2
3pprint.defaults = {
4    -- type display trigger, hide not useful datatypes by default
5    -- custom types are treated as table
6    show_nil = true,
7    show_boolean = true,
8    show_number = true,
9    show_string = true,
10    show_table = true,
11    show_function = false,
12    show_thread = false,
13    show_userdata = false,
14    -- additional display trigger
15    show_metatable = false,     -- show metatable
16    show_all = false,           -- override other show settings and show everything
17    use_tostring = false,       -- use __tostring to print table if available
18    filter_function = nil,      -- called like callback(value[,key, parent]), return truty value to hide
19    object_cache = 'local',     -- cache blob and table to give it a id, 'local' cache per print, 'global' cache
20                                -- per process, falsy value to disable (might cause infinite loop)
21    -- format settings
22    indent_size = 2,            -- indent for each nested table level
23    level_width = 80,           -- max width per indent level
24    wrap_string = true,         -- wrap string when it's longer than level_width
25    wrap_array = false,         -- wrap every array elements
26    sort_keys = true,           -- sort table keys
27}
28
29local TYPES = {
30    ['nil'] = 1, ['boolean'] = 2, ['number'] = 3, ['string'] = 4,
31    ['table'] = 5, ['function'] = 6, ['thread'] = 7, ['userdata'] = 8
32}
33
34-- seems this is the only way to escape these, as lua don't know how to map char '\a' to 'a'
35local ESCAPE_MAP = {
36    ['\a'] = '\\a', ['\b'] = '\\b', ['\f'] = '\\f', ['\n'] = '\\n', ['\r'] = '\\r',
37    ['\t'] = '\\t', ['\v'] = '\\v', ['\\'] = '\\\\',
38}
39
40-- generic utilities
41local function escape(s)
42    s = s:gsub('([%c\\])', ESCAPE_MAP)
43    local dq = s:find('"')
44    local sq = s:find("'")
45    if dq and sq then
46        return s:gsub('"', '\\"'), '"'
47    elseif sq then
48        return s, '"'
49    else
50        return s, "'"
51    end
52end
53
54local function is_plain_key(key)
55    return type(key) == 'string' and key:match('^[%a_][%a%d_]*$')
56end
57
58local CACHE_TYPES = {
59    ['table'] = true, ['function'] = true, ['thread'] = true, ['userdata'] = true
60}
61
62-- cache would be populated to be like:
63-- {
64--     function = { `fun1` = 1, _cnt = 1 }, -- object id
65--     table = { `table1` = 1, `table2` = 2, _cnt = 2 },
66--     visited_tables = { `table1` = 7, `table2` = 8  }, -- visit count
67-- }
68-- use weakrefs to avoid accidentall adding refcount
69local function cache_apperance(obj, cache, option)
70    if not cache.visited_tables then
71        cache.visited_tables = setmetatable({}, {__mode = 'k'})
72    end
73    local t = type(obj)
74
75    -- TODO can't test filter_function here as we don't have the ix and key,
76    -- might cause different results?
77    -- respect show_xxx and filter_function to be consistent with print results
78    if (not TYPES[t] and not option.show_table)
79        or (TYPES[t] and not option['show_'..t]) then
80        return
81    end
82
83    if CACHE_TYPES[t] or TYPES[t] == nil then
84        if not cache[t] then
85            cache[t] = setmetatable({}, {__mode = 'k'})
86            cache[t]._cnt = 0
87        end
88        if not cache[t][obj] then
89            cache[t]._cnt = cache[t]._cnt + 1
90            cache[t][obj] = cache[t]._cnt
91        end
92    end
93    if t == 'table' or TYPES[t] == nil then
94        if cache.visited_tables[obj] == false then
95            -- already printed, no need to mark this and its children anymore
96            return
97        elseif cache.visited_tables[obj] == nil then
98            cache.visited_tables[obj] = 1
99        else
100            -- visited already, increment and continue
101            cache.visited_tables[obj] = cache.visited_tables[obj] + 1
102            return
103        end
104        for k, v in pairs(obj) do
105            cache_apperance(k, cache, option)
106            cache_apperance(v, cache, option)
107        end
108        local mt = getmetatable(obj)
109        if mt and option.show_metatable then
110            cache_apperance(mt, cache, option)
111        end
112    end
113end
114
115-- makes 'foo2' < 'foo100000'. string.sub makes substring anyway, no need to use index based method
116local function str_natural_cmp(lhs, rhs)
117    while #lhs > 0 and #rhs > 0 do
118        local lmid, lend = lhs:find('%d+')
119        local rmid, rend = rhs:find('%d+')
120        if not (lmid and rmid) then return lhs < rhs end
121
122        local lsub = lhs:sub(1, lmid-1)
123        local rsub = rhs:sub(1, rmid-1)
124        if lsub ~= rsub then
125            return lsub < rsub
126        end
127
128        local lnum = tonumber(lhs:sub(lmid, lend))
129        local rnum = tonumber(rhs:sub(rmid, rend))
130        if lnum ~= rnum then
131            return lnum < rnum
132        end
133
134        lhs = lhs:sub(lend+1)
135        rhs = rhs:sub(rend+1)
136    end
137    return lhs < rhs
138end
139
140local function cmp(lhs, rhs)
141    local tleft = type(lhs)
142    local tright = type(rhs)
143    if tleft == 'number' and tright == 'number' then return lhs < rhs end
144    if tleft == 'string' and tright == 'string' then return str_natural_cmp(lhs, rhs) end
145    if tleft == tright then return str_natural_cmp(tostring(lhs), tostring(rhs)) end
146
147    -- allow custom types
148    local oleft = TYPES[tleft] or 9
149    local oright = TYPES[tright] or 9
150    return oleft < oright
151end
152
153-- setup option with default
154local function make_option(option)
155    if option == nil then
156        option = {}
157    end
158    for k, v in pairs(pprint.defaults) do
159        if option[k] == nil then
160            option[k] = v
161        end
162        if option.show_all then
163            for t, _ in pairs(TYPES) do
164                option['show_'..t] = true
165            end
166            option.show_metatable = true
167        end
168    end
169    return option
170end
171
172-- override defaults and take effects for all following calls
173function pprint.setup(option)
174    pprint.defaults = make_option(option)
175end
176
177-- format lua object into a string
178function pprint.pformat(obj, option, printer)
179    option = make_option(option)
180    local buf = {}
181    local function default_printer(s)
182        table.insert(buf, s)
183    end
184    printer = printer or default_printer
185
186    local cache
187    if option.object_cache == 'global' then
188        -- steal the cache into a local var so it's not visible from _G or anywhere
189        -- still can't avoid user explicitly referentce pprint._cache but it shouldn't happen anyway
190        cache = pprint._cache or {}
191        pprint._cache = nil
192    elseif option.object_cache == 'local' then
193        cache = {}
194    end
195
196    local last = '' -- used for look back and remove trailing comma
197    local status = {
198        indent = '', -- current indent
199        len = 0,     -- current line length
200    }
201
202    local wrapped_printer = function(s)
203        printer(last)
204        last = s
205    end
206
207    local function _indent(d)
208        status.indent = string.rep(' ', d + #(status.indent))
209    end
210
211    local function _n(d)
212        wrapped_printer('\n')
213        wrapped_printer(status.indent)
214        if d then
215            _indent(d)
216        end
217        status.len = 0
218        return true -- used to close bracket correctly
219    end
220
221    local function _p(s, nowrap)
222        status.len = status.len + #s
223        if not nowrap and status.len > option.level_width then
224            _n()
225            wrapped_printer(s)
226            status.len = #s
227        else
228            wrapped_printer(s)
229        end
230    end
231
232    local formatter = {}
233    local function format(v)
234        local f = formatter[type(v)]
235        f = f or formatter.table -- allow patched type()
236        if option.filter_function and option.filter_function(v, nil, nil) then
237            return ''
238        else
239            return f(v)
240        end
241    end
242
243    local function tostring_formatter(v)
244        return tostring(v)
245    end
246
247    local function number_formatter(n)
248        return n == math.huge and '[[math.huge]]' or tostring(n)
249    end
250
251    local function nop_formatter(v)
252        return ''
253    end
254
255    local function make_fixed_formatter(t, has_cache)
256        if has_cache then
257            return function (v)
258                return string.format('[[%s %d]]', t, cache[t][v])
259            end
260        else
261            return function (v)
262                return '[['..t..']]'
263            end
264        end
265    end
266
267    local function string_formatter(s, force_long_quote)
268        local s, quote = escape(s)
269        local quote_len = force_long_quote and 4 or 2
270        if quote_len + #s + status.len > option.level_width then
271            _n()
272            -- only wrap string when is longer than level_width
273            if option.wrap_string and #s + quote_len > option.level_width then
274                -- keep the quotes together
275                _p('[[')
276                while #s + status.len >= option.level_width do
277                    local seg = option.level_width - status.len
278                    _p(string.sub(s, 1, seg), true)
279                    _n()
280                    s = string.sub(s, seg+1)
281                end
282                _p(s) -- print the remaining parts
283                return ']]'
284            end
285        end
286
287        return force_long_quote and '[['..s..']]' or quote..s..quote
288    end
289
290    local function table_formatter(t)
291        if option.use_tostring then
292            local mt = getmetatable(t)
293            if mt and mt.__tostring then
294                return string_formatter(tostring(t), true)
295            end
296        end
297
298        local print_header_ix = nil
299        local ttype = type(t)
300        if option.object_cache then
301            local cache_state = cache.visited_tables[t]
302            local tix = cache[ttype][t]
303            -- FIXME should really handle `cache_state == nil`
304            -- as user might add things through filter_function
305            if cache_state == false then
306                -- already printed, just print the the number
307                return string_formatter(string.format('%s %d', ttype, tix), true)
308            elseif cache_state > 1 then
309                -- appeared more than once, print table header with number
310                print_header_ix = tix
311                cache.visited_tables[t] = false
312            else
313                -- appeared exactly once, print like a normal table
314            end
315        end
316
317        local tlen = #t
318        local wrapped = false
319        _p('{')
320        _indent(option.indent_size)
321        _p(string.rep(' ', option.indent_size - 1))
322        if print_header_ix then
323            _p(string.format('--[[%s %d]] ', ttype, print_header_ix))
324        end
325        for ix = 1,tlen do
326            local v = t[ix]
327            if formatter[type(v)] == nop_formatter or
328               (option.filter_function and option.filter_function(v, ix, t)) then
329               -- pass
330            else
331                if option.wrap_array then
332                    wrapped = _n()
333                end
334                _p(format(v)..', ')
335            end
336        end
337
338        -- hashmap part of the table, in contrast to array part
339        local function is_hash_key(k)
340            local numkey = tonumber(k)
341            if numkey ~= k or numkey > tlen then
342                return true
343            end
344        end
345
346        local function print_kv(k, v, t)
347            -- can't use option.show_x as obj may contain custom type
348            if formatter[type(v)] == nop_formatter or
349               formatter[type(k)] == nop_formatter or
350               (option.filter_function and option.filter_function(v, k, t)) then
351                return
352            end
353            wrapped = _n()
354            if is_plain_key(k) then
355                _p(k, true)
356            else
357                _p('[')
358                -- [[]] type string in key is illegal, needs to add spaces inbetween
359                local k = format(k)
360                if string.match(k, '%[%[') then
361                    _p(' '..k..' ', true)
362                else
363                    _p(k, true)
364                end
365                _p(']')
366            end
367            _p(' = ', true)
368            _p(format(v), true)
369            _p(',', true)
370        end
371
372        if option.sort_keys then
373            local keys = {}
374            for k, _ in pairs(t) do
375                if is_hash_key(k) then
376                    table.insert(keys, k)
377                end
378            end
379            table.sort(keys, cmp)
380            for _, k in ipairs(keys) do
381                print_kv(k, t[k], t)
382            end
383        else
384            for k, v in pairs(t) do
385                if is_hash_key(k) then
386                    print_kv(k, v, t)
387                end
388            end
389        end
390
391        if option.show_metatable then
392            local mt = getmetatable(t)
393            if mt then
394                print_kv('__metatable', mt, t)
395            end
396        end
397
398        _indent(-option.indent_size)
399        -- make { } into {}
400        last = string.gsub(last, '^ +$', '')
401        -- peek last to remove trailing comma
402        last = string.gsub(last, ',%s*$', ' ')
403        if wrapped then
404            _n()
405        end
406        _p('}')
407
408        return ''
409    end
410
411    -- set formatters
412    formatter['nil'] = option.show_nil and tostring_formatter or nop_formatter
413    formatter['boolean'] = option.show_boolean and tostring_formatter or nop_formatter
414    formatter['number'] = option.show_number and number_formatter or nop_formatter -- need to handle math.huge
415    formatter['function'] = option.show_function and make_fixed_formatter('function', option.object_cache) or nop_formatter
416    formatter['thread'] = option.show_thread and make_fixed_formatter('thread', option.object_cache) or nop_formatter
417    formatter['userdata'] = option.show_userdata and make_fixed_formatter('userdata', option.object_cache) or nop_formatter
418    formatter['string'] = option.show_string and string_formatter or nop_formatter
419    formatter['table'] = option.show_table and table_formatter or nop_formatter
420
421    if option.object_cache then
422        -- needs to visit the table before start printing
423        cache_apperance(obj, cache, option)
424    end
425
426    _p(format(obj))
427    printer(last) -- close the buffered one
428
429    -- put cache back if global
430    if option.object_cache == 'global' then
431        pprint._cache = cache
432    end
433
434    return table.concat(buf)
435end
436
437-- pprint all the arguments
438function pprint.pprint( ... )
439    local args = {...}
440    -- select will get an accurate count of array len, counting trailing nils
441    local len = select('#', ...)
442    for ix = 1,len do
443        pprint.pformat(args[ix], nil, io.write)
444        io.write('\n')
445    end
446end
447
448setmetatable(pprint, {
449    __call = function (_, ...)
450        pprint.pprint(...)
451    end
452})
453
454return pprint
455
456