580 lines
17 KiB
Lua
580 lines
17 KiB
Lua
local ErrorHandler = {}
|
|
local ErrorCodes = nil -- Will be injected via init
|
|
|
|
local LOG_LEVELS = {
|
|
CRITICAL = 1,
|
|
ERROR = 2,
|
|
WARNING = 3,
|
|
INFO = 4,
|
|
DEBUG = 5,
|
|
}
|
|
|
|
local config = {
|
|
debugMode = false,
|
|
includeStackTrace = false,
|
|
logLevel = LOG_LEVELS.WARNING, -- Default: log errors and warnings
|
|
logTarget = "console", -- Options: "console", "file", "both", "none"
|
|
logFormat = "human", -- Options: "human", "json"
|
|
logFile = "flexlove-errors.log",
|
|
maxLogSize = 10 * 1024 * 1024, -- 10MB default
|
|
maxLogFiles = 5, -- Keep 5 rotated logs
|
|
enableRotation = true,
|
|
}
|
|
|
|
-- Internal state
|
|
local logFileHandle = nil
|
|
local currentLogSize = 0
|
|
|
|
--- Initialize ErrorHandler with dependencies
|
|
---@param deps table Dependencies table with ErrorCodes
|
|
function ErrorHandler.init(deps)
|
|
if deps and deps.ErrorCodes then
|
|
ErrorCodes = deps.ErrorCodes
|
|
else
|
|
-- Try to require if not provided (backward compatibility)
|
|
local success, module = pcall(require, "modules.ErrorCodes")
|
|
if success then
|
|
ErrorCodes = module
|
|
else
|
|
-- Create minimal stub if ErrorCodes not available
|
|
ErrorCodes = {
|
|
get = function() return nil end,
|
|
describe = function(code) return code end,
|
|
getSuggestion = function() return "" end,
|
|
}
|
|
end
|
|
end
|
|
end
|
|
|
|
--- Set debug mode (enables stack traces and verbose output)
|
|
---@param enabled boolean Enable debug mode
|
|
function ErrorHandler.setDebugMode(enabled)
|
|
config.debugMode = enabled
|
|
config.includeStackTrace = enabled
|
|
if enabled then
|
|
config.logLevel = LOG_LEVELS.DEBUG
|
|
end
|
|
end
|
|
|
|
--- Set whether to include stack traces
|
|
---@param enabled boolean Enable stack traces
|
|
function ErrorHandler.setStackTrace(enabled)
|
|
config.includeStackTrace = enabled
|
|
end
|
|
|
|
--- Set log level (minimum level to log)
|
|
---@param level string|number Log level ("CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG") or number
|
|
function ErrorHandler.setLogLevel(level)
|
|
if type(level) == "string" then
|
|
config.logLevel = LOG_LEVELS[level:upper()] or LOG_LEVELS.WARNING
|
|
elseif type(level) == "number" then
|
|
config.logLevel = level
|
|
end
|
|
end
|
|
|
|
--- Set log target
|
|
---@param target string "console", "file", "both", or "none"
|
|
function ErrorHandler.setLogTarget(target)
|
|
config.logTarget = target
|
|
-- Note: File will be opened lazily on first write
|
|
if target == "console" or target == "none" then
|
|
-- Close log file if open
|
|
if logFileHandle then
|
|
logFileHandle:close()
|
|
logFileHandle = nil
|
|
currentLogSize = 0
|
|
end
|
|
end
|
|
end
|
|
|
|
--- Set log format
|
|
---@param format string "human" or "json"
|
|
function ErrorHandler.setLogFormat(format)
|
|
config.logFormat = format
|
|
end
|
|
|
|
--- Set log file path
|
|
---@param path string Path to log file
|
|
function ErrorHandler.setLogFile(path)
|
|
-- Close existing log file
|
|
if logFileHandle then
|
|
logFileHandle:close()
|
|
logFileHandle = nil
|
|
end
|
|
|
|
config.logFile = path
|
|
currentLogSize = 0
|
|
|
|
-- Note: File will be opened lazily on first write
|
|
end
|
|
|
|
--- Enable/disable log rotation
|
|
---@param enabled boolean|table Enable rotation or config table
|
|
function ErrorHandler.enableLogRotation(enabled)
|
|
if type(enabled) == "boolean" then
|
|
config.enableRotation = enabled
|
|
elseif type(enabled) == "table" then
|
|
config.enableRotation = true
|
|
if enabled.maxSize then
|
|
config.maxLogSize = enabled.maxSize
|
|
end
|
|
if enabled.maxFiles then
|
|
config.maxLogFiles = enabled.maxFiles
|
|
end
|
|
end
|
|
end
|
|
|
|
--- Get current timestamp with milliseconds
|
|
---@return string Formatted timestamp
|
|
local function getTimestamp()
|
|
local time = os.time()
|
|
local date = os.date("%Y-%m-%d %H:%M:%S", time)
|
|
-- Note: Lua doesn't have millisecond precision by default, so we approximate
|
|
return date
|
|
end
|
|
|
|
--- Rotate log file if needed
|
|
local function rotateLogIfNeeded()
|
|
if not config.enableRotation then
|
|
return
|
|
end
|
|
if currentLogSize < config.maxLogSize then
|
|
return
|
|
end
|
|
|
|
-- Close current log
|
|
if logFileHandle then
|
|
logFileHandle:close()
|
|
logFileHandle = nil
|
|
end
|
|
|
|
-- Rotate existing logs
|
|
for i = config.maxLogFiles - 1, 1, -1 do
|
|
local oldName = config.logFile .. "." .. i
|
|
local newName = config.logFile .. "." .. (i + 1)
|
|
os.rename(oldName, newName) -- Will fail silently if file doesn't exist
|
|
end
|
|
|
|
-- Move current log to .1
|
|
os.rename(config.logFile, config.logFile .. ".1")
|
|
|
|
-- Create new log file
|
|
logFileHandle = io.open(config.logFile, "a")
|
|
currentLogSize = 0
|
|
end
|
|
|
|
--- Escape string for JSON
|
|
---@param str string String to escape
|
|
---@return string Escaped string
|
|
local function escapeJson(str)
|
|
str = tostring(str)
|
|
str = str:gsub("\\", "\\\\")
|
|
str = str:gsub('"', '\\"')
|
|
str = str:gsub("\n", "\\n")
|
|
str = str:gsub("\r", "\\r")
|
|
str = str:gsub("\t", "\\t")
|
|
return str
|
|
end
|
|
|
|
--- Format details as JSON object
|
|
---@param details table|nil Details object
|
|
---@return string JSON string
|
|
local function formatDetailsJson(details)
|
|
if not details or type(details) ~= "table" then
|
|
return "{}"
|
|
end
|
|
|
|
local parts = {}
|
|
for key, value in pairs(details) do
|
|
local jsonKey = escapeJson(tostring(key))
|
|
local jsonValue = escapeJson(tostring(value))
|
|
table.insert(parts, string.format('"%s":"%s"', jsonKey, jsonValue))
|
|
end
|
|
|
|
return "{" .. table.concat(parts, ",") .. "}"
|
|
end
|
|
|
|
--- Format details object as readable key-value pairs
|
|
---@param details table|nil Details object
|
|
---@return string Formatted details
|
|
local function formatDetails(details)
|
|
if not details or type(details) ~= "table" then
|
|
return ""
|
|
end
|
|
|
|
local lines = {}
|
|
for key, value in pairs(details) do
|
|
local formattedKey = tostring(key):gsub("^%l", string.upper)
|
|
local formattedValue = tostring(value)
|
|
-- Truncate very long values
|
|
if #formattedValue > 100 then
|
|
formattedValue = formattedValue:sub(1, 97) .. "..."
|
|
end
|
|
table.insert(lines, string.format(" %s: %s", formattedKey, formattedValue))
|
|
end
|
|
|
|
if #lines > 0 then
|
|
return "\n\nDetails:\n" .. table.concat(lines, "\n")
|
|
end
|
|
return ""
|
|
end
|
|
|
|
--- Extract and format stack trace
|
|
---@param level number Stack level to start from
|
|
---@return string Formatted stack trace
|
|
local function formatStackTrace(level)
|
|
if not config.includeStackTrace then
|
|
return ""
|
|
end
|
|
|
|
local lines = {}
|
|
local currentLevel = level or 3
|
|
|
|
while true do
|
|
local info = debug.getinfo(currentLevel, "Sl")
|
|
if not info then
|
|
break
|
|
end
|
|
|
|
-- Skip internal Lua files
|
|
if info.source:match("^@") and not info.source:match("loveStub") then
|
|
local source = info.source:sub(2) -- Remove @ prefix
|
|
local location = string.format("%s:%d", source, info.currentline)
|
|
table.insert(lines, " " .. location)
|
|
end
|
|
|
|
currentLevel = currentLevel + 1
|
|
if currentLevel > level + 10 then
|
|
break
|
|
end -- Limit depth
|
|
end
|
|
|
|
if #lines > 0 then
|
|
return "\n\nStack trace:\n" .. table.concat(lines, "\n")
|
|
end
|
|
return ""
|
|
end
|
|
|
|
--- Format an error or warning message with optional error code
|
|
---@param module string The module name (e.g., "Element", "Units", "Theme")
|
|
---@param level string "Error" or "Warning"
|
|
---@param codeOrMessage string Error code (e.g., "VAL_001") or message
|
|
---@param messageOrDetails string|table|nil Message or details object
|
|
---@param detailsOrSuggestion table|string|nil Details or suggestion
|
|
---@param suggestionOrNil string|nil Suggestion
|
|
---@return string Formatted message
|
|
local function formatMessage(module, level, codeOrMessage, messageOrDetails, detailsOrSuggestion, suggestionOrNil)
|
|
local code = nil
|
|
local message = codeOrMessage
|
|
local details = nil
|
|
local suggestion = nil
|
|
|
|
-- Parse arguments (support multiple signatures)
|
|
if type(codeOrMessage) == "string" and ErrorCodes.get(codeOrMessage) then
|
|
-- Called with error code
|
|
code = codeOrMessage
|
|
message = messageOrDetails or ErrorCodes.describe(code)
|
|
|
|
if type(detailsOrSuggestion) == "table" then
|
|
details = detailsOrSuggestion
|
|
suggestion = suggestionOrNil or ErrorCodes.getSuggestion(code)
|
|
elseif type(detailsOrSuggestion) == "string" then
|
|
suggestion = detailsOrSuggestion
|
|
else
|
|
suggestion = ErrorCodes.getSuggestion(code)
|
|
end
|
|
else
|
|
-- Called with message only (backward compatibility)
|
|
message = codeOrMessage
|
|
if type(messageOrDetails) == "table" then
|
|
details = messageOrDetails
|
|
suggestion = detailsOrSuggestion
|
|
elseif type(messageOrDetails) == "string" then
|
|
suggestion = messageOrDetails
|
|
end
|
|
end
|
|
|
|
-- Build formatted message
|
|
local parts = {}
|
|
|
|
-- Header: [FlexLove - Module] Level [CODE]: Message
|
|
if code then
|
|
local codeInfo = ErrorCodes.get(code)
|
|
table.insert(parts, string.format("[FlexLove - %s] %s [%s]: %s", module, level, codeInfo.code, message))
|
|
else
|
|
table.insert(parts, string.format("[FlexLove - %s] %s: %s", module, level, message))
|
|
end
|
|
|
|
-- Details section
|
|
if details then
|
|
table.insert(parts, formatDetails(details))
|
|
end
|
|
|
|
-- Suggestion section
|
|
if suggestion and suggestion ~= "" then
|
|
table.insert(parts, string.format("\n\nSuggestion: %s", suggestion))
|
|
end
|
|
|
|
return table.concat(parts, "")
|
|
end
|
|
|
|
--- Write log entry to file and/or console
|
|
---@param level string Log level
|
|
---@param levelNum number Log level number
|
|
---@param module string Module name
|
|
---@param code string|nil Error code
|
|
---@param message string Message
|
|
---@param details table|nil Details
|
|
---@param suggestion string|nil Suggestion
|
|
local function writeLog(level, levelNum, module, code, message, details, suggestion)
|
|
-- Check if we should log this level
|
|
if levelNum > config.logLevel then
|
|
return
|
|
end
|
|
|
|
local timestamp = getTimestamp()
|
|
local logEntry
|
|
|
|
if config.logFormat == "json" then
|
|
-- JSON format
|
|
local jsonParts = {
|
|
string.format('"timestamp":"%s"', escapeJson(timestamp)),
|
|
string.format('"level":"%s"', level),
|
|
string.format('"module":"%s"', escapeJson(module)),
|
|
string.format('"message":"%s"', escapeJson(message)),
|
|
}
|
|
|
|
if code then
|
|
table.insert(jsonParts, string.format('"code":"%s"', escapeJson(code)))
|
|
end
|
|
|
|
if details then
|
|
table.insert(jsonParts, string.format('"details":%s', formatDetailsJson(details)))
|
|
end
|
|
|
|
if suggestion then
|
|
table.insert(jsonParts, string.format('"suggestion":"%s"', escapeJson(suggestion)))
|
|
end
|
|
|
|
logEntry = "{" .. table.concat(jsonParts, ",") .. "}\n"
|
|
else
|
|
-- Human-readable format
|
|
local parts = {
|
|
string.format("[%s] [%s] [%s]", timestamp, level, module),
|
|
}
|
|
|
|
if code then
|
|
table.insert(parts, string.format("[%s]", code))
|
|
end
|
|
|
|
table.insert(parts, message)
|
|
logEntry = table.concat(parts, " ") .. "\n"
|
|
|
|
if details then
|
|
logEntry = logEntry .. formatDetails(details):gsub("^\n\n", "") .. "\n"
|
|
end
|
|
|
|
if suggestion then
|
|
logEntry = logEntry .. "Suggestion: " .. suggestion .. "\n"
|
|
end
|
|
|
|
logEntry = logEntry .. "\n"
|
|
end
|
|
|
|
-- Write to console
|
|
if config.logTarget == "console" or config.logTarget == "both" then
|
|
io.write(logEntry)
|
|
io.flush()
|
|
end
|
|
|
|
-- Write to file
|
|
if config.logTarget == "file" or config.logTarget == "both" then
|
|
-- Lazy file opening: open on first write
|
|
if not logFileHandle then
|
|
logFileHandle = io.open(config.logFile, "a")
|
|
if logFileHandle then
|
|
-- Get current file size
|
|
local currentPos = logFileHandle:seek("end")
|
|
currentLogSize = currentPos or 0
|
|
end
|
|
end
|
|
|
|
if logFileHandle then
|
|
rotateLogIfNeeded()
|
|
|
|
-- Reopen if rotation closed it
|
|
if not logFileHandle then
|
|
logFileHandle = io.open(config.logFile, "a")
|
|
end
|
|
|
|
if logFileHandle then
|
|
logFileHandle:write(logEntry)
|
|
logFileHandle:flush()
|
|
currentLogSize = currentLogSize + #logEntry
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
--- Throw a critical error (stops execution)
|
|
---@param module string The module name
|
|
---@param codeOrMessage string Error code or message
|
|
---@param messageOrDetails string|table|nil Message or details
|
|
---@param detailsOrSuggestion table|string|nil Details or suggestion
|
|
---@param suggestion string|nil Suggestion
|
|
function ErrorHandler.error(module, codeOrMessage, messageOrDetails, detailsOrSuggestion, suggestion)
|
|
local formattedMessage = formatMessage(module, "Error", codeOrMessage, messageOrDetails, detailsOrSuggestion, suggestion)
|
|
|
|
-- Parse arguments for logging
|
|
local code = nil
|
|
local message = codeOrMessage
|
|
local details = nil
|
|
local logSuggestion = nil
|
|
|
|
if type(codeOrMessage) == "string" and ErrorCodes.get(codeOrMessage) then
|
|
code = codeOrMessage
|
|
message = messageOrDetails or ErrorCodes.describe(code)
|
|
|
|
if type(detailsOrSuggestion) == "table" then
|
|
details = detailsOrSuggestion
|
|
logSuggestion = suggestion or ErrorCodes.getSuggestion(code)
|
|
elseif type(detailsOrSuggestion) == "string" then
|
|
logSuggestion = detailsOrSuggestion
|
|
else
|
|
logSuggestion = ErrorCodes.getSuggestion(code)
|
|
end
|
|
else
|
|
message = codeOrMessage
|
|
if type(messageOrDetails) == "table" then
|
|
details = messageOrDetails
|
|
logSuggestion = detailsOrSuggestion
|
|
elseif type(messageOrDetails) == "string" then
|
|
logSuggestion = messageOrDetails
|
|
end
|
|
end
|
|
|
|
-- Log the error
|
|
writeLog("ERROR", LOG_LEVELS.ERROR, module, code, message, details, logSuggestion)
|
|
|
|
-- Add stack trace if enabled
|
|
if config.includeStackTrace then
|
|
formattedMessage = formattedMessage .. formatStackTrace(3)
|
|
end
|
|
|
|
error(formattedMessage, 2)
|
|
end
|
|
|
|
--- Print a warning (non-critical, continues execution)
|
|
---@param module string The module name
|
|
---@param codeOrMessage string Warning code or message
|
|
---@param messageOrDetails string|table|nil Message or details
|
|
---@param detailsOrSuggestion table|string|nil Details or suggestion
|
|
---@param suggestion string|nil Suggestion
|
|
function ErrorHandler.warn(module, codeOrMessage, messageOrDetails, detailsOrSuggestion, suggestion)
|
|
-- Parse arguments for logging
|
|
local code = nil
|
|
local message = codeOrMessage
|
|
local details = nil
|
|
local logSuggestion = nil
|
|
|
|
if type(codeOrMessage) == "string" and ErrorCodes.get(codeOrMessage) then
|
|
code = codeOrMessage
|
|
message = messageOrDetails or ErrorCodes.describe(code)
|
|
|
|
if type(detailsOrSuggestion) == "table" then
|
|
details = detailsOrSuggestion
|
|
logSuggestion = suggestion or ErrorCodes.getSuggestion(code)
|
|
elseif type(detailsOrSuggestion) == "string" then
|
|
logSuggestion = detailsOrSuggestion
|
|
else
|
|
logSuggestion = ErrorCodes.getSuggestion(code)
|
|
end
|
|
else
|
|
message = codeOrMessage
|
|
if type(messageOrDetails) == "table" then
|
|
details = messageOrDetails
|
|
logSuggestion = detailsOrSuggestion
|
|
elseif type(messageOrDetails) == "string" then
|
|
logSuggestion = messageOrDetails
|
|
end
|
|
end
|
|
|
|
-- Log the warning
|
|
writeLog("WARNING", LOG_LEVELS.WARNING, module, code, message, details, logSuggestion)
|
|
|
|
local formattedMessage = formatMessage(module, "Warning", codeOrMessage, messageOrDetails, detailsOrSuggestion, suggestion)
|
|
print(formattedMessage)
|
|
end
|
|
|
|
--- Validate that a value is not nil
|
|
---@param module string The module name
|
|
---@param value any The value to check
|
|
---@param paramName string The parameter name
|
|
---@return boolean True if valid
|
|
function ErrorHandler.assertNotNil(module, value, paramName)
|
|
if value == nil then
|
|
ErrorHandler.error(module, "VAL_003", "Required parameter missing", {
|
|
parameter = paramName,
|
|
})
|
|
return false
|
|
end
|
|
return true
|
|
end
|
|
|
|
--- Validate that a value is of the expected type
|
|
---@param module string The module name
|
|
---@param value any The value to check
|
|
---@param expectedType string The expected type name
|
|
---@param paramName string The parameter name
|
|
---@return boolean True if valid
|
|
function ErrorHandler.assertType(module, value, expectedType, paramName)
|
|
local actualType = type(value)
|
|
if actualType ~= expectedType then
|
|
ErrorHandler.error(module, "VAL_001", "Invalid property type", {
|
|
property = paramName,
|
|
expected = expectedType,
|
|
got = actualType,
|
|
})
|
|
return false
|
|
end
|
|
return true
|
|
end
|
|
|
|
--- Validate that a number is within a range
|
|
---@param module string The module name
|
|
---@param value number The value to check
|
|
---@param min number Minimum value (inclusive)
|
|
---@param max number Maximum value (inclusive)
|
|
---@param paramName string The parameter name
|
|
---@return boolean True if valid
|
|
function ErrorHandler.assertRange(module, value, min, max, paramName)
|
|
if value < min or value > max then
|
|
ErrorHandler.error(module, "VAL_002", "Property value out of range", {
|
|
property = paramName,
|
|
min = tostring(min),
|
|
max = tostring(max),
|
|
value = tostring(value),
|
|
})
|
|
return false
|
|
end
|
|
return true
|
|
end
|
|
|
|
--- Warn if a value is deprecated
|
|
---@param module string The module name
|
|
---@param oldName string The deprecated name
|
|
---@param newName string The new name to use
|
|
function ErrorHandler.warnDeprecated(module, oldName, newName)
|
|
ErrorHandler.warn(module, string.format("'%s' is deprecated. Use '%s' instead", oldName, newName))
|
|
end
|
|
|
|
--- Warn about a common mistake
|
|
---@param module string The module name
|
|
---@param issue string Description of the issue
|
|
---@param suggestion string Suggested fix
|
|
function ErrorHandler.warnCommonMistake(module, issue, suggestion)
|
|
ErrorHandler.warn(module, string.format("%s. Suggestion: %s", issue, suggestion))
|
|
end
|
|
|
|
return ErrorHandler
|