From 500573d9a91072b32273193087de8ff25be35f05 Mon Sep 17 00:00:00 2001 From: Simon Paredes Date: Thu, 9 Nov 2023 17:02:04 -0300 Subject: [PATCH 1/2] Add support for Themes - Use HandlerOptions in encoder to avoid repeating code - HandlerOptions should not be a pointer as it can't be modified from the outside - Encoder doesn't have internal state, so no need to be a pointer - Default Theme tries to follow as close as possible Zerolog's Console theme --- colors.go | 19 ------ encoding.go | 96 ++++++++++++++------------- handler.go | 15 +++-- handler_test.go | 168 ++++++++++++++++++++++++++++++++++++++++++++---- theme.go | 151 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 368 insertions(+), 81 deletions(-) delete mode 100644 colors.go create mode 100644 theme.go diff --git a/colors.go b/colors.go deleted file mode 100644 index 199d207..0000000 --- a/colors.go +++ /dev/null @@ -1,19 +0,0 @@ -package console - -type color string - -const ( - reset color = "\x1b[0m" - bold color = "\x1b[1m" - - colorTimestamp color = "\x1b[90m" - colorSource color = bold + "\x1b[90m" - colorErrorValue color = "\x1b[91m" - colorMessage color = "\x1b[97m" - colorAttrKey color = "\x1b[36m" - colorAttrValue color = "\x1b[90m" - colorLevelError color = bold + colorErrorValue - colorLevelWarn color = "\x1b[93m" - colorLevelInfo color = "\x1b[92m" - colorLevelDebug color = "\x1b[95m" -) diff --git a/encoding.go b/encoding.go index 4573885..69581d6 100644 --- a/encoding.go +++ b/encoding.go @@ -9,91 +9,94 @@ import ( ) type encoder struct { - noColor bool - timeFormat string + opts HandlerOptions } -func (e *encoder) NewLine(buf *buffer) { +func (e encoder) NewLine(buf *buffer) { buf.AppendByte('\n') } -func (e *encoder) withColor(b *buffer, c color, f func()) { - if c == "" || e.noColor { +func (e encoder) withColor(b *buffer, c ANSIMod, f func()) { + if c == "" || e.opts.NoColor { f() return } b.AppendString(string(c)) f() - b.AppendString(string(reset)) + b.AppendString(string(ResetMod)) } -func (e *encoder) writeColoredTime(w *buffer, t time.Time, format string, seq color) { - e.withColor(w, seq, func() { +func (e encoder) writeColoredTime(w *buffer, t time.Time, format string, c ANSIMod) { + e.withColor(w, c, func() { w.AppendTime(t, format) }) } -func (e *encoder) writeColoredString(w *buffer, s string, seq color) { - e.withColor(w, seq, func() { +func (e encoder) writeColoredString(w *buffer, s string, c ANSIMod) { + e.withColor(w, c, func() { w.AppendString(s) }) } -func (e *encoder) writeColoredInt(w *buffer, i int64, seq color) { - e.withColor(w, seq, func() { +func (e encoder) writeColoredInt(w *buffer, i int64, c ANSIMod) { + e.withColor(w, c, func() { w.AppendInt(i) }) } -func (e *encoder) writeColoredUint(w *buffer, i uint64, seq color) { - e.withColor(w, seq, func() { +func (e encoder) writeColoredUint(w *buffer, i uint64, c ANSIMod) { + e.withColor(w, c, func() { w.AppendUint(i) }) } -func (e *encoder) writeColoredFloat(w *buffer, i float64, seq color) { - e.withColor(w, seq, func() { +func (e encoder) writeColoredFloat(w *buffer, i float64, c ANSIMod) { + e.withColor(w, c, func() { w.AppendFloat(i) }) } -func (e *encoder) writeColoredBool(w *buffer, b bool, seq color) { - e.withColor(w, seq, func() { +func (e encoder) writeColoredBool(w *buffer, b bool, c ANSIMod) { + e.withColor(w, c, func() { w.AppendBool(b) }) } -func (e *encoder) writeColoredDuration(w *buffer, d time.Duration, seq color) { - e.withColor(w, seq, func() { +func (e encoder) writeColoredDuration(w *buffer, d time.Duration, c ANSIMod) { + e.withColor(w, c, func() { w.AppendDuration(d) }) } -func (e *encoder) writeTimestamp(buf *buffer, tt time.Time) { - e.writeColoredTime(buf, tt, e.timeFormat, colorTimestamp) +func (e encoder) writeTimestamp(buf *buffer, tt time.Time) { + e.writeColoredTime(buf, tt, e.opts.TimeFormat, e.opts.Theme.Timestamp()) buf.AppendByte(' ') } -func (e *encoder) writeSource(buf *buffer, pc uintptr, cwd string) { +func (e encoder) writeSource(buf *buffer, pc uintptr, cwd string) { frame, _ := runtime.CallersFrames([]uintptr{pc}).Next() if cwd != "" { if ff, err := filepath.Rel(cwd, frame.File); err == nil { frame.File = ff } } - e.withColor(buf, colorSource, func() { + e.withColor(buf, e.opts.Theme.Source(), func() { buf.AppendString(frame.File) buf.AppendByte(':') buf.AppendInt(int64(frame.Line)) }) - e.writeColoredString(buf, " > ", colorAttrKey) + e.writeColoredString(buf, " > ", e.opts.Theme.AttrKey()) } -func (e *encoder) writeMessage(buf *buffer, msg string) { - e.writeColoredString(buf, msg, colorMessage) +func (e encoder) writeMessage(buf *buffer, level slog.Level, msg string) { + if level >= slog.LevelInfo { + e.writeColoredString(buf, msg, e.opts.Theme.Message()) + } else { + e.writeColoredString(buf, msg, e.opts.Theme.MessageDebug()) + } } -func (e *encoder) writeAttr(buf *buffer, a slog.Attr, group string) { +func (e encoder) writeAttr(buf *buffer, a slog.Attr, group string) { value := a.Value.Resolve() if value.Kind() == slog.KindGroup { subgroup := a.Key @@ -106,7 +109,7 @@ func (e *encoder) writeAttr(buf *buffer, a slog.Attr, group string) { return } buf.AppendByte(' ') - e.withColor(buf, colorAttrKey, func() { + e.withColor(buf, e.opts.Theme.AttrKey(), func() { if group != "" { buf.AppendString(group) buf.AppendByte('.') @@ -117,60 +120,61 @@ func (e *encoder) writeAttr(buf *buffer, a slog.Attr, group string) { e.writeValue(buf, value) } -func (e *encoder) writeValue(buf *buffer, value slog.Value) { +func (e encoder) writeValue(buf *buffer, value slog.Value) { + attrValue := e.opts.Theme.AttrValue() switch value.Kind() { case slog.KindInt64: - e.writeColoredInt(buf, value.Int64(), colorAttrValue) + e.writeColoredInt(buf, value.Int64(), attrValue) case slog.KindBool: - e.writeColoredBool(buf, value.Bool(), colorAttrValue) + e.writeColoredBool(buf, value.Bool(), attrValue) case slog.KindFloat64: - e.writeColoredFloat(buf, value.Float64(), colorAttrValue) + e.writeColoredFloat(buf, value.Float64(), attrValue) case slog.KindTime: - e.writeColoredTime(buf, value.Time(), e.timeFormat, colorAttrValue) + e.writeColoredTime(buf, value.Time(), e.opts.TimeFormat, attrValue) case slog.KindUint64: - e.writeColoredUint(buf, value.Uint64(), colorAttrValue) + e.writeColoredUint(buf, value.Uint64(), attrValue) case slog.KindDuration: - e.writeColoredDuration(buf, value.Duration(), colorAttrValue) + e.writeColoredDuration(buf, value.Duration(), attrValue) case slog.KindAny: switch v := value.Any().(type) { case error: - e.writeColoredString(buf, v.Error(), colorErrorValue) + e.writeColoredString(buf, v.Error(), e.opts.Theme.AttrValueError()) return case fmt.Stringer: - e.writeColoredString(buf, v.String(), colorAttrValue) + e.writeColoredString(buf, v.String(), attrValue) return } fallthrough case slog.KindString: fallthrough default: - e.writeColoredString(buf, value.String(), colorAttrValue) + e.writeColoredString(buf, value.String(), attrValue) } } -func (e *encoder) writeLevel(buf *buffer, l slog.Level) { - var style color +func (e encoder) writeLevel(buf *buffer, l slog.Level) { + var style ANSIMod var str string var delta int switch { case l >= slog.LevelError: - style = colorLevelError + style = e.opts.Theme.LevelError() str = "ERR" delta = int(l - slog.LevelError) case l >= slog.LevelWarn: - style = colorLevelWarn + style = e.opts.Theme.LevelWarn() str = "WRN" delta = int(l - slog.LevelWarn) case l >= slog.LevelInfo: - style = colorLevelInfo + style = e.opts.Theme.LevelInfo() str = "INF" delta = int(l - slog.LevelInfo) case l >= slog.LevelDebug: - style = colorLevelDebug + style = e.opts.Theme.LevelDebug() str = "DBG" delta = int(l - slog.LevelDebug) default: - style = bold + style = e.opts.Theme.LevelDebug() str = "DBG" delta = int(l - slog.LevelDebug) } diff --git a/handler.go b/handler.go index ead4462..82ce3ea 100644 --- a/handler.go +++ b/handler.go @@ -35,10 +35,13 @@ type HandlerOptions struct { // TimeFormat is the format used for time.DateTime TimeFormat string + + // Theme defines the colorized output using ANSI escape sequences + Theme Theme } type Handler struct { - opts *HandlerOptions + opts HandlerOptions out io.Writer group string context buffer @@ -60,13 +63,15 @@ func NewHandler(out io.Writer, opts *HandlerOptions) *Handler { if opts.TimeFormat == "" { opts.TimeFormat = time.DateTime } - opt := *opts // Copy struct + if opts.Theme == nil { + opts.Theme = NewDefaultTheme() + } return &Handler{ - opts: &opt, + opts: *opts, // Copy struct out: out, group: "", context: nil, - enc: &encoder{noColor: opt.NoColor, timeFormat: opt.TimeFormat}, + enc: &encoder{opts: *opts}, } } @@ -84,7 +89,7 @@ func (h *Handler) Handle(_ context.Context, rec slog.Record) error { if h.opts.AddSource && rec.PC > 0 { h.enc.writeSource(buf, rec.PC, cwd) } - h.enc.writeMessage(buf, rec.Message) + h.enc.writeMessage(buf, rec.Level, rec.Message) buf.copy(&h.context) rec.Attrs(func(a slog.Attr) bool { h.enc.writeAttr(buf, a, h.group) diff --git a/handler_test.go b/handler_test.go index 2bb4649..ed7d55d 100644 --- a/handler_test.go +++ b/handler_test.go @@ -13,17 +13,6 @@ import ( "time" ) -func TestHandler_colors(t *testing.T) { - buf := bytes.Buffer{} - h := NewHandler(&buf, nil) - now := time.Now() - rec := slog.NewRecord(now, slog.LevelInfo, "foobar", 0) - AssertNoError(t, h.Handle(context.Background(), rec)) - - expected := fmt.Sprintf("\x1b[90m%s\x1b[0m \x1b[92mINF\x1b[0m \x1b[97mfoobar\x1b[0m\n", now.Format(time.DateTime)) - AssertEqual(t, expected, buf.String()) -} - func TestHandler_TimeFormat(t *testing.T) { buf := bytes.Buffer{} h := NewHandler(&buf, &HandlerOptions{TimeFormat: time.RFC3339Nano, NoColor: true}) @@ -180,3 +169,160 @@ func TestHandler_Err(t *testing.T) { rec := slog.NewRecord(time.Now(), slog.LevelInfo, "foobar", 0) AssertError(t, h.Handle(context.Background(), rec)) } + +func TestThemes(t *testing.T) { + for _, theme := range []Theme{ + NewDefaultTheme(), + NewBrightTheme(), + } { + t.Run(theme.Name(), func(t *testing.T) { + level := slog.LevelInfo + rec := slog.Record{} + buf := bytes.Buffer{} + bufBytes := buf.Bytes() + now := time.Now() + timeFormat := time.Kitchen + index := -1 + toIndex := -1 + h := NewHandler(&buf, &HandlerOptions{ + AddSource: true, + TimeFormat: timeFormat, + Theme: theme, + }).WithAttrs([]slog.Attr{{Key: "pid", Value: slog.IntValue(37556)}}) + var pcs [1]uintptr + runtime.Callers(1, pcs[:]) + + checkANSIMod := func(t *testing.T, name string, ansiMod ANSIMod) { + t.Run(name, func(t *testing.T) { + index = bytes.IndexByte(bufBytes, '\x1b') + AssertNotEqual(t, -1, index) + toIndex = index + len(ansiMod) + AssertEqual(t, ansiMod, ANSIMod(bufBytes[index:toIndex])) + bufBytes = bufBytes[toIndex:] + index = bytes.IndexByte(bufBytes, '\x1b') + AssertNotEqual(t, -1, index) + toIndex = index + len(ResetMod) + AssertEqual(t, ResetMod, ANSIMod(bufBytes[index:toIndex])) + bufBytes = bufBytes[toIndex:] + }) + } + + checkLog := func(level slog.Level, attrCount int) { + t.Run("CheckLog_"+level.String(), func(t *testing.T) { + println("log: ", string(buf.Bytes())) + + // Timestamp + if theme.Timestamp() != "" { + checkANSIMod(t, "Timestamp", theme.Timestamp()) + } + + // Level + if theme.Level(level) != "" { + checkANSIMod(t, level.String(), theme.Level(level)) + } + + // Source + if theme.Source() != "" { + checkANSIMod(t, "Source", theme.Source()) + checkANSIMod(t, "AttrKey", theme.AttrKey()) + } + + // Message + if level >= slog.LevelInfo { + if theme.Message() != "" { + checkANSIMod(t, "Message", theme.Message()) + } + } else { + if theme.MessageDebug() != "" { + checkANSIMod(t, "MessageDebug", theme.MessageDebug()) + } + } + + for i := 0; i < attrCount; i++ { + // AttrKey + if theme.AttrKey() != "" { + checkANSIMod(t, "AttrKey", theme.AttrKey()) + } + + // AttrValue + if theme.AttrValue() != "" { + checkANSIMod(t, "AttrValue", theme.AttrValue()) + } + } + }) + } + + buf.Reset() + level = slog.LevelDebug - 1 + rec = slog.NewRecord(now, level, "Access", pcs[0]) + rec.Add("database", "myapp", "host", "localhost:4962") + h.Handle(context.Background(), rec) + bufBytes = buf.Bytes() + checkLog(level, 3) + + buf.Reset() + level = slog.LevelDebug + rec = slog.NewRecord(now, level, "Access", pcs[0]) + rec.Add("database", "myapp", "host", "localhost:4962") + h.Handle(context.Background(), rec) + bufBytes = buf.Bytes() + checkLog(level, 3) + + buf.Reset() + level = slog.LevelDebug + 1 + rec = slog.NewRecord(now, level, "Access", pcs[0]) + rec.Add("database", "myapp", "host", "localhost:4962") + h.Handle(context.Background(), rec) + bufBytes = buf.Bytes() + checkLog(level, 3) + + buf.Reset() + level = slog.LevelInfo + rec = slog.NewRecord(now, level, "Starting listener", pcs[0]) + rec.Add("listen", ":8080") + h.Handle(context.Background(), rec) + bufBytes = buf.Bytes() + checkLog(level, 2) + + buf.Reset() + level = slog.LevelInfo + 1 + rec = slog.NewRecord(now, level, "Access", pcs[0]) + rec.Add("method", "GET", "path", "/users", "resp_time", time.Millisecond*10) + h.Handle(context.Background(), rec) + bufBytes = buf.Bytes() + checkLog(level, 4) + + buf.Reset() + level = slog.LevelWarn + rec = slog.NewRecord(now, level, "Slow request", pcs[0]) + rec.Add("method", "POST", "path", "/posts", "resp_time", time.Second*532) + h.Handle(context.Background(), rec) + bufBytes = buf.Bytes() + checkLog(level, 4) + + buf.Reset() + level = slog.LevelWarn + 1 + rec = slog.NewRecord(now, level, "Slow request", pcs[0]) + rec.Add("method", "POST", "path", "/posts", "resp_time", time.Second*532) + h.Handle(context.Background(), rec) + bufBytes = buf.Bytes() + checkLog(level, 4) + + buf.Reset() + level = slog.LevelError + rec = slog.NewRecord(now, level, "Database connection lost", pcs[0]) + rec.Add("database", "myapp", "error", errors.New("connection reset by peer")) + h.Handle(context.Background(), rec) + bufBytes = buf.Bytes() + checkLog(level, 3) + + buf.Reset() + level = slog.LevelError + 1 + rec = slog.NewRecord(now, level, "Database connection lost", pcs[0]) + rec.Add("database", "myapp", "error", errors.New("connection reset by peer")) + h.Handle(context.Background(), rec) + bufBytes = buf.Bytes() + checkLog(level, 3) + }) + } +} diff --git a/theme.go b/theme.go new file mode 100644 index 0000000..8d1290f --- /dev/null +++ b/theme.go @@ -0,0 +1,151 @@ +package console + +import ( + "fmt" + "log/slog" +) + +type ANSIMod string + +var ResetMod = ToANSICode(Reset) + +const ( + Reset = iota + Bold + Faint + Italic + Underline + CrossedOut = 9 +) + +const ( + Black = iota + 30 + Red + Green + Yellow + Blue + Magenta + Cyan + Gray +) + +const ( + BrightBlack = iota + 90 + BrightRed + BrightGreen + BrightYellow + BrightBlue + BrightMagenta + BrightCyan + White +) + +func (c ANSIMod) String() string { + return string(c) +} + +func ToANSICode(modes ...int) ANSIMod { + if len(modes) == 0 { + return "" + } + + var s string + for i, m := range modes { + if i > 0 { + s += ";" + } + s += fmt.Sprintf("%d", m) + } + return ANSIMod("\x1b[" + s + "m") +} + +type Theme interface { + Name() string + Timestamp() ANSIMod + Source() ANSIMod + + Message() ANSIMod + MessageDebug() ANSIMod + AttrKey() ANSIMod + AttrValue() ANSIMod + AttrValueError() ANSIMod + LevelError() ANSIMod + LevelWarn() ANSIMod + LevelInfo() ANSIMod + LevelDebug() ANSIMod + Level(level slog.Level) ANSIMod +} + +type ThemeDef struct { + name string + timestamp ANSIMod + source ANSIMod + message ANSIMod + messageDebug ANSIMod + attrKey ANSIMod + attrValue ANSIMod + attrValueError ANSIMod + levelError ANSIMod + levelWarn ANSIMod + levelInfo ANSIMod + levelDebug ANSIMod +} + +func (t ThemeDef) Name() string { return t.name } +func (t ThemeDef) Timestamp() ANSIMod { return t.timestamp } +func (t ThemeDef) Source() ANSIMod { return t.source } +func (t ThemeDef) Message() ANSIMod { return t.message } +func (t ThemeDef) MessageDebug() ANSIMod { return t.messageDebug } +func (t ThemeDef) AttrKey() ANSIMod { return t.attrKey } +func (t ThemeDef) AttrValue() ANSIMod { return t.attrValue } +func (t ThemeDef) AttrValueError() ANSIMod { return t.attrValueError } +func (t ThemeDef) LevelError() ANSIMod { return t.levelError } +func (t ThemeDef) LevelWarn() ANSIMod { return t.levelWarn } +func (t ThemeDef) LevelInfo() ANSIMod { return t.levelInfo } +func (t ThemeDef) LevelDebug() ANSIMod { return t.levelDebug } +func (t ThemeDef) Level(level slog.Level) ANSIMod { + switch { + case level >= slog.LevelError: + return t.LevelError() + case level >= slog.LevelWarn: + return t.LevelWarn() + case level >= slog.LevelInfo: + return t.LevelInfo() + default: + return t.LevelDebug() + } +} + +func NewDefaultTheme() Theme { + return ThemeDef{ + name: "Default", + timestamp: ToANSICode(BrightBlack), + source: ToANSICode(Bold, BrightBlack), + message: ToANSICode(Bold), + messageDebug: ToANSICode(), + attrKey: ToANSICode(Cyan), + attrValue: ToANSICode(), + attrValueError: ToANSICode(Bold, Red), + levelError: ToANSICode(Red), + levelWarn: ToANSICode(Yellow), + levelInfo: ToANSICode(Green), + levelDebug: ToANSICode(), + } +} + +func NewBrightTheme() Theme { + return ThemeDef{ + name: "Bright", + timestamp: ToANSICode(Gray), + source: ToANSICode(Bold, Gray), + message: ToANSICode(Bold, White), + messageDebug: ToANSICode(), + attrKey: ToANSICode(BrightCyan), + attrValue: ToANSICode(), + attrValueError: ToANSICode(Bold, BrightRed), + levelError: ToANSICode(BrightRed), + levelWarn: ToANSICode(BrightYellow), + levelInfo: ToANSICode(BrightGreen), + levelDebug: ToANSICode(), + } +} From a125e8196f52cc812a4a45e8ec275bc286b3d32f Mon Sep 17 00:00:00 2001 From: Simon Paredes Date: Fri, 10 Nov 2023 20:13:40 -0300 Subject: [PATCH 2/2] Do not print empty Attr This is described in Handler.Handle method: If an Attr's key and value are both the zero value, ignore the Attr. --- encoding.go | 4 ++++ handler_test.go | 2 ++ 2 files changed, 6 insertions(+) diff --git a/encoding.go b/encoding.go index 69581d6..31028fa 100644 --- a/encoding.go +++ b/encoding.go @@ -97,6 +97,10 @@ func (e encoder) writeMessage(buf *buffer, level slog.Level, msg string) { } func (e encoder) writeAttr(buf *buffer, a slog.Attr, group string) { + // Elide empty Attrs. + if a.Equal(slog.Attr{}) { + return + } value := a.Value.Resolve() if value.Kind() == slog.KindGroup { subgroup := a.Key diff --git a/handler_test.go b/handler_test.go index ed7d55d..1d92d3c 100644 --- a/handler_test.go +++ b/handler_test.go @@ -62,6 +62,8 @@ func TestHandler_Attr(t *testing.T) { slog.Any("err", errors.New("the error")), slog.Any("stringer", theStringer{}), slog.Any("nostringer", noStringer{Foo: "bar"}), + slog.Attr{}, + slog.Any("", nil), ) AssertNoError(t, h.Handle(context.Background(), rec))