From 3eac1851ec507dc05fbca35bf72b5d36b18963b4 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Mon, 27 Apr 2026 15:42:24 +0300 Subject: [PATCH] (new): prepare v2.1.0 release - add custom LogLevel constructors and richer color configuration - support ANSI, 256-color, RGB, and text attributes on LogLevel - introduce clearer preferred APIs such as NewLogger and SetName - preserve backward compatibility with deprecated wrappers for v2.0.1 APIs - restore legacy ColorStringBuilder signatures as compatibility wrappers - fix newline separator handling in Println-style output - add tests for color precedence, deprecated aliases, and LogLevel attributes - update GoDoc and bilingual README documentation --- README.md | 26 +++++- README_ru.md | 26 +++++- RELEASE_NOTES.md | 27 ++++++ colors.go | 133 +++++++++++++++++++++++---- examples/main.go | 30 +++--- formatter.go | 46 +++++++++- io.go | 45 ++++----- logger.go | 232 ++++++++++++++++++++++++++++++++++++++--------- logger_test.go | 184 +++++++++++++++++++++++++++++++++++++ time_format.go | 2 + writers.go | 36 +++++--- 11 files changed, 669 insertions(+), 118 deletions(-) diff --git a/README.md b/README.md index 4ff69e0..912db29 100644 --- a/README.md +++ b/README.md @@ -32,9 +32,9 @@ import ( ) func main() { - logger := sneklog.CreateLogger(). - Prefix("API"). - Level(sneklog.DEBUG). + logger := sneklog.NewLogger(). + SetName("API"). + SetLevel(sneklog.DEBUG). AddReplacer("SOME_SECRET", "") text := logger.CreateTextStdoutWriter() @@ -59,7 +59,7 @@ func main() { ## Defaults -`CreateLogger()` starts with: +`NewLogger()` starts with: - `Prefix("LOG")` - `Level(sneklog.FATAL)` @@ -67,6 +67,8 @@ func main() { - text formatter: `sneklog.DefaultTextFormatter` - JSON formatter: `sneklog.DefaultJsonFormatter` +`CreateLogger()` is still available for backward compatibility. + Important: with the current level ordering, `Level(sneklog.FATAL)` allows `INFO`, `WARN`, `ERROR`, and `FATAL`, but not `DEBUG`. Use `Level(sneklog.DEBUG)` to enable every level. ## Writers and ownership @@ -117,6 +119,22 @@ JSON writers emit objects with this shape: When `JsonPretty(true)` is enabled, JSON is indented. +## Custom levels and colors + +You can define your own levels and assign ANSI, 256-color, or RGB colors, plus text attributes such as `Bold` or `Italic`. + +```go +httpDelete := sneklog.NewLogLevel(0, "delete") +httpDelete.SetBackgroundRGB(128, 0, 0) +httpDelete.AddAttribute(sneklog.Italic).AddAttribute(sneklog.Bold) + +httpCache := sneklog.NewLogLevel(0, "cache") +httpCache.SetForeground256Color(214) +``` + +Because `LogLevel` setters mutate the level in place, call them on a variable, not on a temporary value returned by `NewLogLevel(...)`. +Short forms such as `SetFgColor` and `SetBgColor` remain available for backward compatibility. + ## Message replacement `AddReplacer(old, new)` replaces matching text in every message before the diff --git a/README_ru.md b/README_ru.md index 6f26060..e7cec6e 100644 --- a/README_ru.md +++ b/README_ru.md @@ -32,9 +32,9 @@ import ( ) func main() { - logger := sneklog.CreateLogger(). - Prefix("API"). - Level(sneklog.DEBUG). + logger := sneklog.NewLogger(). + SetName("API"). + SetLevel(sneklog.DEBUG). AddReplacer("SOME_SECRET", "") text := logger.CreateTextStdoutWriter() @@ -59,7 +59,7 @@ func main() { ## Значения по умолчанию -`CreateLogger()` создает логгер со следующими настройками: +`NewLogger()` создает логгер со следующими настройками: - `Prefix("LOG")` - `Level(sneklog.FATAL)` @@ -67,6 +67,8 @@ func main() { - текстовый formatter: `sneklog.DefaultTextFormatter` - JSON formatter: `sneklog.DefaultJsonFormatter` +`CreateLogger()` по-прежнему доступен для обратной совместимости. + Важно: в текущей модели уровней `Level(sneklog.FATAL)` пропускает `INFO`, `WARN`, `ERROR` и `FATAL`, но не `DEBUG`. Чтобы включить все сообщения, используйте `Level(sneklog.DEBUG)`. ## Writer'ы и владение @@ -117,6 +119,22 @@ JSON writer записывает объект со следующими поля Если включен `JsonPretty(true)`, JSON выводится с отступами. +## Кастомные уровни и цвета + +Можно создавать собственные уровни и назначать им ANSI, 256-color или RGB-цвета, а также текстовые атрибуты вроде `Bold` и `Italic`. + +```go +httpDelete := sneklog.NewLogLevel(0, "delete") +httpDelete.SetBackgroundRGB(128, 0, 0) +httpDelete.AddAttribute(sneklog.Italic).AddAttribute(sneklog.Bold) + +httpCache := sneklog.NewLogLevel(0, "cache") +httpCache.SetForeground256Color(214) +``` + +Так как setter'ы `LogLevel` изменяют уровень на месте, их нужно вызывать на переменной, а не на временном результате `NewLogLevel(...)`. +Короткие формы вроде `SetFgColor` и `SetBgColor` сохранены для обратной совместимости. + ## Замена сообщений `AddReplacer(old, new)` заменяет найденный текст в каждом сообщении до того, diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 4c509be..22810b9 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,30 @@ +# v2.1.0 + +This release expands sneklog color customization while preserving compatibility with the v2.0.1 API. + +## Added +- custom LogLevel constructors with configurable colors +- 256-color and RGB support for LogLevel +- ANSI text attributes on LogLevel such as Bold and Italic +- typed color helpers for foreground/background RGB and 256-color values +- NewLogger, SetName, SetLevel, and SetJSONPretty as clearer preferred APIs + +## Improved +- text and JSON writer handling of trailing newline semantics +- formatter colorization logic for foreground, background, and attributes +- GoDoc coverage across the public API +- English and Russian README examples and API guidance + +## Compatibility +- legacy APIs from v2.0.1 remain available as deprecated wrappers +- legacy ColorStringBuilder method signatures were preserved for backward compatibility +- deprecated methods now explicitly document their replacements and planned removal in v3 + +## Fixed +- extra separator/space issues in Println-style output +- attribute slice handling and defensive copying for LogLevel +- color mode precedence when switching between ANSI, 256-color, and RGB modes + # v2.0.0 ## Highlights diff --git a/colors.go b/colors.go index b3810b1..c151157 100644 --- a/colors.go +++ b/colors.go @@ -7,8 +7,22 @@ import ( type FgColor uint8 type BgColor uint8 +type FgColor256 uint8 +type BgColor256 uint8 +type BgColorRGB []uint8 +type FgColorRGB []uint8 type Attribute uint8 +// NewBgColorRGB builds an RGB background color value. +func NewBgColorRGB(r, g, b uint8) BgColorRGB { + return BgColorRGB{r, g, b} +} + +// NewFgColorRGB builds an RGB foreground color value. +func NewFgColorRGB(r, g, b uint8) FgColorRGB { + return FgColorRGB{r, g, b} +} + const ( Reset Attribute = iota Bold @@ -32,6 +46,7 @@ const ( DisableCrossedOut ) const ( + FgNone = 0 FgBlack FgColor = iota + 30 FgRed FgGreen @@ -43,6 +58,7 @@ const ( FgDefault FgColor = 39 ) const ( + BgNone = 0 BgBlack BgColor = iota + 40 BgRed BgGreen @@ -87,51 +103,96 @@ type ColorStringBuilder struct { str string } +// NewColorStringBuilder creates a builder for composing ANSI-colored strings. func NewColorStringBuilder() *ColorStringBuilder { return &ColorStringBuilder{str: ""} } + +// AddAttribute appends an ANSI text attribute sequence. func (builder *ColorStringBuilder) AddAttribute(attr Attribute) *ColorStringBuilder { builder.str += attr.String() return builder } + +// AddFgColor appends a basic ANSI foreground color sequence. func (builder *ColorStringBuilder) AddFgColor(color FgColor) *ColorStringBuilder { - if color == 0 { + if color <= 0 { return builder } builder.str += color.String() return builder } + +// AddBgColor appends a basic ANSI background color sequence. func (builder *ColorStringBuilder) AddBgColor(color BgColor) *ColorStringBuilder { - if color == 0 { + if color <= 0 { return builder } builder.str += color.String() return builder } + +// AddForeground256Color appends a 256-color foreground sequence. +func (builder *ColorStringBuilder) AddForeground256Color(color FgColor256) *ColorStringBuilder { + builder.str += color.String() + return builder +} + +// AddColor256Fg appends a 256-color foreground sequence. +// Deprecated: use AddForeground256Color. This method will be removed in v3. func (builder *ColorStringBuilder) AddColor256Fg(color uint8) *ColorStringBuilder { - builder.str += color256Fg(color) + return builder.AddForeground256Color(FgColor256(color)) +} + +// AddBackground256Color appends a 256-color background sequence. +func (builder *ColorStringBuilder) AddBackground256Color(color BgColor256) *ColorStringBuilder { + builder.str += color.String() return builder } + +// AddColor256Bg appends a 256-color background sequence. +// Deprecated: use AddBackground256Color. This method will be removed in v3. func (builder *ColorStringBuilder) AddColor256Bg(color uint8) *ColorStringBuilder { - builder.str += color256Bg(color) + return builder.AddBackground256Color(BgColor256(color)) +} + +// AddForegroundRGB appends an RGB foreground sequence. +func (builder *ColorStringBuilder) AddForegroundRGB(color FgColorRGB) *ColorStringBuilder { + builder.str += color.String() return builder } + +// AddColorRgbFg appends an RGB foreground sequence. +// Deprecated: use AddForegroundRGB. This method will be removed in v3. func (builder *ColorStringBuilder) AddColorRgbFg(r, g, b uint8) *ColorStringBuilder { - builder.str += colorRgbFg(r, g, b) + return builder.AddForegroundRGB(FgColorRGB{r, g, b}) +} + +// AddBackgroundRGB appends an RGB background sequence. +func (builder *ColorStringBuilder) AddBackgroundRGB(color BgColorRGB) *ColorStringBuilder { + builder.str += color.String() return builder } + +// AddColorRgbBg appends an RGB background sequence. +// Deprecated: use AddBackgroundRGB. This method will be removed in v3. func (builder *ColorStringBuilder) AddColorRgbBg(r, g, b uint8) *ColorStringBuilder { - builder.str += colorRgbBg(r, g, b) - return builder + return builder.AddBackgroundRGB(BgColorRGB{r, g, b}) } + +// AddReset appends the ANSI reset sequence. func (builder *ColorStringBuilder) AddReset() *ColorStringBuilder { builder.str += "\x1b[0m" return builder } + +// AddText appends plain text to the builder. func (builder *ColorStringBuilder) AddText(text string) *ColorStringBuilder { builder.str += text return builder } + +// String returns the built string and appends a reset sequence when needed. func (builder *ColorStringBuilder) String() string { if strings.Contains(builder.str, "\x1b[") && !strings.HasSuffix(builder.str, "\x1b[0m") { builder.AddReset() @@ -139,43 +200,77 @@ func (builder *ColorStringBuilder) String() string { return builder.str } +// String returns the ANSI escape sequence for the attribute. func (a Attribute) String() string { return fmt.Sprintf("\x1b[%dm", a) } + +// Uint8 returns the raw attribute value. func (a Attribute) Uint8() uint8 { return uint8(a) } + +// Int returns the raw attribute value as int. func (a Attribute) Int() int { return int(a) } + +// String returns the ANSI escape sequence for the foreground color. func (c FgColor) String() string { return fmt.Sprintf("\x1b[%dm", c) } + +// Uint8 returns the raw foreground color value. func (c FgColor) Uint8() uint8 { return uint8(c) } + +// Int returns the raw foreground color value as int. func (c FgColor) Int() int { return int(c) } + +// String returns the ANSI escape sequence for the background color. func (c BgColor) String() string { return fmt.Sprintf("\x1b[%dm", c) } + +// Uint8 returns the raw background color value. func (c BgColor) Uint8() uint8 { return uint8(c) } + +// Int returns the raw background color value as int. func (c BgColor) Int() int { return int(c) } -func color256Fg(color uint8) string { - return fmt.Sprintf("\x1b[38;5;%dm", color) -} -func color256Bg(color uint8) string { - return fmt.Sprintf("\x1b[48;5;%dm", color) -} -func colorRgbFg(r, g, b uint8) string { - return fmt.Sprintf("\x1b[38;2;%d;%d;%dm", r, g, b) -} -func colorRgbBg(r, g, b uint8) string { - return fmt.Sprintf("\x1b[48;2;%d;%d;%dm", r, g, b) -} +// String returns the ANSI escape sequence for the 256-color foreground value. +func (c FgColor256) String() string { return fmt.Sprintf("\u001B[38;5;%dm", c) } + +// Uint8 returns the raw 256-color foreground value. +func (c FgColor256) Uint8() uint8 { return uint8(c) } + +// Int returns the raw 256-color foreground value as int. +func (c FgColor256) Int() int { return int(c) } + +// String returns the ANSI escape sequence for the 256-color background value. +func (c BgColor256) String() string { return fmt.Sprintf("\u001B[48;5;%dm", c) } + +// Uint8 returns the raw 256-color background value. +func (c BgColor256) Uint8() uint8 { return uint8(c) } + +// Int returns the raw 256-color background value as int. +func (c BgColor256) Int() int { return int(c) } + +// String returns the ANSI escape sequence for the RGB foreground value. +func (c FgColorRGB) String() string { return fmt.Sprintf("\x1b[38;2;%d;%d;%dm", c[0], c[1], c[2]) } + +// Uint8 returns the raw RGB foreground components. +func (c FgColorRGB) Uint8() []uint8 { return c } + +// String returns the ANSI escape sequence for the RGB background value. +func (c BgColorRGB) String() string { return fmt.Sprintf("\x1b[48;2;%d;%d;%dm", c[0], c[1], c[2]) } + +// Uint8 returns the raw RGB background components. +func (c BgColorRGB) Uint8() []uint8 { return c } diff --git a/examples/main.go b/examples/main.go index 4b5b65d..023554a 100644 --- a/examples/main.go +++ b/examples/main.go @@ -2,16 +2,21 @@ package main import ( "bytes" - "fmt" "git.scuroneko.dev/scuroneko/sneklog/v2" ) func main() { - logger := sneklog.CreateLogger(). - Prefix("EXAMPLE"). - Level(sneklog.DEBUG). - JsonPretty(true). + httpGet := sneklog.NewLogLevelWithColors(0, "get", sneklog.FgGreen, sneklog.BgNone) + httpPost := sneklog.NewLogLevelWithColors(0, "post", sneklog.FgBlue, sneklog.BgNone) + httpDelete := sneklog.NewLogLevel(0, "delete") + httpDelete.SetBackgroundRGB(128, 0, 0) + httpDelete.AddAttribute(sneklog.Italic).AddAttribute(sneklog.Bold) + + logger := sneklog.NewLogger(). + SetName("EXAMPLE"). + SetLevel(sneklog.DEBUG). + SetJSONPretty(true). AddReplacer("SOME_SECRET", "") sneklog.INFO.SetBgColor(sneklog.BgBlue).SetFgColor(sneklog.FgWhite) @@ -20,13 +25,13 @@ func main() { jsonStdout := logger.CreateJsonStdoutWriter() formatter := sneklog.NewFormatter(). - SetFormat("[%t] [%L] [%N]: %m (%S)"). + SetFormat("[%t] [%L] [%N]: %m (%s)"). SetTimeStampFormat(sneklog.Kitchen). SetTraceBackFormat("%s %f:%n %p") jsonFormatter := sneklog.NewFormatter(). SetFormat("[%L] [%N] %m"). - SetColorOutput(true). + SetColorOutput(false). SetTimeStampFormat(sneklog.Kitchen) textStdout.SetFormatter(formatter) @@ -62,19 +67,22 @@ func main() { logger.Errorln("request failed") logger.Debugln("debug details") logger.Infoln("sensitive info, SOME_SECRET") + logger.Println(httpGet, "this can be logging of http get request/response...") + logger.Println(httpPost, "...and this can be logging of http post request/response...") + logger.Println(httpDelete, "...but this logging of http delete request/response with highly customizable level!") if err := logger.Close(); err != nil { panic(err) } s := sneklog.NewColorStringBuilder(). - AddColorRgbBg(31, 41, 40). - AddColorRgbFg(220, 215, 186). + AddBackgroundRGB(sneklog.NewBgColorRGB(31, 41, 40)). + AddForegroundRGB(sneklog.NewFgColorRGB(220, 215, 186)). AddAttribute(sneklog.Italic).AddAttribute(sneklog.Bold). AddText("Some Very very very cool stuff, themed in Kanagawa colors!").String() println(s) - fmt.Println("external buffer contents:") - fmt.Println(externalBuffer.String()) + //fmt.Println("external buffer contents:") + //fmt.Println(externalBuffer.String()) } diff --git a/formatter.go b/formatter.go index 2d028a0..23607d0 100644 --- a/formatter.go +++ b/formatter.go @@ -47,6 +47,7 @@ var DefaultJsonFormatter = &Formatter{ ColorOutput: false, } +// NewFormatter returns a copy of the default text formatter settings. func NewFormatter() *Formatter { return &Formatter{ Format: DefaultTextFormatter.Format, @@ -58,35 +59,50 @@ func NewFormatter() *Formatter { ColorOnlyStdout: DefaultTextFormatter.ColorOnlyStdout, } } + +// SetFormat sets the formatter template used for text rendering. func (f *Formatter) SetFormat(format string) *Formatter { f.Format = format return f } + +// SetMessageSeparator sets the separator used to join message parts. func (f *Formatter) SetMessageSeparator(separator string) *Formatter { f.MessageSeparator = separator return f } + +// SetTimeStampFormat sets the strftime-like format used for timestamps. func (f *Formatter) SetTimeStampFormat(format string) *Formatter { f.TimeStampFormat = format return f } + +// SetTraceBackFormat sets the format used for a single traceback frame. func (f *Formatter) SetTraceBackFormat(format string) *Formatter { f.TraceBackFormat = format return f } + +// SetTraceBackSeparator sets the separator used between traceback frames. func (f *Formatter) SetTraceBackSeparator(separator string) *Formatter { f.TraceBackSeparator = separator return f } + +// SetColorOutput enables or disables colorized output. func (f *Formatter) SetColorOutput(color bool) *Formatter { f.ColorOutput = color return f } + +// SetColorOnlyStdout restricts colorized output to stdout and stderr when enabled. func (f *Formatter) SetColorOnlyStdout(only bool) *Formatter { f.ColorOnlyStdout = only return f } +// FormatMessage applies the formatter template to the provided log record fields. func (f *Formatter) FormatMessage(level LogLevel, prefix string, tb []*MethodTraceback, messages ...any) string { if f == nil { return fmt.Sprint(messages...) @@ -132,18 +148,40 @@ func (f *Formatter) FormatMessage(level LogLevel, prefix string, tb []*MethodTra return output } +// ColorizeString wraps a string in the ANSI color sequences defined by the level. func (f *Formatter) ColorizeString(s string, level LogLevel) string { - return NewColorStringBuilder(). - AddFgColor(level.fg).AddBgColor(level.bg). - AddText(s).AddReset().String() + builder := NewColorStringBuilder() + if level.fgRgb != nil { + builder.AddForegroundRGB(level.fgRgb) + } else if level.fg256 > 0 { + builder.AddForeground256Color(level.fg256) + } else { + builder.AddFgColor(level.fg) + } + + if level.bgRgb != nil { + builder.AddBackgroundRGB(level.bgRgb) + } else if level.bg256 > 0 { + builder.AddBackground256Color(level.bg256) + } else { + builder.AddBgColor(level.bg) + } + + for _, attr := range level.attrs { + builder.AddAttribute(attr) + } + return builder.AddText(s).AddReset().String() } +// FormatTime formats a timestamp using the formatter timestamp layout. func (f *Formatter) FormatTime(t time.Time) string { if f.TimeStampFormat == "" { return t.Format(time.RFC3339) } return t.Format(StrftimeToGo(f.TimeStampFormat)) } + +// FormatTraceback formats a single traceback frame. func (f *Formatter) FormatTraceback(traceback *MethodTraceback) string { formattedTraceback := f.TraceBackFormat if f.JSONTraceBackPath { @@ -157,6 +195,8 @@ func (f *Formatter) FormatTraceback(traceback *MethodTraceback) string { formattedTraceback = strings.ReplaceAll(formattedTraceback, "%s", traceback.Signature) return formattedTraceback } + +// FormatTracebacks formats and joins multiple traceback frames. func (f *Formatter) FormatTracebacks(traceback []*MethodTraceback) string { var formattedTraceback []string for _, frame := range traceback { diff --git a/io.go b/io.go index c364b4f..2532710 100644 --- a/io.go +++ b/io.go @@ -7,85 +7,85 @@ import ( // Infof logs a formatted info message. func (l *Logger) Infof(format string, args ...any) { - l.print(INFO, fmt.Sprintf(format, args...)) + l.Print(INFO, fmt.Sprintf(format, args...)) } // Info logs an info message. func (l *Logger) Info(m ...any) { - l.print(INFO, m...) + l.Print(INFO, m...) } // Infoln logs an info message and appends a newline semantic for writers that need it. func (l *Logger) Infoln(m ...any) { - l.println(INFO, m...) + l.Println(INFO, m...) } // Warnf logs a formatted warning message. func (l *Logger) Warnf(format string, args ...any) { - l.print(WARN, fmt.Sprintf(format, args...)) + l.Print(WARN, fmt.Sprintf(format, args...)) } // Warn logs a warning message. func (l *Logger) Warn(m ...any) { - l.print(WARN, m...) + l.Print(WARN, m...) } // Warnln logs a warning message with newline semantic. func (l *Logger) Warnln(m ...any) { - l.println(WARN, m...) + l.Println(WARN, m...) } // Errorf logs a formatted error message. func (l *Logger) Errorf(format string, args ...any) { - l.print(ERROR, fmt.Sprintf(format, args...)) + l.Print(ERROR, fmt.Sprintf(format, args...)) } // Error logs an error message. func (l *Logger) Error(m ...any) { - l.print(ERROR, m...) + l.Print(ERROR, m...) } // Errorln logs an error message with newline semantic. func (l *Logger) Errorln(m ...any) { - l.println(ERROR, m...) + l.Println(ERROR, m...) } // Fatalf logs a formatted fatal message and exits the process with code 1. func (l *Logger) Fatalf(format string, args ...any) { - l.print(FATAL, fmt.Sprintf(format, args...)) + l.Print(FATAL, fmt.Sprintf(format, args...)) os.Exit(1) } // Fatal logs a fatal message and exits the process with code 1. func (l *Logger) Fatal(m ...any) { - l.print(FATAL, m...) + l.Print(FATAL, m...) os.Exit(1) } // Fatalln logs a fatal message with newline semantic and exits the process with code 1. func (l *Logger) Fatalln(m ...any) { - l.println(FATAL, m...) + l.Println(FATAL, m...) os.Exit(1) } // Debugf logs a formatted debug message. func (l *Logger) Debugf(format string, args ...any) { - l.print(DEBUG, fmt.Sprintf(format, args...)) + l.Print(DEBUG, fmt.Sprintf(format, args...)) } // Debug logs a debug message. func (l *Logger) Debug(m ...any) { - l.print(DEBUG, m...) + l.Print(DEBUG, m...) } // Debugln logs a debug message with newline semantic. func (l *Logger) Debugln(m ...any) { - l.println(DEBUG, m...) + l.Println(DEBUG, m...) } -// Write message without trailing "\n" +// Print write message without trailing "\n" // Good for database -func (l *Logger) print(level LogLevel, m ...any) { +func (l *Logger) Print(level LogLevel, m ...any) { if l.level.n < level.n { return } @@ -93,7 +93,7 @@ func (l *Logger) print(level LogLevel, m ...any) { return } - tb := getFullTraceback(0) + tb := getFullTraceback(1) for _, writer := range l.writers { if writer == nil { continue @@ -105,9 +105,10 @@ func (l *Logger) print(level LogLevel, m ...any) { } } +// Println // Docker requires "\n" at end to write to log. // print not work for docker, otherwise it will work and write into stdout -func (l *Logger) println(level LogLevel, m ...any) { +func (l *Logger) Println(level LogLevel, m ...any) { if l.level.n < level.n { return } @@ -115,13 +116,13 @@ func (l *Logger) println(level LogLevel, m ...any) { return } - tb := getFullTraceback(0) - messages := append(append(make([]any, 0, len(m)+1), m...), "\n") + tb := getFullTraceback(1) + messages := append(append(make([]any, 0, len(m)+1), l.replaceAll(m...)...), "\n") for _, writer := range l.writers { if writer == nil { continue } - err := writer.Print(level, l.prefix, tb, l.replaceAll(messages...)...) + err := writer.Print(level, l.prefix, tb, messages...) if err != nil { l.reportWriterError(err) } diff --git a/logger.go b/logger.go index 130d98c..f3ff7a8 100644 --- a/logger.go +++ b/logger.go @@ -7,46 +7,154 @@ import ( "strings" ) -type replacer struct { - old string - new string -} - -func (r replacer) replace(s string) string { - return strings.ReplaceAll(s, r.old, r.new) -} - -// Logger routes log records to one or more configured writers. -type Logger struct { - prefix string - level LogLevel - writers []LoggerWriter - replacers []replacer - - jsonPretty bool -} - // LogLevel describes a logging severity. type LogLevel struct { - n uint8 - t string - fg FgColor - bg BgColor + n uint8 + t string + + fg FgColor + fg256 FgColor256 + fgRgb FgColorRGB + + bg BgColor + bg256 BgColor256 + bgRgb BgColorRGB + + attrs []Attribute +} + +// NewLogLevel creates a log level without predefined colors. +func NewLogLevel(index uint8, name string) LogLevel { + return LogLevel{n: index, t: name, attrs: []Attribute{}} +} + +// NewLogLevelWithColors creates a log level with ANSI foreground and background colors. +func NewLogLevelWithColors(index uint8, name string, fg FgColor, bg BgColor) LogLevel { + return LogLevel{n: index, t: name, fg: fg, bg: bg, attrs: []Attribute{}} } // GetName returns the lowercase textual representation of the level. -func (l *LogLevel) GetName() string { return l.t } -func (l *LogLevel) GetFgColor() FgColor { return l.fg } +func (l *LogLevel) GetName() string { return l.t } + +// SetFgColor sets the ANSI foreground color and clears other foreground color modes. +// Deprecated: use SetForegroundColor. This method will be removed in v3. func (l *LogLevel) SetFgColor(color FgColor) *LogLevel { + return l.SetForegroundColor(color) +} + +// GetFgColor returns the ANSI foreground color for the level. +// Deprecated: use GetForegroundColor. This method will be removed in v3. +func (l *LogLevel) GetFgColor() FgColor { return l.GetForegroundColor() } + +// SetForegroundColor sets the ANSI foreground color and clears other foreground color modes. +func (l *LogLevel) SetForegroundColor(color FgColor) *LogLevel { l.fg = color + l.fg256 = 0 + l.fgRgb = nil return l } -func (l *LogLevel) GetBgColor() BgColor { return l.bg } -func (l *LogLevel) SetBgColor(color BgColor) *LogLevel { - l.bg = color + +// GetForegroundColor returns the ANSI foreground color for the level. +func (l *LogLevel) GetForegroundColor() FgColor { return l.fg } + +// SetForeground256Color sets the 256-color foreground and clears other foreground color modes. +func (l *LogLevel) SetForeground256Color(color FgColor256) *LogLevel { + l.fg = 0 + l.fg256 = color + l.fgRgb = nil return l } +// GetForeground256Color returns the configured 256-color foreground value. +func (l *LogLevel) GetForeground256Color() FgColor256 { return l.fg256 } + +// SetForegroundRGB sets the RGB foreground color and clears other foreground color modes. +func (l *LogLevel) SetForegroundRGB(r, g, b uint8) *LogLevel { + l.fg = 0 + l.fg256 = 0 + l.fgRgb = FgColorRGB{r, g, b} + return l +} + +// GetForegroundRGB returns the configured RGB foreground value. +func (l *LogLevel) GetForegroundRGB() FgColorRGB { return l.fgRgb } + +// SetBgColor sets the ANSI background color and clears other background color modes. +// Deprecated: use SetBackgroundColor. This method will be removed in v3. +func (l *LogLevel) SetBgColor(color BgColor) *LogLevel { + return l.SetBackgroundColor(color) +} + +// GetBgColor returns the ANSI background color for the level. +// Deprecated: use GetBackgroundColor. This method will be removed in v3. +func (l *LogLevel) GetBgColor() BgColor { return l.GetBackgroundColor() } + +// SetBackgroundColor sets the ANSI background color and clears other background color modes. +func (l *LogLevel) SetBackgroundColor(color BgColor) *LogLevel { + l.bg = color + l.bg256 = 0 + l.bgRgb = nil + return l +} + +// GetBackgroundColor returns the ANSI background color for the level. +func (l *LogLevel) GetBackgroundColor() BgColor { return l.bg } + +// SetBackground256Color sets the 256-color background and clears other background color modes. +func (l *LogLevel) SetBackground256Color(color BgColor256) *LogLevel { + l.bg = 0 + l.bg256 = color + l.bgRgb = nil + return l +} + +// GetBackground256Color returns the configured 256-color background value. +func (l *LogLevel) GetBackground256Color() BgColor256 { return l.bg256 } + +// SetBackgroundRGB sets the RGB background color and clears other background color modes. +func (l *LogLevel) SetBackgroundRGB(r, g, b uint8) *LogLevel { + l.bg = 0 + l.bg256 = 0 + l.bgRgb = BgColorRGB{r, g, b} + return l +} + +// GetBackgroundRGB returns the configured RGB background value. +func (l *LogLevel) GetBackgroundRGB() BgColorRGB { return l.bgRgb } + +// AddAttribute appends an ANSI text attribute to the level. +func (l *LogLevel) AddAttribute(a Attribute) *LogLevel { + l.attrs = append(l.attrs, a) + return l +} + +// RemoveAttribute removes all matching ANSI text attributes from the level. +func (l *LogLevel) RemoveAttribute(a Attribute) *LogLevel { + attrs := make([]Attribute, 0) + for _, attr := range l.attrs { + if attr != a { + attrs = append(attrs, attr) + } + } + l.attrs = attrs + return l +} + +// SetAttributes replaces the level attributes with a copy of the provided slice. +func (l *LogLevel) SetAttributes(a []Attribute) *LogLevel { + attrs := make([]Attribute, len(a)) + copy(attrs, a) + l.attrs = attrs + return l +} + +// GetAttributes returns a copy of the configured ANSI text attributes. +func (l *LogLevel) GetAttributes() []Attribute { + attrs := make([]Attribute, len(l.attrs)) + copy(attrs, l.attrs) + return attrs +} + // MethodTraceback describes a single stack frame attached to a log entry. type MethodTraceback struct { Method string `json:"method"` @@ -58,14 +166,25 @@ type MethodTraceback struct { // Predefined log levels. var ( - INFO = LogLevel{n: 0, t: "info", fg: FgWhite} - WARN = LogLevel{n: 1, t: "warn", fg: FgHiYellow} - ERROR = LogLevel{n: 2, t: "error", fg: FgHiRed} - FATAL = LogLevel{n: 3, t: "fatal", fg: FgRed} - DEBUG = LogLevel{n: 4, t: "debug", fg: FgGreen} + INFO = NewLogLevelWithColors(0, "info", FgWhite, BgNone) + WARN = NewLogLevelWithColors(1, "warn", FgHiYellow, BgNone) + ERROR = NewLogLevelWithColors(2, "error", FgHiRed, BgNone) + FATAL = NewLogLevelWithColors(3, "fatal", FgRed, BgNone) + DEBUG = NewLogLevelWithColors(4, "debug", FgGreen, BgNone) ) +// Logger routes log records to one or more configured writers. +type Logger struct { + prefix string + level LogLevel + writers []LoggerWriter + replacers []replacer + + jsonPretty bool +} + // CreateLogger creates a logger with default settings. +// Deprecated: use NewLogger. This method will be removed in v3. func CreateLogger() *Logger { return &Logger{ prefix: "LOG", @@ -73,24 +192,53 @@ func CreateLogger() *Logger { } } +// NewLogger creates a logger with default settings. +func NewLogger() *Logger { + return &Logger{ + prefix: "LOG", + level: FATAL, + } +} + // Prefix sets the record prefix and returns the logger for chaining. +// Deprecated: use SetName. This method will be removed in v3. func (l *Logger) Prefix(prefix string) *Logger { l.prefix = prefix return l } +// SetName sets the record prefix and returns the logger for chaining. +func (l *Logger) SetName(name string) *Logger { + l.prefix = name + return l +} + // Level sets the maximum enabled level and returns the logger for chaining. +// Deprecated: use SetLevel. This method will be removed in v3. func (l *Logger) Level(level LogLevel) *Logger { l.level = level return l } -// JsonPretty enables indented JSON output for JSON writers. +// SetLevel sets the maximum enabled level and returns the logger for chaining. +func (l *Logger) SetLevel(level LogLevel) *Logger { + l.level = level + return l +} + +// JsonPretty enables or disables indented JSON output for JSON writers created by the logger. +// Deprecated: use SetJSONPretty. This method will be removed in v3. func (l *Logger) JsonPretty(b bool) *Logger { l.jsonPretty = b return l } +// SetJSONPretty enables or disables indented output for JSON writers created by the logger. +func (l *Logger) SetJSONPretty(b bool) *Logger { + l.jsonPretty = b + return l +} + // AddWriters appends multiple writers to the logger. func (l *Logger) AddWriters(writers ...LoggerWriter) *Logger { l.writers = append(l.writers, writers...) @@ -98,6 +246,7 @@ func (l *Logger) AddWriters(writers ...LoggerWriter) *Logger { } // AddWriter appends a single writer to the logger. +// Deprecated: use AddWriters. This method will be removed in v3. func (l *Logger) AddWriter(writer LoggerWriter) *Logger { l.writers = append(l.writers, writer) return l @@ -134,14 +283,10 @@ func (l *Logger) Close() error { } // CreateTextWriter wraps an external writer with the logger text settings. -func (l *Logger) CreateTextWriter(w io.Writer) *LoggerTextWriter { - return CreateTextWriter(w) -} +func (l *Logger) CreateTextWriter(w io.Writer) *LoggerTextWriter { return CreateTextWriter(w) } // CreateTextStdoutWriter creates a non-owning text writer for os.Stdout. -func (l *Logger) CreateTextStdoutWriter() *LoggerTextWriter { - return CreateTextStdoutWriter() -} +func (l *Logger) CreateTextStdoutWriter() *LoggerTextWriter { return CreateTextStdoutWriter() } // CreateTextFileWriter creates an owning text writer for a file. func (l *Logger) CreateTextFileWriter(filename string) (*LoggerTextWriter, error) { @@ -163,6 +308,12 @@ func (l *Logger) CreateJsonFileWriter(filename string) (*LoggerJsonWriter, error return CreateJsonFileWriter(filename, l.jsonPretty) } +type replacer struct { + old string + new string +} + +func (r replacer) replace(s string) string { return strings.ReplaceAll(s, r.old, r.new) } func (l *Logger) replace(s string) string { out := s for _, repl := range l.replacers { @@ -170,7 +321,6 @@ func (l *Logger) replace(s string) string { } return out } - func (l *Logger) replaceAll(messages ...any) []any { if len(l.replacers) == 0 { return messages diff --git a/logger_test.go b/logger_test.go index bb0c44c..41d7833 100644 --- a/logger_test.go +++ b/logger_test.go @@ -189,6 +189,190 @@ func TestJsonWriterPrintPreservesTrailingNewlineSemantic(t *testing.T) { } } +func TestTextWriterPrintlnDoesNotLeaveTrailingMessageSeparator(t *testing.T) { + var buf bytes.Buffer + formatter := NewFormatter(). + SetFormat("%m (%S)"). + SetColorOutput(false) + writer := CreateTextWriter(&buf).SetFormatter(formatter) + + if err := writer.Print(DEBUG, "TEST", nil, "debug details", "\n"); err != nil { + t.Fatalf("Print() error = %v", err) + } + + got := strings.TrimSuffix(buf.String(), "\n") + if got != "debug details (%S)" { + t.Fatalf("println newline marker should not leave trailing message separator, got %q", got) + } +} + +func TestTextWriterPrintlnDoesNotLeaveTrailingMessageSeparatorAfterReplacement(t *testing.T) { + var buf bytes.Buffer + formatter := NewFormatter(). + SetFormat("%m (%S)"). + SetColorOutput(false) + writer := CreateTextWriter(&buf).SetFormatter(formatter) + logger := CreateLogger(). + SetLevel(DEBUG). + AddWriter(writer). + AddReplacer("details", "details") + + logger.Debugln("debug details") + + got := strings.TrimSuffix(buf.String(), "\n") + if got != "debug details (%S)" { + t.Fatalf("println newline marker should not leave trailing message separator after replacements, got %q", got) + } +} + +func TestJsonWriterPrintlnDoesNotLeaveTrailingMessageSeparator(t *testing.T) { + var buf bytes.Buffer + writer := CreateJsonWriter(&buf, false) + + if err := writer.Print(INFO, "TEST", nil, "hello", "\n"); err != nil { + t.Fatalf("Print() error = %v", err) + } + + var message LoggerJsonMessage + if err := json.Unmarshal(bytes.TrimSuffix(buf.Bytes(), []byte("\n")), &message); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if message.Message != "hello" { + t.Fatalf("message should not include trailing separator from newline marker, got %q", message.Message) + } +} + +func TestLogLevelForegroundSettersOverridePreviousColorMode(t *testing.T) { + level := NewLogLevel(0, "custom") + formatter := NewFormatter() + + level.SetFgColor(FgRed) + if got := formatter.ColorizeString("x", level); !strings.HasPrefix(got, FgRed.String()) { + t.Fatalf("expected plain foreground color prefix %q, got %q", FgRed.String(), got) + } + + level.SetForeground256Color(FgColor256(123)) + if got := formatter.ColorizeString("x", level); !strings.HasPrefix(got, FgColor256(123).String()) { + t.Fatalf("expected 256-color foreground prefix %q, got %q", FgColor256(123).String(), got) + } + + level.SetForegroundRGB(1, 2, 3) + expectedRGB := NewFgColorRGB(1, 2, 3).String() + if got := formatter.ColorizeString("x", level); !strings.HasPrefix(got, expectedRGB) { + t.Fatalf("expected RGB foreground prefix %q, got %q", expectedRGB, got) + } + + level.SetFgColor(FgBlue) + if got := formatter.ColorizeString("x", level); !strings.HasPrefix(got, FgBlue.String()) { + t.Fatalf("expected last plain foreground color to win with prefix %q, got %q", FgBlue.String(), got) + } +} + +func TestLogLevelBackgroundSettersOverridePreviousColorMode(t *testing.T) { + level := NewLogLevel(0, "custom") + formatter := NewFormatter() + + level.SetBgColor(BgRed) + if got := formatter.ColorizeString("x", level); !strings.HasPrefix(got, BgRed.String()) { + t.Fatalf("expected plain background color prefix %q, got %q", BgRed.String(), got) + } + + level.SetBackground256Color(BgColor256(123)) + if got := formatter.ColorizeString("x", level); !strings.HasPrefix(got, BgColor256(123).String()) { + t.Fatalf("expected 256-color background prefix %q, got %q", BgColor256(123).String(), got) + } + + level.SetBackgroundRGB(1, 2, 3) + expectedRGB := NewBgColorRGB(1, 2, 3).String() + if got := formatter.ColorizeString("x", level); !strings.HasPrefix(got, expectedRGB) { + t.Fatalf("expected RGB background prefix %q, got %q", expectedRGB, got) + } + + level.SetBgColor(BgBlue) + if got := formatter.ColorizeString("x", level); !strings.HasPrefix(got, BgBlue.String()) { + t.Fatalf("expected last plain background color to win with prefix %q, got %q", BgBlue.String(), got) + } +} + +func TestLogLevelAttributesAffectColorizedOutput(t *testing.T) { + level := NewLogLevel(0, "custom") + level.AddAttribute(Italic).AddAttribute(Bold) + + got := NewFormatter().ColorizeString("x", level) + if !strings.Contains(got, Italic.String()) { + t.Fatalf("expected italic attribute in colorized output, got %q", got) + } + if !strings.Contains(got, Bold.String()) { + t.Fatalf("expected bold attribute in colorized output, got %q", got) + } +} + +func TestLogLevelRemoveAttributeRemovesAllMatches(t *testing.T) { + level := NewLogLevel(0, "custom") + level.AddAttribute(Bold).AddAttribute(Italic).AddAttribute(Bold) + + level.RemoveAttribute(Bold) + + got := level.GetAttributes() + if len(got) != 1 || got[0] != Italic { + t.Fatalf("expected only italic attribute to remain, got %#v", got) + } +} + +func TestLogLevelSetAttributesCopiesInputSlice(t *testing.T) { + level := NewLogLevel(0, "custom") + attrs := []Attribute{Bold, Italic} + + level.SetAttributes(attrs) + attrs[0] = Underline + + got := level.GetAttributes() + if len(got) != 2 || got[0] != Bold || got[1] != Italic { + t.Fatalf("expected SetAttributes to copy input slice, got %#v", got) + } +} + +func TestLogLevelGetAttributesReturnsCopy(t *testing.T) { + level := NewLogLevel(0, "custom") + level.SetAttributes([]Attribute{Bold, Italic}) + + got := level.GetAttributes() + got[0] = Underline + + again := level.GetAttributes() + if len(again) != 2 || again[0] != Bold || again[1] != Italic { + t.Fatalf("expected GetAttributes to return a copy, got %#v", again) + } +} + +func TestLogLevelDeprecatedForegroundAccessorsRemainCompatible(t *testing.T) { + level := NewLogLevel(0, "custom") + + level.SetFgColor(FgBlue) + if got := level.GetFgColor(); got != FgBlue { + t.Fatalf("expected deprecated foreground accessors to round-trip %v, got %v", FgBlue, got) + } + + level.SetForegroundColor(FgRed) + if got := level.GetFgColor(); got != FgRed { + t.Fatalf("expected deprecated getter to reflect new foreground setter, got %v", got) + } +} + +func TestLogLevelDeprecatedBackgroundAccessorsRemainCompatible(t *testing.T) { + level := NewLogLevel(0, "custom") + + level.SetBgColor(BgBlue) + if got := level.GetBgColor(); got != BgBlue { + t.Fatalf("expected deprecated background accessors to round-trip %v, got %v", BgBlue, got) + } + + level.SetBackgroundColor(BgRed) + if got := level.GetBgColor(); got != BgRed { + t.Fatalf("expected deprecated getter to reflect new background setter, got %v", got) + } +} + func TestFormatterHandlesEmptyTracebackPlaceholders(t *testing.T) { formatter := NewFormatter(). SetFormat("%m|%b|%B|%M|%f|%n|%s|%p") diff --git a/time_format.go b/time_format.go index 752ab75..2fb92d9 100644 --- a/time_format.go +++ b/time_format.go @@ -91,6 +91,7 @@ var strftimeToGoRules = []rule{ {"%r", "03:04:05 PM"}, } +// StrftimeToGo converts a strftime-like layout into a Go time layout. func StrftimeToGo(format string) string { var out strings.Builder @@ -121,6 +122,7 @@ func StrftimeToGo(format string) string { return out.String() } +// GoToStrftime converts a Go time layout into an approximate strftime-like layout. func GoToStrftime(format string) string { format = strings.ReplaceAll(format, "2006", "%Y") format = strings.ReplaceAll(format, "06", "%y") diff --git a/writers.go b/writers.go index 716e2c8..91e0910 100644 --- a/writers.go +++ b/writers.go @@ -17,6 +17,20 @@ type LoggerWriter interface { Print(level LogLevel, prefix string, traceback []*MethodTraceback, messages ...any) error } +// PrepareMessages normalizes message parts and extracts a trailing newline marker. +func PrepareMessages(messages ...any) (bool, []any) { + msg := Map(messages, func(el any) string { return fmt.Sprint(el) }) + newline := false + if len(msg) > 0 && strings.HasSuffix(msg[len(msg)-1], "\n") { + newline = true + msg[len(msg)-1] = strings.TrimSuffix(msg[len(msg)-1], "\n") + if msg[len(msg)-1] == "" { + msg = msg[:len(msg)-1] + } + } + return newline, Map(msg, func(el string) any { return any(el) }) +} + // LoggerTextWriter writes human-readable log records to an io.Writer. type LoggerTextWriter struct { LoggerWriter @@ -25,10 +39,13 @@ type LoggerTextWriter struct { formatter *Formatter } +// SetFormatter replaces the formatter used by the text writer. func (w *LoggerTextWriter) SetFormatter(formatter *Formatter) *LoggerTextWriter { w.formatter = formatter return w } + +// Formatter returns the effective formatter for the text writer. func (w *LoggerTextWriter) Formatter() *Formatter { if w.formatter == nil { return DefaultTextFormatter @@ -43,13 +60,7 @@ func (w *LoggerTextWriter) Write(p []byte) (n int, err error) { // Print formats the provided record as text and writes it to the underlying writer. func (w *LoggerTextWriter) Print(level LogLevel, prefix string, traceback []*MethodTraceback, messages ...any) error { - msg := Map(messages, func(el any) string { return fmt.Sprint(el) }) - newline := false - if len(msg) > 0 && strings.HasSuffix(msg[len(msg)-1], "\n") { - newline = true - msg[len(msg)-1] = strings.TrimSuffix(msg[len(msg)-1], "\n") - } - messages = Map(msg, func(el string) any { return any(el) }) + newline, messages := PrepareMessages(messages...) f := w.Formatter() s := f.FormatMessage(level, prefix, traceback, messages...) @@ -88,12 +99,15 @@ type LoggerJsonWriter struct { formatter *Formatter } +// Formatter returns the effective formatter for the JSON writer. func (w *LoggerJsonWriter) Formatter() *Formatter { if w.formatter == nil { return DefaultJsonFormatter } return w.formatter } + +// SetFormatter replaces the formatter used by the JSON writer. func (w *LoggerJsonWriter) SetFormatter(f *Formatter) *LoggerJsonWriter { w.formatter = f return w @@ -116,13 +130,7 @@ func (w *LoggerJsonWriter) Write(data []byte) (int, error) { // Print encodes the provided record as JSON and writes it to the underlying writer. func (w *LoggerJsonWriter) Print(level LogLevel, prefix string, traceback []*MethodTraceback, messages ...any) error { - msg := Map(messages, func(el any) string { return fmt.Sprint(el) }) - newline := false - if len(msg) > 0 && strings.HasSuffix(msg[len(msg)-1], "\n") { - newline = true - msg[len(msg)-1] = strings.TrimSuffix(msg[len(msg)-1], "\n") - } - messages = Map(msg, func(el string) any { return any(el) }) + newline, messages := PrepareMessages(messages...) f := w.Formatter() s := f.FormatMessage(level, prefix, traceback, messages...)