feat(log): add colored NO_COLOR-aware logging
This commit is contained in:
+242
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user