added logging support
This commit is contained in:
+355
@@ -0,0 +1,355 @@
|
||||
--[[
|
||||
fmt.lua - Go's fmt.Printf() style formatting for Lua
|
||||
|
||||
Supported verbs:
|
||||
%v - default format (tostring)
|
||||
%T - type of the value
|
||||
%t - boolean (true/false)
|
||||
%d - integer (decimal)
|
||||
%b - integer (binary)
|
||||
%o - integer (octal)
|
||||
%x - integer (hex, lowercase)
|
||||
%X - integer (hex, uppercase)
|
||||
%e - float (scientific, lowercase)
|
||||
%E - float (scientific, uppercase)
|
||||
%f - float (decimal point)
|
||||
%g - float (%e for large, %f otherwise)
|
||||
%G - float (%E for large, %f otherwise)
|
||||
%s - string
|
||||
%q - quoted string (Lua-escaped)
|
||||
%c - character (from integer codepoint)
|
||||
%% - literal percent sign
|
||||
|
||||
Width & precision:
|
||||
%8d - right-aligned, width 8
|
||||
%-8d - left-aligned, width 8
|
||||
%08d - zero-padded, width 8
|
||||
%8.2f - width 8, 2 decimal places
|
||||
%.5s - truncate string to 5 chars
|
||||
%*d - width from next argument
|
||||
%.*f - precision from next argument
|
||||
--]]
|
||||
|
||||
local fmt = {}
|
||||
|
||||
-- ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
local function to_int(v)
|
||||
local n = math.tointeger and math.tointeger(v) or (type(v) == "number" and math.floor(v) or nil)
|
||||
if n == nil then
|
||||
error(string.format("fmt: expected integer, got %s (%s)", type(v), tostring(v)), 3)
|
||||
end
|
||||
return n
|
||||
end
|
||||
|
||||
local function to_num(v)
|
||||
if type(v) ~= "number" then
|
||||
error(string.format("fmt: expected number, got %s (%s)", type(v), tostring(v)), 3)
|
||||
end
|
||||
return v
|
||||
end
|
||||
|
||||
-- Convert integer to binary string
|
||||
local function to_binary(n)
|
||||
n = to_int(n)
|
||||
if n == 0 then return "0" end
|
||||
local neg = n < 0
|
||||
if neg then n = -n end
|
||||
local bits = {}
|
||||
while n > 0 do
|
||||
table.insert(bits, 1, n % 2)
|
||||
n = math.floor(n / 2)
|
||||
end
|
||||
local s = table.concat(bits)
|
||||
return neg and "-" .. s or s
|
||||
end
|
||||
|
||||
-- Apply width/alignment padding
|
||||
local function apply_width(s, width, left_align, zero_pad, is_numeric)
|
||||
if not width or width == 0 or #s >= width then return s end
|
||||
local pad_char = (zero_pad and is_numeric) and "0" or " "
|
||||
local pad = string.rep(pad_char, width - #s)
|
||||
if zero_pad and is_numeric then
|
||||
-- keep sign before zeros: "-007"
|
||||
if s:sub(1,1) == "-" then
|
||||
return "-" .. pad .. s:sub(2)
|
||||
end
|
||||
return pad .. s
|
||||
end
|
||||
return left_align and (s .. pad) or (pad .. s)
|
||||
end
|
||||
|
||||
-- Apply precision to a string (truncation)
|
||||
local function apply_str_precision(s, prec)
|
||||
if prec and #s > prec then
|
||||
return s:sub(1, prec)
|
||||
end
|
||||
return s
|
||||
end
|
||||
|
||||
-- Quoted / escaped string (like Go %q)
|
||||
local function quote_string(s)
|
||||
s = s:gsub('\\', '\\\\')
|
||||
s = s:gsub('"', '\\"')
|
||||
s = s:gsub('\n', '\\n')
|
||||
s = s:gsub('\r', '\\r')
|
||||
s = s:gsub('\t', '\\t')
|
||||
s = s:gsub('%z', '\\0')
|
||||
return '"' .. s .. '"'
|
||||
end
|
||||
|
||||
-- ── core formatter ────────────────────────────────────────────────────────────
|
||||
|
||||
--[[
|
||||
fmt.Sprintf(format, ...) → string
|
||||
Returns the formatted string without printing it.
|
||||
--]]
|
||||
function fmt.Sprintf(format, ...)
|
||||
local args = { ... }
|
||||
local ai = 1 -- argument index
|
||||
local out = {} -- output buffer
|
||||
|
||||
local i = 1
|
||||
local len = #format
|
||||
|
||||
while i <= len do
|
||||
local c = format:sub(i, i)
|
||||
|
||||
if c ~= "%" then
|
||||
out[#out+1] = c
|
||||
i = i + 1
|
||||
else
|
||||
i = i + 1
|
||||
if i > len then
|
||||
error("fmt: trailing '%' in format string", 2)
|
||||
end
|
||||
|
||||
-- %% literal
|
||||
if format:sub(i, i) == "%" then
|
||||
out[#out+1] = "%"
|
||||
i = i + 1
|
||||
else
|
||||
-- ── parse flags ───────────────────────────────────────────
|
||||
local left_align = false
|
||||
local zero_pad = false
|
||||
local plus_sign = false
|
||||
local space_sign = false
|
||||
local hash_flag = false
|
||||
|
||||
while i <= len do
|
||||
local f = format:sub(i, i)
|
||||
if f == "-" then left_align = true
|
||||
elseif f == "0" then zero_pad = true
|
||||
elseif f == "+" then plus_sign = true
|
||||
elseif f == " " then space_sign = true
|
||||
elseif f == "#" then hash_flag = true
|
||||
else break
|
||||
end
|
||||
i = i + 1
|
||||
end
|
||||
|
||||
-- ── parse width ───────────────────────────────────────────
|
||||
local width = nil
|
||||
if i <= len and format:sub(i, i) == "*" then
|
||||
width = to_int(args[ai]); ai = ai + 1
|
||||
i = i + 1
|
||||
else
|
||||
local w = format:match("^%d+", i)
|
||||
if w then width = tonumber(w); i = i + #w end
|
||||
end
|
||||
|
||||
-- ── parse precision ───────────────────────────────────────
|
||||
local prec = nil
|
||||
if i <= len and format:sub(i, i) == "." then
|
||||
i = i + 1
|
||||
if i <= len and format:sub(i, i) == "*" then
|
||||
prec = to_int(args[ai]); ai = ai + 1
|
||||
i = i + 1
|
||||
else
|
||||
local p = format:match("^%d*", i)
|
||||
prec = tonumber(p) or 0
|
||||
i = i + #p
|
||||
end
|
||||
end
|
||||
|
||||
-- ── parse verb ────────────────────────────────────────────
|
||||
if i > len then
|
||||
error("fmt: missing verb in format string", 2)
|
||||
end
|
||||
local verb = format:sub(i, i)
|
||||
i = i + 1
|
||||
|
||||
local arg = args[ai]; ai = ai + 1
|
||||
local s = ""
|
||||
local is_numeric = false
|
||||
|
||||
-- %v default
|
||||
if verb == "v" then
|
||||
s = tostring(arg)
|
||||
|
||||
-- %T type
|
||||
elseif verb == "T" then
|
||||
s = type(arg)
|
||||
|
||||
-- %t boolean
|
||||
elseif verb == "t" then
|
||||
s = arg and "true" or "false"
|
||||
|
||||
-- %d decimal integer
|
||||
elseif verb == "d" then
|
||||
is_numeric = true
|
||||
local n = to_int(arg)
|
||||
s = tostring(math.abs(n))
|
||||
if prec then s = string.rep("0", math.max(0, prec - #s)) .. s end
|
||||
if n < 0 then s = "-" .. s
|
||||
elseif plus_sign then s = "+" .. s
|
||||
elseif space_sign then s = " " .. s
|
||||
end
|
||||
|
||||
-- %b binary
|
||||
elseif verb == "b" then
|
||||
is_numeric = true
|
||||
s = to_binary(arg)
|
||||
if hash_flag then s = "0b" .. s end
|
||||
|
||||
-- %o octal
|
||||
elseif verb == "o" then
|
||||
is_numeric = true
|
||||
local n = to_int(arg)
|
||||
local neg = n < 0
|
||||
s = string.format("%o", math.abs(n))
|
||||
if prec then s = string.rep("0", math.max(0, prec - #s)) .. s end
|
||||
if hash_flag and s:sub(1,1) ~= "0" then s = "0" .. s end
|
||||
if neg then s = "-" .. s end
|
||||
|
||||
-- %x hex lowercase
|
||||
elseif verb == "x" then
|
||||
is_numeric = true
|
||||
local n = to_int(arg)
|
||||
local neg = n < 0
|
||||
s = string.format("%x", math.abs(n))
|
||||
if prec then s = string.rep("0", math.max(0, prec - #s)) .. s end
|
||||
if hash_flag then s = "0x" .. s end
|
||||
if neg then s = "-" .. s end
|
||||
|
||||
-- %X hex uppercase
|
||||
elseif verb == "X" then
|
||||
is_numeric = true
|
||||
local n = to_int(arg)
|
||||
local neg = n < 0
|
||||
s = string.format("%X", math.abs(n))
|
||||
if prec then s = string.rep("0", math.max(0, prec - #s)) .. s end
|
||||
if hash_flag then s = "0X" .. s end
|
||||
if neg then s = "-" .. s end
|
||||
|
||||
-- %e scientific lowercase
|
||||
elseif verb == "e" then
|
||||
is_numeric = true
|
||||
local p = prec ~= nil and prec or 6
|
||||
s = string.format("%." .. p .. "e", to_num(arg))
|
||||
if plus_sign and arg >= 0 then s = "+" .. s end
|
||||
|
||||
-- %E scientific uppercase
|
||||
elseif verb == "E" then
|
||||
is_numeric = true
|
||||
local p = prec ~= nil and prec or 6
|
||||
s = string.format("%." .. p .. "E", to_num(arg))
|
||||
if plus_sign and arg >= 0 then s = "+" .. s end
|
||||
|
||||
-- %f decimal float
|
||||
elseif verb == "f" then
|
||||
is_numeric = true
|
||||
local p = prec ~= nil and prec or 6
|
||||
s = string.format("%." .. p .. "f", to_num(arg))
|
||||
if plus_sign and arg >= 0 then s = "+" .. s end
|
||||
if space_sign and arg >= 0 then s = " " .. s end
|
||||
|
||||
-- %g / %G shortest float representation
|
||||
elseif verb == "g" or verb == "G" then
|
||||
is_numeric = true
|
||||
local p = prec ~= nil and prec or -1
|
||||
local n = to_num(arg)
|
||||
local abs_n = math.abs(n)
|
||||
if p == -1 then
|
||||
-- mimic Go: use %e if exponent < -4 or >= precision(default 6)
|
||||
if abs_n ~= 0 and (abs_n < 1e-4 or abs_n >= 1e6) then
|
||||
s = string.format(verb == "G" and "%.6E" or "%.6e", n)
|
||||
else
|
||||
s = string.format("%.6g", n)
|
||||
if verb == "G" then s = s:upper() end
|
||||
end
|
||||
else
|
||||
s = string.format("%." .. p .. (verb == "G" and "G" or "g"), n)
|
||||
end
|
||||
if plus_sign and n >= 0 then s = "+" .. s end
|
||||
|
||||
-- %s string
|
||||
elseif verb == "s" then
|
||||
s = tostring(arg)
|
||||
s = apply_str_precision(s, prec)
|
||||
|
||||
-- %q quoted string
|
||||
elseif verb == "q" then
|
||||
s = quote_string(tostring(arg))
|
||||
|
||||
-- %c character
|
||||
elseif verb == "c" then
|
||||
local n = to_int(arg)
|
||||
-- utf8.char available in Lua 5.3+
|
||||
if utf8 then
|
||||
s = utf8.char(n)
|
||||
else
|
||||
s = string.char(n) -- ASCII only fallback
|
||||
end
|
||||
|
||||
else
|
||||
error(string.format("fmt: unknown verb %%%s", verb), 2)
|
||||
end
|
||||
|
||||
-- Apply width padding
|
||||
s = apply_width(s, width, left_align, zero_pad, is_numeric)
|
||||
out[#out+1] = s
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return table.concat(out)
|
||||
end
|
||||
|
||||
--[[
|
||||
fmt.Printf(format, ...)
|
||||
Prints to stdout (no trailing newline, just like Go).
|
||||
--]]
|
||||
function fmt.Printf(format, ...)
|
||||
io.write(fmt.Sprintf(format, ...))
|
||||
end
|
||||
|
||||
--[[
|
||||
fmt.Println(...)
|
||||
Prints args separated by spaces with a trailing newline.
|
||||
--]]
|
||||
function fmt.Println(...)
|
||||
local parts = {}
|
||||
for i = 1, select("#", ...) do
|
||||
parts[i] = tostring(select(i, ...))
|
||||
end
|
||||
print(table.concat(parts, " "))
|
||||
end
|
||||
|
||||
--[[
|
||||
fmt.Fprintf(file, format, ...)
|
||||
Writes formatted output to a file handle.
|
||||
--]]
|
||||
function fmt.Fprintf(file, format, ...)
|
||||
file:write(fmt.Sprintf(format, ...))
|
||||
end
|
||||
|
||||
--[[
|
||||
fmt.Errorf(format, ...) → string
|
||||
Returns a formatted error string (for use with error()).
|
||||
--]]
|
||||
function fmt.Errorf(format, ...)
|
||||
return fmt.Sprintf(format, ...)
|
||||
end
|
||||
|
||||
return fmt
|
||||
@@ -0,0 +1,41 @@
|
||||
local yaml = require("utils/yaml")
|
||||
local loader = {}
|
||||
loader.__index = loader
|
||||
|
||||
local loaderLog = log:child("loader")
|
||||
|
||||
function loader:new(path_or_file)
|
||||
local c = {}
|
||||
setmetatable(c, loader)
|
||||
c.source = path_or_file
|
||||
local file = love.filesystem.read(path_or_file .. "/index.yml")
|
||||
|
||||
if not file then
|
||||
loaderLog:error("Unable to load file: %s/index.yml", path_or_file)
|
||||
end
|
||||
|
||||
c.index = yaml.parse(file)
|
||||
|
||||
for i,v in pairs(c.index.categories) do
|
||||
local link = love.filesystem.read(path_or_file .. "/" .. v.name .. ".yml")
|
||||
if not link then
|
||||
loaderLog:error("Error! Cannot find file: %s/%s.yml", path_or_file,v.name)
|
||||
else
|
||||
local category = yaml.parse(link)
|
||||
c.index.categories[i].questions = category.questions
|
||||
c.index.categories[i].global = category.global or {}
|
||||
c.index.categories[i].cosmetics = category.cosmetics or {}
|
||||
end
|
||||
end
|
||||
return c
|
||||
end
|
||||
|
||||
function loader:getCategories()
|
||||
return self.index.categories
|
||||
end
|
||||
|
||||
function loader:getSettings()
|
||||
return self.index.settings
|
||||
end
|
||||
|
||||
return loader
|
||||
@@ -0,0 +1,300 @@
|
||||
--- logger.lua — A feature-rich logging library for Lua
|
||||
-- Supports log levels, multiple handlers, formatting, and filtering.
|
||||
--
|
||||
-- Usage:
|
||||
-- local Logger = require("logger")
|
||||
-- local log = Logger.new("MyApp")
|
||||
-- log:info("Server started on port %d", 8080)
|
||||
-- log:warn("Disk usage at %d%%", 92)
|
||||
-- log:error("Connection refused: %s", err)
|
||||
|
||||
local Logger = {}
|
||||
Logger.__index = Logger
|
||||
|
||||
-- ── Level definitions ──────────────────────────────────────────────────────
|
||||
|
||||
Logger.LEVELS = {
|
||||
TRACE = 1,
|
||||
DEBUG = 2,
|
||||
INFO = 3,
|
||||
WARN = 4,
|
||||
ERROR = 5,
|
||||
FATAL = 6,
|
||||
OFF = 7, -- disable all output
|
||||
}
|
||||
|
||||
-- Reverse map: number → name
|
||||
local LEVEL_NAMES = {}
|
||||
for name, val in pairs(Logger.LEVELS) do
|
||||
LEVEL_NAMES[val] = name
|
||||
end
|
||||
|
||||
-- ANSI colour codes (used by the ConsoleHandler when colours are enabled)
|
||||
local COLOURS = {
|
||||
[1] = "\27[36m", -- TRACE cyan
|
||||
[2] = "\27[34m", -- DEBUG blue
|
||||
[3] = "\27[32m", -- INFO green
|
||||
[4] = "\27[33m", -- WARN yellow
|
||||
[5] = "\27[31m", -- ERROR red
|
||||
[6] = "\27[35m", -- FATAL magenta
|
||||
RESET = "\27[0m",
|
||||
}
|
||||
|
||||
-- ── Utility helpers ────────────────────────────────────────────────────────
|
||||
|
||||
--- Returns an ISO-8601-style timestamp: "2025-06-08 14:32:01"
|
||||
local function timestamp()
|
||||
return os.date("%Y-%m-%d %H:%M:%S")
|
||||
end
|
||||
|
||||
--- Safe string formatting — falls back gracefully when fmt is not a string
|
||||
local function safe_format(fmt, ...)
|
||||
if select("#", ...) == 0 then
|
||||
return tostring(fmt)
|
||||
end
|
||||
local ok, result = pcall(string.format, tostring(fmt), ...)
|
||||
return ok and result or (tostring(fmt) .. " " .. table.concat({...}, " "))
|
||||
end
|
||||
|
||||
--- Pads or truncates a string to a fixed width
|
||||
local function pad(s, width)
|
||||
s = tostring(s)
|
||||
if #s >= width then return s:sub(1, width) end
|
||||
return s .. string.rep(" ", width - #s)
|
||||
end
|
||||
|
||||
-- ── Built-in Handlers ──────────────────────────────────────────────────────
|
||||
|
||||
--- ConsoleHandler — writes to stdout (or stderr for ERROR+)
|
||||
-- Options:
|
||||
-- colours (bool, default true) — enable ANSI colours
|
||||
-- stderr (bool, default false) — always write to stderr
|
||||
local function ConsoleHandler(opts)
|
||||
opts = opts or {}
|
||||
local use_colour = (opts.colours ~= false)
|
||||
local force_stderr = opts.stderr or false
|
||||
|
||||
return function(record)
|
||||
local line = record.formatted
|
||||
if use_colour then
|
||||
local c = COLOURS[record.level] or ""
|
||||
line = c .. line .. COLOURS.RESET
|
||||
end
|
||||
local dest = (force_stderr or record.level >= Logger.LEVELS.ERROR) and io.stderr or io.stdout
|
||||
dest:write(line .. "\n")
|
||||
dest:flush()
|
||||
end
|
||||
end
|
||||
|
||||
--- FileHandler — appends log records to a file
|
||||
-- Options:
|
||||
-- path (string, required) — path to the log file
|
||||
-- max_size (number, optional) — rotate when file exceeds this many bytes
|
||||
-- max_files(number, optional) — keep this many rotated files (default 5)
|
||||
local function FileHandler(opts)
|
||||
assert(opts and opts.path, "FileHandler requires opts.path")
|
||||
local path = opts.path
|
||||
local max_size = opts.max_size -- nil = never rotate
|
||||
local max_files = opts.max_files or 5
|
||||
local file
|
||||
|
||||
local function open_file()
|
||||
file = assert(io.open(path, "a"), "Cannot open log file: " .. path)
|
||||
end
|
||||
|
||||
local function rotate()
|
||||
if file then file:close(); file = nil end
|
||||
-- shift old files: .4 → .5, .3 → .4, …, .1 → .2, base → .1
|
||||
for i = max_files - 1, 1, -1 do
|
||||
os.rename(path .. "." .. i, path .. "." .. (i + 1))
|
||||
end
|
||||
os.rename(path, path .. ".1")
|
||||
open_file()
|
||||
end
|
||||
|
||||
open_file()
|
||||
|
||||
return function(record)
|
||||
if not file then open_file() end
|
||||
file:write(record.formatted .. "\n")
|
||||
file:flush()
|
||||
if max_size then
|
||||
local pos = file:seek("cur")
|
||||
if pos and pos >= max_size then rotate() end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- MemoryHandler — stores records in a Lua table (useful for testing)
|
||||
-- Returns handler, get_records() function
|
||||
local function MemoryHandler(opts)
|
||||
opts = opts or {}
|
||||
local limit = opts.limit or 10000
|
||||
local records = {}
|
||||
|
||||
local handler = function(record)
|
||||
if #records >= limit then table.remove(records, 1) end
|
||||
table.insert(records, record)
|
||||
end
|
||||
|
||||
local function get_records() return records end
|
||||
local function clear() records = {} end
|
||||
|
||||
return handler, get_records, clear
|
||||
end
|
||||
|
||||
-- Expose built-ins
|
||||
Logger.handlers = {
|
||||
ConsoleHandler = ConsoleHandler,
|
||||
FileHandler = FileHandler,
|
||||
MemoryHandler = MemoryHandler,
|
||||
}
|
||||
|
||||
-- ── Formatter ─────────────────────────────────────────────────────────────
|
||||
-- Tokens in the format string:
|
||||
-- {time} — timestamp
|
||||
-- {level} — padded level name (5 chars)
|
||||
-- {name} — logger name
|
||||
-- {message} — the log message
|
||||
|
||||
local DEFAULT_FORMAT = "[{time}] [{level}] [{name}] {message}"
|
||||
|
||||
local function escape_replacement(s)
|
||||
-- In Lua's gsub, '%' in the replacement string is special; escape it.
|
||||
return (s:gsub("%%", "%%%%"))
|
||||
end
|
||||
|
||||
local function apply_format(fmt, record)
|
||||
return (fmt
|
||||
:gsub("{time}", escape_replacement(record.time))
|
||||
:gsub("{level}", escape_replacement(pad(record.level_name, 5)))
|
||||
:gsub("{name}", escape_replacement(record.name))
|
||||
:gsub("{message}", escape_replacement(record.message)))
|
||||
end
|
||||
|
||||
-- ── Logger constructor ─────────────────────────────────────────────────────
|
||||
|
||||
--- Create a new logger instance.
|
||||
-- @param name string — logger name shown in output
|
||||
-- @param opts table — optional configuration:
|
||||
-- level (number) — minimum level, default INFO
|
||||
-- format (string) — format string, default DEFAULT_FORMAT
|
||||
-- handlers (table) — list of handler functions; defaults to ConsoleHandler
|
||||
function Logger.new(name, opts)
|
||||
opts = opts or {}
|
||||
local self = setmetatable({}, Logger)
|
||||
self.name = name or "root"
|
||||
self.level = opts.level or Logger.LEVELS.INFO
|
||||
self.format = opts.format or DEFAULT_FORMAT
|
||||
self._handlers = opts.handlers or { ConsoleHandler() }
|
||||
self._filters = {} -- list of predicate functions
|
||||
return self
|
||||
end
|
||||
|
||||
-- ── Configuration API ──────────────────────────────────────────────────────
|
||||
|
||||
--- Set the minimum log level (Logger.LEVELS.*)
|
||||
function Logger:set_level(level)
|
||||
assert(type(level) == "number", "level must be a number from Logger.LEVELS")
|
||||
self.level = level
|
||||
return self
|
||||
end
|
||||
|
||||
--- Add a handler function
|
||||
function Logger:add_handler(handler)
|
||||
table.insert(self._handlers, handler)
|
||||
return self
|
||||
end
|
||||
|
||||
--- Remove all handlers and replace with the given ones
|
||||
function Logger:set_handlers(handlers)
|
||||
self._handlers = handlers
|
||||
return self
|
||||
end
|
||||
|
||||
--- Add a filter predicate — a function(record) → bool
|
||||
-- If ANY filter returns false the record is dropped.
|
||||
function Logger:add_filter(fn)
|
||||
table.insert(self._filters, fn)
|
||||
return self
|
||||
end
|
||||
|
||||
--- Set the output format string
|
||||
function Logger:set_format(fmt)
|
||||
self.format = fmt
|
||||
return self
|
||||
end
|
||||
|
||||
-- ── Core emit logic ────────────────────────────────────────────────────────
|
||||
|
||||
function Logger:_emit(level, fmt, ...)
|
||||
if level < self.level then return end
|
||||
|
||||
local record = {
|
||||
time = timestamp(),
|
||||
level = level,
|
||||
level_name = LEVEL_NAMES[level] or ("LVL" .. level),
|
||||
name = self.name,
|
||||
message = safe_format(fmt, ...),
|
||||
}
|
||||
record.formatted = apply_format(self.format, record)
|
||||
|
||||
-- run filters
|
||||
for _, filter in ipairs(self._filters) do
|
||||
if not filter(record) then return end
|
||||
end
|
||||
|
||||
-- dispatch to handlers
|
||||
for _, handler in ipairs(self._handlers) do
|
||||
local ok, err = pcall(handler, record)
|
||||
if not ok then
|
||||
io.stderr:write("[logger] handler error: " .. tostring(err) .. "\n")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ── Convenience level methods ──────────────────────────────────────────────
|
||||
|
||||
function Logger:trace(fmt, ...) self:_emit(Logger.LEVELS.TRACE, fmt, ...) end
|
||||
function Logger:debug(fmt, ...) self:_emit(Logger.LEVELS.DEBUG, fmt, ...) end
|
||||
function Logger:info (fmt, ...) self:_emit(Logger.LEVELS.INFO, fmt, ...) end
|
||||
function Logger:warn (fmt, ...) self:_emit(Logger.LEVELS.WARN, fmt, ...) end
|
||||
function Logger:error(fmt, ...) self:_emit(Logger.LEVELS.ERROR, fmt, ...) end
|
||||
function Logger:fatal(fmt, ...) self:_emit(Logger.LEVELS.FATAL, fmt, ...) end
|
||||
|
||||
--- Log at a given numeric level
|
||||
function Logger:log(level, fmt, ...)
|
||||
self:_emit(level, fmt, ...)
|
||||
end
|
||||
|
||||
--- Returns true if the given level would be emitted
|
||||
function Logger:is_enabled(level)
|
||||
return level >= self.level
|
||||
end
|
||||
|
||||
-- ── Child loggers ──────────────────────────────────────────────────────────
|
||||
|
||||
--- Create a child logger that inherits handlers/format but has its own name.
|
||||
function Logger:child(name, opts)
|
||||
opts = opts or {}
|
||||
return Logger.new(self.name .. "." .. name, {
|
||||
level = opts.level or self.level,
|
||||
format = opts.format or self.format,
|
||||
handlers = opts.handlers or self._handlers,
|
||||
})
|
||||
end
|
||||
|
||||
-- ── Module-level root logger ───────────────────────────────────────────────
|
||||
|
||||
Logger._root = Logger.new("root")
|
||||
|
||||
--- Get / lazily create a named logger backed by the root's handlers.
|
||||
local _registry = {}
|
||||
function Logger.get(name)
|
||||
if not _registry[name] then
|
||||
_registry[name] = Logger._root:child(name)
|
||||
end
|
||||
return _registry[name]
|
||||
end
|
||||
|
||||
return Logger
|
||||
@@ -0,0 +1,222 @@
|
||||
--- love_file_handler.lua — LÖVE2D file handler for logger.lua
|
||||
--
|
||||
-- Uses love.filesystem to write logs into LÖVE's save directory, which works
|
||||
-- correctly on every platform LÖVE supports (Windows, macOS, Linux, Android).
|
||||
--
|
||||
-- Requires:
|
||||
-- logger.lua in the same directory (or on package.path)
|
||||
-- LÖVE2D 11.x+ (love.filesystem must be available)
|
||||
--
|
||||
-- Usage inside main.lua / conf.lua:
|
||||
--
|
||||
-- local Logger = require("logger")
|
||||
-- local LoveHandler = require("love_file_handler")
|
||||
--
|
||||
-- local log = Logger.new("game", {
|
||||
-- handlers = {
|
||||
-- Logger.handlers.ConsoleHandler(), -- print() fallback
|
||||
-- LoveHandler.new("logs/game.log"), -- love.filesystem
|
||||
-- },
|
||||
-- })
|
||||
--
|
||||
-- function love.load()
|
||||
-- log:info("Game started — LÖVE %s", love.getVersion())
|
||||
-- end
|
||||
|
||||
-- ── Sanity check ──────────────────────────────────────────────────────────
|
||||
|
||||
assert(love and love.filesystem,
|
||||
"love_file_handler requires the LÖVE2D runtime (love.filesystem)")
|
||||
|
||||
-- ── Module table ──────────────────────────────────────────────────────────
|
||||
|
||||
local LoveHandler = {}
|
||||
|
||||
-- ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
--- Ensure every directory component in `path` exists.
|
||||
-- love.filesystem.createDirectory is recursive on LÖVE 11+.
|
||||
local function ensure_dir(path)
|
||||
local dir = path:match("^(.*)/[^/]+$")
|
||||
if dir and dir ~= "" then
|
||||
love.filesystem.createDirectory(dir)
|
||||
end
|
||||
end
|
||||
|
||||
--- Return current file size in bytes, or 0 on error / missing file.
|
||||
local function file_size(path)
|
||||
local info = love.filesystem.getInfo(path)
|
||||
return info and info.size or 0
|
||||
end
|
||||
|
||||
--- Shift rotated files: .4→.5, .3→.4, …, base→.1
|
||||
local function rotate(path, max_files)
|
||||
for i = max_files - 1, 1, -1 do
|
||||
local src = path .. "." .. i
|
||||
local dest = path .. "." .. (i + 1)
|
||||
if love.filesystem.getInfo(src) then
|
||||
-- love.filesystem has no rename; copy + delete
|
||||
local data = love.filesystem.read(src)
|
||||
if data then
|
||||
love.filesystem.write(dest, data)
|
||||
love.filesystem.remove(src)
|
||||
end
|
||||
end
|
||||
end
|
||||
-- move base → .1
|
||||
if love.filesystem.getInfo(path) then
|
||||
local data = love.filesystem.read(path)
|
||||
if data then
|
||||
love.filesystem.write(path .. ".1", data)
|
||||
love.filesystem.remove(path)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ── Constructor ───────────────────────────────────────────────────────────
|
||||
|
||||
--- Create a new LÖVE2D file handler.
|
||||
--
|
||||
-- @param path string Path inside the LÖVE save directory.
|
||||
-- Subdirectories are created automatically.
|
||||
-- Default: "logs/game.log"
|
||||
--
|
||||
-- @param opts table Optional settings:
|
||||
-- max_size (number) Rotate when the log file reaches this many bytes.
|
||||
-- Default: 1 MB (1 048 576). Set to 0 to disable.
|
||||
-- max_files (number) How many rotated files to keep. Default: 5.
|
||||
-- buffered (bool) Accumulate lines in memory and flush in batches.
|
||||
-- Reduces I/O overhead on mobile / web exports.
|
||||
-- Default: false.
|
||||
-- flush_size (number) Lines to buffer before auto-flushing (buffered mode).
|
||||
-- Default: 20.
|
||||
-- on_error (func) Called with (err_string) on any I/O error.
|
||||
-- Default: prints a warning to love.graphics console.
|
||||
--
|
||||
-- @return handler, control_table
|
||||
-- handler — function accepted by Logger:add_handler()
|
||||
-- control — table with :flush(), :close(), :path(), :size() methods
|
||||
function LoveHandler.new(path, opts)
|
||||
path = path or "logs/game.log"
|
||||
opts = opts or {}
|
||||
|
||||
local max_size = (opts.max_size == nil) and (1024 * 1024) or opts.max_size
|
||||
local max_files = opts.max_files or 5
|
||||
local buffered = opts.buffered or false
|
||||
local flush_size = opts.flush_size or 20
|
||||
local on_error = opts.on_error or function(err)
|
||||
if love.graphics and love.graphics.print then
|
||||
print("[love_file_handler] I/O error: " .. tostring(err))
|
||||
end
|
||||
end
|
||||
|
||||
-- State
|
||||
local buffer = {} -- pending lines (buffered mode)
|
||||
local closed = false
|
||||
|
||||
ensure_dir(path)
|
||||
|
||||
-- ── Internal write ────────────────────────────────────────────────────
|
||||
|
||||
local function raw_write(lines)
|
||||
if #lines == 0 then return end
|
||||
local text = table.concat(lines, "\n") .. "\n"
|
||||
|
||||
-- Rotate if needed
|
||||
if max_size and max_size > 0 and file_size(path) + #text >= max_size then
|
||||
rotate(path, max_files)
|
||||
end
|
||||
|
||||
-- Append
|
||||
local ok, err = love.filesystem.append(path, text)
|
||||
if not ok then
|
||||
on_error(tostring(err))
|
||||
end
|
||||
end
|
||||
|
||||
-- ── Public control table ──────────────────────────────────────────────
|
||||
|
||||
local control = {}
|
||||
|
||||
--- Flush buffered lines to disk immediately.
|
||||
function control:flush()
|
||||
if #buffer > 0 then
|
||||
raw_write(buffer)
|
||||
buffer = {}
|
||||
end
|
||||
end
|
||||
|
||||
--- Close the handler (flushes first). After close() the handler is a no-op.
|
||||
function control:close()
|
||||
self:flush()
|
||||
closed = true
|
||||
end
|
||||
|
||||
--- Full path as seen by love.filesystem.
|
||||
function control:path()
|
||||
return path
|
||||
end
|
||||
|
||||
--- Real filesystem path to the save directory file (for external tools).
|
||||
function control:real_path()
|
||||
local save_dir = love.filesystem.getSaveDirectory()
|
||||
return save_dir .. "/" .. path
|
||||
end
|
||||
|
||||
--- Current log file size in bytes.
|
||||
function control:size()
|
||||
return file_size(path)
|
||||
end
|
||||
|
||||
--- List rotated files that exist (e.g. {"logs/game.log.1", ...})
|
||||
function control:rotated_files()
|
||||
local result = {}
|
||||
for i = 1, max_files do
|
||||
local p = path .. "." .. i
|
||||
if love.filesystem.getInfo(p) then
|
||||
table.insert(result, p)
|
||||
end
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
--- Read the entire current log file as a string (nil on error).
|
||||
function control:read()
|
||||
self:flush()
|
||||
return love.filesystem.read(path)
|
||||
end
|
||||
|
||||
-- ── Handler function ──────────────────────────────────────────────────
|
||||
|
||||
local function handler(record)
|
||||
if closed then return end
|
||||
|
||||
if buffered then
|
||||
table.insert(buffer, record.formatted)
|
||||
if #buffer >= flush_size then
|
||||
raw_write(buffer)
|
||||
buffer = {}
|
||||
end
|
||||
else
|
||||
raw_write({ record.formatted })
|
||||
end
|
||||
end
|
||||
|
||||
return handler, control
|
||||
end
|
||||
|
||||
-- ── Convenience: hook love.quit to flush ──────────────────────────────────
|
||||
|
||||
--- Wrap love.quit so a control table's flush() is called automatically.
|
||||
-- Call once per handler after Logger.new(); safe to call multiple times.
|
||||
--
|
||||
-- @param control The control table returned by LoveHandler.new()
|
||||
function LoveHandler.auto_flush_on_quit(control)
|
||||
local original_quit = love.quit or function() end
|
||||
love.quit = function()
|
||||
control:flush()
|
||||
return original_quit()
|
||||
end
|
||||
end
|
||||
|
||||
return LoveHandler
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
local gui = require("gui")
|
||||
local color = require("gui.core.color")
|
||||
local multi, thread = require("multi"):init()
|
||||
|
||||
local function startTimer(opt)
|
||||
local default = {
|
||||
duration = 30,
|
||||
autoText = true,
|
||||
autoColor = true,
|
||||
autoCleanup = false,
|
||||
startColor = color.green,
|
||||
textColor = color.black,
|
||||
warnColor = color.yellow,
|
||||
timeColor = color.red,
|
||||
finegrained = false,
|
||||
visibility = 1
|
||||
}
|
||||
|
||||
if type(opt) == "number" then
|
||||
local d = opt
|
||||
opt = default
|
||||
opt.duration = d
|
||||
elseif type(opt) ~= "table" then
|
||||
opt = default
|
||||
else
|
||||
for i, v in pairs(opt) do
|
||||
default[i] = v
|
||||
end
|
||||
opt = default
|
||||
end
|
||||
|
||||
local timeRemaining = opt.duration or 30
|
||||
|
||||
local tpie = gui:newFrame():makeArc("pie", -200, 0, 100, 1, 0, 0, 1.5*math.pi, 3.5*math.pi, 360)
|
||||
local tlabel = tpie:newTextLabel("")
|
||||
tlabel.textColor = opt.textColor
|
||||
tlabel.align = gui.ALIGN_CENTER
|
||||
tlabel:fullFrame()
|
||||
tlabel.visibility = 0
|
||||
tpie.color = opt.startColor
|
||||
tpie.visibility = opt.visibility
|
||||
|
||||
local tm
|
||||
local stopped = false
|
||||
local onTimeConn = multi:newConnection()
|
||||
local onStopConn = multi:newConnection()
|
||||
|
||||
local function cleanup()
|
||||
if stopped then return end
|
||||
stopped = true
|
||||
if onTimeConn and not onTimeConn.destroyed then
|
||||
onTimeConn:Destroy()
|
||||
end
|
||||
if not tpie.destroyed then
|
||||
tpie:destroy()
|
||||
end
|
||||
end
|
||||
|
||||
local timerThread = thread:newThread("Pie Timer", function()
|
||||
local startTime = love.timer.getTime()
|
||||
local lastSecond = timeRemaining
|
||||
|
||||
while not stopped do
|
||||
thread.sleep(1/60)
|
||||
|
||||
local now = love.timer.getTime()
|
||||
local elapsed = now - startTime
|
||||
local t = math.min(elapsed, timeRemaining)
|
||||
local num = timeRemaining - math.floor(t)
|
||||
local p = 3.5 - (t / timeRemaining) * 2
|
||||
|
||||
if opt.autoColor then
|
||||
tpie.color = opt.startColor
|
||||
if num <= timeRemaining / 3 then
|
||||
tpie.color = opt.timeColor
|
||||
elseif num <= timeRemaining / 2 then
|
||||
tpie.color = opt.warnColor
|
||||
end
|
||||
end
|
||||
|
||||
tpie:makeArc("pie", -200, 0, 100, 1, 0, 0, 1.5*math.pi, p*math.pi, 360)
|
||||
|
||||
if opt.autoText then
|
||||
tlabel.text = num
|
||||
tlabel:fitFont(nil, nil, {scale=1/2})
|
||||
tlabel:centerFont()
|
||||
end
|
||||
|
||||
if opt.finegrained then
|
||||
onTimeConn:Fire(tm, num, t)
|
||||
elseif num < lastSecond then
|
||||
-- crossed a second boundary
|
||||
lastSecond = num
|
||||
onTimeConn:Fire(tm, num, t)
|
||||
end
|
||||
|
||||
if elapsed >= timeRemaining then
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
-- Timer finished
|
||||
if stopped then return end
|
||||
|
||||
if opt.autoText then
|
||||
tlabel.text = "Time"
|
||||
tlabel:fitFont(nil, nil, {scale=1/2})
|
||||
tlabel:centerFont()
|
||||
end
|
||||
tpie:makeArc("pie", -200, 0, 100, 1, 0, 0, 1.5*math.pi, 3.5*math.pi, 360)
|
||||
|
||||
local onStop = onStopConn
|
||||
cleanup()
|
||||
onStop:Fire(tm)
|
||||
end)
|
||||
|
||||
tm = {
|
||||
Duration = timeRemaining,
|
||||
OnTime = onTimeConn,
|
||||
OnStop = onStopConn,
|
||||
SetText = function(str) tlabel.text = str end,
|
||||
SetColor = function(c) tpie.color = c end,
|
||||
Cleanup = function()
|
||||
timerThread:Kill()
|
||||
cleanup()
|
||||
end,
|
||||
}
|
||||
|
||||
return tm
|
||||
end
|
||||
|
||||
return {
|
||||
startTimer = startTimer
|
||||
}
|
||||
+776
@@ -0,0 +1,776 @@
|
||||
-------------------------------------------------------------------------------
|
||||
-- tinyyaml - YAML subset parser
|
||||
-------------------------------------------------------------------------------
|
||||
|
||||
local table = table
|
||||
local string = string
|
||||
local schar = string.char
|
||||
local ssub, gsub = string.sub, string.gsub
|
||||
local sfind, smatch = string.find, string.match
|
||||
local tinsert, tremove = table.insert, table.remove
|
||||
local setmetatable = setmetatable
|
||||
local pairs = pairs
|
||||
local type = type
|
||||
local tonumber = tonumber
|
||||
local math = math
|
||||
local getmetatable = getmetatable
|
||||
local error = error
|
||||
|
||||
local UNESCAPES = {
|
||||
['0'] = "\x00", z = "\x00", N = "\x85",
|
||||
a = "\x07", b = "\x08", t = "\x09",
|
||||
n = "\x0a", v = "\x0b", f = "\x0c",
|
||||
r = "\x0d", e = "\x1b", ['\\'] = '\\',
|
||||
};
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
-- utils
|
||||
local function select(list, pred)
|
||||
local selected = {}
|
||||
for i = 0, #list do
|
||||
local v = list[i]
|
||||
if v and pred(v, i) then
|
||||
tinsert(selected, v)
|
||||
end
|
||||
end
|
||||
return selected
|
||||
end
|
||||
|
||||
local function startswith(haystack, needle)
|
||||
return ssub(haystack, 1, #needle) == needle
|
||||
end
|
||||
|
||||
local function ltrim(str)
|
||||
return smatch(str, "^%s*(.-)$")
|
||||
end
|
||||
|
||||
local function rtrim(str)
|
||||
return smatch(str, "^(.-)%s*$")
|
||||
end
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
-- Implementation.
|
||||
--
|
||||
local class = {__meta={}}
|
||||
function class.__meta.__call(cls, ...)
|
||||
local self = setmetatable({}, cls)
|
||||
if cls.__init then
|
||||
cls.__init(self, ...)
|
||||
end
|
||||
return self
|
||||
end
|
||||
|
||||
function class.def(base, typ, cls)
|
||||
base = base or class
|
||||
local mt = {__metatable=base, __index=base}
|
||||
for k, v in pairs(base.__meta) do mt[k] = v end
|
||||
cls = setmetatable(cls or {}, mt)
|
||||
cls.__index = cls
|
||||
cls.__metatable = cls
|
||||
cls.__type = typ
|
||||
cls.__meta = mt
|
||||
return cls
|
||||
end
|
||||
|
||||
|
||||
local types = {
|
||||
null = class:def('null'),
|
||||
map = class:def('map'),
|
||||
omap = class:def('omap'),
|
||||
pairs = class:def('pairs'),
|
||||
set = class:def('set'),
|
||||
seq = class:def('seq'),
|
||||
timestamp = class:def('timestamp'),
|
||||
}
|
||||
|
||||
local Null = types.null
|
||||
function Null.__tostring() return 'yaml.null' end
|
||||
function Null.isnull(v)
|
||||
if v == nil then return true end
|
||||
if type(v) == 'table' and getmetatable(v) == Null then return true end
|
||||
return false
|
||||
end
|
||||
local null = Null()
|
||||
|
||||
function types.timestamp:__init(y, m, d, h, i, s, f, z)
|
||||
self.year = tonumber(y)
|
||||
self.month = tonumber(m)
|
||||
self.day = tonumber(d)
|
||||
self.hour = tonumber(h or 0)
|
||||
self.minute = tonumber(i or 0)
|
||||
self.second = tonumber(s or 0)
|
||||
if type(f) == 'string' and sfind(f, '^%d+$') then
|
||||
self.fraction = tonumber(f) * math.pow(10, 3 - #f)
|
||||
elseif f then
|
||||
self.fraction = f
|
||||
else
|
||||
self.fraction = 0
|
||||
end
|
||||
self.timezone = z
|
||||
end
|
||||
|
||||
function types.timestamp:__tostring()
|
||||
return string.format(
|
||||
'%04d-%02d-%02dT%02d:%02d:%02d.%03d%s',
|
||||
self.year, self.month, self.day,
|
||||
self.hour, self.minute, self.second, self.fraction,
|
||||
self:gettz())
|
||||
end
|
||||
|
||||
function types.timestamp:gettz()
|
||||
if not self.timezone then
|
||||
return ''
|
||||
end
|
||||
if self.timezone == 0 then
|
||||
return 'Z'
|
||||
end
|
||||
local sign = self.timezone > 0
|
||||
local z = sign and self.timezone or -self.timezone
|
||||
local zh = math.floor(z)
|
||||
local zi = (z - zh) * 60
|
||||
return string.format(
|
||||
'%s%02d:%02d', sign and '+' or '-', zh, zi)
|
||||
end
|
||||
|
||||
|
||||
local function countindent(line)
|
||||
local _, j = sfind(line, '^%s+')
|
||||
if not j then
|
||||
return 0, line
|
||||
end
|
||||
return j, ssub(line, j+1)
|
||||
end
|
||||
|
||||
local function parsestring(line, stopper)
|
||||
stopper = stopper or ''
|
||||
local q = ssub(line, 1, 1)
|
||||
if q == ' ' or q == '\t' then
|
||||
return parsestring(ssub(line, 2))
|
||||
end
|
||||
if q == "'" then
|
||||
local i = sfind(line, "'", 2, true)
|
||||
if not i then
|
||||
return nil, line
|
||||
end
|
||||
return ssub(line, 2, i-1), ssub(line, i+1)
|
||||
end
|
||||
if q == '"' then
|
||||
local i, buf = 2, ''
|
||||
while i < #line do
|
||||
local c = ssub(line, i, i)
|
||||
if c == '\\' then
|
||||
local n = ssub(line, i+1, i+1)
|
||||
if UNESCAPES[n] ~= nil then
|
||||
buf = buf..UNESCAPES[n]
|
||||
elseif n == 'x' then
|
||||
local h = ssub(i+2,i+3)
|
||||
if sfind(h, '^[0-9a-fA-F]$') then
|
||||
buf = buf..schar(tonumber(h, 16))
|
||||
i = i + 2
|
||||
else
|
||||
buf = buf..'x'
|
||||
end
|
||||
else
|
||||
buf = buf..n
|
||||
end
|
||||
i = i + 1
|
||||
elseif c == q then
|
||||
break
|
||||
else
|
||||
buf = buf..c
|
||||
end
|
||||
i = i + 1
|
||||
end
|
||||
return buf, ssub(line, i+1)
|
||||
end
|
||||
if q == '{' or q == '[' then -- flow style
|
||||
return nil, line
|
||||
end
|
||||
if q == '|' or q == '>' then -- block
|
||||
return nil, line
|
||||
end
|
||||
if q == '-' or q == ':' then
|
||||
if ssub(line, 2, 2) == ' ' or #line == 1 then
|
||||
return nil, line
|
||||
end
|
||||
end
|
||||
local buf = ''
|
||||
while #line > 0 do
|
||||
local c = ssub(line, 1, 1)
|
||||
if sfind(stopper, c, 1, true) then
|
||||
break
|
||||
elseif c == ':' and (ssub(line, 2, 2) == ' ' or #line == 1) then
|
||||
break
|
||||
elseif c == '#' and (ssub(buf, #buf, #buf) == ' ') then
|
||||
break
|
||||
else
|
||||
buf = buf..c
|
||||
end
|
||||
line = ssub(line, 2)
|
||||
end
|
||||
return rtrim(buf), line
|
||||
end
|
||||
|
||||
local function isemptyline(line)
|
||||
return line == '' or sfind(line, '^%s*$') or sfind(line, '^%s*#')
|
||||
end
|
||||
|
||||
local function equalsline(line, needle)
|
||||
return startswith(line, needle) and isemptyline(ssub(line, #needle+1))
|
||||
end
|
||||
|
||||
local function checkdupekey(map, key)
|
||||
if map[key] ~= nil then
|
||||
-- print("found a duplicate key '"..key.."' in line: "..line)
|
||||
local suffix = 1
|
||||
while map[key..'_'..suffix] do
|
||||
suffix = suffix + 1
|
||||
end
|
||||
key = key ..'_'..suffix
|
||||
end
|
||||
return key
|
||||
end
|
||||
|
||||
local function parseflowstyle(line, lines)
|
||||
local stack = {}
|
||||
while true do
|
||||
if #line == 0 then
|
||||
if #lines == 0 then
|
||||
break
|
||||
else
|
||||
line = tremove(lines, 1)
|
||||
end
|
||||
end
|
||||
local c = ssub(line, 1, 1)
|
||||
if c == '#' then
|
||||
line = ''
|
||||
elseif c == ' ' or c == '\t' or c == '\r' or c == '\n' then
|
||||
line = ssub(line, 2)
|
||||
elseif c == '{' or c == '[' then
|
||||
tinsert(stack, {v={},t=c})
|
||||
line = ssub(line, 2)
|
||||
elseif c == ':' then
|
||||
local s = tremove(stack)
|
||||
tinsert(stack, {v=s.v, t=':'})
|
||||
line = ssub(line, 2)
|
||||
elseif c == ',' then
|
||||
local value = tremove(stack)
|
||||
if value.t == ':' or value.t == '{' or value.t == '[' then error() end
|
||||
if stack[#stack].t == ':' then
|
||||
-- map
|
||||
local key = tremove(stack)
|
||||
key.v = checkdupekey(stack[#stack].v, key.v)
|
||||
stack[#stack].v[key.v] = value.v
|
||||
elseif stack[#stack].t == '{' then
|
||||
-- set
|
||||
stack[#stack].v[value.v] = true
|
||||
elseif stack[#stack].t == '[' then
|
||||
-- seq
|
||||
tinsert(stack[#stack].v, value.v)
|
||||
end
|
||||
line = ssub(line, 2)
|
||||
elseif c == '}' then
|
||||
if stack[#stack].t == '{' then
|
||||
if #stack == 1 then break end
|
||||
stack[#stack].t = '}'
|
||||
line = ssub(line, 2)
|
||||
else
|
||||
line = ','..line
|
||||
end
|
||||
elseif c == ']' then
|
||||
if stack[#stack].t == '[' then
|
||||
if #stack == 1 then break end
|
||||
stack[#stack].t = ']'
|
||||
line = ssub(line, 2)
|
||||
else
|
||||
line = ','..line
|
||||
end
|
||||
else
|
||||
local s, rest = parsestring(line, ',{}[]')
|
||||
if not s then
|
||||
error('invalid flowstyle line: '..line)
|
||||
end
|
||||
tinsert(stack, {v=s, t='s'})
|
||||
line = rest
|
||||
end
|
||||
end
|
||||
return stack[1].v, line
|
||||
end
|
||||
|
||||
local function parseblockstylestring(line, lines, indent)
|
||||
if #lines == 0 then
|
||||
error("failed to find multi-line scalar content")
|
||||
end
|
||||
local s = {}
|
||||
local firstindent = -1
|
||||
local endline = -1
|
||||
for i = 1, #lines do
|
||||
local ln = lines[i]
|
||||
local idt = countindent(ln)
|
||||
if idt <= indent then
|
||||
break
|
||||
end
|
||||
if ln == '' then
|
||||
tinsert(s, '')
|
||||
else
|
||||
if firstindent == -1 then
|
||||
firstindent = idt
|
||||
elseif idt < firstindent then
|
||||
break
|
||||
end
|
||||
tinsert(s, ssub(ln, firstindent + 1))
|
||||
end
|
||||
endline = i
|
||||
end
|
||||
|
||||
local striptrailing = true
|
||||
local sep = '\n'
|
||||
local newlineatend = true
|
||||
if line == '|' then
|
||||
striptrailing = true
|
||||
sep = '\n'
|
||||
newlineatend = true
|
||||
elseif line == '|+' then
|
||||
striptrailing = false
|
||||
sep = '\n'
|
||||
newlineatend = true
|
||||
elseif line == '|-' then
|
||||
striptrailing = true
|
||||
sep = '\n'
|
||||
newlineatend = false
|
||||
elseif line == '>' then
|
||||
striptrailing = true
|
||||
sep = ' '
|
||||
newlineatend = true
|
||||
elseif line == '>+' then
|
||||
striptrailing = false
|
||||
sep = ' '
|
||||
newlineatend = true
|
||||
elseif line == '>-' then
|
||||
striptrailing = true
|
||||
sep = ' '
|
||||
newlineatend = false
|
||||
else
|
||||
error('invalid blockstyle string:'..line)
|
||||
end
|
||||
local eonl = 0
|
||||
for i = #s, 1, -1 do
|
||||
if s[i] == '' then
|
||||
tremove(s, i)
|
||||
eonl = eonl + 1
|
||||
end
|
||||
end
|
||||
if striptrailing then
|
||||
eonl = 0
|
||||
end
|
||||
if newlineatend then
|
||||
eonl = eonl + 1
|
||||
end
|
||||
for i = endline, 1, -1 do
|
||||
tremove(lines, i)
|
||||
end
|
||||
return table.concat(s, sep)..string.rep('\n', eonl)
|
||||
end
|
||||
|
||||
local function parsetimestamp(line)
|
||||
local _, p1, y, m, d = sfind(line, '^(%d%d%d%d)%-(%d%d)%-(%d%d)')
|
||||
if not p1 then
|
||||
return nil, line
|
||||
end
|
||||
if p1 == #line then
|
||||
return types.timestamp(y, m, d), ''
|
||||
end
|
||||
local _, p2, h, i, s = sfind(line, '^[Tt ](%d+):(%d+):(%d+)', p1+1)
|
||||
if not p2 then
|
||||
return types.timestamp(y, m, d), ssub(line, p1+1)
|
||||
end
|
||||
if p2 == #line then
|
||||
return types.timestamp(y, m, d, h, i, s), ''
|
||||
end
|
||||
local _, p3, f = sfind(line, '^%.(%d+)', p2+1)
|
||||
if not p3 then
|
||||
p3 = p2
|
||||
f = 0
|
||||
end
|
||||
local zc = ssub(line, p3+1, p3+1)
|
||||
local _, p4, zs, z = sfind(line, '^ ?([%+%-])(%d+)', p3+1)
|
||||
if p4 then
|
||||
z = tonumber(z)
|
||||
local _, p5, zi = sfind(line, '^:(%d+)', p4+1)
|
||||
if p5 then
|
||||
z = z + tonumber(zi) / 60
|
||||
end
|
||||
z = zs == '-' and -tonumber(z) or tonumber(z)
|
||||
elseif zc == 'Z' then
|
||||
p4 = p3 + 1
|
||||
z = 0
|
||||
else
|
||||
p4 = p3
|
||||
z = false
|
||||
end
|
||||
return types.timestamp(y, m, d, h, i, s, f, z), ssub(line, p4+1)
|
||||
end
|
||||
|
||||
local function parsescalar(line, lines, indent)
|
||||
line = ltrim(line)
|
||||
line = gsub(line, '^%s*#.*$', '') -- comment only -> ''
|
||||
line = gsub(line, '^%s*', '') -- trim head spaces
|
||||
|
||||
if line == '' or line == '~' then
|
||||
return null
|
||||
end
|
||||
|
||||
local ts, _ = parsetimestamp(line)
|
||||
if ts then
|
||||
return ts
|
||||
end
|
||||
|
||||
local s, _ = parsestring(line)
|
||||
-- startswith quote ... string
|
||||
-- not startswith quote ... maybe string
|
||||
if s and (startswith(line, '"') or startswith(line, "'")) then
|
||||
return s
|
||||
end
|
||||
|
||||
if startswith('!', line) then -- unexpected tagchar
|
||||
error('unsupported line: '..line)
|
||||
end
|
||||
|
||||
if equalsline(line, '{}') then
|
||||
return {}
|
||||
end
|
||||
if equalsline(line, '[]') then
|
||||
return {}
|
||||
end
|
||||
|
||||
if startswith(line, '{') or startswith(line, '[') then
|
||||
return parseflowstyle(line, lines)
|
||||
end
|
||||
|
||||
if startswith(line, '|') or startswith(line, '>') then
|
||||
return parseblockstylestring(line, lines, indent)
|
||||
end
|
||||
|
||||
-- Regular unquoted string
|
||||
line = gsub(line, '%s*#.*$', '') -- trim tail comment
|
||||
local v = line
|
||||
if v == 'null' or v == 'Null' or v == 'NULL'then
|
||||
return null
|
||||
elseif v == 'true' or v == 'True' or v == 'TRUE' then
|
||||
return true
|
||||
elseif v == 'false' or v == 'False' or v == 'FALSE' then
|
||||
return false
|
||||
elseif v == '.inf' or v == '.Inf' or v == '.INF' then
|
||||
return math.huge
|
||||
elseif v == '+.inf' or v == '+.Inf' or v == '+.INF' then
|
||||
return math.huge
|
||||
elseif v == '-.inf' or v == '-.Inf' or v == '-.INF' then
|
||||
return -math.huge
|
||||
elseif v == '.nan' or v == '.NaN' or v == '.NAN' then
|
||||
return 0 / 0
|
||||
elseif sfind(v, '^[%+%-]?[0-9]+$') or sfind(v, '^[%+%-]?[0-9]+%.$')then
|
||||
return tonumber(v) -- : int
|
||||
elseif sfind(v, '^[%+%-]?[0-9]+%.[0-9]+$') then
|
||||
return tonumber(v)
|
||||
end
|
||||
return s or v
|
||||
end
|
||||
|
||||
local parsemap; -- : func
|
||||
|
||||
local function parseseq(line, lines, indent)
|
||||
local seq = setmetatable({}, types.seq)
|
||||
if line ~= '' then
|
||||
error()
|
||||
end
|
||||
while #lines > 0 do
|
||||
-- Check for a new document
|
||||
line = lines[1]
|
||||
if startswith(line, '---') then
|
||||
while #lines > 0 and not startswith(lines, '---') do
|
||||
tremove(lines, 1)
|
||||
end
|
||||
return seq
|
||||
end
|
||||
|
||||
-- Check the indent level
|
||||
local level = countindent(line)
|
||||
if level < indent then
|
||||
return seq
|
||||
elseif level > indent then
|
||||
error("found bad indenting in line: ".. line)
|
||||
end
|
||||
|
||||
local i, j = sfind(line, '%-%s+')
|
||||
if not i then
|
||||
i, j = sfind(line, '%-$')
|
||||
if not i then
|
||||
return seq
|
||||
end
|
||||
end
|
||||
local rest = ssub(line, j+1)
|
||||
|
||||
if sfind(rest, '^[^\'\"%s]*:') then
|
||||
-- Inline nested hash
|
||||
local indent2 = j
|
||||
lines[1] = string.rep(' ', indent2)..rest
|
||||
tinsert(seq, parsemap('', lines, indent2))
|
||||
elseif sfind(rest, '^%-%s+') then
|
||||
-- Inline nested seq
|
||||
local indent2 = j
|
||||
lines[1] = string.rep(' ', indent2)..rest
|
||||
tinsert(seq, parseseq('', lines, indent2))
|
||||
elseif isemptyline(rest) then
|
||||
tremove(lines, 1)
|
||||
if #lines == 0 then
|
||||
tinsert(seq, null)
|
||||
return seq
|
||||
end
|
||||
if sfind(lines[1], '^%s*%-') then
|
||||
local nextline = lines[1]
|
||||
local indent2 = countindent(nextline)
|
||||
if indent2 == indent then
|
||||
-- Null seqay entry
|
||||
tinsert(seq, null)
|
||||
else
|
||||
tinsert(seq, parseseq('', lines, indent2))
|
||||
end
|
||||
else
|
||||
-- - # comment
|
||||
-- key: value
|
||||
local nextline = lines[1]
|
||||
local indent2 = countindent(nextline)
|
||||
tinsert(seq, parsemap('', lines, indent2))
|
||||
end
|
||||
elseif rest then
|
||||
-- Array entry with a value
|
||||
tremove(lines, 1)
|
||||
tinsert(seq, parsescalar(rest, lines))
|
||||
end
|
||||
end
|
||||
return seq
|
||||
end
|
||||
|
||||
local function parseset(line, lines, indent)
|
||||
if not isemptyline(line) then
|
||||
error('not seq line: '..line)
|
||||
end
|
||||
local set = setmetatable({}, types.set)
|
||||
while #lines > 0 do
|
||||
-- Check for a new document
|
||||
line = lines[1]
|
||||
if startswith(line, '---') then
|
||||
while #lines > 0 and not startswith(lines, '---') do
|
||||
tremove(lines, 1)
|
||||
end
|
||||
return set
|
||||
end
|
||||
|
||||
-- Check the indent level
|
||||
local level = countindent(line)
|
||||
if level < indent then
|
||||
return set
|
||||
elseif level > indent then
|
||||
error("found bad indenting in line: ".. line)
|
||||
end
|
||||
|
||||
local i, j = sfind(line, '%?%s+')
|
||||
if not i then
|
||||
i, j = sfind(line, '%?$')
|
||||
if not i then
|
||||
return set
|
||||
end
|
||||
end
|
||||
local rest = ssub(line, j+1)
|
||||
|
||||
if sfind(rest, '^[^\'\"%s]*:') then
|
||||
-- Inline nested hash
|
||||
local indent2 = j
|
||||
lines[1] = string.rep(' ', indent2)..rest
|
||||
set[parsemap('', lines, indent2)] = true
|
||||
elseif sfind(rest, '^%s+$') then
|
||||
tremove(lines, 1)
|
||||
if #lines == 0 then
|
||||
tinsert(set, null)
|
||||
return set
|
||||
end
|
||||
if sfind(lines[1], '^%s*%?') then
|
||||
local indent2 = countindent(lines[1])
|
||||
if indent2 == indent then
|
||||
-- Null array entry
|
||||
set[null] = true
|
||||
else
|
||||
set[parseseq('', lines, indent2)] = true
|
||||
end
|
||||
end
|
||||
|
||||
elseif rest then
|
||||
tremove(lines, 1)
|
||||
set[parsescalar(rest, lines)] = true
|
||||
else
|
||||
error("failed to classify line: "..line)
|
||||
end
|
||||
end
|
||||
return set
|
||||
end
|
||||
|
||||
function parsemap(line, lines, indent)
|
||||
if not isemptyline(line) then
|
||||
error('not map line: '..line)
|
||||
end
|
||||
local map = setmetatable({}, types.map)
|
||||
while #lines > 0 do
|
||||
-- Check for a new document
|
||||
line = lines[1]
|
||||
if startswith(line, '---') then
|
||||
while #lines > 0 and not startswith(lines, '---') do
|
||||
tremove(lines, 1)
|
||||
end
|
||||
return map
|
||||
end
|
||||
|
||||
-- Check the indent level
|
||||
local level, _ = countindent(line)
|
||||
if level < indent then
|
||||
return map
|
||||
elseif level > indent then
|
||||
error("found bad indenting in line: ".. line)
|
||||
end
|
||||
|
||||
-- Find the key
|
||||
local key
|
||||
local s, rest = parsestring(line)
|
||||
|
||||
-- Quoted keys
|
||||
if s and startswith(rest, ':') then
|
||||
local sc = parsescalar(s, {}, 0)
|
||||
if sc and type(sc) ~= 'string' then
|
||||
key = sc
|
||||
else
|
||||
key = s
|
||||
end
|
||||
line = ssub(rest, 2)
|
||||
else
|
||||
error("failed to classify line: "..line)
|
||||
end
|
||||
|
||||
key = checkdupekey(map, key)
|
||||
line = ltrim(line)
|
||||
|
||||
if ssub(line, 1, 1) == '!' then
|
||||
-- ignore type
|
||||
local rh = ltrim(ssub(line, 3))
|
||||
local typename = smatch(rh, '^!?[^%s]+')
|
||||
line = ltrim(ssub(rh, #typename+1))
|
||||
end
|
||||
|
||||
if not isemptyline(line) then
|
||||
tremove(lines, 1)
|
||||
line = ltrim(line)
|
||||
map[key] = parsescalar(line, lines, indent)
|
||||
else
|
||||
-- An indent
|
||||
tremove(lines, 1)
|
||||
if #lines == 0 then
|
||||
map[key] = null
|
||||
return map;
|
||||
end
|
||||
if sfind(lines[1], '^%s*%-') then
|
||||
local indent2 = countindent(lines[1])
|
||||
map[key] = parseseq('', lines, indent2)
|
||||
elseif sfind(lines[1], '^%s*%?') then
|
||||
local indent2 = countindent(lines[1])
|
||||
map[key] = parseset('', lines, indent2)
|
||||
else
|
||||
local indent2 = countindent(lines[1])
|
||||
if indent >= indent2 then
|
||||
-- Null hash entry
|
||||
map[key] = null
|
||||
else
|
||||
map[key] = parsemap('', lines, indent2)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return map
|
||||
end
|
||||
|
||||
|
||||
-- : (list<str>)->dict
|
||||
local function parsedocuments(lines)
|
||||
lines = select(lines, function(s) return not isemptyline(s) end)
|
||||
|
||||
if sfind(lines[1], '^%%YAML') then tremove(lines, 1) end
|
||||
|
||||
local root = {}
|
||||
local in_document = false
|
||||
while #lines > 0 do
|
||||
local line = lines[1]
|
||||
-- Do we have a document header?
|
||||
local docright;
|
||||
if sfind(line, '^%-%-%-') then
|
||||
-- Handle scalar documents
|
||||
docright = ssub(line, 4)
|
||||
tremove(lines, 1)
|
||||
in_document = true
|
||||
end
|
||||
if docright then
|
||||
if (not sfind(docright, '^%s+$') and
|
||||
not sfind(docright, '^%s+#')) then
|
||||
tinsert(root, parsescalar(docright, lines))
|
||||
end
|
||||
elseif #lines == 0 or startswith(line, '---') then
|
||||
-- A naked document
|
||||
tinsert(root, null)
|
||||
while #lines > 0 and not sfind(lines[1], '---') do
|
||||
tremove(lines, 1)
|
||||
end
|
||||
in_document = false
|
||||
-- XXX The final '-+$' is to look for -- which ends up being an
|
||||
-- error later.
|
||||
elseif not in_document and #root > 0 then
|
||||
-- only the first document can be explicit
|
||||
error('parse error: '..line)
|
||||
elseif sfind(line, '^%s*%-') then
|
||||
-- An array at the root
|
||||
tinsert(root, parseseq('', lines, 0))
|
||||
elseif sfind(line, '^%s*[^%s]') then
|
||||
-- A hash at the root
|
||||
local level = countindent(line)
|
||||
tinsert(root, parsemap('', lines, level))
|
||||
else
|
||||
-- Shouldn't get here. @lines have whitespace-only lines
|
||||
-- stripped, and previous match is a line with any
|
||||
-- non-whitespace. So this clause should only be reachable via
|
||||
-- a perlbug where \s is not symmetric with \S
|
||||
|
||||
-- uncoverable statement
|
||||
error('parse error: '..line)
|
||||
end
|
||||
end
|
||||
if #root > 1 and Null.isnull(root[1]) then
|
||||
tremove(root, 1)
|
||||
return root
|
||||
end
|
||||
return root
|
||||
end
|
||||
|
||||
--- Parse yaml string into table.
|
||||
local function parse(source)
|
||||
local lines = {}
|
||||
for line in string.gmatch(source .. '\n', '(.-)\r?\n') do
|
||||
tinsert(lines, line)
|
||||
end
|
||||
|
||||
local docs = parsedocuments(lines)
|
||||
if #docs == 1 then
|
||||
return docs[1]
|
||||
end
|
||||
|
||||
return docs
|
||||
end
|
||||
|
||||
return {
|
||||
version = 0.1,
|
||||
parse = parse,
|
||||
}
|
||||
Reference in New Issue
Block a user