1
0
pretty/pretty.lua

588 lines
21 KiB
Lua
Raw Normal View History

2016-12-28 23:51:07 +00:00
2017-07-17 19:33:11 +00:00
-- pretty.pretty
-- Main module of the `pretty` library.
-- TODO: Maybe move table formatting into its own file?
2017-07-20 08:38:24 +00:00
--[=[ Thoughts on displaying tables in an intuitive way.
Lua's table datastructure is likely to be the most consise data structure ever
invented. (If not, please send me a link!) Lists, maps, objects, classes,
proxies, etc. This obviously brings about it some difficulty when attempting to
represent these tables. What do we want to highlight, and what do we choose to
avoid?
One notable issue is whether to show every key that a table answers to, or to
just display those it contains. That is, do we think about `__index` and what it
returns, or do we ignore `__index`? For cases where `__index` is a function,
we cannot say anything about the keys that the table answers to. If `__index` is
a table, we have a better idea, but then the output would be cluttered.
1. Native representation: Lua's native representation includes the type and
address of the table. It allows for distinguishing between unique tables,
but won't tell about the contents.
2. It continues: By representing tables as the pseudo-parsable `{...}`, it's
clear we are talking about a table. We disregard the ability to
distinguish between tables.
3. Special case for 2. If the table is empty, we could represent it as `{}`.
But what if the table has a metatable with `__index` defined? We could
continue to represent it as `{}`, but `{...}` would be more "honest".
4. Single-line: TODO
5. Multi-line: TODO
6. Format into columns: (like ls) TODO
2017-07-20 08:38:24 +00:00
7. Tabular: TODO
8. Special cases: (Array-tree, Table-Tree, Linked-List) TODO
--]=]
2017-07-17 19:33:11 +00:00
--------------------------------------------------------------------------------
-- Ensure loading library, if it exists, no matter where pretty.lua was loaded from.
-- Load the library component
local format_number, format_function, format_string, analyze_structure, TABLE_TYPE
do
local thispath = ... and select('1', ...):match('.+%.') or ''
local function import (name, ignore_failure)
local was_loaded, lib_or_error = pcall(require, thispath..name)
if not was_loaded then
if ignore_failure then return nil end
error('[pretty]: Could not load vital library: '..name..'.lua:\n\t'..lib_or_error)
end
return lib_or_error
end
-- Load number and function formatting
-- Will use a very simple number formatting, if number.lua is not available.
-- Will use a very simple function formatting, if function.lua is not available.
-- Will use a very simple string formatting, if string.lua is not available.
format_number = import('number', true) or function (value, _, l) l[#l+1] = tostring(value) end
format_function = import('function', true) or function (value, _, l) l[#l+1] = 'function (...) --[['..tostring(value):sub(11)..']] end' end
format_string = import('pstring', true) or function (value, _, l) l[#l+1] = '[['..value..']]' end
-- Load other stuff
analyze_structure = import 'analyze_structure'
TABLE_TYPE = import 'table_type'
end
--------------------------------------------------------------------------------
-- Constants
local ERROR_UNKNOWN_TYPE = [[
[pretty]: Attempting to format unsupported value of type "%s".
A native formatting of the value is: %s
We are attempting to cover all Lua features, so please report this bug, so we can improve.
]]
2016-12-29 11:11:48 +00:00
local MAX_WIDTH_FOR_SINGLE_LINE_TABLE = 38
2016-12-28 23:51:07 +00:00
2017-04-05 11:10:23 +00:00
local KEY_TYPE_SORT_ORDER = {
['number'] = 0,
['string'] = 1,
['boolean'] = 2,
['table'] = 3,
['userdata'] = 4,
['thread'] = 5,
['function'] = 6,
}
local VALUE_TYPE_SORT_ORDER = {
2016-12-28 23:51:07 +00:00
['nil'] = 0,
['boolean'] = 1,
['number'] = 2,
['string'] = 3,
['table'] = 4,
['userdata'] = 5,
['thread'] = 6,
['function'] = 7,
}
--------------------------------------------------------------------------------
-- Key-value-pair Util
2016-12-28 23:51:07 +00:00
local function padnum(d)
local dec, n = string.match(d, "(%.?)0*(.+)")
return #dec > 0 and ("%.12f"):format(d) or ("%s%03d%s"):format(dec, #n, n)
end
local function alphanum_compare_strings (a, b)
2017-07-18 11:38:05 +00:00
-- Compares two strings alphanumerically.
assert(type(a) == 'string')
assert(type(b) == 'string')
local a_padded = a:gsub("%.?%d+", padnum)..("\0%3d"):format(#b)
local b_padded = b:gsub("%.?%d+", padnum)..("\0%3d"):format(#a)
2017-06-25 11:16:34 +00:00
local A_padded, B_padded = a_padded:upper(), b_padded:upper()
if A_padded == B_padded then return a_padded < b_padded end
return A_padded < B_padded
2016-12-28 23:51:07 +00:00
end
2017-07-18 11:38:05 +00:00
local function compare_key_value_pair (a, b)
-- Function for comparing two key-value pairs, given as `{ key, value }`.
-- Pretty complex due to our high standards for sorting.
assert(type(a) == 'table')
assert(type(b) == 'table')
2016-12-28 23:51:07 +00:00
-- Get types
local type_key_a, type_key_b = type(a[1]), type(b[1])
local type_value_a, type_value_b = type(a[2]), type(b[2])
-- Tons of compare
2017-04-05 11:10:23 +00:00
if (1 == (type_key_a == 'number' and 1 or 0) + (type_key_b == 'number' and 1 or 0)) then
return type_key_a == 'number'
elseif (type_key_a == 'number' and type_key_b == 'number') then
return a[1] < b[1]
2017-04-05 11:10:23 +00:00
elseif (type_value_a ~= type_value_b) then
return VALUE_TYPE_SORT_ORDER[type_value_a] < VALUE_TYPE_SORT_ORDER[type_value_b]
elseif (type_key_a == 'string' and type_key_b == 'string') then
return alphanum_compare_strings(a[1], b[1])
elseif (type_key_a ~= type_key_b) then
return KEY_TYPE_SORT_ORDER[type_value_a] < KEY_TYPE_SORT_ORDER[type_value_b]
2016-12-28 23:51:07 +00:00
end
end
local function get_key_value_pairs_in_proper_order (t)
-- Generates a sequence of key value pairs, in proper order.
-- Proper order is:
2017-04-05 11:10:23 +00:00
-- 1. All integer keys are first, in order
-- 2. Then by value type, as defined in VALUE_TYPE_SORT_ORDER in the top.
-- 3. Then by key type.
-- 3.1. String in alphanumeric order
-- 3.2. Other wierdness.
2016-12-28 23:51:07 +00:00
-- Error checking
assert(type(t) == 'table')
-- Do stuff
2016-12-28 23:51:07 +00:00
local key_value_pairs = {}
for key, value in pairs(t) do
key_value_pairs[#key_value_pairs+1] = { key, value }
end
2017-07-18 11:38:05 +00:00
table.sort(key_value_pairs, compare_key_value_pair)
2016-12-28 23:51:07 +00:00
return key_value_pairs
end
local function fill_holes_in_key_value_pairs (key_value_pairs)
-- NOTE: Assumes that all keys are numbers
-- Error checking
assert(type(key_value_pairs) == 'table')
-- Do stuff
for i = 2, #key_value_pairs do
for j = key_value_pairs[i-1][1] + 1, key_value_pairs[i][1] - 1 do
key_value_pairs[#key_value_pairs+1] = { j, nil }
end
end
2017-07-18 11:38:05 +00:00
table.sort(key_value_pairs, compare_key_value_pair)
end
--------------------------------------------------------------------------------
-- Formatting Util
2016-12-28 23:51:07 +00:00
local function width_of_strings_in_l (l, start_i, stop_i)
-- Argument fixing and Error Checking
assert(type(l) == 'table')
local start_i, stop_i = start_i or 1, stop_i or #l
assert(type(start_i) == 'number')
assert(type(start_i) == 'number')
-- Do stuff
local width = 0
for i = start_i, stop_i do
width = width + ((type(l[i]) ~= 'string') and 1 or #l[i])
end
return width
end
2017-01-05 13:28:31 +00:00
local function ignore_alignment_info (l, start_i, stop_i)
-- Argument fixing and Error Checking
assert(type(l) == 'table')
local start_i, stop_i = start_i or 1, stop_i or #l
assert(type(start_i) == 'number')
assert(type(start_i) == 'number')
-- Do stuff
for i = start_i, stop_i do
if type(l[i]) == 'table' and l[i][1] == 'align' then
2017-01-05 13:28:31 +00:00
l[i] = ''
end
end
end
local function fix_alignment (l, start_i, stop_i)
-- Argument fixing and Error Checking
assert(type(l) == 'table')
local start_i, stop_i = start_i or 1, stop_i or #l
assert(type(start_i) == 'number')
assert(type(start_i) == 'number')
2017-01-05 13:28:31 +00:00
-- Find maximums
local max = {}
for i = start_i, stop_i do
if type(l[i]) == 'table' and l[i][1] == 'align' then
max[ l[i][2] ] = math.max( l[i][3], max[ l[i][2] ] or 0 )
2017-01-05 13:28:31 +00:00
end
end
-- Insert the proper whitespace
for i = start_i, stop_i do
if type(l[i]) == 'table' and l[i][1] == 'align' then
l[i] = string.rep(' ', max[ l[i][2] ] - l[i][3])
2017-01-05 13:28:31 +00:00
end
end
end
local function attempt_to_align_into_columns (l, start_i, stop_i, nr_items_pr_row)
assert(type(l) == 'table')
assert(type(start_i) == 'number')
assert(type(stop_i) == 'number')
assert(type(nr_items_pr_row) == 'number')
local column = {}
---
local start_of_item_i, item_nr = nil, 0
for i = start_i, stop_i do
if type(l[i]) == 'table' and (l[i][1] == 'indent' or l[i][1] == 'seperator' or l[i][1] == 'unindent') then
if start_of_item_i then
local width_of_item = width_of_strings_in_l(l, start_of_item_i, i-1)
local column_i = (item_nr-1)%nr_items_pr_row+1
column[column_i] = math.max(column[column_i] or 0, width_of_item)
end
start_of_item_i, item_nr = i + 1, item_nr + 1
end
end
---
local width = nr_items_pr_row * 2 - 1 -- FIXME: Magic numbers: 2 = #', ', 1 = #' '
for i = 1, #column do width = width + column[i] end
--
return width, column
end
local function align_into_columns (l, start_i, stop_i)
-- Argument fixing and Error Checking
assert(type(l) == 'table')
local start_i, stop_i = start_i or 1, stop_i or #l
assert(type(start_i) == 'number')
assert(type(start_i) == 'number')
-- Find columns
local columns = nil
for nr_items_pr_row = 10, 1, -1 do -- TODO: Do this more intelligently.
local column_width
column_width, columns = attempt_to_align_into_columns(l, start_i, stop_i, nr_items_pr_row)
if column_width <= MAX_WIDTH_FOR_SINGLE_LINE_TABLE then break end
end
-- Change alignment of columns
local start_of_item_i, item_nr = nil, 0
for i = start_i, stop_i do
if type(l[i]) == 'table' and l[i][1] == 'align' then
local column_i = (item_nr-1)%#columns+1
l[i][2] = l[i][2] .. '_column_'..column_i
elseif (l[i][1] == 'indent' or l[i][1] == 'seperator' or l[i][1] == 'unindent') then
start_of_item_i, item_nr = i + 1, item_nr + 1
end
end
-- Fix newly changed alignment
fix_alignment(l, start_i, stop_i)
-- Quick-exit on only a single column
if #columns == 1 then return end
-- Fit into columns.
local start_of_item_i, item_nr = nil, 0
for i = start_i, stop_i do
if type(l[i]) ~= 'table' then
-- Do nothing
elseif (l[i][1] == 'indent' or l[i][1] == 'seperator' or l[i][1] == 'unindent') then
if start_of_item_i and l[i][1] == 'seperator' then
local column_i = (item_nr-1)%#columns+1
if column_i ~= #columns then
local width_of_item = width_of_strings_in_l(l, start_of_item_i, i-1)
l[i] = l[i][2] .. ' ' .. (' '):rep(columns[column_i]-width_of_item)
end
end
start_of_item_i, item_nr = i + 1, item_nr + 1
end
end
end
local function fix_seperator_info (l, indent_char, max_depth)
-- Error Checking
assert(type(l) == 'table')
assert(type(indent_char) == 'string')
assert(type(max_depth) == 'number')
-- Do stuff
local depth, inline_depth = 0, nil
for i = 1, #l do
if type(l[i]) ~= 'table' then
-- Do nothing
elseif l[i][1] == 'seperator' then
2017-06-11 11:53:06 +00:00
assert(l[i][2] == nil or type(l[i][2]) == 'string')
l[i] = (l[i][2] or '') .. (inline_depth and ' ' or ('\n' .. indent_char:rep(depth)))
elseif l[i][1] == 'indent' then
depth, inline_depth = depth + 1, inline_depth or l[i][3] == 'inline' and depth + 1 or nil
l[i] = l[i][2] .. (inline_depth and ' ' or ('\n' .. indent_char:rep(depth)))
elseif l[i][1] == 'unindent' then
l[i] = (inline_depth and ' ' or ('\n' .. indent_char:rep(depth-1))) .. l[i][2]
depth, inline_depth = depth - 1, (depth ~= inline_depth) and inline_depth or nil
end
end
end
2016-12-28 23:51:07 +00:00
--------------------------------------------------------------------------------
-- Formatting stuff
local format_value
2016-12-28 23:51:07 +00:00
-- Ways to format keys
local function format_key_and_value_string_map (l, key, value, depth)
l[#l+1] = key
l[#l+1] = { 'align', 'key', #key }
l[#l+1] = ' = '
return format_value(value, depth, l)
2016-12-28 23:51:07 +00:00
end
local function format_key_and_value_arbitr_map (l, key, value, depth)
local index_before_key = #l+1
l[#l+1] = '['
format_value(key, math.huge, l)
l[#l+1] = ']'
l[#l+1] = { 'align', 'key', width_of_strings_in_l(l, index_before_key) }
l[#l+1] = ' = '
return format_value(value, depth, l)
2016-12-28 23:51:07 +00:00
end
local function format_key_and_value_sequence (l, key, value, depth)
return format_value(value, depth, l)
end
2016-12-28 23:51:07 +00:00
2016-12-29 11:11:48 +00:00
local TABLE_TYPE_TO_PAIR_FORMAT = {
[TABLE_TYPE.EMPTY] = format_key_and_value_sequence,
[TABLE_TYPE.SEQUENCE] = format_key_and_value_sequence,
[TABLE_TYPE.SET] = format_key_and_value_arbitr_map,
[TABLE_TYPE.MIXED] = format_key_and_value_arbitr_map,
[TABLE_TYPE.STRING_MAP] = format_key_and_value_string_map,
[TABLE_TYPE.PURE_MAP] = format_key_and_value_arbitr_map,
2016-12-29 11:11:48 +00:00
}
2016-12-28 23:51:07 +00:00
-- Formatting tables
local function format_table (t, depth, l)
-- TODO: Add more nuanced formatting.
-- Error Checking
assert(type(t) == 'table')
assert(type(depth) == 'number' and type(l) == 'table')
-- Do stuff
if not l.info[t] then analyze_structure(t, l.options.max_depth-depth, l.info) end
local table_info = l.info[t]
assert(table_info)
if l.options.recursion == 'marked' and table_info.marker then
l[#l+1], l[#l+2], l[#l+3] = '<', table_info.marker, '>'
end
local already_visited = l.visited[t]
l.visited[t] = true
2017-07-20 08:38:24 +00:00
-- If empty, visited or above max-depth, give a small represetation: `{...}`
if table_info.type == TABLE_TYPE.EMPTY or depth >= l.options.max_depth or already_visited then
l '{'
if l.options._table_addr_comment then l[#l+1] = ' --[[' .. table_info.address .. ']] ' end
if table_info.type ~= TABLE_TYPE.EMPTY then l[#l+1] = '...' end
return l '}'
end
-- Get key-value pairs, and possibly fill holes.
2016-12-29 11:11:48 +00:00
local key_value_pairs = get_key_value_pairs_in_proper_order(t)
if table_info.type == TABLE_TYPE.SEQUENCE and l.info[t].has_holes then
fill_holes_in_key_value_pairs(key_value_pairs)
end
2017-07-20 08:38:24 +00:00
-- Find pair formatting function
local pair_format_func = TABLE_TYPE_TO_PAIR_FORMAT[table_info.type]
local start_of_table_i = #l + 1
assert(pair_format_func)
-- Begin formatting table.
l[#l+1] = {'indent', '{'}
if l.options._table_addr_comment then l[#l+1], l[#l+2] = '--[['..table_info.address..']]', {'seperator'} end
2016-12-28 23:51:07 +00:00
for _, pair in ipairs(key_value_pairs) do
pair_format_func(l, pair[1], pair[2], depth + 1)
2017-06-11 11:53:06 +00:00
l[#l+1] = {'seperator', ','}
2016-12-28 23:51:07 +00:00
end
2017-06-11 11:53:06 +00:00
if l[#l][1] == 'seperator' then l[#l] = nil end
l[#l+1] = {'unindent', '}'}
-- Decide for short or long table formatting.
local table_width = width_of_strings_in_l(l, start_of_table_i)
if table_width <= MAX_WIDTH_FOR_SINGLE_LINE_TABLE then
-- Is short table: Ignore the "width of key"-shit
l[start_of_table_i][3] = 'inline'
ignore_alignment_info(l, start_of_table_i)
elseif table_info.is_leaf_node then
-- Is leaf node: Can format into columns.
-- NOTE: Currently we only allow leaf-nodes to format into columns, due
-- to issues with table alignment.
align_into_columns(l, start_of_table_i)
else
-- Is long table: Fix whitespace alignment
fix_alignment(l, start_of_table_i)
end
2016-12-28 23:51:07 +00:00
end
-- Formatting Strings
2016-12-28 23:51:07 +00:00
local function format_coroutine (value, depth, l)
2017-07-18 11:38:05 +00:00
-- Formats a coroutine. Unfortunantly we cannot gather a lot of information
-- about coroutines.
-- Error check
assert(type(value) == 'thread')
assert(type(depth) == 'number' and type(l) == 'table')
2017-07-18 11:38:05 +00:00
-- Do stuff
l[#l+1] = coroutine.status(value)
l[#l+1] = ' coroutine: '
l[#l+1] = tostring(value):sub(9)
end
local function format_primitive (value, depth, l)
-- Error check
assert(type(depth) == 'number' and type(l) == 'table')
-- Do stuff
l[#l+1] = tostring(value)
end
local TYPE_TO_FORMAT_FUNC = {
['nil'] = format_primitive,
['boolean'] = format_primitive,
['number'] = format_number,
['string'] = format_string,
['thread'] = format_coroutine,
['table'] = format_table,
['function'] = format_function,
2017-04-14 11:24:29 +00:00
['userdata'] = format_primitive, -- TODO
['cdata'] = format_primitive, -- TODO & Luajit only
}
function format_value (value, depth, l)
assert(type(depth) == 'number' and type(l) == 'table')
local formatting = TYPE_TO_FORMAT_FUNC[type(value)]
if formatting then
formatting(value, depth, l, format_value)
else
error(ERROR_UNKNOWN_TYPE:format(type(value), tostring(value)), 2)
2016-12-28 23:51:07 +00:00
end
end
--------------------------------------------------------------------------------
-- StringBuilder
local StringBuilder = {}
StringBuilder.__index = StringBuilder
StringBuilder.__call = function (l, s) l[#l+1] = s end
setmetatable(StringBuilder, {
__call = function() return setmetatable({}, StringBuilder) end
})
--------------------------------------------------------------------------------
local DEBUG_OPTION_USED = { }
2017-04-03 14:39:19 +00:00
local KNOWN_OPTIONS = {
_table_addr_comment = { type = 'boolean', default = false, debug = 'debug' },
2017-06-09 14:20:33 +00:00
cut_strings = { type = 'boolean', default = false },
indent = { type = 'string', default = ' ' },
2017-06-09 14:20:33 +00:00
max_depth = { type = 'number', default = math.huge },
short_builtins = { type = 'boolean', default = false }, -- TODO: Outphase this. Rather automatically use the short versions in places where it would be strange to find the function, like keys, etc.
recursion = { type = 'string', default = 'ignore', accepted = {['ignore'] = true, ['marked'] = true} },
2017-04-03 14:39:19 +00:00
}
local function ensure_that_all_options_are_known (input_options)
-- Goes through all the given options, throws error if one is unknown, gives
-- warning if debug or experimental. Creates a clone of the given table, to
-- avoid defaults leaking.
-- Error check that options were table
assert(type(input_options) == 'table')
2017-06-09 14:20:33 +00:00
-- Error check options
for option_name, option_value in pairs(input_options) do
2017-04-03 14:39:19 +00:00
if not KNOWN_OPTIONS[option_name] then
error(('[pretty]: Unknown option: %s. Was given value %s (%s)'):format(option_name, option_value, type(option_value)), 2)
2017-06-09 14:20:33 +00:00
elseif type(option_value) ~= KNOWN_OPTIONS[option_name].type then
error(('[pretty]: Bad value given to option %s: %s (%s). Expected value of type %s'):format(option_name, option_value, type(option_value), KNOWN_OPTIONS[option_name].type), 2)
elseif KNOWN_OPTIONS[option_name].accepted and not KNOWN_OPTIONS[option_name].accepted[option_value] then
error(('[pretty]: Bad value given to option %s: %s (%s). Expected one of: %s'):format(option_name, option_value, type(option_value), table.concat(KNOWN_OPTIONS[option_name].accepted, ', ')), 2)
elseif KNOWN_OPTIONS[option_name].debug and not DEBUG_OPTION_USED[option_name] then
DEBUG_OPTION_USED[option_name] = true
print(('[pretty]: Using %s option "%s".\n Please note that this option may change at any time. It is not stable,\n not tested, and may indeed break or be removed without warning.'):format(KNOWN_OPTIONS[option_name].debug, option_name))
2017-04-03 14:39:19 +00:00
end
end
-- Create output options
local output_options = {}
2017-06-09 14:20:33 +00:00
-- Assign default values
for option_name, option_info in pairs(KNOWN_OPTIONS) do
if input_options[option_name] ~= nil then
output_options[option_name] = input_options[option_name]
else
output_options[option_name] = option_info.default
2017-06-09 14:20:33 +00:00
end
end
-- Returns input_options
return output_options
2017-04-03 14:39:19 +00:00
end
2016-12-28 23:51:07 +00:00
local function pretty_format (value, options)
-- Error checking
2017-06-09 14:20:33 +00:00
local options = ensure_that_all_options_are_known(options or {})
-- Setup StringBuilder
local l = StringBuilder()
l.visited = { next_mark = 1 }
l.options = options
l.info = analyze_structure(value, options.max_depth)
-- Format value.
format_value(value, 0, l)
2017-01-05 13:28:31 +00:00
-- If any alignment info still exists, ignore it
fix_seperator_info(l, l.options.indent, l.options.max_depth)
2017-01-05 13:28:31 +00:00
ignore_alignment_info(l)
return table.concat(l, '')
2016-12-28 23:51:07 +00:00
end
return pretty_format