From 6b864a2d9badd2da79a6fbd6ad7198cd8e548f6b Mon Sep 17 00:00:00 2001 From: Ryan Ward Date: Mon, 8 Jun 2026 20:29:59 -0700 Subject: [PATCH] added logging support --- README.md | 2 +- main.lua | 41 +- menus/board.lua | 30 +- menus/title.lua | 7 + package.bat | 2 +- test_logger.lua | 197 ++++ test_logger_output.log | 2 + utils.lua | 245 ----- fmt.lua => utils/fmt.lua | 0 loader.lua => utils/loader.lua | 75 +- utils/logger.lua | 300 +++++++ utils/love_file_handler.lua | 222 +++++ utils/utils.lua | 134 +++ yaml.lua => utils/yaml.lua | 1550 ++++++++++++++++---------------- version.txt | 2 +- 15 files changed, 1736 insertions(+), 1073 deletions(-) create mode 100644 test_logger.lua create mode 100644 test_logger_output.log delete mode 100644 utils.lua rename fmt.lua => utils/fmt.lua (100%) rename loader.lua => utils/loader.lua (77%) create mode 100644 utils/logger.lua create mode 100644 utils/love_file_handler.lua create mode 100644 utils/utils.lua rename yaml.lua => utils/yaml.lua (96%) diff --git a/README.md b/README.md index 28126ce..f438da9 100644 --- a/README.md +++ b/README.md @@ -1 +1 @@ -# ascension-rebirth \ No newline at end of file +# jeopardy game \ No newline at end of file diff --git a/main.lua b/main.lua index 4355413..8b57cb6 100644 --- a/main.lua +++ b/main.lua @@ -1,7 +1,28 @@ +local logger = require("utils/logger") +local lovehandler = require("utils/love_file_handler") + multi, thread = require("multi"):init({priority=true}) multi.setClock(require("socket").gettime) -- When on linux os.clock doesn't reture actual seconds the program has elapsed for GLOBAL, THREAD = require("multi.integration.loveManager"):init() + + +local fileHandler, logCtrl = lovehandler.new("logs/jeopardy.log", { + max_size = 512 * 1024, -- rotate at 512 KB + max_files = 3, + buffered = false, -- write every line immediately (safest for games) +}) + +lovehandler.auto_flush_on_quit(logCtrl) + +log = logger.new("jeopardy", { + level = logger.LEVELS.DEBUG, + handlers = { + logger.handlers.ConsoleHandler({ colours = true }), + fileHandler, + }, +}) + local gui = require("gui") local color = require("gui.core.color") local title = require("menus.title") @@ -23,25 +44,35 @@ gui:setHotKey({"f11"})(function() love.window.setFullscreen(not fullscreen, "desktop") end) +local loadLog = log:child("load") local old = multi.error -multi.error = function(...) - old(...) - os.exit() +multi.error = function(self, err) + loadLog:error("Error: %s", err) + love.quit() end function love.load() + loadLog:info("Starting Jeopardy Game") + loadLog:debug("Setting game identity to jeopardy") love.filesystem.setIdentity("jeopardy") + loadLog:trace("Creating save directory") success = love.filesystem.createDirectory("") + if not success then + loadLog:fatal("Unable to create save directory") + love.quit() + end + loadLog:trace("Creating save directory") gui:cacheImage({"assets/images/checked.png","assets/images/unchecked.png","assets/images/speaker.png"}) gui:setAspectSize(1920, 1080) gui.aspect_ratio = true - + loadLog:debug("Creating main background image") local bg = gui:newImageLabel("assets/images/background.png") bg:fullFrame() + loadLog:debug("Init menu module") menu.init(bg) - + loadLog:debug("Displaying title menu") title:visible(true) end diff --git a/menus/board.lua b/menus/board.lua index 37e0515..4f611aa 100644 --- a/menus/board.lua +++ b/menus/board.lua @@ -1,9 +1,9 @@ local gui = require("gui") local color = require("gui.core.color") -local loader = require("loader") +local loader = require("utils/loader") local theme = require("gui.core.theme") -local fmt = require("fmt") -local timer = require("utils") +local fmt = require("utils/fmt") +local timer = require("utils/utils") local multi, thread = require("multi"):init() local menu = require("gui.core.menus") @@ -22,7 +22,10 @@ local scoreboard = {} -- Create a table to manage GUI elements local manage = {} +local boardLog = log:child("board") + -- Function to resize fonts for all managed elements +boardLog:debug("Creating resize fonts handler") local function resizeFonts() -- Use multi-threading to improve performance multi:newThread(function() @@ -87,6 +90,7 @@ end function applyDD() if not applied_dd and completed_questions > min_questions then + boardLog:debug("Creating double jeopardy questions: %d",dd_count) local dd = 0 local dd_list = {} local dds = {pickFiltered(board.children, dd_count)} @@ -97,13 +101,15 @@ function applyDD() end end +local templateLog = log:child("template") function LoadTemplate(name, path) + boardLog:debug("Loading Template: %s", name) if love.filesystem.getInfo(path .."/templates/" .. name .. ".lua") then data = love.filesystem.read(path .."/templates/" .. name .. ".lua") elseif love.filesystem.getInfo("templates/" .. name .. ".lua") then data = love.filesystem.read("templates/" .. name .. ".lua") else - print("Error Loading Template: " .. name, path) + boardLog:error("Error Loading Template: %s", name) gui:newMessageBox({ title = "Error!", message = "Unable to load template: " .. name, @@ -157,7 +163,8 @@ function LoadTemplate(name, path) gui = gui, timer = timer, -- love stuff - newSource = love.audio.newSource + newSource = love.audio.newSource, + log = templateLog } env._G = env @@ -167,6 +174,7 @@ function LoadTemplate(name, path) end local function manageCosmetics(frame, cosmetics) + boardLog:debug("Managing cosmetics") if cosmetics["bg-img"] then frame:setImage(cosmetics["bg-img"]) else @@ -192,9 +200,9 @@ local reset = boardUpdater:newConnection() local default_text = "Pick a Category" function Menu(frame, path) + boardLog:info("Building board") if path:match(".zip") then -- local basename = path:match("([^/\\]+)$") - print(path) local success = love.filesystem.mount(path, "quiz", true) if not success then gui:newMessageBox({ @@ -215,6 +223,7 @@ function Menu(frame, path) end -- Start the processors now + boardLog:debug("Starting processors") boardUpdater.Start() qUpdater.Start() scoreUpdater.Start() @@ -329,20 +338,21 @@ function Menu(frame, path) if dd_enabled then applyDD() -- check and run DD if conditions meet end - if index.categories[cat].questions == nil then fmt.Printf("Question not defined: File: %v Category: %v - %v\n",path,index.categories[cat].name,start + inc*(tier-1)) return end + if index.categories[cat].questions == nil then boardLog:info("Question not defined: File: %s Category: %s - %s\n",path,index.categories[cat].name,start + inc*(tier-1)) return end local globals = index.categories[cat].global if not globals then globals = {} end local q = index.categories[cat].questions[self.index] if not q then return end - label.text = t.category .. ": " .. t.price + label.text = t.category .. " - " .. t.price + boardLog:debug("Displaying question for: %s - %s", t.category, t.price) local mt = debug.getmetatable(q) if mt then mt.__metatable = nil end setmetatable(q, {__index = globals}) - if q == nil then fmt.Printf("Question contains no data: File: %v Category: %v Tier: %v\n",path,index.categories[cat].name,start + inc*(tier-1)) return end + if q == nil then boardLog:info("Question contains no data: File: %s Category: %s Tier: %s\n",path,index.categories[cat].name,start + inc*(tier-1)) return end if q.cosmetics then manageCosmetics(frame, q.cosmetics) end @@ -352,7 +362,7 @@ function Menu(frame, path) local player = GetActivePlayer() local tm local stop - fmt.Printf("--------------------\nQuestion: %v \nAnswer: %v\n--------------------\n",q["title"],q["answer"]) + boardLog:info("Question: '%s' Answer: '%s'",q["title"],q["answer"]) local mul = 1 boardUpdater:newThread(function() if self.isDouble then diff --git a/menus/title.lua b/menus/title.lua index 7e2df5a..a9d1008 100644 --- a/menus/title.lua +++ b/menus/title.lua @@ -7,12 +7,15 @@ local APP_VERSION = love.filesystem.read("version.txt") local board = require("menus.board") +local titleLog = log:child("title") + function Menu(frame) local background = frame:newImageLabel("assets/images/background.jpg") background:fullFrame() local title = background:newTextLabel("Jeopardy",0,0,0,0,0,.1,1,.25) title:setFont("assets/fonts/gyparody.ttf",300) + titleLog:debug("Setting title shader") title:setShader(gui.SHADERS.vignette,{ intensity = .5, smoothness = .8 @@ -23,6 +26,7 @@ function Menu(frame) title.textColor = color.title_font title.drawBorder = false + titleLog:debug("Creating font thread") proc:newThread(function() local function enabled() return background.visible @@ -64,17 +68,20 @@ function Menu(frame) },play,settings,quit) play:OnReleased(function() + titleLog:debug("Play button pressed") if love.filesystem.getInfo("quiz") then menu.getMenu("board"):visible(true, "quiz") return else gui:newFilePicker("File picker test",nil, {".zip"}, function(file) + titleLog:info("File picked: %s", file) menu.getMenu("board"):visible(true, file:gsub("\\","/"):gsub(love.filesystem.getSaveDirectory(),""):sub(2,-1)) end) end end) quit:OnReleased(function() + titleLog:debug("Quit button pressed") local f = frame:newFrame() f:fullFrame() f.visibility = .8 diff --git a/package.bat b/package.bat index 3eed7c4..3c0b00e 100644 --- a/package.bat +++ b/package.bat @@ -5,7 +5,7 @@ for /f "usebackq delims=" %%i in ("version.txt") do ( ) :done -"C:\Program Files\7-Zip\7z.exe" a -tzip game.love templates version.txt assets multi\integration\loveManager multi\integration\sharedExtensions multi\init.lua gui menus conf.lua fmt.lua loader.lua main.lua utils.lua yaml.lua +"C:\Program Files\7-Zip\7z.exe" a -tzip game.love templates version.txt assets multi\integration\loveManager multi\integration\sharedExtensions multi\init.lua utils gui menus conf.lua main.lua rd /s /q "bin\%userInput%" if not exist "bin\%userInput%" mkdir "bin\%userInput%" copy /b love\love.exe + game.love "bin\%userInput%\Jeopardy.exe" diff --git a/test_logger.lua b/test_logger.lua new file mode 100644 index 0000000..ff10c74 --- /dev/null +++ b/test_logger.lua @@ -0,0 +1,197 @@ +--- test_logger.lua — Demonstrates and tests all logger.lua features + +-- Adjust path if needed +package.path = "./?.lua;" .. package.path +local Logger = require("utils/logger") + +local PASS = 0 +local FAIL = 0 + +local function test(label, cond) + if cond then + print(string.format(" [PASS] %s", label)) + PASS = PASS + 1 + else + io.stderr:write(string.format(" [FAIL] %s\n", label)) + FAIL = FAIL + 1 + end +end + +print("━━━ logger.lua test suite ━━━\n") + +-- ── 1. Basic levels ──────────────────────────────────────────────────────── +print("▶ 1. Basic level output (INFO+)") +do + local handler, get, clear = Logger.handlers.MemoryHandler() + local log = Logger.new("basic", { level = Logger.LEVELS.INFO, handlers = { handler } }) + + log:trace("should be filtered") + log:debug("should be filtered") + log:info("hello %s", "world") + log:warn("disk %d%%", 90) + log:error("boom") + log:fatal("catastrophe") + + local records = get() + test("TRACE filtered", records[1].level == Logger.LEVELS.INFO) + test("INFO captured", records[1].message == "hello world") + test("WARN captured", records[2].message == "disk 90%") + test("ERROR captured", records[3].level == Logger.LEVELS.ERROR) + test("FATAL captured", records[4].level == Logger.LEVELS.FATAL) + test("exactly 4 records", #records == 4) +end + +-- ── 2. Level filtering ───────────────────────────────────────────────────── +print("\n▶ 2. Dynamic level changes") +do + local handler, get = Logger.handlers.MemoryHandler() + local log = Logger.new("dyn", { handlers = { handler } }) + + log:set_level(Logger.LEVELS.DEBUG) + log:debug("visible") + log:set_level(Logger.LEVELS.ERROR) + log:info("invisible") + log:error("visible again") + + local records = get() + test("DEBUG visible at DEBUG level", records[1].message == "visible") + test("INFO invisible at ERROR level", records[2].message == "visible again") + test("total 2 records", #records == 2) +end + +-- ── 3. is_enabled ────────────────────────────────────────────────────────── +print("\n▶ 3. is_enabled()") +do + local log = Logger.new("enabled", { level = Logger.LEVELS.WARN }) + test("WARN enabled", log:is_enabled(Logger.LEVELS.WARN)) + test("INFO disabled", not log:is_enabled(Logger.LEVELS.INFO)) + test("ERROR enabled", log:is_enabled(Logger.LEVELS.ERROR)) +end + +-- ── 4. Custom format ─────────────────────────────────────────────────────── +print("\n▶ 4. Custom format string") +do + local handler, get = Logger.handlers.MemoryHandler() + local log = Logger.new("fmt", { + format = "{level}|{name}|{message}", + handlers = { handler }, + }) + log:info("hi") + local f = get()[1].formatted + test("contains level", f:find("INFO") ~= nil) + test("contains name", f:find("fmt") ~= nil) + test("contains message", f:find("hi") ~= nil) + test("pipe separator", f:find("|") ~= nil) +end + +-- ── 5. Filters ───────────────────────────────────────────────────────────── +print("\n▶ 5. Custom filter") +do + local handler, get = Logger.handlers.MemoryHandler() + local log = Logger.new("filter", { level = Logger.LEVELS.DEBUG, handlers = { handler } }) + + -- only pass records whose message contains "keep" + log:add_filter(function(rec) return rec.message:find("keep") ~= nil end) + + log:info("drop this") + log:info("keep this") + log:warn("also drop") + log:debug("keep me too") + + local records = get() + test("only 'keep' records pass", #records == 2) + test("first kept record", records[1].message == "keep this") + test("second kept record", records[2].message == "keep me too") +end + +-- ── 6. Multiple handlers ─────────────────────────────────────────────────── +print("\n▶ 6. Multiple handlers") +do + local h1, get1 = Logger.handlers.MemoryHandler() + local h2, get2 = Logger.handlers.MemoryHandler() + local log = Logger.new("multi", { handlers = { h1 } }) + log:add_handler(h2) + + log:info("broadcast") + + test("handler1 received", #get1() == 1) + test("handler2 received", #get2() == 1) + test("same message", get1()[1].message == get2()[1].message) +end + +-- ── 7. Child loggers ─────────────────────────────────────────────────────── +print("\n▶ 7. Child loggers") +do + local handler, get = Logger.handlers.MemoryHandler() + local parent = Logger.new("app", { handlers = { handler } }) + local child = parent:child("db") + + parent:info("parent message") + child:info("child message") + + local records = get() + test("parent name", records[1].name == "app") + test("child name", records[2].name == "app.db") + test("child shares handler", #records == 2) +end + +-- ── 8. Logger.get() registry ────────────────────────────────────────────── +print("\n▶ 8. Logger.get() registry") +do + local a = Logger.get("svc.auth") + local b = Logger.get("svc.auth") + test("same instance returned", a == b) + + local c = Logger.get("svc.payments") + test("different names differ", a ~= c) +end + +-- ── 9. Format with no args ──────────────────────────────────────────────── +print("\n▶ 9. Message with no format args") +do + local handler, get = Logger.handlers.MemoryHandler() + local log = Logger.new("noargs", { handlers = { handler } }) + log:info("plain string, no args") + test("plain string works", get()[1].message == "plain string, no args") +end + +-- ── 10. FileHandler ─────────────────────────────────────────────────────── +print("\n▶ 10. FileHandler") +do + local path = "test_logger_output.log" + os.remove(path) + + local fh = Logger.handlers.FileHandler({ path = path }) + local log = Logger.new("file", { handlers = { fh } }) + log:info("written to file") + log:warn("second line") + + local f = io.open(path) + local content = f and f:read("*a") or "" + if f then f:close() end + + test("file created", content ~= "") + test("INFO line present", content:find("written to file") ~= nil) + test("WARN line present", content:find("second line") ~= nil) + test("two newlines", select(2, content:gsub("\n","")) == 2) +end + +-- ── 11. Console handler (visual) ────────────────────────────────────────── +print("\n▶ 11. ConsoleHandler — visual check (colours enabled)") +do + local log = Logger.new("visual", { + level = Logger.LEVELS.TRACE, + handlers = { Logger.handlers.ConsoleHandler({ colours = true }) }, + }) + log:trace("trace message") + log:debug("debug message") + log:info ("info message") + log:warn ("warn message") + log:error("error message") + log:fatal("fatal message") + test("no crash", true) +end + +-- ── Summary ──────────────────────────────────────────────────────────────── +print(string.format("\n━━━ Results: %d passed, %d failed ━━━", PASS, FAIL)) +if FAIL > 0 then os.exit(1) end diff --git a/test_logger_output.log b/test_logger_output.log new file mode 100644 index 0000000..ba3c7e8 --- /dev/null +++ b/test_logger_output.log @@ -0,0 +1,2 @@ +[2026-06-08 19:40:49] [INFO ] [file] written to file +[2026-06-08 19:40:49] [WARN ] [file] second line diff --git a/utils.lua b/utils.lua deleted file mode 100644 index c9a2fc4..0000000 --- a/utils.lua +++ /dev/null @@ -1,245 +0,0 @@ --- local gui = require("gui") --- local color = require("gui.core.color") --- local transition = require("gui.core.transitions") --- local multi = require("multi"):init() --- local timer = transition.glide(3.5,1.5,5) - --- local function startTimer(opt) --- local default = { --- duration = 30, --- autoText = true, --- autoColor = true, --- autoCleanup = true, --- 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 --- elseif type(opt) == "table" then --- for i,v in pairs(opt) do --- default[i] = v --- end --- opt = default --- end - --- local timeRemaining = opt.duration or 30 - --- transition.glide:SetFPS(60) --- local handle = timer(3.5, 1.5, timeRemaining) --- 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, num --- local func = function(p,t) --- if num ~= timeRemaining-math.floor(t) then --- num = timeRemaining-math.floor(t) --- end - --- 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 num == 0 then --- thread:newThread("Pie Timer",function() --- thread.yield() --- 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) --- tm.OnStop:Fire(tm) --- if opt.autoCleanup then --- tm:Cleanup() --- end --- tm.OnTime:Destroy() --- tm.OnStop:Destroy() --- end) --- end - --- return tm, num, t --- end - --- tm = { --- Duration = timeRemaining, --- Cleanup = function() handle:Kill() tpie:destroy() tm.Cleanup = function() end end, --- SetText = function(str) tlabel.text = str end, --- SetColor = function(c) tpie.color = c end, --- OnStop = multi:newConnection() --- } - --- if opt.finegrained then --- tm.OnTime = func % handle.OnStep --- else --- tm.OnTime = function(self, sec, t) return math.floor(t) == t, self, sec end / (func % handle.OnStep) --- end - --- return tm --- end - --- return { --- startTimer = startTimer --- } - -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 -} \ No newline at end of file diff --git a/fmt.lua b/utils/fmt.lua similarity index 100% rename from fmt.lua rename to utils/fmt.lua diff --git a/loader.lua b/utils/loader.lua similarity index 77% rename from loader.lua rename to utils/loader.lua index 4ab0eae..e7550d1 100644 --- a/loader.lua +++ b/utils/loader.lua @@ -1,36 +1,41 @@ -local yaml = require("yaml") -local loader = {} -loader.__index = 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 - error("Unable to load file: ".. path_or_file .."/index.yml") - 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 - print("Error! Cannot find file: " .. path_or_file .."/" .. v.name .. ".yml") - 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 - +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 \ No newline at end of file diff --git a/utils/logger.lua b/utils/logger.lua new file mode 100644 index 0000000..d5a8736 --- /dev/null +++ b/utils/logger.lua @@ -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 diff --git a/utils/love_file_handler.lua b/utils/love_file_handler.lua new file mode 100644 index 0000000..e872fd3 --- /dev/null +++ b/utils/love_file_handler.lua @@ -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 diff --git a/utils/utils.lua b/utils/utils.lua new file mode 100644 index 0000000..5699b0e --- /dev/null +++ b/utils/utils.lua @@ -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 +} \ No newline at end of file diff --git a/yaml.lua b/utils/yaml.lua similarity index 96% rename from yaml.lua rename to utils/yaml.lua index 36c105a..6d4bcba 100644 --- a/yaml.lua +++ b/utils/yaml.lua @@ -1,776 +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)->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, +------------------------------------------------------------------------------- +-- 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)->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, } \ No newline at end of file diff --git a/version.txt b/version.txt index 7f20734..e6d5cb8 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.0.1 \ No newline at end of file +1.0.2 \ No newline at end of file