From baa29ba9d9b14dbb0653f77a16fa04ba176b8a71 Mon Sep 17 00:00:00 2001 From: Jason Xu <769155+jxai@users.noreply.github.com> Date: Wed, 18 Mar 2026 10:30:15 -0700 Subject: [PATCH 01/10] Add unit tests --- test.lua | 238 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 test.lua diff --git a/test.lua b/test.lua new file mode 100644 index 0000000..36c6952 --- /dev/null +++ b/test.lua @@ -0,0 +1,238 @@ +-- test.lua — unit tests for log.lua + +local log = require("log") + +local passed = 0 +local failed = 0 + +local function assert_eq(label, got, expected) + if got == expected then + print(" PASS " .. label) + passed = passed + 1 + else + print(" FAIL " .. label) + print(" expected: " .. tostring(expected)) + print(" got: " .. tostring(got)) + failed = failed + 1 + end +end + +local function assert_true(label, v) + assert_eq(label, not not v, true) +end + +local function assert_false(label, v) + assert_eq(label, not not v, false) +end + +-- Capture print output for inspection +local captured = {} +local real_print = print +local function capture_start() + captured = {} + print = function(s) captured[#captured + 1] = s end +end +local function capture_stop() + print = real_print +end + + +-- ── Helpers ────────────────────────────────────────────────────────────────── + +local function reset_log() + log.level = "trace" + log.usecolor = true + log.outfile = nil +end + +-- Strip ANSI escape codes so we can assert on plain text +local function strip_ansi(s) + return s:gsub("\27%[%d+m", "") +end + + +-- ── Suite: level filtering ──────────────────────────────────────────────────── + +real_print("\n── level filtering ──") + +do + reset_log() + log.level = "warn" + + capture_start() + log.trace("t"); log.debug("d"); log.info("i") + capture_stop() + assert_eq("levels below threshold produce no output", #captured, 0) + + capture_start() + log.warn("w"); log.error("e"); log.fatal("f") + capture_stop() + assert_eq("levels at/above threshold produce output", #captured, 3) +end + +do + reset_log() + log.level = "error" + + capture_start() + log.warn("suppressed") + capture_stop() + assert_eq("warn suppressed when level=error", #captured, 0) + + capture_start() + log.error("shown"); log.fatal("shown") + capture_stop() + assert_eq("error and fatal shown when level=error", #captured, 2) +end + +do + reset_log() + log.level = "trace" + + capture_start() + for _, fn in ipairs({"trace","debug","info","warn","error","fatal"}) do + log[fn]("x") + end + capture_stop() + assert_eq("all 6 levels shown when level=trace", #captured, 6) +end + + +-- ── Suite: output format ────────────────────────────────────────────────────── + +real_print("\n── output format ──") + +do + reset_log() + log.usecolor = false + + capture_start() + log.info("hello world") + capture_stop() + + local line = captured[1] + assert_true("output contains level label INFO", line:find("%[INFO")) + assert_true("output contains HH:MM:SS timestamp", line:find("%d%d:%d%d:%d%d")) + assert_true("output contains the message", line:find("hello world")) + assert_true("output contains source:line", line:find("[^:]+:%d+")) +end + +do + reset_log() + log.usecolor = true + + capture_start() + log.warn("colored") + capture_stop() + + assert_true("ANSI color codes present when usecolor=true", + captured[1]:find("\27%[")) + + log.usecolor = false + capture_start() + log.warn("plain") + capture_stop() + + assert_false("no ANSI codes when usecolor=false", + captured[1]:find("\27%[")) +end + +do + reset_log() + log.usecolor = false + + -- Each level should use its own uppercased label + local labels = {"TRACE","DEBUG","INFO","WARN","ERROR","FATAL"} + local fns = {"trace","debug","info","warn","error","fatal"} + for k, fn in ipairs(fns) do + capture_start() + log[fn]("x") + capture_stop() + assert_true("level label " .. labels[k], captured[1]:find(labels[k])) + end +end + + +-- ── Suite: number rounding ──────────────────────────────────────────────────── + +real_print("\n── number rounding ──") + +do + reset_log() + log.usecolor = false + + capture_start() + log.info(1.23456) + capture_stop() + assert_true("float rounded to 2dp", strip_ansi(captured[1]):find("1.23")) + assert_false("float not printed beyond 2dp", strip_ansi(captured[1]):find("1.2345")) + + capture_start() + log.info(1.236) + capture_stop() + assert_true("float rounds up correctly", strip_ansi(captured[1]):find("1.24")) + + capture_start() + log.info(-1.23456) + capture_stop() + assert_true("negative float rounded to 2dp", strip_ansi(captured[1]):find("-1.23")) +end + + +-- ── Suite: multi-argument concatenation ────────────────────────────────────── + +real_print("\n── multi-argument concatenation ──") + +do + reset_log() + log.usecolor = false + + capture_start() + log.info("a", "b", "c") + capture_stop() + assert_true("multiple args joined with spaces", captured[1]:find("a b c")) + + capture_start() + log.info("val:", 42) + capture_stop() + assert_true("string and number concatenated", captured[1]:find("val: 42")) +end + + +-- ── Suite: outfile ──────────────────────────────────────────────────────────── + +real_print("\n── outfile ──") + +do + local tmpfile = os.tmpname() + reset_log() + log.usecolor = false + log.outfile = tmpfile + + log.warn("written to file") + log.outfile = nil -- stop further writes + + local f = assert(io.open(tmpfile, "r")) + local contents = f:read("*a") + f:close() + os.remove(tmpfile) + + assert_true("outfile contains level label", contents:find("WARN")) + assert_true("outfile contains the message", contents:find("written to file")) + assert_false("outfile has no ANSI codes", contents:find("\27%[")) +end + +do + -- Bad path should raise an error (fail loudly on misconfigured outfile) + reset_log() + log.outfile = "/nonexistent_dir/nope.log" + local ok = pcall(function() log.info("safe") end) + log.outfile = nil + assert_false("bad outfile path raises an error", ok) +end + + +-- ── Summary ────────────────────────────────────────────────────────────────── + +real_print(string.format("\n%d passed, %d failed", passed, failed)) +if failed > 0 then os.exit(1) end From 0599d8cbb72779ddbaf7721f1e92bc60ffc0852b Mon Sep 17 00:00:00 2001 From: Jason Xu <769155+jxai@users.noreply.github.com> Date: Wed, 18 Mar 2026 09:51:47 -0700 Subject: [PATCH 02/10] Make log instantiable to create named/customized loggers --- LICENSE | 2 +- README.md | 59 ++++++++++++++++++++++++++++++-------- log.lua | 85 ++++++++++++++++++++++++++++++++++++------------------- 3 files changed, 104 insertions(+), 42 deletions(-) diff --git a/LICENSE b/LICENSE index 5818e8d..33c8dc0 100644 --- a/LICENSE +++ b/LICENSE @@ -1,5 +1,5 @@ Copyright (c) 2016 rxi - +Copyright (c) 2026 jxai Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in diff --git a/README.md b/README.md index 8c5b707..8c802f8 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,43 @@ # log.lua -A tiny logging module for Lua. -![screenshot from 2014-07-04 19 55 55](https://cloud.githubusercontent.com/assets/3920290/3484524/2ea2a9c6-03ad-11e4-9ed5-a9744c6fd75d.png) +A tiny logging module for Lua. +![screenshot from 2014-07-04 19 55 55](https://cloud.githubusercontent.com/assets/3920290/3484524/2ea2a9c6-03ad-11e4-9ed5-a9744c6fd75d.png) ## Installation + The [log.lua](log.lua?raw=1) file should be dropped into an existing project and required by it. + ```lua log = require "log" -``` - +``` ## Usage + log.lua provides 6 functions, each function takes all its arguments, concatenates them into a string then outputs the string to the console and -- if one is set -- the log file: -* **log.trace(...)** -* **log.debug(...)** -* **log.info(...)** -* **log.warn(...)** -* **log.error(...)** -* **log.fatal(...)** - +- **log.trace(...)** +- **log.debug(...)** +- **log.info(...)** +- **log.warn(...)** +- **log.error(...)** +- **log.fatal(...)** ### Additional options + log.lua provides variables for setting additional options: #### log.usecolor + Whether colors should be used when outputting to the console, this is `true` by default. If you're using a console which does not support ANSI color escape codes then this should be disabled. #### log.outfile + The name of the file where the log should be written, log files do not contain ANSI colors and always use the full date rather than just the time. By default `log.outfile` is `nil` (no log file is used). If a file which does not exist is @@ -41,6 +45,7 @@ set as the `log.outfile` then it is created on the first message logged. If the file already exists it is appended to. #### log.level + The minimum level to log, any logging function called with a lower level than the `log.level` is ignored and no text is outputted or written. By default this value is set to `"trace"`, the lowest log level, such that no log messages are @@ -49,8 +54,38 @@ ignored. The level of each log mode, starting with the lowest log level is as follows: `"trace"` `"debug"` `"info"` `"warn"` `"error"` `"fatal"` +## Logger instances + +The global logger `log` is also callable and returns a new independent logger +instance with its own configuration. Unspecified options inherit from the +global logger's current values at creation time. + +```lua +local logger = log{ name="my module", level="warn", outfile="app.log" } +logger.warn("something went wrong") +-- [WARN 14:32:01] my module src.lua:2: something went wrong +``` + +All instance options are mutable after creation: + +```lua +logger.level = "trace" +logger.usecolor = false +``` + +### Instance options + +#### name + +A string label included in each log line to identify the logger, `nil` by +default. The global logger doesn't have a name. + +#### level, usecolor, outfile + +Same semantics as the global options above. When not specified, the global +values are used as defaults at instance creation time. ## License + This library is free software; you can redistribute it and/or modify it under the terms of the MIT license. See [LICENSE](LICENSE) for details. - diff --git a/log.lua b/log.lua index d7bc2d4..6cde132 100644 --- a/log.lua +++ b/log.lua @@ -2,6 +2,7 @@ -- log.lua -- -- Copyright (c) 2016 rxi +-- Copyright (c) 2026 jxai -- -- This library is free software; you can redistribute it and/or modify it -- under the terms of the MIT license. See LICENSE for details. @@ -52,39 +53,65 @@ local tostring = function(...) end -for i, x in ipairs(modes) do - local nameupper = x.name:upper() - log[x.name] = function(...) - - -- Return early if we're below the log level - if i < levels[log.level] then - return +local function attach_log_methods(instance) + for i, x in ipairs(modes) do + local nameupper = x.name:upper() + instance[x.name] = function(...) + -- Return early if we're below the log level + if i < levels[instance.level] then + return + end + + local msg = tostring(...) + local info = debug.getinfo(2, "Sl") + local lineinfo = info.short_src .. ":" .. info.currentline + local prefix = instance.name and instance.name .. ":" or "" + + -- Output to console + print(string.format("%s[%-6s%s]%s %s%s: %s", + instance.usecolor and x.color or "", + nameupper, + os.date("%H:%M:%S"), + instance.usecolor and "\27[0m" or "", + prefix, + lineinfo, + msg)) + + -- Output to log file + if instance.outfile then + local fp = io.open(instance.outfile, "a") + local str = string.format("[%-6s%s] %s%s: %s\n", + nameupper, os.date(), prefix, lineinfo, msg) + assert(fp) + fp:write(str) + fp:close() + end end + end +end - local msg = tostring(...) - local info = debug.getinfo(2, "Sl") - local lineinfo = info.short_src .. ":" .. info.currentline - - -- Output to console - print(string.format("%s[%-6s%s]%s %s: %s", - log.usecolor and x.color or "", - nameupper, - os.date("%H:%M:%S"), - log.usecolor and "\27[0m" or "", - lineinfo, - msg)) - - -- Output to log file - if log.outfile then - local fp = io.open(log.outfile, "a") - local str = string.format("[%-6s%s] %s: %s\n", - nameupper, os.date(), lineinfo, msg) - fp:write(str) - fp:close() - end +-- Attach methods to the global logger, closing over `log` so that mutations +-- like `log.level = "warn"` take effect immediately without recreation. +attach_log_methods(log) + + +-- Make log callable to create named/customized logger instances. +-- Usage: local logger = log{ name="my logger", level="debug" } +-- Unspecified options inherit from the global values at creation time. +setmetatable(log, { + __call = function(_, config) + config = config or {} + local instance = { + usecolor = config.usecolor ~= nil and config.usecolor or log.usecolor, + outfile = config.outfile ~= nil and config.outfile or log.outfile, + level = config.level or log.level, + name = config.name, + } + attach_log_methods(instance) + return instance end -end +}) return log From c1c10634da2d6c351ea2404c07098bef90092b92 Mon Sep 17 00:00:00 2001 From: Jason Xu <769155+jxai@users.noreply.github.com> Date: Wed, 18 Mar 2026 10:06:42 -0700 Subject: [PATCH 03/10] Set noop to logging functions below level - more efficient --- log.lua | 59 ++++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 44 insertions(+), 15 deletions(-) diff --git a/log.lua b/log.lua index 6cde132..b257134 100644 --- a/log.lua +++ b/log.lua @@ -53,15 +53,24 @@ local tostring = function(...) end -local function attach_log_methods(instance) +local noop = function() end + +-- attach_log_methods(instance, extra_mt): +-- Builds one real implementation per log level and stores them in a closure. +-- apply_level() rawsets each mode key to either the real impl or noop, so +-- calls to disabled levels cost nothing beyond a table lookup. +-- `level` is kept out of the raw table so that __newindex always intercepts +-- assignments and triggers apply_level() automatically. +-- extra_mt allows the caller to inject additional metamethods (e.g. __call +-- on the singleton) into the same metatable. +local function attach_log_methods(instance, extra_mt) + local current_level = instance.level + + -- Build real implementations upfront, closed over `instance`. + local impls = {} for i, x in ipairs(modes) do local nameupper = x.name:upper() - instance[x.name] = function(...) - -- Return early if we're below the log level - if i < levels[instance.level] then - return - end - + impls[i] = function(...) local msg = tostring(...) local info = debug.getinfo(2, "Sl") local lineinfo = info.short_src .. ":" .. info.currentline @@ -88,18 +97,38 @@ local function attach_log_methods(instance) end end end -end + local function apply_level(level) + local threshold = levels[level] + for i, x in ipairs(modes) do + rawset(instance, x.name, i >= threshold and impls[i] or noop) + end + end + + -- Remove `level` from the raw table so __newindex always fires for it. + rawset(instance, "level", nil) --- Attach methods to the global logger, closing over `log` so that mutations --- like `log.level = "warn"` take effect immediately without recreation. -attach_log_methods(log) + local mt = extra_mt or {} + mt.__index = function(_, k) + if k == "level" then return current_level end + end + mt.__newindex = function(t, k, v) + if k == "level" then + current_level = v + apply_level(v) + else + rawset(t, k, v) + end + end + setmetatable(instance, mt) + + apply_level(current_level) +end --- Make log callable to create named/customized logger instances. --- Usage: local logger = log{ name="my logger", level="debug" } --- Unspecified options inherit from the global values at creation time. -setmetatable(log, { +-- Attach methods to the global logger. Pass the __call metamethod in the same +-- table so the singleton ends up with a single combined metatable. +attach_log_methods(log, { __call = function(_, config) config = config or {} local instance = { From 97a6bc8dfec3bf69b8e482c299a6fbb6add8f85e Mon Sep 17 00:00:00 2001 From: Jason Xu <769155+jxai@users.noreply.github.com> Date: Wed, 18 Mar 2026 11:16:33 -0700 Subject: [PATCH 04/10] Updated tests to cover instances and noop --- test.lua | 212 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 202 insertions(+), 10 deletions(-) diff --git a/test.lua b/test.lua index 36c6952..82a9baf 100644 --- a/test.lua +++ b/test.lua @@ -90,7 +90,7 @@ do log.level = "trace" capture_start() - for _, fn in ipairs({"trace","debug","info","warn","error","fatal"}) do + for _, fn in ipairs({ "trace", "debug", "info", "warn", "error", "fatal" }) do log[fn]("x") end capture_stop() @@ -111,10 +111,10 @@ do capture_stop() local line = captured[1] - assert_true("output contains level label INFO", line:find("%[INFO")) + assert_true("output contains level label INFO", line:find("%[INFO")) assert_true("output contains HH:MM:SS timestamp", line:find("%d%d:%d%d:%d%d")) - assert_true("output contains the message", line:find("hello world")) - assert_true("output contains source:line", line:find("[^:]+:%d+")) + assert_true("output contains the message", line:find("hello world")) + assert_true("output contains source:line", line:find("[^:]+:%d+")) end do @@ -142,8 +142,8 @@ do log.usecolor = false -- Each level should use its own uppercased label - local labels = {"TRACE","DEBUG","INFO","WARN","ERROR","FATAL"} - local fns = {"trace","debug","info","warn","error","fatal"} + local labels = { "TRACE", "DEBUG", "INFO", "WARN", "ERROR", "FATAL" } + local fns = { "trace", "debug", "info", "warn", "error", "fatal" } for k, fn in ipairs(fns) do capture_start() log[fn]("x") @@ -210,16 +210,16 @@ do log.outfile = tmpfile log.warn("written to file") - log.outfile = nil -- stop further writes + log.outfile = nil -- stop further writes local f = assert(io.open(tmpfile, "r")) local contents = f:read("*a") f:close() os.remove(tmpfile) - assert_true("outfile contains level label", contents:find("WARN")) - assert_true("outfile contains the message", contents:find("written to file")) - assert_false("outfile has no ANSI codes", contents:find("\27%[")) + assert_true("outfile contains level label", contents:find("WARN")) + assert_true("outfile contains the message", contents:find("written to file")) + assert_false("outfile has no ANSI codes", contents:find("\27%[")) end do @@ -232,6 +232,198 @@ do end +-- ── Suite: logger instances ─────────────────────────────────────────────────── + +real_print("\n── logger instances ──") + +do + -- log is callable and returns a table with all 6 log methods + local inst = log {} + assert_eq("log{} returns a table", type(inst), "table") + for _, fn in ipairs({ "trace", "debug", "info", "warn", "error", "fatal" }) do + assert_eq("instance has method " .. fn, type(inst[fn]), "function") + end +end + +do + -- instance level filtering is independent from the global logger + reset_log() + log.level = "trace" + local inst = log { level = "error" } + + capture_start() + inst.trace("t"); inst.debug("d"); inst.info("i"); inst.warn("w") + capture_stop() + assert_eq("instance level=error suppresses trace/debug/info/warn", #captured, 0) + + capture_start() + inst.error("e"); inst.fatal("f") + capture_stop() + assert_eq("instance level=error passes error/fatal", #captured, 2) + + -- global logger must be unaffected + capture_start() + log.trace("global logger trace") + capture_stop() + assert_eq("global logger level unaffected by instance level", #captured, 1) +end + +do + -- instance inherits global logger defaults when options are omitted + reset_log() + log.level = "warn" + log.usecolor = false + local inst = log {} + assert_eq("instance inherits level from global logger", inst.level, "warn") + assert_eq("instance inherits usecolor from global logger", inst.usecolor, false) + reset_log() +end + +do + -- instance config is isolated: mutating the instance does not affect global logger + reset_log() + local inst = log { level = "trace", usecolor = false } + inst.level = "fatal" + inst.usecolor = true + assert_eq("global logger level unchanged after instance mutation", log.level, "trace") + assert_eq("global logger usecolor unchanged after instance mutation", log.usecolor, true) +end + +do + -- instance level is mutable after creation + reset_log() + local inst = log { level = "error", usecolor = false } + + capture_start() + inst.info("before") + capture_stop() + assert_eq("info suppressed before level change", #captured, 0) + + inst.level = "info" + + capture_start() + inst.info("after") + capture_stop() + assert_eq("info shown after level lowered to info", #captured, 1) +end + +do + -- instance usecolor is respected + reset_log() + local colored = log { usecolor = true, level = "trace" } + local plain = log { usecolor = false, level = "trace" } + + capture_start(); colored.info("c"); capture_stop() + assert_true("instance usecolor=true emits ANSI codes", captured[1]:find("\27%[")) + + capture_start(); plain.info("p"); capture_stop() + assert_false("instance usecolor=false emits no ANSI codes", captured[1]:find("\27%[")) +end + + +-- ── Suite: instance name ────────────────────────────────────────────────────── + +real_print("\n── instance name ──") + +do + -- name appears in console output + reset_log() + local inst = log { name = "mymod", usecolor = false } + + capture_start() + inst.info("hello") + capture_stop() + assert_true("name appears in console output", captured[1]:find("mymod")) +end + +do + -- global logger output has no name prefix + reset_log() + log.usecolor = false + + capture_start() + log.info("hello") + capture_stop() + -- output format is "[INFO HH:MM:SS] src:line: msg" — nothing before src:line + assert_true("global logger output matches expected format", + strip_ansi(captured[1]):match("%[%u+%s+%d+:%d+:%d+%] [^%s]+:%d+:")) +end + +do + -- unnamed instance output has no name prefix either + reset_log() + local inst = log { usecolor = false } + + capture_start() + inst.info("hello") + capture_stop() + assert_true("unnamed instance matches same format as global logger", + strip_ansi(captured[1]):match("%[%u+%s+%d+:%d+:%d+%] [^%s]+:%d+:")) +end + +do + -- name appears in outfile output + local tmpfile = os.tmpname() + local inst = log { name = "mymod", usecolor = false, outfile = tmpfile } + inst.warn("to file") + + local f = assert(io.open(tmpfile, "r")) + local contents = f:read("*a") + f:close() + os.remove(tmpfile) + + assert_true("name appears in outfile output", contents:find("mymod")) +end + + +-- ── Suite: noop optimization ───────────────────────────────────────────────── + +real_print("\n── noop optimization ──") + +do + -- all disabled levels share the same noop function reference + reset_log() + log.level = "fatal" + assert_true("disabled levels share one noop function", + log.trace == log.debug and log.debug == log.info and + log.info == log.warn and log.warn == log.error) + assert_false("enabled level is not the noop", log.trace == log.fatal) + reset_log() +end + +do + -- after raising the level, newly disabled levels become noop + reset_log() + local was_info = log.info -- real impl at level=trace + log.level = "error" + assert_false("info becomes noop after level raised to error", log.info == was_info) + assert_true("info and warn are now the same noop", log.info == log.warn) + reset_log() +end + +do + -- after lowering the level, previously disabled levels become real again + reset_log() + log.level = "fatal" + local noop_ref = log.info -- captured noop + log.level = "trace" + assert_false("info is no longer noop after level lowered to trace", log.info == noop_ref) + reset_log() +end + +do + -- same noop optimization applies to instances + local inst = log { level = "warn" } + assert_true("instance: disabled levels share one noop", + inst.trace == inst.debug and inst.debug == inst.info) + assert_false("instance: warn (enabled) is not noop", inst.trace == inst.warn) + + local noop_ref = inst.trace + inst.level = "trace" + assert_false("instance: trace is real impl after level lowered", inst.trace == noop_ref) +end + + -- ── Summary ────────────────────────────────────────────────────────────────── real_print(string.format("\n%d passed, %d failed", passed, failed)) From 087adb20be9f314f88bb78799a09c0387a6c4d25 Mon Sep 17 00:00:00 2001 From: Jason Xu <769155+jxai@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:43:19 -0700 Subject: [PATCH 05/10] Fix a few bugs, updated tests --- log.lua | 13 +++++++++---- test.lua | 32 +++++++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/log.lua b/log.lua index b257134..3d99ed1 100644 --- a/log.lua +++ b/log.lua @@ -88,10 +88,10 @@ local function attach_log_methods(instance, extra_mt) -- Output to log file if instance.outfile then - local fp = io.open(instance.outfile, "a") + local fp, err = io.open(instance.outfile, "a") + if not fp then error("could not open log file: " .. err) end local str = string.format("[%-6s%s] %s%s: %s\n", nameupper, os.date(), prefix, lineinfo, msg) - assert(fp) fp:write(str) fp:close() end @@ -100,6 +100,9 @@ local function attach_log_methods(instance, extra_mt) local function apply_level(level) local threshold = levels[level] + if not threshold then + error("invalid log level: " .. tostring(level), 2) + end for i, x in ipairs(modes) do rawset(instance, x.name, i >= threshold and impls[i] or noop) end @@ -131,9 +134,11 @@ end attach_log_methods(log, { __call = function(_, config) config = config or {} + local usecolor = config.usecolor + if usecolor == nil then usecolor = log.usecolor end local instance = { - usecolor = config.usecolor ~= nil and config.usecolor or log.usecolor, - outfile = config.outfile ~= nil and config.outfile or log.outfile, + usecolor = usecolor, + outfile = config.outfile or log.outfile, level = config.level or log.level, name = config.name, } diff --git a/test.lua b/test.lua index 82a9baf..a5bd73e 100644 --- a/test.lua +++ b/test.lua @@ -226,9 +226,39 @@ do -- Bad path should raise an error (fail loudly on misconfigured outfile) reset_log() log.outfile = "/nonexistent_dir/nope.log" - local ok = pcall(function() log.info("safe") end) + local ok, err = pcall(function() log.info("safe") end) log.outfile = nil assert_false("bad outfile path raises an error", ok) + assert_true("bad outfile error message contains OS reason", err and err:find("No such") ~= nil) +end + + +-- ── Suite: invalid level ───────────────────────────────────────────────────── + +real_print("\n── invalid level ──") + +do + -- setting an invalid level on the global logger raises immediately + reset_log() + local ok, err = pcall(function() log.level = "verbose" end) + reset_log() + assert_false("invalid level on global logger raises an error", ok) + assert_true("error message names the invalid value", err and err:find("verbose") ~= nil) +end + +do + -- same check for an instance + local inst = log { level = "trace" } + local ok, err = pcall(function() inst.level = "verbose" end) + assert_false("invalid level on instance raises an error", ok) + assert_true("instance error message names the invalid value", err and err:find("verbose") ~= nil) +end + +do + -- creating an instance with an invalid level raises immediately + local ok, err = pcall(function() log { level = "verbose" } end) + assert_false("log{level='invalid'} raises an error", ok) + assert_true("creation error message names the invalid value", err and err:find("verbose") ~= nil) end From f270fb427a0d7ad0e5370828a35ee09343285a8f Mon Sep 17 00:00:00 2001 From: Jason Xu <769155+jxai@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:46:47 -0700 Subject: [PATCH 06/10] Bump version to 0.2.0 --- log.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/log.lua b/log.lua index 3d99ed1..2ff6706 100644 --- a/log.lua +++ b/log.lua @@ -8,7 +8,7 @@ -- under the terms of the MIT license. See LICENSE for details. -- -local log = { _version = "0.1.0" } +local log = { _version = "0.2.0" } log.usecolor = true log.outfile = nil From f72bcfa0d311c2316137c5d621c4097e8d8f71ab Mon Sep 17 00:00:00 2001 From: Jason Xu <769155+jxai@users.noreply.github.com> Date: Fri, 20 Mar 2026 10:35:40 -0700 Subject: [PATCH 07/10] Add option to specify a custom serializer --- README.md | 30 +++++++++++++++++-- log.lua | 15 +++++----- test.lua | 88 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 8c802f8..b8fe424 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,30 @@ ignored. The level of each log mode, starting with the lowest log level is as follows: `"trace"` `"debug"` `"info"` `"warn"` `"error"` `"fatal"` +#### log.tostr + +A custom function that takes a single value and returns its string +representation, useful when you need richer or domain-specific output. `nil` +by default, falling back to the plain `tostring`. + +```lua +log.tostr = function(v) + if type(v) == "table" then + -- your own serialization + end + return tostring(v) +end +``` + +An example using [inspect.lua](https://github.com/kikito/inspect.lua): + +```lua +local inspect = require "inspect" +log.tostr = function(v) return inspect(v, {newline=" ", indent="", depth=2}) end +log.info({ key = "value" }) +-- [INFO 14:32:01] src.lua:2: { key = "value" } +``` + ## Logger instances The global logger `log` is also callable and returns a new independent logger @@ -77,10 +101,10 @@ logger.usecolor = false #### name -A string label included in each log line to identify the logger, `nil` by -default. The global logger doesn't have a name. +A string label included in each log line to identify the logger. `nil` by +default (no label), as with the global logger. -#### level, usecolor, outfile +#### level, usecolor, outfile, tostr Same semantics as the global options above. When not specified, the global values are used as defaults at instance creation time. diff --git a/log.lua b/log.lua index 2ff6706..7c69af5 100644 --- a/log.lua +++ b/log.lua @@ -38,16 +38,16 @@ local round = function(x, increment) end -local _tostring = tostring - -local tostring = function(...) +local make_msg = function(tostr, ...) local t = {} for i = 1, select('#', ...) do local x = select(i, ...) - if type(x) == "number" then - x = round(x, .01) + if tostr then + t[#t + 1] = tostr(x) + else + if type(x) == "number" then x = round(x, .01) end + t[#t + 1] = tostring(x) end - t[#t + 1] = _tostring(x) end return table.concat(t, " ") end @@ -71,7 +71,7 @@ local function attach_log_methods(instance, extra_mt) for i, x in ipairs(modes) do local nameupper = x.name:upper() impls[i] = function(...) - local msg = tostring(...) + local msg = make_msg(instance.tostr, ...) local info = debug.getinfo(2, "Sl") local lineinfo = info.short_src .. ":" .. info.currentline local prefix = instance.name and instance.name .. ":" or "" @@ -141,6 +141,7 @@ attach_log_methods(log, { outfile = config.outfile or log.outfile, level = config.level or log.level, name = config.name, + tostr = config.tostr or log.tostr, } attach_log_methods(instance) return instance diff --git a/test.lua b/test.lua index a5bd73e..38f6848 100644 --- a/test.lua +++ b/test.lua @@ -43,6 +43,7 @@ local function reset_log() log.level = "trace" log.usecolor = true log.outfile = nil + log.tostr = nil end -- Strip ANSI escape codes so we can assert on plain text @@ -454,6 +455,93 @@ do end +-- ── Suite: tostr ───────────────────────────────────────────────────────────── + +real_print("\n── tostr ──") + +do + -- nil by default: existing behavior unchanged + reset_log() + log.usecolor = false + assert_eq("tostr is nil by default on global logger", log.tostr, nil) + + capture_start() + log.info("plain") + capture_stop() + assert_true("nil tostr still produces output", captured[1]:find("plain")) +end + +do + -- custom tostr is called for each argument + reset_log() + log.usecolor = false + local calls = {} + log.tostr = function(v) calls[#calls + 1] = v; return "x" end + + capture_start() + log.info("a", "b") + capture_stop() + + assert_eq("custom tostr called once per arg", #calls, 2) + assert_true("custom tostr return value appears in output", captured[1]:find("x x")) +end + +do + -- custom tostr bypasses number rounding + log.tostr = function(v) return tostring(v) end + + capture_start() + log.info(1.23456) + capture_stop() + + assert_true("custom tostr bypasses number rounding", strip_ansi(captured[1]):find("1.23456")) +end + +do + -- custom tostr on an instance, does not affect global logger + reset_log() + log.usecolor = false + local inst = log { tostr = function(v) return "T:" .. tostring(v) end, usecolor = false } + + capture_start() + inst.info("hello") + capture_stop() + assert_true("instance tostr wraps value", captured[1]:find("T:hello")) + + capture_start() + log.info("hello") + capture_stop() + assert_false("global logger unaffected by instance tostr", captured[1]:find("T:hello")) +end + +do + -- instance inherits tostr from global logger + reset_log() + log.tostr = function(v) return "G:" .. tostring(v) end + local inst = log { usecolor = false } + log.usecolor = false + + capture_start() + inst.info("hello") + capture_stop() + + assert_true("instance inherits tostr from global logger", captured[1]:find("G:hello")) +end + +do + -- instance-level tostr overrides inherited global tostr + reset_log() + log.tostr = function(v) return "G:" .. tostring(v) end + local inst = log { tostr = function(v) return "I:" .. tostring(v) end, usecolor = false } + + capture_start() + inst.info("hello") + capture_stop() + assert_true("instance tostr overrides global tostr", captured[1]:find("I:hello")) + assert_false("global tostr not used when instance tostr set", captured[1]:find("G:hello")) +end + + -- ── Summary ────────────────────────────────────────────────────────────────── real_print(string.format("\n%d passed, %d failed", passed, failed)) From 52684d46448c7914325275fe7b040b18e3d8e44c Mon Sep 17 00:00:00 2001 From: Jason Xu <769155+jxai@users.noreply.github.com> Date: Fri, 20 Mar 2026 12:27:43 -0700 Subject: [PATCH 08/10] Refine doc --- README.md | 55 +++++++++++++++++++++++++------------------------------ 1 file changed, 25 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index b8fe424..84ef8c0 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,16 @@ if one is set -- the log file: log.lua provides variables for setting additional options: +#### log.level + +The minimum level to log, any logging function called with a lower level than +the `log.level` is ignored and no text is outputted or written. By default this +value is set to `"trace"`, the lowest log level, such that no log messages are +ignored. + +The level of each log mode, starting with the lowest log level is as follows: +`"trace"` `"debug"` `"info"` `"warn"` `"error"` `"fatal"` + #### log.usecolor Whether colors should be used when outputting to the console, this is `true` by @@ -44,31 +54,12 @@ ANSI colors and always use the full date rather than just the time. By default set as the `log.outfile` then it is created on the first message logged. If the file already exists it is appended to. -#### log.level - -The minimum level to log, any logging function called with a lower level than -the `log.level` is ignored and no text is outputted or written. By default this -value is set to `"trace"`, the lowest log level, such that no log messages are -ignored. - -The level of each log mode, starting with the lowest log level is as follows: -`"trace"` `"debug"` `"info"` `"warn"` `"error"` `"fatal"` - #### log.tostr A custom function that takes a single value and returns its string representation, useful when you need richer or domain-specific output. `nil` by default, falling back to the plain `tostring`. -```lua -log.tostr = function(v) - if type(v) == "table" then - -- your own serialization - end - return tostring(v) -end -``` - An example using [inspect.lua](https://github.com/kikito/inspect.lua): ```lua @@ -85,16 +76,9 @@ instance with its own configuration. Unspecified options inherit from the global logger's current values at creation time. ```lua -local logger = log{ name="my module", level="warn", outfile="app.log" } +local logger = log{ name="MyModule", level="warn" } logger.warn("something went wrong") --- [WARN 14:32:01] my module src.lua:2: something went wrong -``` - -All instance options are mutable after creation: - -```lua -logger.level = "trace" -logger.usecolor = false +-- [WARN 14:32:01] MyModule:src.lua:2: something went wrong ``` ### Instance options @@ -106,8 +90,19 @@ default (no label), as with the global logger. #### level, usecolor, outfile, tostr -Same semantics as the global options above. When not specified, the global -values are used as defaults at instance creation time. +Same semantics as the global options above. They are mutable after creation: + +```lua +logger.level = "trace" +logger.usecolor = false +logger.outfile = "app.log" +logger.tostr = function(v) + if type(v) == "table" then + -- your own serialization + end + return tostring(v) +end +``` ## License From 2e00b105f1c6371e0203c311a06a477b950d3b26 Mon Sep 17 00:00:00 2001 From: Jason Xu <769155+jxai@users.noreply.github.com> Date: Fri, 20 Mar 2026 15:10:35 -0700 Subject: [PATCH 09/10] Add option to toggle output stderr vs stdout --- README.md | 9 ++++- log.lua | 12 ++++-- test.lua | 109 ++++++++++++++++++++++++++++++++++++++++++++++-------- 3 files changed, 109 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 84ef8c0..351898e 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,12 @@ ANSI colors and always use the full date rather than just the time. By default set as the `log.outfile` then it is created on the first message logged. If the file already exists it is appended to. +#### log.stderr + +Whether to write console output to `stderr` instead of `stdout`. `false` by +default. Useful when the calling program uses stdout for data output and expects +log messages on stderr. + #### log.tostr A custom function that takes a single value and returns its string @@ -88,7 +94,7 @@ logger.warn("something went wrong") A string label included in each log line to identify the logger. `nil` by default (no label), as with the global logger. -#### level, usecolor, outfile, tostr +#### level, usecolor, outfile, stderr, tostr Same semantics as the global options above. They are mutable after creation: @@ -96,6 +102,7 @@ Same semantics as the global options above. They are mutable after creation: logger.level = "trace" logger.usecolor = false logger.outfile = "app.log" +logger.stderr = true logger.tostr = function(v) if type(v) == "table" then -- your own serialization diff --git a/log.lua b/log.lua index 7c69af5..f056350 100644 --- a/log.lua +++ b/log.lua @@ -10,9 +10,11 @@ local log = { _version = "0.2.0" } +log.level = "trace" log.usecolor = true log.outfile = nil -log.level = "trace" +log.stderr = false +log.tostr = nil local modes = { @@ -77,7 +79,8 @@ local function attach_log_methods(instance, extra_mt) local prefix = instance.name and instance.name .. ":" or "" -- Output to console - print(string.format("%s[%-6s%s]%s %s%s: %s", + local out = instance.stderr and io.stderr or io.stdout + out:write(string.format("%s[%-6s%s]%s %s%s: %s\n", instance.usecolor and x.color or "", nameupper, os.date("%H:%M:%S"), @@ -137,10 +140,11 @@ attach_log_methods(log, { local usecolor = config.usecolor if usecolor == nil then usecolor = log.usecolor end local instance = { + name = config.name, + level = config.level or log.level, usecolor = usecolor, outfile = config.outfile or log.outfile, - level = config.level or log.level, - name = config.name, + stderr = config.stderr ~= nil and config.stderr or log.stderr, tostr = config.tostr or log.tostr, } attach_log_methods(instance) diff --git a/test.lua b/test.lua index 38f6848..bd2a040 100644 --- a/test.lua +++ b/test.lua @@ -25,15 +25,19 @@ local function assert_false(label, v) assert_eq(label, not not v, false) end --- Capture print output for inspection +-- Capture console output for inspection local captured = {} -local real_print = print +local real_stdout = io.stdout +local real_stderr = io.stderr +local mock_io = { write = function(_, s) captured[#captured + 1] = s end } local function capture_start() captured = {} - print = function(s) captured[#captured + 1] = s end + io.stdout = mock_io + io.stderr = mock_io end local function capture_stop() - print = real_print + io.stdout = real_stdout + io.stderr = real_stderr end @@ -43,6 +47,7 @@ local function reset_log() log.level = "trace" log.usecolor = true log.outfile = nil + log.stderr = false log.tostr = nil end @@ -54,7 +59,7 @@ end -- ── Suite: level filtering ──────────────────────────────────────────────────── -real_print("\n── level filtering ──") +print("\n── level filtering ──") do reset_log() @@ -101,7 +106,7 @@ end -- ── Suite: output format ────────────────────────────────────────────────────── -real_print("\n── output format ──") +print("\n── output format ──") do reset_log() @@ -156,7 +161,7 @@ end -- ── Suite: number rounding ──────────────────────────────────────────────────── -real_print("\n── number rounding ──") +print("\n── number rounding ──") do reset_log() @@ -182,7 +187,7 @@ end -- ── Suite: multi-argument concatenation ────────────────────────────────────── -real_print("\n── multi-argument concatenation ──") +print("\n── multi-argument concatenation ──") do reset_log() @@ -202,7 +207,7 @@ end -- ── Suite: outfile ──────────────────────────────────────────────────────────── -real_print("\n── outfile ──") +print("\n── outfile ──") do local tmpfile = os.tmpname() @@ -236,7 +241,7 @@ end -- ── Suite: invalid level ───────────────────────────────────────────────────── -real_print("\n── invalid level ──") +print("\n── invalid level ──") do -- setting an invalid level on the global logger raises immediately @@ -265,7 +270,7 @@ end -- ── Suite: logger instances ─────────────────────────────────────────────────── -real_print("\n── logger instances ──") +print("\n── logger instances ──") do -- log is callable and returns a table with all 6 log methods @@ -354,7 +359,7 @@ end -- ── Suite: instance name ────────────────────────────────────────────────────── -real_print("\n── instance name ──") +print("\n── instance name ──") do -- name appears in console output @@ -409,7 +414,7 @@ end -- ── Suite: noop optimization ───────────────────────────────────────────────── -real_print("\n── noop optimization ──") +print("\n── noop optimization ──") do -- all disabled levels share the same noop function reference @@ -457,7 +462,7 @@ end -- ── Suite: tostr ───────────────────────────────────────────────────────────── -real_print("\n── tostr ──") +print("\n── tostr ──") do -- nil by default: existing behavior unchanged @@ -476,7 +481,9 @@ do reset_log() log.usecolor = false local calls = {} - log.tostr = function(v) calls[#calls + 1] = v; return "x" end + log.tostr = function(v) + calls[#calls + 1] = v; return "x" + end capture_start() log.info("a", "b") @@ -542,7 +549,77 @@ do end +-- ── Suite: stderr ──────────────────────────────────────────────────────────── + +print("\n── stderr ──") + +do + -- stderr=false by default: output goes to stdout + reset_log() + log.usecolor = false + assert_eq("stderr is false by default", log.stderr, false) +end + +do + -- stderr=false: output goes to stdout, nothing to stderr + reset_log() + log.usecolor = false + + local stdout_captured = {} + local stderr_captured = {} + io.stdout = { write = function(_, s) stdout_captured[#stdout_captured + 1] = s end } + io.stderr = { write = function(_, s) stderr_captured[#stderr_captured + 1] = s end } + + log.info("to stdout") + + io.stdout = real_stdout + io.stderr = real_stderr + + assert_eq("stderr=false: stdout receives output", #stdout_captured, 1) + assert_true("stderr=false: stdout contains message", stdout_captured[1]:find("to stdout")) + assert_eq("stderr=false: stderr receives nothing", #stderr_captured, 0) +end + +do + -- stderr=true: output goes to stderr, nothing to stdout + reset_log() + log.usecolor = false + log.stderr = true + + local stdout_captured = {} + local stderr_captured = {} + io.stdout = { write = function(_, s) stdout_captured[#stdout_captured + 1] = s end } + io.stderr = { write = function(_, s) stderr_captured[#stderr_captured + 1] = s end } + + log.info("to stderr") + + io.stdout = real_stdout + io.stderr = real_stderr + + assert_eq("stderr=true: stderr receives output", #stderr_captured, 1) + assert_true("stderr=true: stderr contains message", stderr_captured[1]:find("to stderr")) + assert_eq("stderr=true: stdout receives nothing", #stdout_captured, 0) +end + +do + -- instance inherits stderr from global logger + reset_log() + log.stderr = true + local inst = log { usecolor = false } + assert_eq("instance inherits stderr from global logger", inst.stderr, true) + reset_log() +end + +do + -- instance stderr overrides global + reset_log() + log.stderr = false + local inst = log { stderr = true, usecolor = false } + assert_eq("instance stderr overrides global", inst.stderr, true) +end + + -- ── Summary ────────────────────────────────────────────────────────────────── -real_print(string.format("\n%d passed, %d failed", passed, failed)) +print(string.format("\n%d passed, %d failed", passed, failed)) if failed > 0 then os.exit(1) end From 138e641356b52ebb849a2dd1a6e2cbcd1792c4d2 Mon Sep 17 00:00:00 2001 From: Jason Xu <769155+jxai@users.noreply.github.com> Date: Fri, 20 Mar 2026 15:27:17 -0700 Subject: [PATCH 10/10] Make instance name read-only after creation --- README.md | 4 ++-- log.lua | 11 ++++++++--- test.lua | 9 +++++++++ 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 351898e..ad93381 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ log messages on stderr. A custom function that takes a single value and returns its string representation, useful when you need richer or domain-specific output. `nil` -by default, falling back to the plain `tostring`. +by default, falling back to the built-in behavior (number rounding + `tostring`). An example using [inspect.lua](https://github.com/kikito/inspect.lua): @@ -92,7 +92,7 @@ logger.warn("something went wrong") #### name A string label included in each log line to identify the logger. `nil` by -default (no label), as with the global logger. +default (no label), as with the global logger. Read-only after creation. #### level, usecolor, outfile, stderr, tostr diff --git a/log.lua b/log.lua index f056350..f424d1a 100644 --- a/log.lua +++ b/log.lua @@ -67,16 +67,17 @@ local noop = function() end -- on the singleton) into the same metatable. local function attach_log_methods(instance, extra_mt) local current_level = instance.level + local current_name = instance.name + local impls = {} -- Build real implementations upfront, closed over `instance`. - local impls = {} for i, x in ipairs(modes) do local nameupper = x.name:upper() impls[i] = function(...) local msg = make_msg(instance.tostr, ...) local info = debug.getinfo(2, "Sl") local lineinfo = info.short_src .. ":" .. info.currentline - local prefix = instance.name and instance.name .. ":" or "" + local prefix = current_name and current_name .. ":" or "" -- Output to console local out = instance.stderr and io.stderr or io.stdout @@ -111,17 +112,21 @@ local function attach_log_methods(instance, extra_mt) end end - -- Remove `level` from the raw table so __newindex always fires for it. + -- Remove level and name from the raw table so __newindex always fires for them. rawset(instance, "level", nil) + rawset(instance, "name", nil) local mt = extra_mt or {} mt.__index = function(_, k) if k == "level" then return current_level end + if k == "name" then return current_name end end mt.__newindex = function(t, k, v) if k == "level" then current_level = v apply_level(v) + elseif k == "name" then + error("name is read-only after creation", 2) else rawset(t, k, v) end diff --git a/test.lua b/test.lua index bd2a040..66fe31a 100644 --- a/test.lua +++ b/test.lua @@ -411,6 +411,15 @@ do assert_true("name appears in outfile output", contents:find("mymod")) end +do + -- name is read-only after creation + local inst = log { name = "mymod" } + local ok, err = pcall(function() inst.name = "other" end) + assert_false("assigning name after creation raises an error", ok) + assert_true("error message mentions name is read-only", err and err:find("read%-only")) + assert_eq("name unchanged after failed assignment", inst.name, "mymod") +end + -- ── Suite: noop optimization ─────────────────────────────────────────────────