feat(log): add colored NO_COLOR-aware logging

This commit is contained in:
2026-08-08 17:16:50 -04:00
parent b2d1df72f9
commit c331416623
3 changed files with 346 additions and 0 deletions
+242
View File
@@ -0,0 +1,242 @@
/// Colored, always-verbose logging for tofu, mirroring zeta-toolchain's
/// `toolchain/lib/log.lua`.
///
/// Every operation is printed so the user sees exactly what is happening.
/// Colors are disabled when `NO_COLOR` is set (to any value, including
/// empty) or when `TERM` is unset, empty, or `dumb`. All functions take a
/// format string plus optional arguments, e.g. `logStep("building %s", "foo")`,
/// and write immediately — nothing is buffered.
module tofu.log;
import core.stdc.stdlib : exit;
import std.format : format;
import std.process : environment;
import std.stdio : File, stdout, stderr;
/// ANSI escape sequences used by tofu. Values match zeta-toolchain exactly.
private enum ColorCodes
{
reset = "\x1b[0m",
cyan = "\x1b[36m",
green = "\x1b[32m",
yellow = "\x1b[33m",
red = "\x1b[31m",
dim = "\x1b[2m",
}
/// Whether the current terminal supports color output.
///
/// Re-reads the environment on every call so it reacts to runtime changes
/// and is trivially unit-testable without restarting the process. Returns
/// `false` when `NO_COLOR` is set, or when `TERM` is missing, empty, or
/// `dumb`.
bool colorEnabled() @safe
{
if (environment.get("NO_COLOR") !is null)
return false;
auto term = environment.get("TERM");
return term !is null && term.length > 0 && term != "dumb";
}
/// Wrap `text` in `code` and a trailing reset, or return it unchanged when
/// colors are disabled.
private string paint(string code, string text) @safe
{
return colorEnabled() ? code ~ text ~ ColorCodes.reset : text;
}
/// Write `line` (already newline-terminated by `writeln`) to stdout.
/// `@trusted`: `stdout` itself is `@system` to access in dmd 2.112.
private void writeStdout(string line) @trusted
{
stdout.writeln(line);
}
/// Write `line` to stderr. See `writeStdout`.
private void writeStderr(string line) @trusted
{
stderr.writeln(line);
}
/// `==> message` in cyan on stdout.
void logStep(A...)(A args) @safe
{
writeStdout(paint(ColorCodes.cyan, "==> " ~ format(args)));
}
/// ` ok message` in green on stdout.
void logOk(A...)(A args) @safe
{
writeStdout(paint(ColorCodes.green, " ok " ~ format(args)));
}
/// `warn message` in yellow on stderr.
void logWarn(A...)(A args) @safe
{
writeStderr(paint(ColorCodes.yellow, "warn " ~ format(args)));
}
/// `error message` in red on stderr.
void logError(A...)(A args) @safe
{
writeStderr(paint(ColorCodes.red, "error") ~ " " ~ format(args));
}
/// ` - message` plain on stdout.
void logInfo(A...)(A args) @safe
{
writeStdout(" - " ~ format(args));
}
/// ` . message` dim on stdout.
void logDetail(A...)(A args) @safe
{
writeStdout(paint(ColorCodes.dim, " . " ~ format(args)));
}
/// `error message` in red on stderr, then exits with status 1.
void logFatal(A...)(A args) @trusted
{
logError(args);
exit(1);
}
/// Test helper: set `NO_COLOR`/`TERM` for a color test and restore the
/// previous values on scope exit, so tests are independent of the ambient
/// environment. A `null` value removes the variable.
private struct ColorEnv
{
private
{
string oldNoColor;
string oldTerm;
bool hadNoColor;
bool hadTerm;
}
this(string noColor, string term) @trusted
{
auto nc = environment.get("NO_COLOR");
if (nc !is null)
{
hadNoColor = true;
oldNoColor = nc;
}
auto t = environment.get("TERM");
if (t !is null)
{
hadTerm = true;
oldTerm = t;
}
if (noColor is null)
environment.remove("NO_COLOR");
else
environment["NO_COLOR"] = noColor;
if (term is null)
environment.remove("TERM");
else
environment["TERM"] = term;
}
~this() @trusted
{
if (hadNoColor)
environment["NO_COLOR"] = oldNoColor;
else
environment.remove("NO_COLOR");
if (hadTerm)
environment["TERM"] = oldTerm;
else
environment.remove("TERM");
}
}
/// Captures stdout (or stderr) writes of a callback into a memory string by
/// temporarily swapping the global stream, mirroring the `(1)`/`(3)` tests.
private string capture(void delegate() dg) @trusted
{
import std.file : exists, remove, tempDir;
auto name = tempDir() ~ "/tofu-log-capture.tmp";
scope (exit) if (exists(name)) remove(name);
auto file = File(name, "w+");
auto savedStream = stdout;
stdout = file;
scope (exit) stdout = savedStream;
scope (failure) stdout = savedStream;
dg();
stdout.flush();
file.rewind();
return file.readln();
}
unittest
{
// (1) logStep paints a cyan "==> " line to stdout.
import std.algorithm.searching : canFind;
auto env = ColorEnv(null, "xterm-256color");
auto line = capture({ logStep("building %s", "foo"); });
assert(line.canFind("\x1b[36m"), "expected cyan escape in: `" ~ line ~ "`");
assert(line.canFind("==> building foo"),
"expected prefixed message in: `" ~ line ~ "`");
}
unittest
{
// (2) NO_COLOR, set to any value including empty, disables color.
auto env = ColorEnv(null, "xterm");
assert(colorEnabled(), "color should be on with a real TERM and no NO_COLOR");
auto env1 = ColorEnv("1", "xterm");
assert(!colorEnabled(), "NO_COLOR=1 must disable color");
auto env2 = ColorEnv("", "xterm");
assert(!colorEnabled(), "NO_COLOR set-but-empty must disable color");
}
unittest
{
// (3) logError writes a red "error " line to stderr.
import std.algorithm.searching : canFind;
import std.file : exists, remove, tempDir;
import std.stdio : stderr;
auto env = ColorEnv(null, "xterm-256color");
auto name = tempDir() ~ "/tofu-log-stderr.tmp";
scope (exit) if (exists(name)) remove(name);
auto file = File(name, "w+");
auto savedStream = stderr;
stderr = file;
scope (exit) stderr = savedStream;
logError("failed %s", "deploy");
stderr.flush();
file.rewind();
auto line = file.readln();
assert(line.canFind("\x1b[31m"), "expected red escape in: `" ~ line ~ "`");
assert(line.canFind("\x1b[31merror\x1b[0m failed deploy"),
"expected error line in: `" ~ line ~ "`");
}
unittest
{
// (4) TERM unset, empty, or "dumb" disables color.
auto env1 = ColorEnv(null, "dumb");
assert(!colorEnabled(), "TERM=dumb must disable color");
auto env2 = ColorEnv(null, null);
assert(!colorEnabled(), "missing TERM must disable color");
auto env3 = ColorEnv(null, "");
assert(!colorEnabled(), "empty TERM must disable color");
auto env4 = ColorEnv(null, "xterm");
assert(colorEnabled(), "TERM=xterm must enable color");
}