start testing

This commit is contained in:
Michael Freno
2025-11-14 20:59:40 -05:00
parent a218b4abed
commit 1dab1a197e
18 changed files with 4886 additions and 11 deletions

View File

@@ -6,6 +6,36 @@ local function formatError(module, message)
return string.format("[FlexLove.%s] %s", module, message)
end
-- Named colors (CSS3 color names)
local NAMED_COLORS = {
-- Basic colors
black = {0, 0, 0, 1},
white = {1, 1, 1, 1},
red = {1, 0, 0, 1},
green = {0, 0.502, 0, 1},
blue = {0, 0, 1, 1},
yellow = {1, 1, 0, 1},
cyan = {0, 1, 1, 1},
magenta = {1, 0, 1, 1},
-- Extended colors
gray = {0.502, 0.502, 0.502, 1},
grey = {0.502, 0.502, 0.502, 1},
silver = {0.753, 0.753, 0.753, 1},
maroon = {0.502, 0, 0, 1},
olive = {0.502, 0.502, 0, 1},
lime = {0, 1, 0, 1},
aqua = {0, 1, 1, 1},
teal = {0, 0.502, 0.502, 1},
navy = {0, 0, 0.502, 1},
fuchsia = {1, 0, 1, 1},
purple = {0.502, 0, 0.502, 1},
orange = {1, 0.647, 0, 1},
pink = {1, 0.753, 0.796, 1},
brown = {0.647, 0.165, 0.165, 1},
transparent = {0, 0, 0, 0},
}
--- Utility class for color handling
---@class Color
---@field r number -- Red component (0-1)
@@ -64,4 +94,283 @@ function Color.fromHex(hexWithTag)
end
end
--- Validate a single color channel value
---@param value any -- Value to validate
---@param max number? -- Maximum value (255 for 0-255 range, 1 for 0-1 range)
---@return boolean valid -- True if valid
---@return number? clamped -- Clamped value in 0-1 range
function Color.validateColorChannel(value, max)
max = max or 1
if type(value) ~= "number" then
return false, nil
end
-- Check for NaN
if value ~= value then
return false, nil
end
-- Check for Infinity
if value == math.huge or value == -math.huge then
return false, nil
end
-- Normalize to 0-1 range
local normalized = value
if max == 255 then
normalized = value / 255
end
-- Clamp to valid range
normalized = math.max(0, math.min(1, normalized))
return true, normalized
end
--- Validate hex color format
---@param hex string -- Hex color string (with or without #)
---@return boolean valid -- True if valid format
---@return string? error -- Error message if invalid
function Color.validateHexColor(hex)
if type(hex) ~= "string" then
return false, "Hex color must be a string"
end
-- Remove # prefix
local cleanHex = hex:gsub("^#", "")
-- Check length (3, 6, or 8 characters)
if #cleanHex ~= 3 and #cleanHex ~= 6 and #cleanHex ~= 8 then
return false, string.format("Invalid hex length: %d. Expected 3, 6, or 8 characters", #cleanHex)
end
-- Check for valid hex characters
if not cleanHex:match("^[0-9A-Fa-f]+$") then
return false, "Invalid hex characters. Use only 0-9, A-F"
end
return true, nil
end
--- Validate RGB/RGBA color values
---@param r number -- Red component
---@param g number -- Green component
---@param b number -- Blue component
---@param a number? -- Alpha component (optional)
---@param max number? -- Maximum value (255 or 1)
---@return boolean valid -- True if valid
---@return string? error -- Error message if invalid
function Color.validateRGBColor(r, g, b, a, max)
max = max or 1
a = a or max
local rValid = Color.validateColorChannel(r, max)
local gValid = Color.validateColorChannel(g, max)
local bValid = Color.validateColorChannel(b, max)
local aValid = Color.validateColorChannel(a, max)
if not rValid then
return false, string.format("Invalid red channel: %s", tostring(r))
end
if not gValid then
return false, string.format("Invalid green channel: %s", tostring(g))
end
if not bValid then
return false, string.format("Invalid blue channel: %s", tostring(b))
end
if not aValid then
return false, string.format("Invalid alpha channel: %s", tostring(a))
end
return true, nil
end
--- Validate named color
---@param name string -- Color name
---@return boolean valid -- True if valid
---@return string? error -- Error message if invalid
function Color.validateNamedColor(name)
if type(name) ~= "string" then
return false, "Color name must be a string"
end
local lowerName = name:lower()
if not NAMED_COLORS[lowerName] then
return false, string.format("Unknown color name: '%s'", name)
end
return true, nil
end
--- Check if a value is a valid color format
---@param value any -- Value to check
---@return string? format -- Format type (hex, rgb, rgba, named, table, nil if invalid)
function Color.isValidColorFormat(value)
local valueType = type(value)
-- Check for hex string
if valueType == "string" then
if value:match("^#?[0-9A-Fa-f]+$") then
local valid = Color.validateHexColor(value)
if valid then
return "hex"
end
end
-- Check for named color
if NAMED_COLORS[value:lower()] then
return "named"
end
return nil
end
-- Check for table format
if valueType == "table" then
-- Check for Color instance
if getmetatable(value) == Color then
return "table"
end
-- Check for array format {r, g, b, a}
if value[1] and value[2] and value[3] then
local valid = Color.validateRGBColor(value[1], value[2], value[3], value[4])
if valid then
return "table"
end
end
-- Check for named format {r=, g=, b=, a=}
if value.r and value.g and value.b then
local valid = Color.validateRGBColor(value.r, value.g, value.b, value.a)
if valid then
return "table"
end
end
return nil
end
return nil
end
--- Validate a color value
---@param value any -- Color value to validate
---@param options table? -- Validation options
---@return boolean valid -- True if valid
---@return string? error -- Error message if invalid
function Color.validateColor(value, options)
options = options or {}
local allowNamed = options.allowNamed ~= false
local requireAlpha = options.requireAlpha or false
if value == nil then
return false, "Color value is nil"
end
local format = Color.isValidColorFormat(value)
if not format then
return false, string.format("Invalid color format: %s", tostring(value))
end
if format == "named" and not allowNamed then
return false, "Named colors not allowed"
end
-- Additional validation for alpha requirement
if requireAlpha and format == "hex" then
local cleanHex = value:gsub("^#", "")
if #cleanHex ~= 8 then
return false, "Alpha channel required (use 8-digit hex)"
end
end
return true, nil
end
--- Sanitize a color value
---@param value any -- Color value to sanitize
---@param default Color? -- Default color if invalid
---@return Color -- Sanitized color
function Color.sanitizeColor(value, default)
default = default or Color.new(0, 0, 0, 1)
local format = Color.isValidColorFormat(value)
if not format then
return default
end
-- Handle hex format
if format == "hex" then
local cleanHex = value:gsub("^#", "")
-- Expand 3-digit hex to 6-digit
if #cleanHex == 3 then
cleanHex = cleanHex:gsub("(.)", "%1%1")
end
-- Try to parse
local success, result = pcall(Color.fromHex, "#" .. cleanHex)
if success then
return result
else
return default
end
end
-- Handle named format
if format == "named" then
local lowerName = value:lower()
local rgba = NAMED_COLORS[lowerName]
if rgba then
return Color.new(rgba[1], rgba[2], rgba[3], rgba[4])
end
return default
end
-- Handle table format
if format == "table" then
-- Color instance
if getmetatable(value) == Color then
return value
end
-- Array format
if value[1] then
local _, r = Color.validateColorChannel(value[1], 1)
local _, g = Color.validateColorChannel(value[2], 1)
local _, b = Color.validateColorChannel(value[3], 1)
local _, a = Color.validateColorChannel(value[4] or 1, 1)
if r and g and b and a then
return Color.new(r, g, b, a)
end
end
-- Named format
if value.r then
local _, r = Color.validateColorChannel(value.r, 1)
local _, g = Color.validateColorChannel(value.g, 1)
local _, b = Color.validateColorChannel(value.b, 1)
local _, a = Color.validateColorChannel(value.a or 1, 1)
if r and g and b and a then
return Color.new(r, g, b, a)
end
end
end
return default
end
--- Parse a color from various formats
---@param value any -- Color value (hex, named, table)
---@return Color -- Parsed color
function Color.parse(value)
return Color.sanitizeColor(value, Color.new(0, 0, 0, 1))
end
return Color

445
modules/ErrorCodes.lua Normal file
View File

@@ -0,0 +1,445 @@
--- Error code definitions for FlexLove
--- Provides centralized error codes, descriptions, and suggested fixes
---@class ErrorCodes
local ErrorCodes = {}
-- Error code categories
ErrorCodes.categories = {
VAL = "Validation",
LAY = "Layout",
REN = "Render",
THM = "Theme",
EVT = "Event",
RES = "Resource",
SYS = "System",
}
-- Error code definitions
ErrorCodes.codes = {
-- Validation Errors (VAL_001 - VAL_099)
VAL_001 = {
code = "FLEXLOVE_VAL_001",
category = "VAL",
description = "Invalid property type",
suggestion = "Check the property type matches the expected type (e.g., number, string, table)",
},
VAL_002 = {
code = "FLEXLOVE_VAL_002",
category = "VAL",
description = "Property value out of range",
suggestion = "Ensure the value is within the allowed min/max range",
},
VAL_003 = {
code = "FLEXLOVE_VAL_003",
category = "VAL",
description = "Required property missing",
suggestion = "Provide the required property in your element definition",
},
VAL_004 = {
code = "FLEXLOVE_VAL_004",
category = "VAL",
description = "Invalid color format",
suggestion = "Use valid color format: {r, g, b, a} with values 0-1, hex string, or Color object",
},
VAL_005 = {
code = "FLEXLOVE_VAL_005",
category = "VAL",
description = "Invalid unit format",
suggestion = "Use valid unit format: number (px), '50%', '10vw', '5vh', etc.",
},
VAL_006 = {
code = "FLEXLOVE_VAL_006",
category = "VAL",
description = "Invalid file path",
suggestion = "Check that the file path is correct and the file exists",
},
VAL_007 = {
code = "FLEXLOVE_VAL_007",
category = "VAL",
description = "Invalid enum value",
suggestion = "Use one of the allowed enum values for this property",
},
VAL_008 = {
code = "FLEXLOVE_VAL_008",
category = "VAL",
description = "Invalid text input",
suggestion = "Ensure text meets validation requirements (length, pattern, allowed characters)",
},
-- Layout Errors (LAY_001 - LAY_099)
LAY_001 = {
code = "FLEXLOVE_LAY_001",
category = "LAY",
description = "Invalid flex direction",
suggestion = "Use 'horizontal' or 'vertical' for flexDirection",
},
LAY_002 = {
code = "FLEXLOVE_LAY_002",
category = "LAY",
description = "Circular dependency detected",
suggestion = "Remove circular references in element hierarchy or layout constraints",
},
LAY_003 = {
code = "FLEXLOVE_LAY_003",
category = "LAY",
description = "Invalid dimensions (negative or NaN)",
suggestion = "Ensure width and height are positive numbers",
},
LAY_004 = {
code = "FLEXLOVE_LAY_004",
category = "LAY",
description = "Layout calculation overflow",
suggestion = "Reduce complexity of layout or increase recursion limit",
},
LAY_005 = {
code = "FLEXLOVE_LAY_005",
category = "LAY",
description = "Invalid alignment value",
suggestion = "Use valid alignment values (flex-start, center, flex-end, etc.)",
},
LAY_006 = {
code = "FLEXLOVE_LAY_006",
category = "LAY",
description = "Invalid positioning mode",
suggestion = "Use 'absolute', 'relative', 'flex', or 'grid' for positioning",
},
LAY_007 = {
code = "FLEXLOVE_LAY_007",
category = "LAY",
description = "Grid layout error",
suggestion = "Check grid template columns/rows and item placement",
},
-- Rendering Errors (REN_001 - REN_099)
REN_001 = {
code = "FLEXLOVE_REN_001",
category = "REN",
description = "Invalid render state",
suggestion = "Ensure element is properly initialized before rendering",
},
REN_002 = {
code = "FLEXLOVE_REN_002",
category = "REN",
description = "Texture loading failed",
suggestion = "Check image path and format, ensure file exists",
},
REN_003 = {
code = "FLEXLOVE_REN_003",
category = "REN",
description = "Font loading failed",
suggestion = "Check font path and format, ensure file exists",
},
REN_004 = {
code = "FLEXLOVE_REN_004",
category = "REN",
description = "Invalid color value",
suggestion = "Color components must be numbers between 0 and 1",
},
REN_005 = {
code = "FLEXLOVE_REN_005",
category = "REN",
description = "Clipping stack overflow",
suggestion = "Reduce nesting depth or check for missing scissor pops",
},
REN_006 = {
code = "FLEXLOVE_REN_006",
category = "REN",
description = "Shader compilation failed",
suggestion = "Check shader code for syntax errors",
},
REN_007 = {
code = "FLEXLOVE_REN_007",
category = "REN",
description = "Invalid nine-patch configuration",
suggestion = "Check nine-patch slice values and image dimensions",
},
-- Theme Errors (THM_001 - THM_099)
THM_001 = {
code = "FLEXLOVE_THM_001",
category = "THM",
description = "Theme file not found",
suggestion = "Check theme file path and ensure file exists",
},
THM_002 = {
code = "FLEXLOVE_THM_002",
category = "THM",
description = "Invalid theme structure",
suggestion = "Theme must return a table with 'name' and component styles",
},
THM_003 = {
code = "FLEXLOVE_THM_003",
category = "THM",
description = "Required theme property missing",
suggestion = "Ensure theme has required properties (name, base styles, etc.)",
},
THM_004 = {
code = "FLEXLOVE_THM_004",
category = "THM",
description = "Invalid component style",
suggestion = "Component styles must be tables with valid properties",
},
THM_005 = {
code = "FLEXLOVE_THM_005",
category = "THM",
description = "Theme loading failed",
suggestion = "Check theme file for Lua syntax errors",
},
THM_006 = {
code = "FLEXLOVE_THM_006",
category = "THM",
description = "Invalid theme color",
suggestion = "Theme colors must be valid color values (hex, rgba, Color object)",
},
-- Event Errors (EVT_001 - EVT_099)
EVT_001 = {
code = "FLEXLOVE_EVT_001",
category = "EVT",
description = "Invalid event type",
suggestion = "Use valid event types (mousepressed, textinput, etc.)",
},
EVT_002 = {
code = "FLEXLOVE_EVT_002",
category = "EVT",
description = "Event handler error",
suggestion = "Check event handler function for errors",
},
EVT_003 = {
code = "FLEXLOVE_EVT_003",
category = "EVT",
description = "Event propagation error",
suggestion = "Check event bubbling/capturing logic",
},
EVT_004 = {
code = "FLEXLOVE_EVT_004",
category = "EVT",
description = "Invalid event target",
suggestion = "Ensure event target element exists and is valid",
},
EVT_005 = {
code = "FLEXLOVE_EVT_005",
category = "EVT",
description = "Event handler not a function",
suggestion = "Event handlers must be functions",
},
-- Resource Errors (RES_001 - RES_099)
RES_001 = {
code = "FLEXLOVE_RES_001",
category = "RES",
description = "File not found",
suggestion = "Check file path and ensure file exists in the filesystem",
},
RES_002 = {
code = "FLEXLOVE_RES_002",
category = "RES",
description = "Permission denied",
suggestion = "Check file permissions and access rights",
},
RES_003 = {
code = "FLEXLOVE_RES_003",
category = "RES",
description = "Invalid file format",
suggestion = "Ensure file format is supported (png, jpg, ttf, etc.)",
},
RES_004 = {
code = "FLEXLOVE_RES_004",
category = "RES",
description = "Resource loading failed",
suggestion = "Check file integrity and format compatibility",
},
RES_005 = {
code = "FLEXLOVE_RES_005",
category = "RES",
description = "Image cache error",
suggestion = "Clear image cache or check memory availability",
},
-- System Errors (SYS_001 - SYS_099)
SYS_001 = {
code = "FLEXLOVE_SYS_001",
category = "SYS",
description = "Memory allocation failed",
suggestion = "Reduce memory usage or check available memory",
},
SYS_002 = {
code = "FLEXLOVE_SYS_002",
category = "SYS",
description = "Stack overflow",
suggestion = "Reduce recursion depth or check for infinite loops",
},
SYS_003 = {
code = "FLEXLOVE_SYS_003",
category = "SYS",
description = "Invalid state",
suggestion = "Check initialization order and state management",
},
SYS_004 = {
code = "FLEXLOVE_SYS_004",
category = "SYS",
description = "Module initialization failed",
suggestion = "Check module dependencies and initialization order",
},
}
--- Get error information by code
--- @param code string Error code (e.g., "VAL_001" or "FLEXLOVE_VAL_001")
--- @return table? errorInfo Error information or nil if not found
function ErrorCodes.get(code)
-- Handle both short and full format
local shortCode = code:gsub("^FLEXLOVE_", "")
return ErrorCodes.codes[shortCode]
end
--- Get human-readable description for error code
--- @param code string Error code
--- @return string description Error description
function ErrorCodes.describe(code)
local info = ErrorCodes.get(code)
if info then
return info.description
end
return "Unknown error code: " .. code
end
--- Get suggested fix for error code
--- @param code string Error code
--- @return string suggestion Suggested fix
function ErrorCodes.getSuggestion(code)
local info = ErrorCodes.get(code)
if info then
return info.suggestion
end
return "No suggestion available"
end
--- Get category for error code
--- @param code string Error code
--- @return string category Error category name
function ErrorCodes.getCategory(code)
local info = ErrorCodes.get(code)
if info then
return ErrorCodes.categories[info.category] or info.category
end
return "Unknown"
end
--- List all error codes in a category
--- @param category string Category code (e.g., "VAL", "LAY")
--- @return table codes List of error codes in category
function ErrorCodes.listByCategory(category)
local result = {}
for code, info in pairs(ErrorCodes.codes) do
if info.category == category then
table.insert(result, {
code = code,
fullCode = info.code,
description = info.description,
suggestion = info.suggestion,
})
end
end
table.sort(result, function(a, b)
return a.code < b.code
end)
return result
end
--- Search error codes by keyword
--- @param keyword string Keyword to search for
--- @return table codes Matching error codes
function ErrorCodes.search(keyword)
keyword = keyword:lower()
local result = {}
for code, info in pairs(ErrorCodes.codes) do
local searchText = (code .. " " .. info.description .. " " .. info.suggestion):lower()
if searchText:find(keyword, 1, true) then
table.insert(result, {
code = code,
fullCode = info.code,
description = info.description,
suggestion = info.suggestion,
category = ErrorCodes.categories[info.category],
})
end
end
return result
end
--- Get all error codes
--- @return table codes All error codes
function ErrorCodes.listAll()
local result = {}
for code, info in pairs(ErrorCodes.codes) do
table.insert(result, {
code = code,
fullCode = info.code,
description = info.description,
suggestion = info.suggestion,
category = ErrorCodes.categories[info.category],
})
end
table.sort(result, function(a, b)
return a.code < b.code
end)
return result
end
--- Format error message with code
--- @param code string Error code
--- @param message string Error message
--- @return string formattedMessage Formatted error message with code
function ErrorCodes.formatMessage(code, message)
local info = ErrorCodes.get(code)
if info then
return string.format("[%s] %s", info.code, message)
end
return message
end
--- Validate that all error codes are unique and properly formatted
--- @return boolean, string? Returns true if valid, or false with error message
function ErrorCodes.validate()
local seen = {}
local fullCodes = {}
for code, info in pairs(ErrorCodes.codes) do
-- Check for duplicates
if seen[code] then
return false, "Duplicate error code: " .. code
end
seen[code] = true
if fullCodes[info.code] then
return false, "Duplicate full error code: " .. info.code
end
fullCodes[info.code] = true
-- Check format
if not code:match("^[A-Z]+_[0-9]+$") then
return false, "Invalid code format: " .. code .. " (expected CATEGORY_NUMBER)"
end
-- Check full code format
local expectedFullCode = "FLEXLOVE_" .. code
if info.code ~= expectedFullCode then
return false, "Mismatched full code for " .. code .. ": expected " .. expectedFullCode .. ", got " .. info.code
end
-- Check required fields
if not info.description or info.description == "" then
return false, "Missing description for " .. code
end
if not info.suggestion or info.suggestion == "" then
return false, "Missing suggestion for " .. code
end
if not info.category or info.category == "" then
return false, "Missing category for " .. code
end
end
return true, nil
end
return ErrorCodes

435
modules/Performance.lua Normal file
View File

@@ -0,0 +1,435 @@
--- Performance monitoring module for FlexLove
--- Provides timing, profiling, and performance metrics
---@class Performance
local Performance = {}
-- Configuration
local config = {
enabled = false,
hudEnabled = false,
hudToggleKey = "f3",
warningThresholdMs = 13.0, -- Yellow warning
criticalThresholdMs = 16.67, -- Red warning (60 FPS)
logToConsole = false,
logWarnings = true,
}
-- State
local timers = {} -- Active timers {name -> startTime}
local metrics = {} -- Accumulated metrics {name -> {total, count, min, max}}
local frameMetrics = {
frameCount = 0,
totalTime = 0,
lastFrameTime = 0,
minFrameTime = math.huge,
maxFrameTime = 0,
fps = 0,
lastFpsUpdate = 0,
fpsUpdateInterval = 0.5, -- Update FPS every 0.5s
}
local memoryMetrics = {
current = 0,
peak = 0,
gcCount = 0,
lastGcCheck = 0,
}
local warnings = {}
local lastFrameStart = nil
--- Initialize performance monitoring
--- @param options table? Optional configuration overrides
function Performance.init(options)
if options then
for k, v in pairs(options) do
config[k] = v
end
end
Performance.reset()
end
--- Enable performance monitoring
function Performance.enable()
config.enabled = true
end
--- Disable performance monitoring
function Performance.disable()
config.enabled = false
end
--- Check if performance monitoring is enabled
--- @return boolean
function Performance.isEnabled()
return config.enabled
end
--- Toggle performance HUD
function Performance.toggleHUD()
config.hudEnabled = not config.hudEnabled
end
--- Reset all metrics
function Performance.reset()
timers = {}
metrics = {}
warnings = {}
frameMetrics.frameCount = 0
frameMetrics.totalTime = 0
frameMetrics.lastFrameTime = 0
frameMetrics.minFrameTime = math.huge
frameMetrics.maxFrameTime = 0
memoryMetrics.current = 0
memoryMetrics.peak = 0
memoryMetrics.gcCount = 0
end
--- Start a named timer
--- @param name string Timer name
function Performance.startTimer(name)
if not config.enabled then
return
end
timers[name] = love.timer.getTime()
end
--- Stop a named timer and record the elapsed time
--- @param name string Timer name
--- @return number? elapsedMs Elapsed time in milliseconds, or nil if timer not found
function Performance.stopTimer(name)
if not config.enabled then
return nil
end
local startTime = timers[name]
if not startTime then
if config.logWarnings then
print(string.format("[Performance] Warning: Timer '%s' was not started", name))
end
return nil
end
local elapsed = (love.timer.getTime() - startTime) * 1000 -- Convert to ms
timers[name] = nil
-- Update metrics
if not metrics[name] then
metrics[name] = {
total = 0,
count = 0,
min = math.huge,
max = 0,
average = 0,
}
end
local m = metrics[name]
m.total = m.total + elapsed
m.count = m.count + 1
m.min = math.min(m.min, elapsed)
m.max = math.max(m.max, elapsed)
m.average = m.total / m.count
-- Check for warnings
if elapsed > config.criticalThresholdMs then
Performance.addWarning(name, elapsed, "critical")
elseif elapsed > config.warningThresholdMs then
Performance.addWarning(name, elapsed, "warning")
end
if config.logToConsole then
print(string.format("[Performance] %s: %.3fms", name, elapsed))
end
return elapsed
end
--- Wrap a function with performance timing
--- @param name string Metric name
--- @param fn function Function to measure
--- @return function Wrapped function
function Performance.measure(name, fn)
if not config.enabled then
return fn
end
return function(...)
Performance.startTimer(name)
local results = table.pack(fn(...))
Performance.stopTimer(name)
return table.unpack(results, 1, results.n)
end
end
--- Start frame timing (call at beginning of frame)
function Performance.startFrame()
if not config.enabled then
return
end
lastFrameStart = love.timer.getTime()
Performance.updateMemory()
end
--- End frame timing (call at end of frame)
function Performance.endFrame()
if not config.enabled or not lastFrameStart then
return
end
local now = love.timer.getTime()
local frameTime = (now - lastFrameStart) * 1000 -- ms
frameMetrics.lastFrameTime = frameTime
frameMetrics.totalTime = frameMetrics.totalTime + frameTime
frameMetrics.frameCount = frameMetrics.frameCount + 1
frameMetrics.minFrameTime = math.min(frameMetrics.minFrameTime, frameTime)
frameMetrics.maxFrameTime = math.max(frameMetrics.maxFrameTime, frameTime)
-- Update FPS
if now - frameMetrics.lastFpsUpdate >= frameMetrics.fpsUpdateInterval then
frameMetrics.fps = math.floor(1000 / frameTime + 0.5)
frameMetrics.lastFpsUpdate = now
end
-- Check for frame drops
if frameTime > config.criticalThresholdMs then
Performance.addWarning("frame", frameTime, "critical")
end
end
--- Update memory metrics
function Performance.updateMemory()
if not config.enabled then
return
end
local memKb = collectgarbage("count")
memoryMetrics.current = memKb
memoryMetrics.peak = math.max(memoryMetrics.peak, memKb)
-- Track GC cycles
local now = love.timer.getTime()
if now - memoryMetrics.lastGcCheck >= 1.0 then
memoryMetrics.gcCount = memoryMetrics.gcCount + 1
memoryMetrics.lastGcCheck = now
end
end
--- Add a performance warning
--- @param name string Metric name
--- @param value number Metric value
--- @param level "warning"|"critical" Warning level
function Performance.addWarning(name, value, level)
if not config.logWarnings then
return
end
table.insert(warnings, {
name = name,
value = value,
level = level,
time = love.timer.getTime(),
})
-- Keep only last 100 warnings
if #warnings > 100 then
table.remove(warnings, 1)
end
end
--- Get current FPS
--- @return number fps Frames per second
function Performance.getFPS()
return frameMetrics.fps
end
--- Get frame metrics
--- @return table frameMetrics Frame timing data
function Performance.getFrameMetrics()
return {
fps = frameMetrics.fps,
lastFrameTime = frameMetrics.lastFrameTime,
minFrameTime = frameMetrics.minFrameTime,
maxFrameTime = frameMetrics.maxFrameTime,
averageFrameTime = frameMetrics.frameCount > 0 and frameMetrics.totalTime / frameMetrics.frameCount or 0,
frameCount = frameMetrics.frameCount,
}
end
--- Get memory metrics
--- @return table memoryMetrics Memory usage data
function Performance.getMemoryMetrics()
Performance.updateMemory()
return {
currentKb = memoryMetrics.current,
currentMb = memoryMetrics.current / 1024,
peakKb = memoryMetrics.peak,
peakMb = memoryMetrics.peak / 1024,
gcCount = memoryMetrics.gcCount,
}
end
--- Get all performance metrics
--- @return table metrics All collected metrics
function Performance.getMetrics()
local result = {
frame = Performance.getFrameMetrics(),
memory = Performance.getMemoryMetrics(),
timings = {},
}
for name, data in pairs(metrics) do
result.timings[name] = {
average = data.average,
min = data.min,
max = data.max,
total = data.total,
count = data.count,
}
end
return result
end
--- Get recent warnings
--- @param count number? Number of warnings to return (default: 10)
--- @return table warnings Recent warnings
function Performance.getWarnings(count)
count = count or 10
local result = {}
local start = math.max(1, #warnings - count + 1)
for i = start, #warnings do
table.insert(result, warnings[i])
end
return result
end
--- Export metrics to JSON format
--- @return string json JSON string of metrics
function Performance.exportJSON()
local allMetrics = Performance.getMetrics()
-- Simple JSON encoding (for more complex needs, use a JSON library)
local json = "{\n"
json = json .. string.format(' "fps": %d,\n', allMetrics.frame.fps)
json = json .. string.format(' "averageFrameTime": %.3f,\n', allMetrics.frame.averageFrameTime)
json = json .. string.format(' "memoryMb": %.2f,\n', allMetrics.memory.currentMb)
json = json .. ' "timings": {\n'
local timingPairs = {}
for name, data in pairs(allMetrics.timings) do
table.insert(
timingPairs,
string.format(' "%s": {"average": %.3f, "min": %.3f, "max": %.3f, "count": %d}', name, data.average, data.min, data.max, data.count)
)
end
json = json .. table.concat(timingPairs, ",\n") .. "\n"
json = json .. " }\n"
json = json .. "}"
return json
end
--- Export metrics to CSV format
--- @return string csv CSV string of metrics
function Performance.exportCSV()
local csv = "Name,Average (ms),Min (ms),Max (ms),Count\n"
for name, data in pairs(metrics) do
csv = csv .. string.format("%s,%.3f,%.3f,%.3f,%d\n", name, data.average, data.min, data.max, data.count)
end
return csv
end
--- Render performance HUD
--- @param x number? X position (default: 10)
--- @param y number? Y position (default: 10)
function Performance.renderHUD(x, y)
if not config.hudEnabled then
return
end
x = x or 10
y = y or 10
local fm = Performance.getFrameMetrics()
local mm = Performance.getMemoryMetrics()
-- Background
love.graphics.setColor(0, 0, 0, 0.8)
love.graphics.rectangle("fill", x, y, 300, 200)
-- Text
love.graphics.setColor(1, 1, 1, 1)
local lineHeight = 18
local currentY = y + 10
-- FPS
local fpsColor = { 1, 1, 1 }
if fm.lastFrameTime > config.criticalThresholdMs then
fpsColor = { 1, 0, 0 } -- Red
elseif fm.lastFrameTime > config.warningThresholdMs then
fpsColor = { 1, 1, 0 } -- Yellow
end
love.graphics.setColor(fpsColor)
love.graphics.print(string.format("FPS: %d (%.2fms)", fm.fps, fm.lastFrameTime), x + 10, currentY)
currentY = currentY + lineHeight
-- Frame times
love.graphics.setColor(1, 1, 1, 1)
love.graphics.print(string.format("Avg Frame: %.2fms", fm.averageFrameTime), x + 10, currentY)
currentY = currentY + lineHeight
love.graphics.print(string.format("Min/Max: %.2f/%.2fms", fm.minFrameTime, fm.maxFrameTime), x + 10, currentY)
currentY = currentY + lineHeight
-- Memory
love.graphics.print(string.format("Memory: %.2f MB (peak: %.2f MB)", mm.currentMb, mm.peakMb), x + 10, currentY)
currentY = currentY + lineHeight
-- Separator
currentY = currentY + 5
-- Top timings
local sortedMetrics = {}
for name, data in pairs(metrics) do
table.insert(sortedMetrics, { name = name, average = data.average })
end
table.sort(sortedMetrics, function(a, b)
return a.average > b.average
end)
love.graphics.print("Top Timings:", x + 10, currentY)
currentY = currentY + lineHeight
for i = 1, math.min(5, #sortedMetrics) do
local m = sortedMetrics[i]
love.graphics.print(string.format(" %s: %.3fms", m.name, m.average), x + 10, currentY)
currentY = currentY + lineHeight
end
-- Warnings count
if #warnings > 0 then
love.graphics.setColor(1, 0.5, 0, 1)
love.graphics.print(string.format("Warnings: %d", #warnings), x + 10, currentY)
end
end
--- Handle keyboard input for HUD toggle
--- @param key string Key pressed
function Performance.keypressed(key)
if key == config.hudToggleKey then
Performance.toggleHUD()
end
end
--- Get configuration
--- @return table config Current configuration
function Performance.getConfig()
return config
end
--- Set configuration option
--- @param key string Configuration key
--- @param value any Configuration value
function Performance.setConfig(key, value)
config[key] = value
end
return Performance

View File

@@ -13,6 +13,10 @@ local utf8 = utf8 or require("utf8")
---@field scrollable boolean
---@field autoGrow boolean
---@field selectOnFocus boolean
---@field sanitize boolean
---@field allowNewlines boolean
---@field allowTabs boolean
---@field customSanitizer function?
---@field cursorColor Color?
---@field selectionColor Color?
---@field cursorBlinkRate number
@@ -37,12 +41,14 @@ local utf8 = utf8 or require("utf8")
---@field onTextInput fun(element:Element, text:string)?
---@field onTextChange fun(element:Element, text:string)?
---@field onEnter fun(element:Element)?
---@field onSanitize fun(element:Element, original:string, sanitized:string)?
---@field _element Element?
---@field _Context table
---@field _StateManager table
---@field _Color table
---@field _FONT_CACHE table
---@field _getModifiers function
---@field _utils table
---@field _textDragOccurred boolean?
local TextEditor = {}
TextEditor.__index = TextEditor
@@ -60,6 +66,10 @@ TextEditor.__index = TextEditor
---@field scrollable boolean -- Whether text is scrollable
---@field autoGrow boolean -- Whether element auto-grows with text
---@field selectOnFocus boolean -- Whether to select all text on focus
---@field sanitize boolean? -- Whether to sanitize text input (default: true)
---@field allowNewlines boolean? -- Whether to allow newline characters (default: true in multiline)
---@field allowTabs boolean? -- Whether to allow tab characters (default: true)
---@field customSanitizer function? -- Custom sanitization function
---@field cursorColor Color? -- Cursor color
---@field selectionColor Color? -- Selection background color
---@field cursorBlinkRate number -- Cursor blink rate in seconds
@@ -77,6 +87,7 @@ function TextEditor.new(config, deps)
self._Color = deps.Color
self._FONT_CACHE = deps.utils.FONT_CACHE
self._getModifiers = deps.utils.getModifiers
self._utils = deps.utils
-- Store configuration
self.editable = config.editable or false
@@ -94,9 +105,21 @@ function TextEditor.new(config, deps)
self.cursorColor = config.cursorColor
self.selectionColor = config.selectionColor
self.cursorBlinkRate = config.cursorBlinkRate or 0.5
-- Sanitization configuration
self.sanitize = config.sanitize ~= false -- Default to true
-- If allowNewlines is explicitly set, use that value; otherwise follow multiline setting
if config.allowNewlines ~= nil then
self.allowNewlines = config.allowNewlines
else
self.allowNewlines = self.multiline
end
self.allowTabs = config.allowTabs ~= false -- Default to true
self.customSanitizer = config.customSanitizer
-- Initialize text buffer state
self._textBuffer = config.text or ""
-- Initialize text buffer state (with sanitization)
local initialText = config.text or ""
self._textBuffer = self:_sanitizeText(initialText)
self._lines = nil
self._wrappedLines = nil
self._textDirty = true
@@ -127,6 +150,7 @@ function TextEditor.new(config, deps)
self.onTextInput = config.onTextInput
self.onTextChange = config.onTextChange
self.onEnter = config.onEnter
self.onSanitize = config.onSanitize
-- Element reference (set via initialize)
self._element = nil
@@ -134,6 +158,36 @@ function TextEditor.new(config, deps)
return self
end
---Internal: Sanitize text input
---@param text string -- Text to sanitize
---@return string -- Sanitized text
function TextEditor:_sanitizeText(text)
if not self.sanitize then
return text
end
-- Use custom sanitizer if provided
if self.customSanitizer then
return self.customSanitizer(text) or text
end
local options = {
maxLength = self.maxLength,
allowNewlines = self.allowNewlines,
allowTabs = self.allowTabs,
trimWhitespace = false -- Preserve whitespace in text editors
}
local sanitized = self._utils.sanitizeText(text, options)
-- Trigger callback if text was sanitized
if sanitized ~= text and self.onSanitize and self._element then
self.onSanitize(self._element, text, sanitized)
end
return sanitized
end
---Initialize TextEditor with parent element reference
---@param element table The parent Element instance
function TextEditor:initialize(element)
@@ -187,8 +241,16 @@ end
---Set text buffer and mark dirty
---@param text string
function TextEditor:setText(text)
self._textBuffer = text or ""
---@param skipSanitization boolean? -- Skip sanitization (for trusted input)
function TextEditor:setText(text, skipSanitization)
text = text or ""
-- Sanitize text unless explicitly skipped
if not skipSanitization then
text = self:_sanitizeText(text)
end
self._textBuffer = text
self:_markTextDirty()
self:_updateTextIfDirty()
self:_validateCursorPosition()
@@ -198,9 +260,20 @@ end
---Insert text at position
---@param text string -- Text to insert
---@param position number? -- Position to insert at (default: cursor position)
function TextEditor:insertText(text, position)
---@param skipSanitization boolean? -- Skip sanitization (for internal use)
function TextEditor:insertText(text, position, skipSanitization)
position = position or self._cursorPosition
local buffer = self._textBuffer or ""
-- Sanitize text unless explicitly skipped
if not skipSanitization then
text = self:_sanitizeText(text)
end
-- Check if text is empty after sanitization
if not text or text == "" then
return
end
-- Check maxLength constraint before inserting
if self.maxLength then
@@ -209,7 +282,22 @@ function TextEditor:insertText(text, position)
local newLength = currentLength + textLength
if newLength > self.maxLength then
return
-- Truncate text to fit
local remaining = self.maxLength - currentLength
if remaining <= 0 then
return
end
-- Truncate to remaining characters
local truncated = ""
local count = 0
for _, code in utf8.codes(text) do
if count >= remaining then
break
end
truncated = truncated .. utf8.char(code)
count = count + 1
end
text = truncated
end
end

View File

@@ -188,7 +188,29 @@ function Units.isValid(unitStr)
return false
end
local _, unit = Units.parse(unitStr)
-- Check for invalid format (space between number and unit)
if unitStr:match("%d%s+%a") then
return false
end
-- Match number followed by optional unit
local numStr, unit = unitStr:match("^([%-]?[%d%.]+)(.*)$")
if not numStr then
return false
end
-- Check if numeric part is valid
local num = tonumber(numStr)
if not num then
return false
end
-- Default to pixels if no unit specified
if unit == "" then
unit = "px"
end
-- Check if unit is valid
local validUnits = { px = true, ["%"] = true, vw = true, vh = true, ew = true, eh = true }
return validUnits[unit] == true
end

View File

@@ -429,6 +429,525 @@ local function normalizeBooleanTable(value, defaultValue)
return { vertical = defaultValue, horizontal = defaultValue }
end
-- Text sanitization utilities
--- Sanitize text to prevent security vulnerabilities
--- @param text string? Text to sanitize
--- @param options table? Sanitization options
--- @return string Sanitized text
local function sanitizeText(text, options)
-- Handle nil or non-string inputs
if text == nil then
return ""
end
if type(text) ~= "string" then
text = tostring(text)
end
-- Default options
options = options or {}
local maxLength = options.maxLength or 10000
local allowNewlines = options.allowNewlines ~= false -- default true
local allowTabs = options.allowTabs ~= false -- default true
local stripControls = options.stripControls ~= false -- default true
local trimWhitespace = options.trimWhitespace ~= false -- default true
-- Remove null bytes (critical security risk)
text = text:gsub("%z", "")
-- Strip control characters except allowed ones
if stripControls then
local pattern = "[\1-\31\127]" -- All control characters
if allowNewlines and allowTabs then
pattern = "[\1-\8\11\12\14-\31\127]" -- Exclude \t (9), \n (10), \r (13)
elseif allowNewlines then
pattern = "[\1-\9\11\12\14-\31\127]" -- Exclude \n (10), \r (13)
elseif allowTabs then
pattern = "[\1-\8\10\12-\31\127]" -- Exclude \t (9)
end
text = text:gsub(pattern, "")
end
-- Trim leading/trailing whitespace
if trimWhitespace then
text = text:match("^%s*(.-)%s*$") or ""
end
-- Limit string length
if #text > maxLength then
text = text:sub(1, maxLength)
if ErrorHandler then
ErrorHandler.warn("utils", string.format("Text truncated from %d to %d characters", #text, maxLength))
end
end
return text
end
--- Validate text input against rules
--- @param text string Text to validate
--- @param rules table Validation rules
--- @return boolean, string? Returns true if valid, or false with error message
local function validateTextInput(text, rules)
rules = rules or {}
-- Check minimum length
if rules.minLength and #text < rules.minLength then
return false, string.format("Text must be at least %d characters", rules.minLength)
end
-- Check maximum length
if rules.maxLength and #text > rules.maxLength then
return false, string.format("Text must be at most %d characters", rules.maxLength)
end
-- Check pattern match
if rules.pattern and not text:match(rules.pattern) then
return false, rules.patternError or "Text does not match required pattern"
end
-- Check character whitelist
if rules.allowedChars then
local pattern = "[^" .. rules.allowedChars .. "]"
if text:match(pattern) then
return false, "Text contains invalid characters"
end
end
-- Check character blacklist
if rules.forbiddenChars then
local pattern = "[" .. rules.forbiddenChars .. "]"
if text:match(pattern) then
return false, "Text contains forbidden characters"
end
end
return true, nil
end
--- Escape HTML special characters
--- @param text string Text to escape
--- @return string Escaped text
local function escapeHtml(text)
if text == nil then
return ""
end
text = tostring(text)
text = text:gsub("&", "&amp;")
text = text:gsub("<", "&lt;")
text = text:gsub(">", "&gt;")
text = text:gsub('"', "&quot;")
text = text:gsub("'", "&#39;")
return text
end
--- Escape Lua pattern special characters
--- @param text string Text to escape
--- @return string Escaped text
local function escapeLuaPattern(text)
if text == nil then
return ""
end
text = tostring(text)
-- Escape all Lua pattern special characters
text = text:gsub("([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1")
return text
end
--- Strip all non-printable characters from text
--- @param text string Text to clean
--- @return string Cleaned text
local function stripNonPrintable(text)
if text == nil then
return ""
end
text = tostring(text)
-- Keep printable ASCII (32-126), newline (10), tab (9), and carriage return (13)
text = text:gsub("[^\9\10\13\32-\126]", "")
return text
end
-- Path validation utilities
--- Sanitize a file path
--- @param path string Path to sanitize
--- @return string Sanitized path
local function sanitizePath(path)
if path == nil then
return ""
end
path = tostring(path)
-- Trim whitespace
path = path:match("^%s*(.-)%s*$") or ""
-- Normalize separators to forward slash
path = path:gsub("\\", "/")
-- Remove duplicate slashes
path = path:gsub("/+", "/")
-- Remove trailing slash (except for root)
if #path > 1 and path:sub(-1) == "/" then
path = path:sub(1, -2)
end
return path
end
--- Check if a path is safe (no traversal attacks)
--- @param path string Path to check
--- @param baseDir string? Base directory to check against (optional)
--- @return boolean, string? Returns true if safe, or false with reason
local function isPathSafe(path, baseDir)
if path == nil or path == "" then
return false, "Path is empty"
end
-- Sanitize the path
path = sanitizePath(path)
-- Check for suspicious patterns
if path:match("%.%.") then
return false, "Path contains '..' (parent directory reference)"
end
-- Check for null bytes
if path:match("%z") then
return false, "Path contains null bytes"
end
-- Check for encoded traversal attempts (including double-encoding)
local lowerPath = path:lower()
if lowerPath:match("%%2e") or lowerPath:match("%%2f") or lowerPath:match("%%5c") or
lowerPath:match("%%252e") or lowerPath:match("%%252f") or lowerPath:match("%%255c") then
return false, "Path contains URL-encoded directory separators"
end
-- If baseDir is provided, ensure path is within it
if baseDir then
baseDir = sanitizePath(baseDir)
-- For relative paths, prepend baseDir
local fullPath = path
if not path:match("^/") and not path:match("^%a:") then
fullPath = baseDir .. "/" .. path
end
fullPath = sanitizePath(fullPath)
-- Check if fullPath starts with baseDir
if not fullPath:match("^" .. baseDir:gsub("[%(%)%.%%%+%-%*%?%[%]%^%$]", "%%%1")) then
return false, "Path is outside allowed directory"
end
end
return true, nil
end
--- Validate a file path with comprehensive checks
--- @param path string Path to validate
--- @param options table? Validation options
--- @return boolean, string? Returns true if valid, or false with error message
local function validatePath(path, options)
options = options or {}
-- Check path is not nil/empty
if path == nil or path == "" then
return false, "Path is empty"
end
path = tostring(path)
-- Check maximum length
local maxLength = options.maxLength or 4096
if #path > maxLength then
return false, string.format("Path exceeds maximum length of %d characters", maxLength)
end
-- Sanitize path
path = sanitizePath(path)
-- Check for safety (traversal attacks)
local safe, reason = isPathSafe(path, options.baseDir)
if not safe then
return false, reason
end
-- Check allowed extensions
if options.allowedExtensions then
local ext = path:match("%.([^%.]+)$")
if not ext then
return false, "Path has no file extension"
end
ext = ext:lower()
local allowed = false
for _, allowedExt in ipairs(options.allowedExtensions) do
if ext == allowedExt:lower() then
allowed = true
break
end
end
if not allowed then
return false, string.format("File extension '%s' is not allowed", ext)
end
end
-- Check if file must exist
if options.mustExist and love and love.filesystem then
local info = love.filesystem.getInfo(path)
if not info then
return false, "File does not exist"
end
end
return true, nil
end
--- Get file extension from path
--- @param path string File path
--- @return string? extension File extension (lowercase) or nil
local function getFileExtension(path)
if not path then
return nil
end
local ext = path:match("%.([^%.]+)$")
return ext and ext:lower() or nil
end
--- Check if path has allowed extension
--- @param path string File path
--- @param allowedExtensions table Array of allowed extensions
--- @return boolean
local function hasAllowedExtension(path, allowedExtensions)
local ext = getFileExtension(path)
if not ext then
return false
end
for _, allowedExt in ipairs(allowedExtensions) do
if ext == allowedExt:lower() then
return true
end
end
return false
end
-- Numeric validation utilities
--- Check if a value is NaN (not-a-number)
--- @param value any Value to check
--- @return boolean
local function isNaN(value)
return type(value) == "number" and value ~= value
end
--- Check if a value is Infinity
--- @param value any Value to check
--- @return boolean
local function isInfinity(value)
return type(value) == "number" and (value == math.huge or value == -math.huge)
end
--- Validate a numeric value with comprehensive checks
--- @param value any Value to validate
--- @param options table? Validation options
--- @return boolean, string?, number? Returns valid, errorMessage, sanitizedValue
local function validateNumber(value, options)
options = options or {}
-- Check if value is a number type
if type(value) ~= "number" then
if options.default ~= nil then
return true, nil, options.default
end
return false, string.format("Value must be a number, got %s", type(value)), nil
end
-- Check for NaN
if isNaN(value) then
if not options.allowNaN then
if options.default ~= nil then
return true, nil, options.default
end
return false, "Value is NaN (not-a-number)", nil
end
end
-- Check for Infinity
if isInfinity(value) then
if not options.allowInfinity then
if options.default ~= nil then
return true, nil, options.default
end
return false, "Value is Infinity", nil
end
end
-- Check for integer requirement
if options.integer and math.floor(value) ~= value then
return false, string.format("Value must be an integer, got %s", value), nil
end
-- Check for positive requirement
if options.positive and value <= 0 then
return false, string.format("Value must be positive, got %s", value), nil
end
-- Check bounds
if options.min and value < options.min then
return false, string.format("Value %s is below minimum %s", value, options.min), nil
end
if options.max and value > options.max then
return false, string.format("Value %s is above maximum %s", value, options.max), nil
end
return true, nil, value
end
--- Sanitize a numeric value (never errors, always returns valid number)
--- @param value any Value to sanitize
--- @param min number? Minimum value
--- @param max number? Maximum value
--- @param default number? Default value for invalid inputs
--- @return number Sanitized value
local function sanitizeNumber(value, min, max, default)
default = default or 0
min = min or -math.huge
max = max or math.huge
-- Convert to number if possible
if type(value) == "string" then
value = tonumber(value)
end
-- Handle non-numeric
if type(value) ~= "number" then
return default
end
-- Handle NaN
if isNaN(value) then
return default
end
-- Handle Infinity
if value == math.huge then
return max
end
if value == -math.huge then
return min
end
-- Clamp to range
return clamp(value, min, max)
end
--- Validate and convert to integer
--- @param value any Value to validate
--- @param min number? Minimum value
--- @param max number? Maximum value
--- @return boolean, string?, number? Returns valid, errorMessage, integerValue
local function validateInteger(value, min, max)
local valid, err, sanitized = validateNumber(value, {
min = min,
max = max,
integer = true,
})
if not valid then
return false, err, nil
end
return true, nil, math.floor(sanitized or value)
end
--- Validate and normalize percentage value
--- @param value any Value to validate (can be "50%", 0.5, or 50)
--- @return boolean, string?, number? Returns valid, errorMessage, normalizedValue (0-1)
local function validatePercentage(value)
-- Handle string percentage
if type(value) == "string" then
local num = value:match("^(%d+%.?%d*)%%$")
if num then
value = tonumber(num)
if value then
value = value / 100
end
else
value = tonumber(value)
end
end
if type(value) ~= "number" then
return false, "Percentage must be a number", nil
end
if isNaN(value) or isInfinity(value) then
return false, "Percentage cannot be NaN or Infinity", nil
end
-- If value is > 1, assume it's 0-100 range
if value > 1 then
value = value / 100
end
-- Clamp to 0-1
value = clamp(value, 0, 1)
return true, nil, value
end
--- Validate opacity value (0-1)
--- @param value any Value to validate
--- @return boolean, string?, number? Returns valid, errorMessage, opacityValue
local function validateOpacity(value)
return validateNumber(value, { min = 0, max = 1, default = 1 })
end
--- Validate degree value (0-360)
--- @param value any Value to validate
--- @return boolean, string?, number? Returns valid, errorMessage, degreeValue
local function validateDegrees(value)
local valid, err, sanitized = validateNumber(value)
if not valid then
return false, err, nil
end
-- Normalize to 0-360 range
local degrees = sanitized or value
degrees = degrees % 360
if degrees < 0 then
degrees = degrees + 360
end
return true, nil, degrees
end
--- Validate coordinate value (pixel position)
--- @param value any Value to validate
--- @return boolean, string?, number? Returns valid, errorMessage, coordinateValue
local function validateCoordinate(value)
return validateNumber(value, {
allowNaN = false,
allowInfinity = false,
})
end
--- Validate dimension value (width/height, must be non-negative)
--- @param value any Value to validate
--- @return boolean, string?, number? Returns valid, errorMessage, dimensionValue
local function validateDimension(value)
return validateNumber(value, {
min = 0,
allowNaN = false,
allowInfinity = false,
})
end
return {
enums = enums,
FONT_CACHE = FONT_CACHE,
@@ -450,4 +969,27 @@ return {
resolveFontPath = resolveFontPath,
getFont = getFont,
applyContentMultiplier = applyContentMultiplier,
-- Text sanitization
sanitizeText = sanitizeText,
validateTextInput = validateTextInput,
escapeHtml = escapeHtml,
escapeLuaPattern = escapeLuaPattern,
stripNonPrintable = stripNonPrintable,
-- Path validation
sanitizePath = sanitizePath,
isPathSafe = isPathSafe,
validatePath = validatePath,
getFileExtension = getFileExtension,
hasAllowedExtension = hasAllowedExtension,
-- Numeric validation
isNaN = isNaN,
isInfinity = isInfinity,
validateNumber = validateNumber,
sanitizeNumber = sanitizeNumber,
validateInteger = validateInteger,
validatePercentage = validatePercentage,
validateOpacity = validateOpacity,
validateDegrees = validateDegrees,
validateCoordinate = validateCoordinate,
validateDimension = validateDimension,
}