diff --git a/go.mod b/go.mod index 4202982..765dadd 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,11 @@ module github.com/lmittmann/tint go 1.21 + +require github.com/fatih/color v1.17.0 + +require ( + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + golang.org/x/sys v0.18.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4ddf511 --- /dev/null +++ b/go.sum @@ -0,0 +1,11 @@ +github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= +github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/handler.go b/handler.go index d54b5d7..51924bc 100644 --- a/handler.go +++ b/handler.go @@ -24,6 +24,34 @@ See [slog.HandlerOptions] for details. }), ) +# Customize Level and Colors + +Options.LevelColorsMap can be used to customize the log level and colors. + + w := os.Stderr + logger := slog.New( + tint.NewHandler(w, &tint.Options{ + LevelColorsMap: tint.LevelColorsMapping{ + slog.LevelDebug: { + Name: "DEBUG", + Color: color.FgBlue, + }, + slog.LevelInfo: { + Name: "INFO", + Color: color.FgGreen, + }, + slog.LevelWarn: { + Name: "WARN", + Color: color.FgYellow, + }, + slog.LevelError: { + Name: "ERROR", + Color: color.FgRed, + }, + }, + }), + ) + # Automatically Enable Colors Colors are enabled by default and can be disabled using the Options.NoColor @@ -99,6 +127,9 @@ type Options struct { // See https://pkg.go.dev/log/slog#HandlerOptions for details. ReplaceAttr func(groups []string, attr slog.Attr) slog.Attr + // LevelColorsMap maps slog.Level to their LevelColor + LevelColorsMap LevelColorsMapping + // Time format (Default: time.StampMilli) TimeFormat string @@ -126,6 +157,13 @@ func NewHandler(w io.Writer, opts *Options) slog.Handler { if opts.TimeFormat != "" { h.timeFormat = opts.TimeFormat } + + if len(opts.LevelColorsMap) > 0 { + h.levelColors = opts.LevelColorsMap.LevelColors() + } else { + h.levelColors = &LevelColors{} + } + h.noColor = opts.NoColor return h } @@ -141,6 +179,7 @@ type handler struct { addSource bool level slog.Leveler + levelColors *LevelColors replaceAttr func([]string, slog.Attr) slog.Attr timeFormat string noColor bool @@ -154,6 +193,7 @@ func (h *handler) clone() *handler { w: h.w, addSource: h.addSource, level: h.level, + levelColors: h.levelColors.Copy(), replaceAttr: h.replaceAttr, timeFormat: h.timeFormat, noColor: h.noColor, @@ -283,6 +323,14 @@ func (h *handler) appendTime(buf *buffer, t time.Time) { } func (h *handler) appendLevel(buf *buffer, level slog.Level) { + // check if a LevelColor is defined + if h.levelColors != nil { + if levelColor := h.levelColors.LevelColor(level); levelColor != nil { + buf.WriteString(levelColor.String(!h.noColor)) + return + } + } + switch { case level < slog.LevelInfo: buf.WriteString("DBG") diff --git a/levels.go b/levels.go new file mode 100644 index 0000000..0c6f25f --- /dev/null +++ b/levels.go @@ -0,0 +1,125 @@ +package tint + +import ( + "log/slog" + + "github.com/fatih/color" +) + +// LevelColors defines the name as displayed to the user and color of a log level. +type LevelColor struct { + // Name is the name of the log level + Name string + // Color is the color of the log level + Color color.Attribute + serialized string + colored bool +} + +// String returns the level name, optionally with color applied. +func (lc *LevelColor) String(colored bool) string { + if len(lc.serialized) == 0 || lc.colored != colored { + if colored { + lc.serialized = color.New(lc.Color).SprintFunc()(lc.Name) + } else { + lc.serialized = lc.Name + } + } + return lc.serialized +} + +// Copy returns a copy of the LevelColor. +func (lc *LevelColor) Copy() *LevelColor { + return &LevelColor{ + Name: lc.Name, + Color: lc.Color, + serialized: lc.serialized, + colored: lc.colored, + } +} + +// LevelColorsMapping is a map of log levels to their colors and is what +// the user defines in their configuration. +type LevelColorsMapping map[slog.Level]LevelColor + +// min returns the mapped minimum index +func (lm *LevelColorsMapping) min() int { + idx := 1000 + for check := range *lm { + if int(check) < idx { + idx = int(check) + } + } + return idx +} + +// size returns the size of the slice needed to store the LevelColors. +func (lm *LevelColorsMapping) size(offset int) int { + maxIdx := -1000 + for check := range *lm { + if int(check) > maxIdx { + maxIdx = int(check) + } + } + return offset + maxIdx + 1 +} + +// offset returns the index offset needed to map negative log levels. +func (lm *LevelColorsMapping) offset() int { + min := lm.min() + if min < 0 { + min = -min + } + return min +} + +// LevelColors returns the LevelColors for the LevelColorsMapping. +func (lm *LevelColorsMapping) LevelColors() *LevelColors { + lcList := make([]*LevelColor, lm.size(lm.offset())) + for idx, lc := range *lm { + lcList[int(idx)+lm.offset()] = lc.Copy() + } + lc := LevelColors{ + levels: lcList, + offset: lm.offset(), + } + return &lc +} + +// LevelColors is our internal representation of the user-defined LevelColorsMapping. +// We map the log levels via their slog.Level to their LevelColor using an offset +// to ensure we can map negative level values to our slice. +type LevelColors struct { + levels []*LevelColor + offset int +} + +// LevelColor returns the LevelColor for the given log level. +// Returns nil indicating if the log level was not found. +func (lc *LevelColors) LevelColor(level slog.Level) *LevelColor { + if len(lc.levels) == 0 { + return nil + } + + idx := int(level.Level()) + lc.offset + if len(lc.levels) < idx { + return &LevelColor{} + } + return lc.levels[idx] +} + +// Copy returns a copy of the LevelColors. +func (lc *LevelColors) Copy() *LevelColors { + if len(lc.levels) == 0 { + return &LevelColors{ + levels: []*LevelColor{}, + } + } + + lcCopy := LevelColors{ + levels: make([]*LevelColor, len(lc.levels)), + offset: lc.offset, + } + copy(lcCopy.levels, lc.levels) + return &lcCopy +}