From 1a356b82e973af271b878084ba7b7d93b84fb909 Mon Sep 17 00:00:00 2001 From: Grigory Zubankov Date: Thu, 19 Mar 2026 18:28:38 +0200 Subject: [PATCH] feat: add Text encoder with colored console output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Text encoder for human-readable development output: - Colored levels: bold cyan [INF], bold yellow [WRN], bold red [ERR] - Message in level color (WRN/ERR) or bold default (INF/DBG) - Field keys in bright blue italic, numeric values in green - Dim auxiliary elements (time, brackets, ›, =, caller) - Caller at end with → arrow, dim italic - Groups as dotted prefix (http.method=GET) - Nested objects/arrays rendered as JSON via shared TypeEncoderFactory - Bag slot cache support - NoColor mode for non-TTY output - Builder API: logf.Text().NoColor().Build() - 35 tests mirroring JSON encoder test suite README: - Add Text encoder to features and Quick Start - Fix Router example to use Text() instead of JSONEncoder for console --- README.md | 36 ++- docs/TODO.md | 6 + examples/basic/main.go | 1 + level.go | 17 ++ textencoder.go | 658 +++++++++++++++++++++++++++++++++++++++++ textencoder_test.go | 371 +++++++++++++++++++++++ 6 files changed, 1083 insertions(+), 6 deletions(-) create mode 100644 textencoder.go create mode 100644 textencoder_test.go diff --git a/README.md b/README.md index 2f64c21..c3f95f3 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Structured logging for Go — context-aware, slog-native, fast. - **Router** — multi-destination fan-out with per-output level filtering and encoder groups - **Async buffered I/O** — SlabWriter with pre-allocated slab pool, zero per-message allocations - **WriterSlot** — placeholder writer for lazy destination initialization +- **JSON and Text encoders** — `logf.JSON()` for production, `logf.Text()` for development (colored, human-readable) - **Builder API** — `logf.NewLogger().Level(logf.LevelInfo).Build()` for quick setup - **Zero-alloc hot path** — 0 allocs/op across all benchmarks @@ -29,7 +30,11 @@ go get github.com/ssgreg/logf/v2 // Minimal — JSON to stderr, debug level, caller enabled: logger := logf.NewLogger().Build() -// Customized: +// Development — colored text output: +logger := logf.NewLogger().EncoderFrom(logf.Text()).Build() +// Mar 19 14:04:02.167 [INF] request handled › method=GET status=200 + +// Production — custom JSON encoder, stdout, context fields: logger := logf.NewLogger(). Level(logf.LevelInfo). Output(os.Stdout). @@ -279,17 +284,14 @@ outputs in that group. Different groups can use different formats: ```go jsonEnc := logf.JSON().Build() -textEnc := logf.NewJSONEncoder(logf.JSONEncoderConfig{ - FieldKeyTime: "time", - EncodeLevel: logf.UpperCaseLevelEncoder, -}) +textEnc := logf.Text().Build() router, close, _ := logf.NewRouter(). Route(jsonEnc, logf.Output(logf.LevelDebug, fileSlab), // JSON to file (async) ). Route(textEnc, - logf.Output(logf.LevelInfo, os.Stderr), // text to console (sync) + logf.Output(logf.LevelInfo, os.Stderr), // colored text to console (sync) ). Build() ``` @@ -410,6 +412,28 @@ See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for design details. - [Acronis](https://www.acronis.com) — global cybersecurity and data protection platform +## Testing + +```go +// Discard all logs (silent tests): +logger := logf.DisabledLogger() + +// Capture logs to a buffer for assertions: +var buf bytes.Buffer +logger := logf.NewLogger().Output(&buf).Build() +logger.Info(ctx, "hello") +// buf.String() contains JSON output + +// Send logs to testing.T (visible with -v or on failure): +type testWriter struct{ t testing.TB } +func (w testWriter) Write(p []byte) (int, error) { + w.t.Helper() + w.t.Log(strings.TrimRight(string(p), "\n")) + return len(p), nil +} +logger := logf.NewLogger().Output(testWriter{t}).Build() +``` + ## Log Rotation logf does not handle log rotation — use `lumberjack` or OS-level `logrotate`: diff --git a/docs/TODO.md b/docs/TODO.md index 5698e37..bde714e 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -45,6 +45,12 @@ with the same key share one counter. Gives the user explicit control over what gets sampled together, unlike per-callsite (zap) or global counter (zerolog) approaches. +### Test utilities (low) + +`logftest.NewHandler()` returning `(Handler, *Entries)` for capturing +and asserting log output in tests. `DisabledLogger()` already works +as a null logger. Add when users request it. + ### Stack trace field (low) `Stack(key)` / `StackSkip(key, skip)` field constructor — capture current diff --git a/examples/basic/main.go b/examples/basic/main.go index c66e3bb..65d16e8 100644 --- a/examples/basic/main.go +++ b/examples/basic/main.go @@ -11,6 +11,7 @@ import ( func main() { // Minimal setup: JSON to stderr, Debug level, caller enabled. + // For colored text output, use: logf.NewLogger().EncoderFrom(logf.Text()).Build() logger := logf.NewLogger().Build() ctx := context.Background() diff --git a/level.go b/level.go index d93ea38..6a86437 100644 --- a/level.go +++ b/level.go @@ -107,6 +107,23 @@ func UpperCaseLevelEncoder(lvl Level, m TypeEncoder) { m.EncodeTypeString(lvl.UpperCaseString()) } +// ShortTextLevelEncoder encodes Level as a 3-character uppercase string +// (DBG, INF, WRN, ERR). Intended for text/console output. +func ShortTextLevelEncoder(lvl Level, m TypeEncoder) { + switch lvl { + case LevelDebug: + m.EncodeTypeString("DBG") + case LevelInfo: + m.EncodeTypeString("INF") + case LevelWarn: + m.EncodeTypeString("WRN") + case LevelError: + m.EncodeTypeString("ERR") + default: + m.EncodeTypeString("UNK") + } +} + // NewMutableLevel creates an instance of MutableLevel with the given // starting level. func NewMutableLevel(l Level) *MutableLevel { diff --git a/textencoder.go b/textencoder.go new file mode 100644 index 0000000..51065fd --- /dev/null +++ b/textencoder.go @@ -0,0 +1,658 @@ +package logf + +import ( + "encoding/base64" + "math" + "sync" + "time" + "unsafe" +) + +// TextEncoderConfig allows to configure the text Encoder. +type TextEncoderConfig struct { + NoColor bool + DisableFieldTime bool + DisableFieldLevel bool + DisableFieldName bool + DisableFieldMsg bool + DisableFieldCaller bool + + EncodeTime TimeEncoder + EncodeDuration DurationEncoder + EncodeError ErrorEncoder + EncodeLevel LevelEncoder + EncodeCaller CallerEncoder +} + +// WithDefaults returns the new config in which all uninitialized fields +// are filled with their default values. +func (c TextEncoderConfig) WithDefaults() TextEncoderConfig { + if c.EncodeDuration == nil { + c.EncodeDuration = StringDurationEncoder + } + if c.EncodeTime == nil { + c.EncodeTime = LayoutTimeEncoder(time.StampMilli) + } + if c.EncodeError == nil { + c.EncodeError = DefaultErrorEncoder + } + if c.EncodeLevel == nil { + c.EncodeLevel = ShortTextLevelEncoder + } + if c.EncodeCaller == nil { + c.EncodeCaller = ShortCallerEncoder + } + return c +} + +// NewTextEncoder creates a new text Encoder with the given config. +func NewTextEncoder(cfg TextEncoderConfig) Encoder { + return buildTextEncoder(cfg) +} + +// Text returns a new TextEncoderBuilder with default settings. +// Colors are enabled by default. Use NoColor() to disable, or +// check the NO_COLOR environment variable (https://no-color.org): +// +// enc := logf.Text().Build() +// enc := logf.Text().NoColor().Build() +// +// Respect NO_COLOR convention: +// b := logf.Text() +// if _, ok := os.LookupEnv("NO_COLOR"); ok { +// b = b.NoColor() +// } +func Text() *TextEncoderBuilder { + return &TextEncoderBuilder{} +} + +// TextEncoderBuilder configures and builds a text Encoder. +type TextEncoderBuilder struct { + cfg TextEncoderConfig +} + +func (b *TextEncoderBuilder) NoColor() *TextEncoderBuilder { + b.cfg.NoColor = true + return b +} + +func (b *TextEncoderBuilder) DisableTime() *TextEncoderBuilder { + b.cfg.DisableFieldTime = true + return b +} + +func (b *TextEncoderBuilder) DisableLevel() *TextEncoderBuilder { + b.cfg.DisableFieldLevel = true + return b +} + +func (b *TextEncoderBuilder) DisableMsg() *TextEncoderBuilder { + b.cfg.DisableFieldMsg = true + return b +} + +func (b *TextEncoderBuilder) DisableName() *TextEncoderBuilder { + b.cfg.DisableFieldName = true + return b +} + +func (b *TextEncoderBuilder) DisableCaller() *TextEncoderBuilder { + b.cfg.DisableFieldCaller = true + return b +} + +func (b *TextEncoderBuilder) EncodeTime(e TimeEncoder) *TextEncoderBuilder { + b.cfg.EncodeTime = e + return b +} + +func (b *TextEncoderBuilder) EncodeDuration(e DurationEncoder) *TextEncoderBuilder { + b.cfg.EncodeDuration = e + return b +} + +func (b *TextEncoderBuilder) EncodeLevel(e LevelEncoder) *TextEncoderBuilder { + b.cfg.EncodeLevel = e + return b +} + +func (b *TextEncoderBuilder) EncodeCaller(e CallerEncoder) *TextEncoderBuilder { + b.cfg.EncodeCaller = e + return b +} + +func (b *TextEncoderBuilder) EncodeError(e ErrorEncoder) *TextEncoderBuilder { + b.cfg.EncodeError = e + return b +} + +// Build finalizes the configuration and returns a ready Encoder. +func (b *TextEncoderBuilder) Build() Encoder { + return buildTextEncoder(b.cfg) +} + +func buildTextEncoder(cfg TextEncoderConfig) Encoder { + cfg = cfg.WithDefaults() + // Shared JSON encoder for nested object/array/any rendering. + jsonTEF := buildJSONEncoder(JSONEncoderConfig{ + EncodeTime: cfg.EncodeTime, + EncodeDuration: cfg.EncodeDuration, + EncodeError: cfg.EncodeError, + }).(TypeEncoderFactory) + enc := &textEncoder{ + TextEncoderConfig: cfg, + slot: AllocEncoderSlot(), + eseq: escSeq{noColor: cfg.NoColor}, + jsonTEF: jsonTEF, + } + enc.pool = &sync.Pool{New: func() any { + return &textEncoder{ + TextEncoderConfig: enc.TextEncoderConfig, + slot: enc.slot, + eseq: enc.eseq, + jsonTEF: enc.jsonTEF, + } + }} + return enc +} + +type textEncoder struct { + TextEncoderConfig + pool *sync.Pool + slot int + + buf *Buffer + startBufLen int + eseq escSeq + jsonTEF TypeEncoderFactory + fieldSepDone bool + groupDepth int + groupPrefix string +} + +func (f *textEncoder) Clone() Encoder { + return &textEncoder{ + TextEncoderConfig: f.TextEncoderConfig, + slot: f.slot, + pool: f.pool, + eseq: f.eseq, + } +} + +func (f *textEncoder) Encode(e Entry) (*Buffer, error) { + clone := f.pool.Get().(*textEncoder) + + buf := GetBuffer() + err := clone.encode(buf, e) + + clone.buf = nil + clone.groupPrefix = "" + clone.groupDepth = 0 + clone.fieldSepDone = false + f.pool.Put(clone) + + if err != nil { + buf.Free() + return nil, err + } + return buf, nil +} + +func (f *textEncoder) encode(buf *Buffer, e Entry) error { + f.buf = buf + f.startBufLen = buf.Len() + + // Time. + if !f.DisableFieldTime && !e.Time.IsZero() { + f.eseq.dim(f.buf, func() { + f.appendTime(e.Time) + }) + } + + // Level. + if !f.DisableFieldLevel { + f.appendSeparator() + f.appendLevel(e.Level) + } + + // Logger name. + if !f.DisableFieldName && e.LoggerName != "" { + f.appendSeparator() + f.eseq.dimItalic(f.buf, func() { + f.buf.AppendString(e.LoggerName) + f.buf.AppendByte(':') + }) + } + + // Message — bold + level color for WRN/ERR, bold only for others. + if !f.DisableFieldMsg && e.Text != "" { + f.appendSeparator() + mc := msgColor(e.Level) + if mc == escDefault { + f.eseq.at(f.buf, escBold, func() { + f.buf.AppendString(e.Text) + }) + } else { + f.eseq.at2(f.buf, escBold, mc, func() { + f.buf.AppendString(e.Text) + }) + } + } + + // › separator will be emitted lazily on first addKey call. + f.fieldSepDone = false + + // Skip trailing groups that would produce empty output. + loggerBag := e.LoggerBag + ctxBag := e.Bag + if len(e.Fields) == 0 { + loggerBag = skipTrailingGroups(loggerBag) + if !bagHasFields(loggerBag) { + ctxBag = skipTrailingGroups(ctxBag) + } + } + + // Context fields. + f.encodeBag(ctxBag) + + // Logger's fields. + f.encodeBag(loggerBag) + + // Entry's fields. + for i := range e.Fields { + e.Fields[i].Accept(f) + } + + // Caller — at the very end, after all fields. + if !f.DisableFieldCaller && e.CallerPC != 0 { + f.appendSeparator() + f.eseq.dimItalic(f.buf, func() { + f.buf.AppendString("→ ") + f.EncodeCaller(e.CallerPC, f.TypeEncoder(f.buf)) + }) + } + + f.buf.AppendByte('\n') + return nil +} + +func (f *textEncoder) encodeBag(bag *Bag) { + if bag == nil { + return + } + if bag.group != "" { + f.encodeBag(bag.parent) + // Push group prefix for nested fields. + f.groupPrefix += bag.group + "." + f.groupDepth++ + return + } + + // Field node: use cache. + if data := bag.LoadCache(f.slot); data != nil { + f.buf.AppendBytes(data) + return + } + + start := f.buf.Len() + f.encodeBag(bag.parent) + for _, field := range bag.fields { + field.Accept(f) + } + + if f.slot != 0 { + encoded := make([]byte, f.buf.Len()-start) + copy(encoded, f.buf.Data[start:]) + bag.StoreCache(f.slot, encoded) + } +} + +// --- TypeEncoder --- + +func (f *textEncoder) TypeEncoder(buf *Buffer) TypeEncoder { + f.buf = buf + f.startBufLen = f.buf.Len() + return f +} + +func (f *textEncoder) EncodeTypeAny(v interface{}) { + f.jsonTypeEncoder().EncodeTypeAny(v) +} + +func (f *textEncoder) EncodeTypeBool(v bool) { + f.eseq.at(f.buf, escGreen, func() { + f.buf.AppendBool(v) + }) +} + +func (f *textEncoder) EncodeTypeInt64(v int64) { + f.eseq.at(f.buf, escGreen, func() { + f.buf.AppendInt(v) + }) +} + +func (f *textEncoder) EncodeTypeUint64(v uint64) { + f.eseq.at(f.buf, escGreen, func() { + f.buf.AppendUint(v) + }) +} + +func (f *textEncoder) EncodeTypeFloat64(v float64) { + f.eseq.at(f.buf, escGreen, func() { + switch { + case math.IsNaN(v): + f.buf.AppendString("NaN") + case math.IsInf(v, 1): + f.buf.AppendString("+Inf") + case math.IsInf(v, -1): + f.buf.AppendString("-Inf") + default: + f.buf.AppendFloat64(v) + } + }) +} + +func (f *textEncoder) EncodeTypeDuration(v time.Duration) { + f.EncodeDuration(v, f) +} + +func (f *textEncoder) EncodeTypeTime(v time.Time) { + f.EncodeTime(v, f) +} + +func (f *textEncoder) EncodeTypeString(v string) { + // Quote if contains spaces or special chars. + needsQuote := false + for i := 0; i < len(v); i++ { + if v[i] <= ' ' || v[i] == '"' || v[i] == '\\' { + needsQuote = true + break + } + } + if needsQuote { + f.buf.AppendByte('"') + _ = EscapeString(f.buf, v) + f.buf.AppendByte('"') + } else { + f.buf.AppendString(v) + } +} + +func (f *textEncoder) EncodeTypeStrings(v []string) { + f.buf.AppendByte('[') + for i, s := range v { + if i > 0 { + f.buf.AppendByte(',') + } + f.EncodeTypeString(s) + } + f.buf.AppendByte(']') +} + +func (f *textEncoder) EncodeTypeBytes(v []byte) { + f.buf.AppendByte('"') + base64.StdEncoding.Encode(f.buf.ExtendBytes(base64.StdEncoding.EncodedLen(len(v))), v) + f.buf.AppendByte('"') +} + +func (f *textEncoder) EncodeTypeInts64(v []int64) { + f.buf.AppendByte('[') + for i, n := range v { + if i > 0 { + f.buf.AppendByte(',') + } + f.buf.AppendInt(n) + } + f.buf.AppendByte(']') +} + +func (f *textEncoder) EncodeTypeFloats64(v []float64) { + f.buf.AppendByte('[') + for i, n := range v { + if i > 0 { + f.buf.AppendByte(',') + } + f.EncodeTypeFloat64(n) + } + f.buf.AppendByte(']') +} + +func (f *textEncoder) EncodeTypeDurations(v []time.Duration) { + f.buf.AppendByte('[') + for i, d := range v { + if i > 0 { + f.buf.AppendByte(',') + } + f.EncodeTypeDuration(d) + } + f.buf.AppendByte(']') +} + +func (f *textEncoder) EncodeTypeArray(v ArrayEncoder) { + f.jsonTypeEncoder().EncodeTypeArray(v) +} + +func (f *textEncoder) EncodeTypeObject(v ObjectEncoder) { + f.jsonTypeEncoder().EncodeTypeObject(v) +} + +// jsonTypeEncoder returns a JSON TypeEncoder writing to f.buf. +// Used for nested objects/arrays where JSON is more readable than key=value. +func (f *textEncoder) jsonTypeEncoder() TypeEncoder { + return f.jsonTEF.TypeEncoder(f.buf) +} + +func (f *textEncoder) EncodeTypeUnsafeBytes(v unsafe.Pointer) { + f.EncodeTypeString(*(*string)(v)) +} + +// --- FieldEncoder --- + +func (f *textEncoder) addKey(k string) { + if !f.fieldSepDone { + // First field — emit › separator. + f.fieldSepDone = true + f.appendSeparator() + f.eseq.dim(f.buf, func() { + f.buf.AppendString("›") + }) + } + f.appendSeparator() + f.eseq.at2(f.buf, escBrightBlue, escItalic, func() { + if f.groupPrefix != "" { + f.buf.AppendString(f.groupPrefix) + } + f.buf.AppendString(k) + }) + f.eseq.dim(f.buf, func() { + f.buf.AppendByte('=') + }) +} + +func (f *textEncoder) EncodeFieldAny(k string, v interface{}) { f.addKey(k); f.EncodeTypeAny(v) } +func (f *textEncoder) EncodeFieldBool(k string, v bool) { f.addKey(k); f.EncodeTypeBool(v) } +func (f *textEncoder) EncodeFieldInt64(k string, v int64) { f.addKey(k); f.EncodeTypeInt64(v) } +func (f *textEncoder) EncodeFieldUint64(k string, v uint64) { f.addKey(k); f.EncodeTypeUint64(v) } +func (f *textEncoder) EncodeFieldFloat64(k string, v float64) { f.addKey(k); f.EncodeTypeFloat64(v) } +func (f *textEncoder) EncodeFieldDuration(k string, v time.Duration) { + f.addKey(k) + f.EncodeTypeDuration(v) +} +func (f *textEncoder) EncodeFieldTime(k string, v time.Time) { f.addKey(k); f.EncodeTypeTime(v) } +func (f *textEncoder) EncodeFieldString(k string, v string) { f.addKey(k); f.EncodeTypeString(v) } +func (f *textEncoder) EncodeFieldStrings(k string, v []string) { f.addKey(k); f.EncodeTypeStrings(v) } +func (f *textEncoder) EncodeFieldBytes(k string, v []byte) { f.addKey(k); f.EncodeTypeBytes(v) } +func (f *textEncoder) EncodeFieldInts64(k string, v []int64) { f.addKey(k); f.EncodeTypeInts64(v) } +func (f *textEncoder) EncodeFieldFloats64(k string, v []float64) { + f.addKey(k) + f.EncodeTypeFloats64(v) +} +func (f *textEncoder) EncodeFieldDurations(k string, v []time.Duration) { + f.addKey(k) + f.EncodeTypeDurations(v) +} +func (f *textEncoder) EncodeFieldArray(k string, v ArrayEncoder) { f.addKey(k); f.EncodeTypeArray(v) } + +func (f *textEncoder) EncodeFieldObject(k string, v ObjectEncoder) { + if k == "" { + _ = v.EncodeLogfObject(f) + return + } + f.addKey(k) + f.EncodeTypeObject(v) +} + +func (f *textEncoder) EncodeFieldGroup(k string, fs []Field) { + if k == "" { + for _, field := range fs { + field.Accept(f) + } + return + } + // Push group prefix, encode fields, pop. + saved := f.groupPrefix + f.groupPrefix += k + "." + for _, field := range fs { + field.Accept(f) + } + f.groupPrefix = saved +} + +func (f *textEncoder) EncodeFieldError(k string, v error) { + f.EncodeError(k, v, f) +} + +// --- helpers --- + +func (f *textEncoder) appendSeparator() { + if f.buf.Len() == f.startBufLen { + return + } + if f.buf.Back() == '=' { + return + } + f.buf.AppendByte(' ') +} + +func (f *textEncoder) appendTime(t time.Time) { + start := f.buf.Len() + f.EncodeTime(t, f) + end := f.buf.Len() + // Strip quotes if TimeEncoder added them. + if end > start && f.buf.Data[start] == '"' && f.buf.Back() == '"' { + copy(f.buf.Data[start:], f.buf.Data[start+1:end-1]) + f.buf.Data = f.buf.Data[:end-2] + } +} + +func (f *textEncoder) appendLevel(lvl Level) { + f.eseq.dim(f.buf, func() { + f.buf.AppendByte('[') + }) + f.eseq.at2(f.buf, escBold, levelColor(lvl), func() { + f.EncodeLevel(lvl, f) + }) + f.eseq.dim(f.buf, func() { + f.buf.AppendByte(']') + }) +} + +func levelColor(lvl Level) escCode { + switch lvl { + case LevelDebug: + return escMagenta + case LevelInfo: + return escCyan + case LevelWarn: + return escBrightYellow + case LevelError: + return escBrightRed + default: + return escBrightRed + } +} + +func msgColor(lvl Level) escCode { + switch lvl { + case LevelWarn: + return escBrightYellow + case LevelError: + return escBrightRed + default: + return escDefault // bold only, terminal default color + } +} + +const escDefault escCode = 0 // no color, used with bold for default text + +// --- ANSI escape sequences --- + +type escCode int8 + +const ( + escBold escCode = 1 + escItalic escCode = 3 + escReverse escCode = 7 + escGreen escCode = 32 + escBlue escCode = 34 + escMagenta escCode = 35 + escCyan escCode = 36 + escWhite escCode = 37 + escBrightBlack escCode = 90 + escBrightRed escCode = 91 + escBrightBlue escCode = 94 + escBrightCyan escCode = 96 + escBrightYellow escCode = 93 + escBrightWhite escCode = 97 +) + +type escSeq struct{ noColor bool } + +// dim emits the muted auxiliary style (time, brackets, separators). +func (es escSeq) dim(buf *Buffer, fn func()) { + if es.noColor { + fn() + return + } + buf.AppendString("\x1b[0;2m") + fn() + buf.AppendString("\x1b[0m") +} + +// dimItalic emits the muted auxiliary style with italic (logger name, caller). +func (es escSeq) dimItalic(buf *Buffer, fn func()) { + if es.noColor { + fn() + return + } + buf.AppendString("\x1b[0;2;3m") + fn() + buf.AppendString("\x1b[0m") +} + +func (es escSeq) at(buf *Buffer, clr escCode, fn func()) { + if es.noColor { + fn() + return + } + buf.AppendString("\x1b[") + buf.AppendInt(int64(clr)) + buf.AppendByte('m') + fn() + buf.AppendString("\x1b[0m") +} + + +func (es escSeq) at2(buf *Buffer, clr1, clr2 escCode, fn func()) { + if es.noColor { + fn() + return + } + buf.AppendString("\x1b[") + buf.AppendInt(int64(clr1)) + buf.AppendByte(';') + buf.AppendInt(int64(clr2)) + buf.AppendByte('m') + fn() + buf.AppendString("\x1b[0m") +} diff --git a/textencoder_test.go b/textencoder_test.go new file mode 100644 index 0000000..c76c0df --- /dev/null +++ b/textencoder_test.go @@ -0,0 +1,371 @@ +package logf + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTextEncoder(t *testing.T) { + testCases := []encoderTestCase{ + { + "Message", + Entry{Text: "m"}, + "[ERR] m\n", + }, + { + "LevelDebug", + Entry{Level: LevelDebug}, + "[DBG]\n", + }, + { + "LevelInfo", + Entry{Level: LevelInfo}, + "[INF]\n", + }, + { + "LevelWarn", + Entry{Level: LevelWarn}, + "[WRN]\n", + }, + { + "LevelError", + Entry{Level: LevelError}, + "[ERR]\n", + }, + { + "LoggerName", + Entry{LoggerName: "logger.name"}, + "[ERR] logger.name:\n", + }, + { + "CallerPC", + Entry{CallerPC: CallerPC(0)}, + "", // checked separately below + }, + { + "FieldsNumbers", + Entry{ + Fields: []Field{ + Bool("bool", true), + Int("int", 42), Int64("int64", 42), Int32("int32", 42), Int16("int16", 42), Int8("int8", 42), + Uint("uint", 42), Uint64("uint64", 42), Uint32("uint32", 42), Uint16("uint16", 42), Uint8("uint8", 42), + Float64("float64", 4.2), Float32("float32", 4.2), + }, + }, + "[ERR] › bool=true int=42 int64=42 int32=42 int16=42 int8=42 uint=42 uint64=42 uint32=42 uint16=42 uint8=42 float64=4.2 float32=4.199999809265137\n", + }, + { + "FieldsSlices", + Entry{ + Fields: []Field{ + Ints("ints", []int{42}), Ints64("ints64", []int64{42}), + Floats64("floats64", []float64{4.2}), + }, + }, + "[ERR] › ints=[42] ints64=[42] floats64=[4.2]\n", + }, + { + "FieldsDuration", + Entry{ + Fields: []Field{ + Duration("duration", time.Second), + Durations("durations", []time.Duration{time.Second}), + }, + }, + "[ERR] › duration=1s durations=[1s]\n", + }, + { + "FieldsTime", + Entry{ + Fields: []Field{ + Time("time", time.Unix(320836234, 0).UTC()), + }, + }, + "[ERR] › time=\"Mar 2 09:10:34.000\"\n", + }, + { + "FieldsArray", + Entry{ + Fields: []Field{ + Array("array", &testArrayEncoder{}), + }, + }, + "[ERR] › array=[42]\n", + }, + { + "FieldsObject", + Entry{ + Fields: []Field{ + Object("object", &testObjectEncoder{}), + }, + }, + "[ERR] › object={\"username\":\"username\",\"code\":42}\n", + }, + { + "FieldsInline", + Entry{ + Fields: []Field{ + Inline(&testObjectEncoder{}), + String("extra", "value"), + }, + }, + "[ERR] › username=username code=42 extra=value\n", + }, + { + "FieldsError", + Entry{ + Fields: []Field{ + Error(&verboseError{"short", "verbose"}), + }, + }, + "[ERR] › error=short error.verbose=verbose\n", + }, + { + "FieldsNilError", + Entry{ + Fields: []Field{ + NamedError("error", nil), + }, + }, + "[ERR] › error=\n", + }, + { + "FieldsBytes", + Entry{ + Fields: []Field{ + Bytes("bytes", []byte{0x42}), + }, + }, + "[ERR] › bytes=\"Qg==\"\n", + }, + { + "FieldsStrings", + Entry{ + Fields: []Field{ + Strings("strings", []string{"a", "b"}), + }, + }, + "[ERR] › strings=[a,b]\n", + }, + { + "FieldsStringer", + Entry{ + Fields: []Field{ + Stringer("stringer", time.Second), + }, + }, + "[ERR] › stringer=1s\n", + }, + { + "FieldsNilStringer", + Entry{ + Fields: []Field{ + Stringer("stringer", nil), + }, + }, + "[ERR] › stringer=nil\n", + }, + { + "FieldsFormatter", + Entry{ + Fields: []Field{ + Formatter("fmt", "%d", 42), + }, + }, + "[ERR] › fmt=42\n", + }, + { + "FieldsAny", + Entry{ + Fields: []Field{ + Any("any", &struct{ Field string }{Field: "42"}), + }, + }, + "[ERR] › any={\"Field\":\"42\"}\n", + }, + { + "FieldsGroup", + Entry{ + Fields: []Field{ + Group("request", String("id", "abc"), Int("status", 200)), + }, + }, + "[ERR] › request.id=abc request.status=200\n", + }, + { + "FieldsGroupEmpty", + Entry{ + Fields: []Field{ + Group("empty"), + }, + }, + "[ERR]\n", + }, + { + "FieldsGroupNested", + Entry{ + Fields: []Field{ + Group("outer", String("a", "1"), Group("inner", Int("b", 2))), + }, + }, + "[ERR] › outer.a=1 outer.inner.b=2\n", + }, + { + "FieldsLoggerBag", + Entry{ + LoggerBag: NewBag(Int("int", 42)), + }, + "[ERR] › int=42\n", + }, + { + "FieldsLoggerBagFirst", + Entry{ + LoggerBag: NewBag(Int("int", 42)), + Fields: []Field{String("string", "42")}, + }, + "[ERR] › int=42 string=42\n", + }, + { + "WithGroup", + Entry{ + LoggerBag: NewBag(String("a", "1")).WithGroup("http").With(String("method", "GET")), + Fields: []Field{Int("status", 200)}, + }, + "[ERR] › a=1 http.method=GET http.status=200\n", + }, + { + "WithGroupNested", + Entry{ + LoggerBag: NewBag().WithGroup("http").WithGroup("request").With(String("path", "/api")), + Fields: []Field{Int("status", 200)}, + }, + "[ERR] › http.request.path=/api http.request.status=200\n", + }, + { + "WithGroupNoFields", + Entry{ + LoggerBag: NewBag().WithGroup("http"), + Fields: []Field{Int("status", 200)}, + }, + "[ERR] › http.status=200\n", + }, + { + "WithGroupAndWith", + Entry{ + LoggerBag: NewBag().WithGroup("http").With(String("method", "GET")).With(String("path", "/api")), + Fields: []Field{Int("status", 200)}, + }, + "[ERR] › http.method=GET http.path=/api http.status=200\n", + }, + { + "WithGroupEmpty", + Entry{ + LoggerBag: NewBag().WithGroup("http"), + }, + "[ERR]\n", + }, + { + "WithGroupNestedEmpty", + Entry{ + LoggerBag: NewBag().WithGroup("http").WithGroup("request"), + }, + "[ERR]\n", + }, + { + "WithGroupPartiallyEmpty", + Entry{ + LoggerBag: NewBag().WithGroup("http").With(String("host", "localhost")).WithGroup("request"), + }, + "[ERR] › http.host=localhost\n", + }, + { + "StringWithSpaces", + Entry{ + Fields: []Field{String("msg", "hello world")}, + }, + "[ERR] › msg=\"hello world\"\n", + }, + { + "MessageAndFields", + Entry{ + Text: "request handled", + Fields: []Field{String("method", "GET"), Int("status", 200)}, + }, + "[ERR] request handled › method=GET status=200\n", + }, + } + + // Color test: full message with all elements. + t.Run("FullColorOutput", func(t *testing.T) { + colorEnc := NewTextEncoder(TextEncoderConfig{ + DisableFieldTime: true, + DisableFieldCaller: true, + }) + e := Entry{ + Level: LevelInfo, + LoggerName: "runvm", + Text: "started processing task", + LoggerBag: NewBag( + String("dc-name", "za01-cloud"), + ), + Fields: []Field{ + String("task-type", "runvm_vm_finalize"), + Int("status", 200), + Bool("ok", true), + Duration("elapsed", 42*time.Millisecond), + }, + } + b, err := colorEnc.Encode(e) + require.NoError(t, err) + got := b.String() + b.Free() + + // Verify structure: [level] name: message › fields + assert.Contains(t, got, "[") // level bracket + assert.Contains(t, got, "INF") // level text + assert.Contains(t, got, "]") // level bracket + assert.Contains(t, got, "runvm:") // logger name + assert.Contains(t, got, "started processing task") // message + assert.Contains(t, got, "›") // field separator + assert.Contains(t, got, "dc-name") // bag field key + assert.Contains(t, got, "za01-cloud") // bag field value + assert.Contains(t, got, "task-type") // entry field key + assert.Contains(t, got, "200") // numeric value + assert.Contains(t, got, "true") // bool value + assert.Contains(t, got, "42ms") // duration value + + // Verify ANSI codes present (not NoColor): + assert.Contains(t, got, "\x1b[") // has escape sequences + assert.Contains(t, got, "\x1b[0;2m") // dim (brackets, separators) + assert.Contains(t, got, "\x1b[1;36m") // bold cyan (INF) + assert.Contains(t, got, "\x1b[1m") // bold (message) + assert.Contains(t, got, "\x1b[0;2;3m") // dim italic (logger name) + assert.Contains(t, got, "\x1b[94;3m") // bright blue italic (field keys) + assert.Contains(t, got, "\x1b[32m") // green (numeric values) + + t.Logf("output:\n%s", got) + }) + + enc := NewTextEncoder(TextEncoderConfig{ + NoColor: true, + DisableFieldTime: true, + }) + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + b, err := enc.Encode(tc.entry) + require.NoError(t, err) + + if tc.golden != "" { + assert.Equal(t, tc.golden, b.String()) + } else { + // CallerPC: line number is dynamic, just check key presence. + assert.Contains(t, b.String(), "→ logf/textencoder_test.go:") + } + b.Free() + }) + } +}