// 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") }