Files
mfer/internal/log/log.go
T
sneak 2e415004a8
check / check (push) Waiting to run
Move internal/log from apex/log to log/slog (closes #77)
internal/log now logs through log/slog. After Init, records go to a small
slog.Handler that prints the same lines as before, colored by the same
rule; before Init they go to slog.Default(). Level filtering, the helpers
and their call sites are unchanged. With NO_COLOR set on a terminal, log
lines are no longer colored.

pterm is dropped: mfer used it only to print progress lines to the
configured stdout, which fmt now does directly, outside slog. apex/log,
pterm and their indirect dependencies leave go.mod; golang.org/x/term,
already in the module graph, becomes direct.

simplelog is not used: its handler writes to stdout and would change the
CLI's output. The logger stays process-global, since injecting it would
change package mfer's API.

Model: opus-5-5
2026-10-04 12:09:28 +00:00

380 lines
8.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package log provides leveled logging on top of log/slog, and helpers that
// print progress lines which overwrite each other in place on a terminal.
//
// Until Init runs, log records go to slog.Default(), so a program that uses
// package mfer as a library gets them wherever it sends its own slog output.
// Init switches to the CLI's format on the stderr writer given to SetOutput.
// Progress lines are not log records: they are written straight to the stdout
// writer, never through slog.
package log
import (
"context"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"runtime"
"sync"
"github.com/davecgh/go-spew/spew"
"golang.org/x/term"
)
// Level represents log severity levels.
// Lower values are more verbose.
type Level int
const (
// DebugLevel is for low-level tracing and structure inspection
DebugLevel Level = iota
// VerboseLevel is for detailed operational info (file listings, etc)
VerboseLevel
// InfoLevel is for operational summaries (default)
InfoLevel
// WarnLevel is for warnings
WarnLevel
// ErrorLevel is for errors
ErrorLevel
// FatalLevel is for fatal errors
FatalLevel
)
func (l Level) String() string {
switch l {
case DebugLevel:
return "debug"
case VerboseLevel:
return "verbose"
case InfoLevel:
return "info"
case WarnLevel:
return "warn"
case ErrorLevel:
return "error"
case FatalLevel:
return "fatal"
default:
return "unknown"
}
}
// callerSkip is the runtime.Caller stack depth from the public Debug
// helpers to the caller of the log package.
const callerSkip = 2
// Escape sequences for colored log lines.
const (
ansiReset = "\x1b[0m"
ansiBold = "\x1b[1m"
ansiRed = "\x1b[31m"
ansiYellow = "\x1b[33m"
ansiBlue = "\x1b[34m"
ansiWhite = "\x1b[37m"
)
//nolint:gochecknoglobals // package-level logger state by design
var (
// mu protects the variables below
mu sync.RWMutex
// stdout is the writer for progress output
stdout io.Writer = os.Stdout
// stderr is the writer for log output
stderr io.Writer = os.Stderr
// styled is false once DisableStyling has been called
styled = true
// logger is the CLI logger Init builds; nil until Init runs
logger *slog.Logger
// currentLevel is our log level (includes Verbose)
currentLevel = InfoLevel
)
// cliHandler is the slog.Handler the CLI logs through. Each record is one
// line: a symbol for its level right-aligned in four columns, then the
// message padded to 25 columns. In color, the symbol is bold and the line
// takes the level's color. Records are filtered by level before they are
// made, so the handler takes every record it is given.
type cliHandler struct {
mu sync.Mutex
w io.Writer
color bool
}
// Enabled reports true for every level.
func (h *cliHandler) Enabled(context.Context, slog.Level) bool {
return true
}
// Handle writes the record as one line.
func (h *cliHandler) Handle(_ context.Context, r slog.Record) error {
symbol, color := "•", ansiBlue
switch {
case r.Level >= slog.LevelError:
symbol, color = "⨯", ansiRed
case r.Level >= slog.LevelWarn:
color = ansiYellow
case r.Level < slog.LevelInfo:
color = ansiWhite
}
h.mu.Lock()
defer h.mu.Unlock()
if !h.color {
_, err := fmt.Fprintf(h.w, "%4s %-25s\n", symbol, r.Message)
return err
}
_, err := fmt.Fprintf(h.w, "%s%s%4s%s %-25s%s\n",
color, ansiBold, symbol, ansiReset, r.Message, ansiReset)
return err
}
// WithAttrs returns the handler unchanged: this package's helpers never
// attach attributes.
func (h *cliHandler) WithAttrs([]slog.Attr) slog.Handler {
return h
}
// WithGroup returns the handler unchanged: this package's helpers never open
// groups.
func (h *cliHandler) WithGroup(string) slog.Handler {
return h
}
// SetOutput configures the output writers for the log package.
// stdout is used for progress output, stderr is used for log messages
// from the next Init on.
func SetOutput(out, err io.Writer) {
mu.Lock()
defer mu.Unlock()
stdout = out
stderr = err
}
// GetStdout returns the configured stdout writer.
func GetStdout() io.Writer {
mu.RLock()
defer mu.RUnlock()
return stdout
}
// GetStderr returns the configured stderr writer.
func GetStderr() io.Writer {
mu.RLock()
defer mu.RUnlock()
return stderr
}
// DisableStyling turns off colors in log lines from the next Init on.
func DisableStyling() {
mu.Lock()
defer mu.Unlock()
styled = false
}
// Init sends log records to the CLI handler on the stderr writer given to
// SetOutput. Log lines are colored when stdout is a terminal whose TERM is
// not dumb, unless DisableStyling has been called.
//
// It replaces the logger under the write lock, and log calls hold the read
// lock while they write, so once Init returns nothing writes through the
// logger it replaced.
func Init() {
mu.Lock()
defer mu.Unlock()
color := styled && os.Getenv("TERM") != "dumb" &&
term.IsTerminal(int(os.Stdout.Fd()))
logger = slog.New(&cliHandler{w: stderr, color: color})
}
// isEnabled returns true if messages at the given level should be logged.
func isEnabled(l Level) bool {
mu.RLock()
defer mu.RUnlock()
return l >= currentLevel
}
// logf logs a formatted message at level l if messages at l are enabled,
// holding the read lock while the record is written. slog has no verbose or
// fatal level: verbose messages are logged as info records, and fatal
// messages as error records.
func logf(l Level, format string, args ...any) {
mu.RLock()
defer mu.RUnlock()
if l < currentLevel {
return
}
lg := logger
if lg == nil {
lg = slog.Default()
}
msg := fmt.Sprintf(format, args...)
switch l {
case DebugLevel:
lg.Debug(msg)
case VerboseLevel, InfoLevel:
lg.Info(msg)
case WarnLevel:
lg.Warn(msg)
case ErrorLevel, FatalLevel:
lg.Error(msg)
}
}
// Fatalf logs a formatted message at fatal level, then exits with status 1.
func Fatalf(format string, args ...any) {
logf(FatalLevel, format, args...)
os.Exit(1)
}
// Fatal logs a message at fatal level, then exits with status 1.
func Fatal(arg string) {
logf(FatalLevel, "%s", arg)
os.Exit(1)
}
// Errorf logs a formatted message at error level.
func Errorf(format string, args ...any) {
logf(ErrorLevel, format, args...)
}
// Error logs a message at error level.
func Error(arg string) {
logf(ErrorLevel, "%s", arg)
}
// Warnf logs a formatted message at warn level.
func Warnf(format string, args ...any) {
logf(WarnLevel, format, args...)
}
// Warn logs a message at warn level.
func Warn(arg string) {
logf(WarnLevel, "%s", arg)
}
// Infof logs a formatted message at info level.
func Infof(format string, args ...any) {
logf(InfoLevel, format, args...)
}
// Info logs a message at info level.
func Info(arg string) {
logf(InfoLevel, "%s", arg)
}
// Verbosef logs a formatted message at verbose level.
func Verbosef(format string, args ...any) {
logf(VerboseLevel, format, args...)
}
// Verbose logs a message at verbose level.
func Verbose(arg string) {
logf(VerboseLevel, "%s", arg)
}
// Debugf logs a formatted message at debug level with caller location.
func Debugf(format string, args ...any) {
if isEnabled(DebugLevel) {
DebugReal(fmt.Sprintf(format, args...), callerSkip)
}
}
// Debug logs a message at debug level with caller location.
func Debug(arg string) {
if isEnabled(DebugLevel) {
DebugReal(arg, callerSkip)
}
}
// DebugReal logs at debug level with caller info from the specified stack depth.
func DebugReal(arg string, cs int) {
_, callerFile, callerLine, ok := runtime.Caller(cs)
if !ok {
return
}
logf(DebugLevel, "%s:%d: %s", filepath.Base(callerFile), callerLine, arg)
}
// Dump logs a spew dump of the arguments at debug level.
func Dump(args ...any) {
if isEnabled(DebugLevel) {
DebugReal(spew.Sdump(args...), callerSkip)
}
}
// EnableDebugLogging sets the log level to debug.
func EnableDebugLogging() {
SetLevel(DebugLevel)
}
// VerbosityStepsToLogLevel converts a -v count to a log level.
// 0 returns InfoLevel, 1 returns VerboseLevel, 2+ returns DebugLevel.
func VerbosityStepsToLogLevel(l int) Level {
switch l {
case 0:
return InfoLevel
case 1:
return VerboseLevel
default:
return DebugLevel
}
}
// SetLevelFromVerbosity sets the log level based on -v flag count.
func SetLevelFromVerbosity(l int) {
SetLevel(VerbosityStepsToLogLevel(l))
}
// SetLevel sets the global log level.
func SetLevel(l Level) {
mu.Lock()
defer mu.Unlock()
currentLevel = l
}
// GetLevel returns the current log level.
func GetLevel() Level {
mu.RLock()
defer mu.RUnlock()
return currentLevel
}
// Progressf prints a progress message that overwrites the current line.
// Use ProgressDone() when progress is complete to move to the next line.
// Progress goes to the stdout writer whatever the log level.
func Progressf(format string, args ...any) {
mu.Lock()
defer mu.Unlock()
_, _ = fmt.Fprintf(stdout, "\r"+format, args...)
}
// ProgressDone clears the progress line when progress is complete.
func ProgressDone() {
mu.Lock()
defer mu.Unlock()
// Return to the start of the line and erase it
_, _ = fmt.Fprint(stdout, "\r\033[K")
}