This is some pretty-print library.
This commit is contained in:
commit
476a2de8f7
419
pretty.lua
Normal file
419
pretty.lua
Normal file
|
@ -0,0 +1,419 @@
|
||||||
|
|
||||||
|
local TABLE_TYPE_SEQUENCE = 'SEQUENCE'
|
||||||
|
local TABLE_TYPE_PURE_MAP = 'PURE MAP'
|
||||||
|
local TABLE_TYPE_MIXED = 'MIXED TABLE'
|
||||||
|
local TABLE_TYPE_EMPTY = 'EMPTY TABLE'
|
||||||
|
|
||||||
|
local SINGLE_LINE_SEQ_MAX_ELEMENTS = 10
|
||||||
|
local SINGLE_LINE_MAP_MAX_ELEMENTS = 5
|
||||||
|
local NR_CHARS_IN_LONG_STRING = 40
|
||||||
|
|
||||||
|
local TYPE_SORT_ORDER = {
|
||||||
|
['nil'] = 0,
|
||||||
|
['boolean'] = 1,
|
||||||
|
['number'] = 2,
|
||||||
|
['string'] = 3,
|
||||||
|
['table'] = 4,
|
||||||
|
['userdata'] = 5,
|
||||||
|
['thread'] = 6,
|
||||||
|
['function'] = 7,
|
||||||
|
}
|
||||||
|
|
||||||
|
local RESERVED_LUA_WORDS = {
|
||||||
|
['and'] = true,
|
||||||
|
['break'] = true,
|
||||||
|
['do'] = true,
|
||||||
|
['else'] = true,
|
||||||
|
['elseif'] = true,
|
||||||
|
['end'] = true,
|
||||||
|
['false'] = true,
|
||||||
|
['for'] = true,
|
||||||
|
['function'] = true,
|
||||||
|
['if'] = true,
|
||||||
|
['in'] = true,
|
||||||
|
['local'] = true,
|
||||||
|
['nil'] = true,
|
||||||
|
['not'] = true,
|
||||||
|
['or'] = true,
|
||||||
|
['repeat'] = true,
|
||||||
|
['return'] = true,
|
||||||
|
['then'] = true,
|
||||||
|
['true'] = true,
|
||||||
|
['until'] = true,
|
||||||
|
['while'] = true,
|
||||||
|
}
|
||||||
|
|
||||||
|
local CHAR_TO_STR_REPR = {}
|
||||||
|
|
||||||
|
do
|
||||||
|
for i = 00, 031 do CHAR_TO_STR_REPR[i] = '\\0'..(i < 10 and '0' or '')..i end
|
||||||
|
for i = 32, 255 do CHAR_TO_STR_REPR[i] = string.char(i) end
|
||||||
|
CHAR_TO_STR_REPR[7] = '\\a'
|
||||||
|
CHAR_TO_STR_REPR[8] = '\\b'
|
||||||
|
CHAR_TO_STR_REPR[9] = '\t'
|
||||||
|
CHAR_TO_STR_REPR[10] = '\n'
|
||||||
|
CHAR_TO_STR_REPR[11] = '\\v'
|
||||||
|
CHAR_TO_STR_REPR[12] = '\\f'
|
||||||
|
CHAR_TO_STR_REPR[13] = '\\r'
|
||||||
|
CHAR_TO_STR_REPR[92] = '\\\\'
|
||||||
|
CHAR_TO_STR_REPR[127] = '\\127'
|
||||||
|
end
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
-- Util
|
||||||
|
|
||||||
|
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)
|
||||||
|
return tostring(a):gsub("%.?%d+", padnum)..("%3d"):format(#b)
|
||||||
|
< tostring(b):gsub("%.?%d+", padnum)..("%3d"):format(#a)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function count_occurances_of_substring_in_string (str, substr)
|
||||||
|
local _, count = string.gsub(str, substr, '')
|
||||||
|
return count
|
||||||
|
end
|
||||||
|
|
||||||
|
local function smallest_secure_longform_string_level (str)
|
||||||
|
-- Determines the level a longform string needs to use, to avoid "code"
|
||||||
|
-- injection. For example, if we want to use longform on the string
|
||||||
|
-- 'Hello ]] World', we cannot use level-0 as this would result in
|
||||||
|
-- '[[Hello ]] World]]', which could be an issue in certain applications.
|
||||||
|
|
||||||
|
local levels = { [1] = 1 }
|
||||||
|
str:gsub('%]=*%]', function (m) levels[m:len()] = true end)
|
||||||
|
return #levels - 1
|
||||||
|
end
|
||||||
|
|
||||||
|
local function compare_key_value_pairs (a, b)
|
||||||
|
-- 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
|
||||||
|
if (type_key_a ~= 'string' or type_key_b ~= 'string') then
|
||||||
|
return TYPE_SORT_ORDER[type_key_a] < TYPE_SORT_ORDER[type_key_b]
|
||||||
|
elseif (type_value_a == type_value_b) then
|
||||||
|
return alphanum_compare_strings(a[1], b[1])
|
||||||
|
else
|
||||||
|
return TYPE_SORT_ORDER[type_value_a] < TYPE_SORT_ORDER[type_value_b]
|
||||||
|
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:
|
||||||
|
-- 1. By value type: as defined by the TYPE_SORT_ORDER in the top.
|
||||||
|
-- 2. By key type: TODO: Implement this.
|
||||||
|
-- 2.1. Numbers
|
||||||
|
-- 2.2. Strings in alphanumeric order
|
||||||
|
-- 2.3. Other wierdness.
|
||||||
|
|
||||||
|
local key_value_pairs = {}
|
||||||
|
|
||||||
|
for key, value in pairs(t) do
|
||||||
|
key_value_pairs[#key_value_pairs+1] = { key, value }
|
||||||
|
end
|
||||||
|
|
||||||
|
table.sort(key_value_pairs, compare_key_value_pairs)
|
||||||
|
|
||||||
|
return key_value_pairs
|
||||||
|
end
|
||||||
|
|
||||||
|
local function nr_elements_in_map (t)
|
||||||
|
local k, count = nil, -1
|
||||||
|
repeat
|
||||||
|
k, count = next(t, k), count + 1
|
||||||
|
until not k
|
||||||
|
return count
|
||||||
|
end
|
||||||
|
|
||||||
|
local function is_identifier(str)
|
||||||
|
-- An identier is defined in the lua reference guide
|
||||||
|
|
||||||
|
return str:match('^[_%a][_%w]*$') and not RESERVED_LUA_WORDS[str]
|
||||||
|
end
|
||||||
|
|
||||||
|
local function contains_only_nice_string_keys (t)
|
||||||
|
-- A "nice" string is here defined is one following the rules of lua
|
||||||
|
-- identifiers.
|
||||||
|
|
||||||
|
for k, _ in pairs(t) do
|
||||||
|
if type(k) ~= 'string' or not is_identifier(k) then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function escape_string (str)
|
||||||
|
local l = {}
|
||||||
|
for i = 1, #str do
|
||||||
|
l[#l+1] = CHAR_TO_STR_REPR[str:byte(i)]
|
||||||
|
end
|
||||||
|
return table.concat(l, '')
|
||||||
|
end
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
-- Identifyer stuff
|
||||||
|
|
||||||
|
local SIMPLE_VALUE_TYPES = {
|
||||||
|
['nil'] = true,
|
||||||
|
['boolean'] = true,
|
||||||
|
['number'] = true,
|
||||||
|
['string'] = true,
|
||||||
|
}
|
||||||
|
|
||||||
|
local function is_empty_table (value)
|
||||||
|
assert( type(value) == 'table', '[is_empty_table]: Only tables allowed!' )
|
||||||
|
return next(value) == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function is_short_table (value)
|
||||||
|
-- In this context, a short table is either an empty table, or one with a
|
||||||
|
-- single element.
|
||||||
|
|
||||||
|
assert( type(value) == 'table', '[is_short_table]: Only tables allowed!' )
|
||||||
|
|
||||||
|
local first_key = next(value)
|
||||||
|
return (not first_key or SIMPLE_VALUE_TYPES[type(value[first_key])])
|
||||||
|
and (next(value, first_key) == nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function is_simple_value (value)
|
||||||
|
-- In this context, a simple value is a either nil, a boolean, a number,
|
||||||
|
-- a string or a short table.
|
||||||
|
-- TODO: Add clause about long strings. (Maybe >7 chars?)
|
||||||
|
|
||||||
|
--if type(value) == 'table' then print(value, is_short_table(value)) end
|
||||||
|
return SIMPLE_VALUE_TYPES[ type(value) ]
|
||||||
|
or type(value) == 'table' and is_short_table(value)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function contains_non_simple_key_or_value (t)
|
||||||
|
for k, v in pairs(t) do
|
||||||
|
if not is_simple_value(k) or not is_simple_value(v) then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function get_table_type (value)
|
||||||
|
-- Determines table type:
|
||||||
|
-- * Sequence: All keys are integer in the range 1..#value
|
||||||
|
-- * Pure Map: #value == 0
|
||||||
|
-- * Mixed: Any other
|
||||||
|
|
||||||
|
if is_empty_table(value) then return TABLE_TYPE_EMPTY end
|
||||||
|
|
||||||
|
local not_sequence = false
|
||||||
|
local not_pure_map = (#value ~= 0)
|
||||||
|
local max_index = #value
|
||||||
|
|
||||||
|
-- Determine if there exist some non-index
|
||||||
|
for k, v in pairs(value) do
|
||||||
|
if type(k) ~= 'number' or k < 1 or max_index < k or k ~= math.floor(k) then
|
||||||
|
not_sequence = true
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Return type
|
||||||
|
if not not_sequence then
|
||||||
|
return TABLE_TYPE_SEQUENCE
|
||||||
|
elseif not_pure_map then
|
||||||
|
return TABLE_TYPE_MIXED
|
||||||
|
else
|
||||||
|
return TABLE_TYPE_PURE_MAP
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function is_single_line_table (value)
|
||||||
|
-- In this context, a single-line table, is:
|
||||||
|
-- A) Either a sequence or a pure map.
|
||||||
|
-- B) Has no non-simple keys or values
|
||||||
|
-- C 1) If sequence, has at most SINGLE_LINE_SEQ_MAX_ELEMENTS elements.
|
||||||
|
-- C 2) If map, has at most SINGLE_LINE_MAP_MAX_ELEMENTS elements.
|
||||||
|
|
||||||
|
local table_type = get_table_type(value)
|
||||||
|
|
||||||
|
return not contains_non_simple_key_or_value(value)
|
||||||
|
and(table_type == TABLE_TYPE_SEQUENCE and #value <= SINGLE_LINE_SEQ_MAX_ELEMENTS
|
||||||
|
or table_type == TABLE_TYPE_PURE_MAP and nr_elements_in_map(value) <= SINGLE_LINE_MAP_MAX_ELEMENTS)
|
||||||
|
end
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
-- Formatting stuff
|
||||||
|
|
||||||
|
local format_table, format_value
|
||||||
|
|
||||||
|
-- Ways to format keys
|
||||||
|
|
||||||
|
local function format_key_strings (key)
|
||||||
|
return key
|
||||||
|
end
|
||||||
|
|
||||||
|
local function format_key_any_type (key, options)
|
||||||
|
return '[' .. format_value(key, options, 'max') .. ']'
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Formatting tables
|
||||||
|
|
||||||
|
local function format_single_line_sequence (t, options)
|
||||||
|
-- NOTE: Assumes that the input table was pre-checked with `is_single_line_table()`
|
||||||
|
|
||||||
|
local l = {}
|
||||||
|
for i = 1, #t do l[i] = format_value(t[i], options) end
|
||||||
|
return '{ ' .. table.concat(l, ', ') .. ' }'
|
||||||
|
end
|
||||||
|
|
||||||
|
local function format_single_line_map (t, options)
|
||||||
|
-- NOTE: Assumes that the input table was pre-checked with `is_single_line_table()`
|
||||||
|
|
||||||
|
local key_format_func = contains_only_nice_string_keys(t) and format_key_strings or format_key_any_type
|
||||||
|
|
||||||
|
local key_value_pairs = get_key_value_pairs_in_proper_order(t)
|
||||||
|
local l = {'{ '}
|
||||||
|
for _, pair in ipairs(key_value_pairs) do
|
||||||
|
l[#l+1] = key_format_func(pair[1], options)
|
||||||
|
l[#l+1] = ' = '
|
||||||
|
l[#l+1] = format_value(pair[2], options)
|
||||||
|
l[#l+1] = ', '
|
||||||
|
end
|
||||||
|
if l[#l] == ', ' then l[#l] = nil end
|
||||||
|
l[#l+1] = ' }'
|
||||||
|
return table.concat(l, '')
|
||||||
|
end
|
||||||
|
|
||||||
|
local function format_sequence (t, options, depth)
|
||||||
|
if depth ~= 'max' and depth >= options.max_depth then return '{...}'
|
||||||
|
elseif is_single_line_table(t) then return format_single_line_sequence(t, options)
|
||||||
|
elseif depth == 'max' then return '{...}'
|
||||||
|
end
|
||||||
|
|
||||||
|
local l = {'{\n'}
|
||||||
|
for index, value in ipairs(t) do
|
||||||
|
l[#l+1] = options.indent:rep(depth + 1)
|
||||||
|
l[#l+1] = format_value(value, options, depth + 1)
|
||||||
|
l[#l+1] = ',\n'
|
||||||
|
end
|
||||||
|
l[#l] = '\n'
|
||||||
|
l[#l+1] = options.indent:rep(depth)
|
||||||
|
l[#l+1] = '}'
|
||||||
|
return table.concat(l, '')
|
||||||
|
end
|
||||||
|
|
||||||
|
local function format_map (t, options, depth)
|
||||||
|
if depth ~= 'max' and depth >= options.max_depth then return '{...}'
|
||||||
|
elseif is_single_line_table(t) then return format_single_line_map(t, options)
|
||||||
|
elseif depth == 'max' then return '{...}'
|
||||||
|
end
|
||||||
|
|
||||||
|
local key_value_pairs = get_key_value_pairs_in_proper_order(t)
|
||||||
|
local key_format_func = contains_only_nice_string_keys(t) and format_key_strings or format_key_any_type
|
||||||
|
local max_key_len = 0
|
||||||
|
|
||||||
|
-- Figure out the max key length
|
||||||
|
for _, pair in pairs(key_value_pairs) do
|
||||||
|
pair[1] = key_format_func(pair[1], options)
|
||||||
|
pair[2] = format_value(pair[2], options, depth + 1)
|
||||||
|
max_key_len = math.max(max_key_len, pair[1]:len())
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Finally figure out formatting
|
||||||
|
local l = {'{\n'}
|
||||||
|
for _, pair in ipairs(key_value_pairs) do
|
||||||
|
l[#l+1] = options.indent:rep(depth + 1)
|
||||||
|
l[#l+1] = pair[1]
|
||||||
|
l[#l+1] = string.rep(' ', max_key_len + 1 - pair[1]:len())
|
||||||
|
l[#l+1] = '= '
|
||||||
|
l[#l+1] = pair[2]
|
||||||
|
l[#l+1] = ',\n'
|
||||||
|
end
|
||||||
|
l[#l] = '\n'
|
||||||
|
l[#l+1] = options.indent:rep(depth)
|
||||||
|
l[#l+1] = '}'
|
||||||
|
return table.concat(l, '')
|
||||||
|
end
|
||||||
|
|
||||||
|
function format_table (t, options, depth)
|
||||||
|
local table_type = get_table_type(t)
|
||||||
|
|
||||||
|
if table_type == TABLE_TYPE_EMPTY then return '{}'
|
||||||
|
elseif table_type == TABLE_TYPE_SEQUENCE then return format_sequence(t, options, depth)
|
||||||
|
else return format_map(t, options, depth)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function format_string (str, options)
|
||||||
|
-- TODO: Add option for escaping unicode characters.
|
||||||
|
|
||||||
|
local is_long_string = (str:len() >= NR_CHARS_IN_LONG_STRING)
|
||||||
|
local newline_or_tab_index = str:find('[\n\t]')
|
||||||
|
local single_quote_index = str:find('\'')
|
||||||
|
local double_quote_index = str:find('\"')
|
||||||
|
|
||||||
|
|
||||||
|
local chance_of_longform = is_long_string and (newline_or_tab_index <= NR_CHARS_IN_LONG_STRING) or double_quote_index and single_quote_index
|
||||||
|
|
||||||
|
|
||||||
|
local cut_string_index = options.cut_strings and (is_long_string or chance_of_longform)
|
||||||
|
and math.min(NR_CHARS_IN_LONG_STRING - 3, newline_or_tab_index or 1/0, double_quote_index or 1/0, single_quote_index or 1/0)
|
||||||
|
|
||||||
|
local longform = chance_of_longform and ((not cut_string_index) or cut_string_index < math.min(newline_or_tab_index or 1/0, double_quote_index or 1/0, single_quote_index or 1/0))
|
||||||
|
|
||||||
|
local escape_newline_and_tab = not longform and newline_or_tab_index
|
||||||
|
|
||||||
|
-- Determine string delimiters
|
||||||
|
local left, right
|
||||||
|
if longform then
|
||||||
|
local level = smallest_secure_longform_string_level(str)
|
||||||
|
left, right = '['..string.rep('=', level)..'[', ']'..string.rep('=', level)..']'
|
||||||
|
if newline_or_tab_index then str = '\n' .. str end
|
||||||
|
elseif not single_quote_index then
|
||||||
|
left, right = '\'', '\''
|
||||||
|
else
|
||||||
|
left, right = '\"', '\"'
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Cut string
|
||||||
|
if cut_string_index then str = str:sub(1, cut_string_index) end
|
||||||
|
str = escape_string(str)
|
||||||
|
-- Escape newline and tab
|
||||||
|
if escape_newline_and_tab then str = str:gsub('\n', '\\n'):gsub('\t', '\\t') end
|
||||||
|
|
||||||
|
return left .. str .. right
|
||||||
|
end
|
||||||
|
|
||||||
|
local function format_number (value, shorthand)
|
||||||
|
if value ~= value then return shorthand and 'nan' or '0/0'
|
||||||
|
elseif value == 1/0 then return shorthand and 'inf' or '1/0'
|
||||||
|
elseif value == -1/0 then return shorthand and '-inf' or '-1/0'
|
||||||
|
else return tostring(value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function format_value (value, options, depth)
|
||||||
|
local type = type(value)
|
||||||
|
|
||||||
|
if type == 'table' then return format_table(value, options, depth or 'max')
|
||||||
|
elseif type == 'string' then return format_string(value, options)
|
||||||
|
elseif type == 'number' then return format_number(value, options.math_shorthand)
|
||||||
|
else return tostring(value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
local function pretty_format (value, options)
|
||||||
|
local options = options or {}
|
||||||
|
options.max_depth = options.max_depth or math.huge
|
||||||
|
options.indent = options.indent or '\t'
|
||||||
|
return format_value(value, options, 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
return pretty_format
|
275
test/TestSuite.lua
Normal file
275
test/TestSuite.lua
Normal file
|
@ -0,0 +1,275 @@
|
||||||
|
|
||||||
|
local TERM_COLOR_CODE_WHITE = '\27[37;1m'
|
||||||
|
local TERM_COLOR_CODE_GREEN = '\27[32;1m'
|
||||||
|
local TERM_COLOR_CODE_RED = '\27[31;1m'
|
||||||
|
local TERM_COLOR_CODE_GREY = '\27[30;0m'
|
||||||
|
|
||||||
|
local ASSERT_ERROR_TYPE = [[
|
||||||
|
Types not compatible:
|
||||||
|
Expected: %s (%s)
|
||||||
|
Gotten: %s (%s)
|
||||||
|
]]
|
||||||
|
|
||||||
|
local ASSERT_ERROR_VALUE = [[
|
||||||
|
Values of type '%s' not equal:
|
||||||
|
Expected: %s
|
||||||
|
Gotten: %s
|
||||||
|
]]
|
||||||
|
|
||||||
|
local ASSERT_ERROR_TABLE_VALUE = [[
|
||||||
|
Values in tables not equal:
|
||||||
|
For key: %s
|
||||||
|
|
||||||
|
Expected: %s (%s)
|
||||||
|
Gotten: %s (%s)
|
||||||
|
]]
|
||||||
|
|
||||||
|
local function assert_equal (expected, gotten)
|
||||||
|
if type(gotten) ~= type(expected) then
|
||||||
|
error(ASSERT_ERROR_TYPE:format(tostring(gotten), type(gotten), tostring(expected), type(expected)))
|
||||||
|
elseif type(expected) == 'table' and gotten ~= expected then
|
||||||
|
for key, expected_value in pairs(expected) do
|
||||||
|
if expected_value ~= gotten[key] then
|
||||||
|
error(ASSERT_ERROR_TABLE_VALUE:format(key, expected_value, type(expected_value), gotten[key], type(gotten[key])))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
elseif gotten ~= expected then
|
||||||
|
error(ASSERT_ERROR_VALUE:format(type(gotten), gotten, expected))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local libraries_tests_and_wrappers = {
|
||||||
|
assert_equal = assert_equal
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v in pairs(_G) do
|
||||||
|
libraries_tests_and_wrappers[k] = v
|
||||||
|
end
|
||||||
|
|
||||||
|
local function indent_string (str, indent)
|
||||||
|
return indent .. str:gsub('\n', '\n'..indent)
|
||||||
|
end
|
||||||
|
|
||||||
|
local TEST_SUITE_TRACEBACK = "\n\t\t[C]: in function 'xpcall'\n\t\t./TestSuite.lua"
|
||||||
|
|
||||||
|
local function find_cutoff_index_for_traceback_string (str)
|
||||||
|
local index = str:find(TEST_SUITE_TRACEBACK, 1, true)
|
||||||
|
return index
|
||||||
|
end
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
local TestSuite = {}
|
||||||
|
TestSuite.__index = TestSuite
|
||||||
|
|
||||||
|
function TestSuite.new (module_name)
|
||||||
|
local new_test_suite = setmetatable({}, TestSuite)
|
||||||
|
new_test_suite.name = module_name
|
||||||
|
new_test_suite.submodules = {}
|
||||||
|
new_test_suite.tests = {}
|
||||||
|
new_test_suite.custom_env = {}
|
||||||
|
return new_test_suite
|
||||||
|
end
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function TestSuite:setEnviroment (new_enviroment)
|
||||||
|
self.custom_env = new_enviroment
|
||||||
|
end
|
||||||
|
|
||||||
|
function TestSuite:createUniqueEnviroment ()
|
||||||
|
local new_enviroment = {}
|
||||||
|
for k, v in pairs(libraries_tests_and_wrappers) do
|
||||||
|
new_enviroment[k] = v
|
||||||
|
end
|
||||||
|
for k, v in pairs(self.custom_env) do
|
||||||
|
new_enviroment[k] = v
|
||||||
|
end
|
||||||
|
return new_enviroment
|
||||||
|
end
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function TestSuite:addModules (modules)
|
||||||
|
for _, module in ipairs(modules) do
|
||||||
|
table.insert(self.submodules, module)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function TestSuite:addTest (test_name, extra_info, test_func)
|
||||||
|
if type(extra_info) == 'function' then
|
||||||
|
extra_info, test_func = test_func, extra_info
|
||||||
|
end
|
||||||
|
|
||||||
|
table.insert(self.tests, {
|
||||||
|
name = test_name,
|
||||||
|
test = test_func,
|
||||||
|
extra_info = extra_info or {}
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function TestSuite:runSubmodules (prefix, indent)
|
||||||
|
local total_errors, total_tests = 0, 0
|
||||||
|
for _, module in ipairs(self.submodules) do
|
||||||
|
local errors, tests = module:runTests(prefix, indent)
|
||||||
|
total_errors, total_tests = total_errors + errors, total_tests + tests
|
||||||
|
end
|
||||||
|
return total_errors, total_tests
|
||||||
|
end
|
||||||
|
|
||||||
|
local function setup_debug_hooks (extra_info)
|
||||||
|
assert(not (extra_info.max_time and extra_info.max_lines), "TEST CONFIG ERROR: Only one line hook allowed pr. test!")
|
||||||
|
local trace
|
||||||
|
if extra_info.max_time then
|
||||||
|
local stop_time = os.clock() + extra_info.max_time
|
||||||
|
trace = function (l)
|
||||||
|
if os.clock() >= stop_time then
|
||||||
|
debug.sethook(nil, 'l')
|
||||||
|
error("Test timed out! This is usually symptom of an infinite loop!")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
elseif extra_info.max_lines then
|
||||||
|
local line_countdown = 10 + extra_info.max_lines
|
||||||
|
trace = function (l)
|
||||||
|
line_countdown = line_countdown - 1
|
||||||
|
if line_countdown <= 0 then
|
||||||
|
debug.sethook(nil, 'l')
|
||||||
|
error("Test exteeded allowed number of lines! This is usually symptom of an infinite loop! (Or too low an estimate!)")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
debug.sethook(trace, 'l')
|
||||||
|
end
|
||||||
|
|
||||||
|
function TestSuite:runTests (parent_prefix, parent_indent)
|
||||||
|
local prefix, indent = self.name, (parent_indent or 0) + 2
|
||||||
|
if parent_prefix then
|
||||||
|
prefix = parent_prefix .. '.' .. prefix
|
||||||
|
else
|
||||||
|
io.write('\n## Running tests ##\n\n')
|
||||||
|
end
|
||||||
|
|
||||||
|
local nr_errors, nr_tests = self:runSubmodules(prefix, indent)
|
||||||
|
|
||||||
|
local function error_handler (err)
|
||||||
|
return err..debug.traceback('', 4)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function print_status (left, right, color)
|
||||||
|
local term_width = 80
|
||||||
|
local color = color or ''
|
||||||
|
io.write(TERM_COLOR_CODE_WHITE..left..': '..string.rep(' ', term_width + 1-#left-#right)..color..right..'\n'..TERM_COLOR_CODE_GREY)
|
||||||
|
end
|
||||||
|
|
||||||
|
for index, test in ipairs(self.tests) do
|
||||||
|
nr_tests = nr_tests + 1
|
||||||
|
-- Setup Test Env, and name
|
||||||
|
local ext_name = prefix .. '.' .. test.name
|
||||||
|
local env = self:createUniqueEnviroment()
|
||||||
|
setfenv(test.test, env)
|
||||||
|
|
||||||
|
setup_debug_hooks(test.extra_info)
|
||||||
|
-- Call tests
|
||||||
|
local success, traceback = xpcall(test.test, error_handler)
|
||||||
|
-- Unset line hook, if set earlier
|
||||||
|
debug.sethook(nil, 'l')
|
||||||
|
-- Write work (or not.)
|
||||||
|
if success then
|
||||||
|
print_status(ext_name, 'SUCCESS!', TERM_COLOR_CODE_GREEN)
|
||||||
|
else
|
||||||
|
print_status(ext_name, 'ERROR!', TERM_COLOR_CODE_RED)
|
||||||
|
traceback = indent_string(traceback, '\t')
|
||||||
|
local stop_index = find_cutoff_index_for_traceback_string(traceback)
|
||||||
|
io.write('\n'..TERM_COLOR_CODE_GREY)
|
||||||
|
io.write(traceback:sub(1, stop_index))
|
||||||
|
io.write('\n\n')
|
||||||
|
nr_errors = nr_errors + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if nr_errors == 0 then
|
||||||
|
print_status(prefix, 'NO ERRORS! WELL DONE!', TERM_COLOR_CODE_GREEN)
|
||||||
|
elseif nr_errors == 1 then
|
||||||
|
print_status(prefix, 'A SINGLE ERROR! ALMOST THERE!', TERM_COLOR_CODE_RED)
|
||||||
|
else
|
||||||
|
print_status(prefix, nr_errors..' ERRORS! GET TO WORK!', TERM_COLOR_CODE_RED)
|
||||||
|
end
|
||||||
|
|
||||||
|
if not parent_prefix then
|
||||||
|
io.write(TERM_COLOR_CODE_WHITE)
|
||||||
|
io.write('\n## All tests run! ##\n\n')
|
||||||
|
|
||||||
|
local width_of_bar = 70
|
||||||
|
local size_of_red_bar = math.ceil( width_of_bar * nr_errors/nr_tests)
|
||||||
|
local size_of_green_bar = width_of_bar-size_of_red_bar
|
||||||
|
|
||||||
|
io.write('Status: ')
|
||||||
|
io.write(nr_errors == 0 and TERM_COLOR_CODE_GREEN or TERM_COLOR_CODE_RED)
|
||||||
|
io.write(string.rep('#', width_of_bar))
|
||||||
|
io.write(TERM_COLOR_CODE_WHITE)
|
||||||
|
|
||||||
|
-- Get
|
||||||
|
io.write('\n\nSummary: '..TERM_COLOR_CODE_GREEN..string.rep('#', size_of_green_bar)..TERM_COLOR_CODE_RED..string.rep('#', size_of_red_bar)..'\n\n')
|
||||||
|
io.write(TERM_COLOR_CODE_WHITE)
|
||||||
|
end
|
||||||
|
|
||||||
|
return nr_errors, nr_tests
|
||||||
|
end
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
local function get_end_of_function (text, start_i)
|
||||||
|
local indent, i, end_i = 0, start_i, text:len()
|
||||||
|
while i <= end_i do
|
||||||
|
if text:sub(i,i) == '"' then -- "style" strings
|
||||||
|
i = text:find('"', i + 1) + 1
|
||||||
|
elseif text:sub(i,i) == "'" then -- 'style' strings
|
||||||
|
i = text:find("'", i + 1) + 1
|
||||||
|
elseif text:sub(i,i+1) == '[[' then -- [[style]] strings
|
||||||
|
i = text:find(']]', i) + 2
|
||||||
|
elseif text:sub(i,i+3) == '--[[' then -- multi line comments
|
||||||
|
i = text:find(']]', i) + 2
|
||||||
|
elseif text:sub(i,i+1) == '--' then -- single line comments
|
||||||
|
i = text:find('\n', i) + 1
|
||||||
|
elseif text:sub(i-1, i+8):find('%sfunction%s') or i == start_i and text:sub(i, i+7) == 'function' then
|
||||||
|
indent, i = indent + 1, i + 8
|
||||||
|
elseif text:sub(i-1, i+5):find('%swhile%s') or i == start_i and text:sub(i, i+4) == 'while' then
|
||||||
|
indent, i = indent + 1, i + 4
|
||||||
|
elseif text:sub(i-1, i+3):find('%sfor%s') or i == start_i and text:sub(i, i+2) == 'for' then
|
||||||
|
indent, i = indent + 1, i + 2
|
||||||
|
elseif text:sub(i-1, i+2):find('%sif%s') or i == start_i and text:sub(i, i+1) == 'if' then
|
||||||
|
indent, i = indent + 1, i + 2
|
||||||
|
elseif text:sub(i-1, i+3):find('%send%s') or (i == end_i - 2) and text:sub(i, i+2) == 'end' then
|
||||||
|
indent, i = indent - 1, i + 2
|
||||||
|
if indent == 0 then
|
||||||
|
return i
|
||||||
|
end
|
||||||
|
else
|
||||||
|
i = i + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function TestSuite.getLocalFunction (module_name, func_name)
|
||||||
|
local file = io.open(module_name .. '.lua')
|
||||||
|
local file_text = file:read('*a')
|
||||||
|
file:close()
|
||||||
|
|
||||||
|
local function_start_index = file_text:find('function '..func_name)
|
||||||
|
local function_end_index = get_end_of_function(file_text, function_start_index)
|
||||||
|
|
||||||
|
local new_text = file_text:sub(1, function_end_index):gsub('local function '..func_name, 'return function')
|
||||||
|
assert(new_text ~= file_text, ('No function in module "%s" with name "%s" '):format(module_name, func_name))
|
||||||
|
local chunk, error_msg = load(new_text)
|
||||||
|
if not chunk then
|
||||||
|
error(('While loading function in module "%s" with name "%s":\n\n\t%s\n'):format(module_name, func_name, error_msg))
|
||||||
|
end
|
||||||
|
assert(chunk, error)
|
||||||
|
return chunk()
|
||||||
|
end
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
return TestSuite
|
277
test/test_pretty.lua
Normal file
277
test/test_pretty.lua
Normal file
|
@ -0,0 +1,277 @@
|
||||||
|
|
||||||
|
local SUITE = require('TestSuite').new('pretty')
|
||||||
|
SUITE:setEnviroment{
|
||||||
|
format = require('pretty')
|
||||||
|
}
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
local function format_test (t)
|
||||||
|
SUITE:addTest(t.expect, function ()
|
||||||
|
local input_value = t.input
|
||||||
|
local input_options = t.options
|
||||||
|
local expected_result = t.expect
|
||||||
|
local actual_result = format(input_value, input_options)
|
||||||
|
assert_equal(actual_result, expected_result)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
-- Strings
|
||||||
|
|
||||||
|
format_test {
|
||||||
|
input = 'Hello World',
|
||||||
|
expect = '\'Hello World\'',
|
||||||
|
}
|
||||||
|
|
||||||
|
format_test {
|
||||||
|
input = 'Hello \'World\'',
|
||||||
|
expect = '\"Hello \'World\'\"',
|
||||||
|
}
|
||||||
|
|
||||||
|
format_test {
|
||||||
|
input = 'Hello \"World\"',
|
||||||
|
expect = '\'Hello \"World\"\'',
|
||||||
|
}
|
||||||
|
|
||||||
|
format_test {
|
||||||
|
input = 'Hello [[World]]',
|
||||||
|
expect = '\'Hello [[World]]\'',
|
||||||
|
}
|
||||||
|
|
||||||
|
format_test {
|
||||||
|
input = '\'Hello\' [[World]]',
|
||||||
|
expect = '\"\'Hello\' [[World]]\"',
|
||||||
|
}
|
||||||
|
|
||||||
|
format_test {
|
||||||
|
input = '\'Hello\' \"there\" [[World]]',
|
||||||
|
expect = '[=[\'Hello\' \"there\" [[World]]]=]',
|
||||||
|
}
|
||||||
|
|
||||||
|
format_test {
|
||||||
|
input = '\'Hello\' \"there\" [=[World]=]',
|
||||||
|
expect = '[[\'Hello\' \"there\" [=[World]=]]]',
|
||||||
|
}
|
||||||
|
|
||||||
|
format_test {
|
||||||
|
input = '\nHello World',
|
||||||
|
expect = '\'\\nHello World\'',
|
||||||
|
}
|
||||||
|
|
||||||
|
format_test {
|
||||||
|
input = '\'\"\n',
|
||||||
|
expect = '[[\n\'\"\n]]',
|
||||||
|
}
|
||||||
|
|
||||||
|
format_test {
|
||||||
|
input = '\n',
|
||||||
|
expect = '\'\\n\'',
|
||||||
|
}
|
||||||
|
|
||||||
|
format_test {
|
||||||
|
input = '\\',
|
||||||
|
expect = '\'\\\\\'',
|
||||||
|
}
|
||||||
|
|
||||||
|
format_test {
|
||||||
|
input = '\000',
|
||||||
|
expect = '\'\\000\'',
|
||||||
|
}
|
||||||
|
|
||||||
|
format_test {
|
||||||
|
input = '\a\b\v\r\f',
|
||||||
|
expect = '\'\\a\\b\\v\\r\\f\'',
|
||||||
|
}
|
||||||
|
|
||||||
|
format_test {
|
||||||
|
input = 'ø',
|
||||||
|
expect = '\'ø\'',
|
||||||
|
}
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
-- Numbers
|
||||||
|
|
||||||
|
format_test {
|
||||||
|
input = 0,
|
||||||
|
expect = '0',
|
||||||
|
}
|
||||||
|
|
||||||
|
format_test {
|
||||||
|
input = 2000,
|
||||||
|
expect = '2000',
|
||||||
|
}
|
||||||
|
|
||||||
|
format_test {
|
||||||
|
input = -2000,
|
||||||
|
expect = '-2000',
|
||||||
|
}
|
||||||
|
|
||||||
|
format_test {
|
||||||
|
input = 1/0, -- Same as math.huge
|
||||||
|
expect = '1/0',
|
||||||
|
}
|
||||||
|
|
||||||
|
format_test {
|
||||||
|
input = -1/0, -- Same as -math.huge
|
||||||
|
expect = '-1/0',
|
||||||
|
}
|
||||||
|
|
||||||
|
format_test {
|
||||||
|
input = 0/0, -- Same as nan
|
||||||
|
expect = '0/0',
|
||||||
|
}
|
||||||
|
|
||||||
|
format_test {
|
||||||
|
input = 1/0, -- Same as math.huge
|
||||||
|
options = { math_shorthand = true },
|
||||||
|
expect = 'inf',
|
||||||
|
}
|
||||||
|
|
||||||
|
format_test {
|
||||||
|
input = -1/0, -- Same as -math.huge
|
||||||
|
options = { math_shorthand = true },
|
||||||
|
expect = '-inf',
|
||||||
|
}
|
||||||
|
|
||||||
|
format_test {
|
||||||
|
input = 0/0, -- Same as nan
|
||||||
|
options = { math_shorthand = true },
|
||||||
|
expect = 'nan',
|
||||||
|
}
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
-- Single-line tables
|
||||||
|
|
||||||
|
format_test {
|
||||||
|
input = {},
|
||||||
|
expect = '{}',
|
||||||
|
}
|
||||||
|
|
||||||
|
format_test {
|
||||||
|
input = {1, 2, 3},
|
||||||
|
expect = '{ 1, 2, 3 }',
|
||||||
|
}
|
||||||
|
|
||||||
|
format_test {
|
||||||
|
input = { 'Hello', 'World' },
|
||||||
|
expect = '{ \'Hello\', \'World\' }',
|
||||||
|
}
|
||||||
|
|
||||||
|
format_test {
|
||||||
|
input = { a = 1, b = 2 },
|
||||||
|
expect = '{ a = 1, b = 2 }',
|
||||||
|
}
|
||||||
|
|
||||||
|
format_test {
|
||||||
|
input = { __hello = true },
|
||||||
|
expect = '{ __hello = true }',
|
||||||
|
}
|
||||||
|
|
||||||
|
format_test {
|
||||||
|
input = { [']]'] = true },
|
||||||
|
expect = '{ [\']]\'] = true }',
|
||||||
|
}
|
||||||
|
|
||||||
|
format_test {
|
||||||
|
input = { ['and'] = true },
|
||||||
|
expect = '{ [\'and\'] = true }',
|
||||||
|
}
|
||||||
|
|
||||||
|
format_test {
|
||||||
|
input = { [false] = false, [true] = true },
|
||||||
|
expect = '{ [false] = false, [true] = true }',
|
||||||
|
}
|
||||||
|
|
||||||
|
format_test {
|
||||||
|
input = { [100] = 'Hi', [300] = 'Hello' },
|
||||||
|
expect = '{ [100] = \'Hi\', [300] = \'Hello\' }',
|
||||||
|
}
|
||||||
|
|
||||||
|
format_test { -- Order does not matter
|
||||||
|
input = { b = 1, a = 2 },
|
||||||
|
expect = '{ a = 2, b = 1 }',
|
||||||
|
}
|
||||||
|
|
||||||
|
format_test { -- Can include empty tables
|
||||||
|
input = { {}, {}, {} },
|
||||||
|
expect = '{ {}, {}, {} }',
|
||||||
|
}
|
||||||
|
|
||||||
|
format_test { -- Can include very small tables
|
||||||
|
input = { {1}, {2}, {3} },
|
||||||
|
expect = '{ { 1 }, { 2 }, { 3 } }',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
-- Multi-line tables
|
||||||
|
|
||||||
|
format_test {
|
||||||
|
input = { {1, 2, 3}, {4, 5, 6} },
|
||||||
|
expect = '{\n\t{ 1, 2, 3 },\n\t{ 4, 5, 6 }\n}',
|
||||||
|
}
|
||||||
|
|
||||||
|
format_test {
|
||||||
|
input = { a = {1, 2, 3}, b = {4, 5, 6} },
|
||||||
|
expect = '{\n\ta = { 1, 2, 3 },\n\tb = { 4, 5, 6 }\n}',
|
||||||
|
}
|
||||||
|
|
||||||
|
format_test {
|
||||||
|
input = { 'Hi', [300] = 'Hello' },
|
||||||
|
expect = '{\n\t[1] = \'Hi\',\n\t[300] = \'Hello\'\n}',
|
||||||
|
}
|
||||||
|
|
||||||
|
format_test {
|
||||||
|
input = { { {} } },
|
||||||
|
expect = '{\n\t{ {} }\n}',
|
||||||
|
}
|
||||||
|
|
||||||
|
format_test {
|
||||||
|
input = { [{ 1, 2 }] = { 2, 1 } },
|
||||||
|
expect = '{\n\t[{ 1, 2 }] = { 2, 1 }\n}',
|
||||||
|
}
|
||||||
|
|
||||||
|
format_test {
|
||||||
|
input = { { {1, 2}, {3, 4} }, {5, 6} },
|
||||||
|
expect = '{\n\t{\n\t\t{ 1, 2 },\n\t\t{ 3, 4 }\n\t},\n\t{ 5, 6 }\n}',
|
||||||
|
}
|
||||||
|
|
||||||
|
format_test {
|
||||||
|
input = { { {1, 2}, {3, 4} }, {5, 6} },
|
||||||
|
options = { max_depth = 0 },
|
||||||
|
expect = '{...}',
|
||||||
|
}
|
||||||
|
|
||||||
|
format_test {
|
||||||
|
input = { { {1, 2}, {3, 4} }, {5, 6} },
|
||||||
|
options = { max_depth = 1 },
|
||||||
|
expect = '{\n\t{...},\n\t{...}\n}',
|
||||||
|
}
|
||||||
|
|
||||||
|
format_test {
|
||||||
|
input = { { {1, 2}, {3, 4} }, {5, 6} },
|
||||||
|
options = { max_depth = 2 },
|
||||||
|
expect = '{\n\t{\n\t\t{...},\n\t\t{...}\n\t},\n\t{ 5, 6 }\n}',
|
||||||
|
}
|
||||||
|
|
||||||
|
format_test {
|
||||||
|
input = { { {1, 2}, {3, 4} }, {5, 6} },
|
||||||
|
options = { max_depth = 3 },
|
||||||
|
expect = '{\n\t{\n\t\t{ 1, 2 },\n\t\t{ 3, 4 }\n\t},\n\t{ 5, 6 }\n}',
|
||||||
|
}
|
||||||
|
|
||||||
|
format_test {
|
||||||
|
input = { [{ {1,2}, {3,4} }] = 'Hello World' },
|
||||||
|
expect = '{\n\t[{...}] = \'Hello World\'\n}',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
format_test {
|
||||||
|
input = false,
|
||||||
|
expect = 'true',
|
||||||
|
}
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
return SUITE
|
49
test/tests.lua
Normal file
49
test/tests.lua
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
|
||||||
|
-- Util function
|
||||||
|
|
||||||
|
local function get_test_modules (test_folder_and_prefix)
|
||||||
|
|
||||||
|
local function string_split (str, sep)
|
||||||
|
local t, i = {}, 1
|
||||||
|
for str in string.gmatch(str, "([^".. (sep or '%s') .."]+)") do
|
||||||
|
t[#t+1] = str
|
||||||
|
end
|
||||||
|
return t
|
||||||
|
end
|
||||||
|
|
||||||
|
local function os_execute (command)
|
||||||
|
local handle = io.popen(command)
|
||||||
|
local result = handle:read("*a")
|
||||||
|
handle:close()
|
||||||
|
return string_split(result)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function get_test_filenames (dir_and_prefix)
|
||||||
|
return os_execute('ls '..dir_and_prefix)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function load_test_modules (test_filenames)
|
||||||
|
local modules = {}
|
||||||
|
for _, filename in ipairs(test_filenames) do
|
||||||
|
local module = require(filename:sub(1,-5))
|
||||||
|
assert(type(module) == 'table', ('ERROR: Module "%s" did not return a Table value.'):format(filename))
|
||||||
|
modules[#modules+1] = module
|
||||||
|
end
|
||||||
|
return modules
|
||||||
|
end
|
||||||
|
|
||||||
|
local test_filenames = get_test_filenames(test_folder_and_prefix)
|
||||||
|
local test_modules = load_test_modules(test_filenames)
|
||||||
|
return test_modules
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Load modules and run them
|
||||||
|
|
||||||
|
package.path = package.path .. ';./test/?.lua'
|
||||||
|
|
||||||
|
local TEST_SUITE = require("TestSuite").new('Pretty')
|
||||||
|
|
||||||
|
local modules = get_test_modules('test/test_*')
|
||||||
|
TEST_SUITE:addModules(modules)
|
||||||
|
|
||||||
|
TEST_SUITE:runTests()
|
Loading…
Reference in New Issue
Block a user