check / check (push) Waiting to run
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
380 lines
8.9 KiB
Go
380 lines
8.9 KiB
Go
// 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")
|
||
}
|