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..ad93381 100644 --- a/README.md +++ b/README.md @@ -1,56 +1,117 @@ # 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.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 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 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. +#### log.stderr -The level of each log mode, starting with the lowest log level is as follows: -`"trace"` `"debug"` `"info"` `"warn"` `"error"` `"fatal"` +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 +representation, useful when you need richer or domain-specific output. `nil` +by default, falling back to the built-in behavior (number rounding + `tostring`). + +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 +instance with its own configuration. Unspecified options inherit from the +global logger's current values at creation time. + +```lua +local logger = log{ name="MyModule", level="warn" } +logger.warn("something went wrong") +-- [WARN 14:32:01] MyModule:src.lua:2: something went wrong +``` + +### Instance options + +#### name + +A string label included in each log line to identify the logger. `nil` by +default (no label), as with the global logger. Read-only after creation. + +#### level, usecolor, outfile, stderr, tostr + +Same semantics as the global options above. They are mutable after creation: + +```lua +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 + end + return tostring(v) +end +``` ## 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..f424d1a 100644 --- a/log.lua +++ b/log.lua @@ -2,16 +2,19 @@ -- 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. -- -local log = { _version = "0.1.0" } +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 = { @@ -37,54 +40,122 @@ 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 -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 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 + local current_name = instance.name + local impls = {} + + -- Build real implementations upfront, closed over `instance`. + 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 = current_name and current_name .. ":" or "" + + -- Output to console + 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"), + instance.usecolor and "\27[0m" or "", + prefix, + lineinfo, + msg)) + + -- Output to log file + if instance.outfile then + 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) + fp:write(str) + fp:close() + 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() + 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 + end + + -- 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 + end + setmetatable(instance, mt) + + apply_level(current_level) end +-- 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 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, + stderr = config.stderr ~= nil and config.stderr or log.stderr, + tostr = config.tostr or log.tostr, + } + attach_log_methods(instance) + return instance + end +}) + + return log diff --git a/test.lua b/test.lua new file mode 100644 index 0000000..66fe31a --- /dev/null +++ b/test.lua @@ -0,0 +1,634 @@ +-- 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 console output for inspection +local captured = {} +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 = {} + io.stdout = mock_io + io.stderr = mock_io +end +local function capture_stop() + io.stdout = real_stdout + io.stderr = real_stderr +end + + +-- ── Helpers ────────────────────────────────────────────────────────────────── + +local function reset_log() + log.level = "trace" + log.usecolor = true + log.outfile = nil + log.stderr = false + log.tostr = 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 ──────────────────────────────────────────────────── + +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 ────────────────────────────────────────────────────── + +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 ──────────────────────────────────────────────────── + +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 ────────────────────────────────────── + +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 ──────────────────────────────────────────────────────────── + +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, 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 ───────────────────────────────────────────────────── + +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 + + +-- ── Suite: logger instances ─────────────────────────────────────────────────── + +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 ────────────────────────────────────────────────────── + +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 + +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 ───────────────────────────────────────────────── + +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 + + +-- ── Suite: tostr ───────────────────────────────────────────────────────────── + +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 + + +-- ── 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 ────────────────────────────────────────────────────────────────── + +print(string.format("\n%d passed, %d failed", passed, failed)) +if failed > 0 then os.exit(1) end