--[[ logging.lua: pandoc-aware logging functions (can also be used standalone) Copyright: (c) 2022 William Lupton License: MIT - see LICENSE file for details Usage: See README.md for details ]] -- if running standalone, create a 'pandoc' global if not pandoc then _G.pandoc = {utils = {}} end -- if there's no pandoc.utils, create a local one if not pcall(require, 'pandoc.utils') then pandoc.utils = {} end -- if there's no pandoc.utils.type, create a local one if not pandoc.utils.type then pandoc.utils.type = function(value) local typ = type(value) if not ({table=1, userdata=1})[typ] then -- unchanged elseif value.__name then typ = value.__name elseif value.tag and value.t then typ = value.tag if typ:match('^Meta.') then typ = typ:sub(5) end if typ == 'Map' then typ = 'table' end end return typ end end -- namespace local logging = {} -- helper function to return a sensible typename logging.type = function(value) -- this can return 'Inlines', 'Blocks', 'Inline', 'Block' etc., or -- anything that built-in type() can return, namely 'nil', 'number', -- 'string', 'boolean', 'table', 'function', 'thread', or 'userdata' local typ = pandoc.utils.type(value) -- it seems that it can also return strings like 'pandoc Row'; replace -- spaces with periods -- XXX I'm not sure that this is done consistently, e.g. I don't think -- it's done for pandoc.Attr or pandoc.List? typ = typ:gsub(' ', '.') -- map Inline and Block to the tag name -- XXX I guess it's intentional that it doesn't already do this? return ({Inline=1, Block=1})[typ] and value.tag or typ end -- derived from https://www.lua.org/pil/19.3.html pairsByKeys() logging.spairs = function(list, comp) local keys = {} for key, _ in pairs(list) do table.insert(keys, tostring(key)) end table.sort(keys, comp) local i = 0 local iter = function() i = i + 1 return keys[i] and keys[i], list[keys[i]] or nil end return iter end -- helper function to dump a value with a prefix (recursive) -- XXX should detect repetition/recursion -- XXX would like maxlen logic to apply at all levels? but not trivial local function dump_(prefix, value, maxlen, level, add) local buffer = {} if prefix == nil then prefix = '' end if level == nil then level = 0 end if add == nil then add = function(item) table.insert(buffer, item) end end local indent = maxlen and '' or (' '):rep(level) -- get typename, mapping to pandoc tag names where possible local typename = logging.type(value) -- don't explicitly indicate 'obvious' typenames local typ = (({boolean=1, number=1, string=1, table=1, userdata=1}) [typename] and '' or typename) -- light userdata is just a pointer (can't iterate over it) -- XXX is there a better way of checking for light userdata? if type(value) == 'userdata' and not pcall(pairs(value)) then value = tostring(value):gsub('userdata:%s*', '') -- modify the value heuristically elseif ({table=1, userdata=1})[type(value)] then local valueCopy, numKeys, lastKey = {}, 0, nil for key, val in pairs(value) do -- pandoc >= 2.15 includes 'tag', nil values and functions if key ~= 'tag' and val and type(val) ~= 'function' then valueCopy[key] = val numKeys = numKeys + 1 lastKey = key end end if numKeys == 0 then -- this allows empty tables to be formatted on a single line -- XXX experimental: render Doc objects value = typename == 'Doc' and '|' .. value:render() .. '|' or typename == 'Space' and '' or '{}' elseif numKeys == 1 and lastKey == 'text' then -- this allows text-only types to be formatted on a single line typ = typename value = value[lastKey] typename = 'string' else value = valueCopy -- XXX experimental: indicate array sizes if #value > 0 then typ = typ .. '[' .. #value .. ']' end end end -- output the possibly-modified value local presep = #prefix > 0 and ' ' or '' local typsep = #typ > 0 and ' ' or '' local valtyp = type(value) if valtyp == 'nil' then add('nil') elseif ({boolean=1, number=1, string=1})[valtyp] then typsep = #typ > 0 and valtyp == 'string' and #value > 0 and ' ' or '' -- don't use the %q format specifier; doesn't work with multi-bytes local quo = typename == 'string' and '"' or '' add(string.format('%s%s%s%s%s%s%s%s', indent, prefix, presep, typ, typsep, quo, value, quo)) -- light userdata is just a pointer (can't iterate over it) -- XXX is there a better way of checking for light userdata? elseif valtyp == 'userdata' and not pcall(pairs(value)) then add(string.format('%s%s%s%s %s', indent, prefix, presep, typ, tostring(value):gsub('userdata:%s*', ''))) elseif ({table=1, userdata=1})[valtyp] then add(string.format('%s%s%s%s%s{', indent, prefix, presep, typ, typsep)) -- Attr and Attr.attributes have both numeric and string keys, so -- ignore the numeric ones -- XXX this is no longer the case for pandoc >= 2.15, so could remove -- the special case? local first = true if prefix ~= 'attributes:' and typ ~= 'Attr' then for i, val in ipairs(value) do local pre = maxlen and not first and ', ' or '' dump_(string.format('%s[%s]', pre, i), val, maxlen, level + 1, add) first = false end end -- report keys in alphabetical order to ensure repeatability for key, val in logging.spairs(value) do local pre = maxlen and not first and ', ' or '' -- this check can avoid an infinite loop, e.g. with metatables -- XXX should have more general and robust infinite loop avoidance if key:match('^__') and type(val) ~= 'string' then add(string.format('%s%s: %s', pre, key, tostring(val))) -- pandoc >= 2.15 includes 'tag' elseif not tonumber(key) and key ~= 'tag' then dump_(string.format('%s%s:', pre, key), val, maxlen, level + 1, add) end first = false end add(string.format('%s}', indent)) end return table.concat(buffer, maxlen and '' or '\n') end logging.dump = function(value, maxlen) if maxlen == nil then maxlen = 70 end local text = dump_(nil, value, maxlen) if #text > maxlen then text = dump_(nil, value, nil) end return text end logging.output = function(...) local need_newline = false for i, item in ipairs({...}) do -- XXX space logic could be cleverer, e.g. no space after newline local maybe_space = i > 1 and ' ' or '' local text = ({table=1, userdata=1})[type(item)] and logging.dump(item) or tostring(item) io.stderr:write(maybe_space, text) need_newline = text:sub(-1) ~= '\n' end if need_newline then io.stderr:write('\n') end end -- basic logging support (-1=errors, 0=warnings, 1=info, 2=debug, 3=debug2) -- XXX should support string levels? logging.loglevel = 0 -- set log level and return the previous level logging.setloglevel = function(loglevel) local oldlevel = logging.loglevel logging.loglevel = loglevel return oldlevel end -- verbosity default is WARNING; --quiet -> ERROR and --verbose -> INFO -- --trace sets TRACE or DEBUG (depending on --verbose) if type(PANDOC_STATE) == 'nil' then -- use the default level elseif PANDOC_STATE.trace then logging.loglevel = PANDOC_STATE.verbosity == 'INFO' and 3 or 2 elseif PANDOC_STATE.verbosity == 'INFO' then logging.loglevel = 1 elseif PANDOC_STATE.verbosity == 'WARNING' then logging.loglevel = 0 elseif PANDOC_STATE.verbosity == 'ERROR' then logging.loglevel = -1 end logging.error = function(...) if logging.loglevel >= -1 then logging.output('(E)', ...) end end logging.warning = function(...) if logging.loglevel >= 0 then logging.output('(W)', ...) end end logging.info = function(...) if logging.loglevel >= 1 then logging.output('(I)', ...) end end logging.debug = function(...) if logging.loglevel >= 2 then logging.output('(D)', ...) end end logging.debug2 = function(...) if logging.loglevel >= 3 then logging.warning('debug2() is deprecated; use trace()') logging.output('(D2)', ...) end end logging.trace = function(...) if logging.loglevel >= 3 then logging.output('(T)', ...) end end -- for temporary unconditional debug output logging.temp = function(...) logging.output('(#)', ...) end return logging