From d534f92d480a81fa29fa214618a1eb3ddea181d3 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Fri, 24 Apr 2026 15:43:14 +0300 Subject: [PATCH] (refactor): prepare breaking v2 API - rename package to sneklog and move module to /v2 - replace text output flags with writer formatters - remove external color dependencies - update migration notes and coverage --- .gitea/workflows/go-lint.yaml | 12 +++ .gitignore | 2 + README.md | 28 +++--- README_ru.md | 28 +++--- RELEASE_NOTES.md | 91 +++++++++++++++++ colors.go | 181 ++++++++++++++++++++++++++++++++++ doc.go | 17 ++-- examples/main.go | 29 +++++- formatter.go | 166 +++++++++++++++++++++++++++++++ go.mod | 10 +- go.sum | 15 --- io.go | 2 +- logger.go | 103 +++++-------------- logger_test.go | 73 +++++++++++++- time_format.go | 161 ++++++++++++++++++++++++++++++ traceback.go | 2 +- utils.go | 2 +- writers.go | 94 ++++++++++++++---- 18 files changed, 845 insertions(+), 171 deletions(-) create mode 100644 .gitea/workflows/go-lint.yaml create mode 100644 colors.go create mode 100644 formatter.go create mode 100644 time_format.go diff --git a/.gitea/workflows/go-lint.yaml b/.gitea/workflows/go-lint.yaml new file mode 100644 index 0000000..5dc1a35 --- /dev/null +++ b/.gitea/workflows/go-lint.yaml @@ -0,0 +1,12 @@ +name: Golang lint +run-name: Linting code +on: [push] + +jobs: + lint: + runs-on: go-latest + steps: + - name: Checkout repository code + uses: actions/checkout@v6 + - name: Run golangci-lint + run: golangci-lint run diff --git a/.gitignore b/.gitignore index 54f6f8f..7924d06 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .idea/ +.vscode/ +.codex test/ *.log \ No newline at end of file diff --git a/README.md b/README.md index e412ac9..26aaf07 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# slog +# SNekLog (ScuroNeko Logger) Small structured logger for Go with text and JSON output, multiple writers, and optional traceback metadata. @@ -17,7 +17,7 @@ Russian version: [README_ru.md](README_ru.md) ## Installation ```bash -go get git.scuroneko.dev/scuroneko/slog +go get git.scuroneko.dev/scuroneko/slog/v2 ``` ## Quick start @@ -28,15 +28,13 @@ package main import ( "log" - "git.scuroneko.dev/scuroneko/slog" + "git.scuroneko.dev/scuroneko/slog/v2" ) func main() { - logger := slog.CreateLogger(). + logger := sneklog.CreateLogger(). Prefix("API"). - Level(slog.DEBUG). - PrintTraceback(true). - JsonPretty(true). + Level(sneklog.DEBUG). AddReplacer("SOME_SECRET", "") text := logger.CreateTextStdoutWriter() @@ -64,12 +62,12 @@ func main() { `CreateLogger()` starts with: - `Prefix("LOG")` -- `Level(slog.FATAL)` -- `PrintTime(true)` -- `PrintTraceback(false)` +- `Level(sneklog.FATAL)` - `JsonPretty(false)` +- text formatter: `sneklog.DefaultTextFormatter` +- JSON formatter: `sneklog.DefaultJsonFormatter` -Important: with the current level ordering, `Level(slog.FATAL)` allows `INFO`, `WARN`, `ERROR`, and `FATAL`, but not `DEBUG`. Use `Level(slog.DEBUG)` to enable every level. +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 @@ -92,10 +90,10 @@ This makes it safe to plug in `bytes.Buffer`, network writers, and other externa Text writers render records like: ```text -[API] [INFO] [main.go:main:27] [17.03.26 14:05:09] service started +2026-03-17T14:05:09+03:00 info API: service started ``` -The traceback field is included only when `PrintTraceback(true)` is enabled. +Use `SetFormatter` on a writer to customize timestamps, traceback fields, colors, and message layout. JSON writers emit objects with this shape: @@ -126,8 +124,8 @@ record reaches any writer. Replacement rules are applied in the order they are added. ```go -logger := slog.CreateLogger(). - Level(slog.DEBUG). +logger := sneklog.CreateLogger(). + Level(sneklog.DEBUG). AddReplacer("SOME_SECRET", ""). AddReplacer("user@example.com", "") diff --git a/README_ru.md b/README_ru.md index 4187cc4..74873a0 100644 --- a/README_ru.md +++ b/README_ru.md @@ -1,4 +1,4 @@ -# slog +# SNekLog (ScuroNeko Logger) Небольшой структурированный логгер для Go с текстовым и JSON-выводом, несколькими writer'ами и настраиваемыми traceback-метаданными. @@ -17,7 +17,7 @@ English version: [README.md](README.md) ## Установка ```bash -go get git.scuroneko.dev/scuroneko/slog +go get git.scuroneko.dev/scuroneko/slog/v2 ``` ## Быстрый старт @@ -28,15 +28,13 @@ package main import ( "log" - "git.scuroneko.dev/scuroneko/slog" + "git.scuroneko.dev/scuroneko/slog/v2" ) func main() { - logger := slog.CreateLogger(). + logger := sneklog.CreateLogger(). Prefix("API"). - Level(slog.DEBUG). - PrintTraceback(true). - JsonPretty(true). + Level(sneklog.DEBUG). AddReplacer("SOME_SECRET", "") text := logger.CreateTextStdoutWriter() @@ -64,12 +62,12 @@ func main() { `CreateLogger()` создает логгер со следующими настройками: - `Prefix("LOG")` -- `Level(slog.FATAL)` -- `PrintTime(true)` -- `PrintTraceback(false)` +- `Level(sneklog.FATAL)` - `JsonPretty(false)` +- текстовый formatter: `sneklog.DefaultTextFormatter` +- JSON formatter: `sneklog.DefaultJsonFormatter` -Важно: в текущей модели уровней `Level(slog.FATAL)` пропускает `INFO`, `WARN`, `ERROR` и `FATAL`, но не `DEBUG`. Чтобы включить все сообщения, используйте `Level(slog.DEBUG)`. +Важно: в текущей модели уровней `Level(sneklog.FATAL)` пропускает `INFO`, `WARN`, `ERROR` и `FATAL`, но не `DEBUG`. Чтобы включить все сообщения, используйте `Level(sneklog.DEBUG)`. ## Writer'ы и владение @@ -92,10 +90,10 @@ func main() { Текстовый writer формирует записи вида: ```text -[API] [INFO] [main.go:main:27] [17.03.26 14:05:09] service started +2026-03-17T14:05:09+03:00 info API: service started ``` -Поле traceback появляется только если включен `PrintTraceback(true)`. +Используйте `SetFormatter` у writer'а, чтобы настроить timestamp'ы, traceback-поля, цвета и шаблон сообщения. JSON writer записывает объект со следующими полями: @@ -125,8 +123,8 @@ JSON writer записывает объект со следующими поля как запись попадет в writer'ы. Правила замены применяются в порядке добавления. ```go -logger := slog.CreateLogger(). - Level(slog.DEBUG). +logger := sneklog.CreateLogger(). + Level(sneklog.DEBUG). AddReplacer("SOME_SECRET", ""). AddReplacer("user@example.com", "") diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 7161a91..8e26fdc 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,94 @@ +# v2.0.0 + +## Highlights + +- Renamed the public Go package from `slog` to `sneklog` to avoid confusion with the standard library `log/slog` package. +- Moved the module path to `git.scuroneko.dev/scuroneko/slog/v2` for Go module major-version compatibility. +- Replaced fixed text writer settings with formatter-based output customization. +- Added built-in ANSI color support and removed the external `github.com/fatih/color` dependency tree. +- Formatters: a new way do display your logs! + +## Breaking Changes + +- Import paths must use the `/v2` module suffix: + +```go +import sneklog "git.scuroneko.dev/scuroneko/slog/v2" +``` + +- Package references should use `sneklog` instead of `slog`. +- Removed `Logger.PrintTime` and `Logger.PrintTraceback`; configure output through writer formatters instead. +- Changed text writer constructors: + - `CreateTextWriter(w, printTraceback, printTime)` is now `CreateTextWriter(w)`. + - `CreateTextFileWriter(path, printTraceback, printTime)` is now `CreateTextFileWriter(path)`. + - `CreateTextStdoutWriter(printTraceback, printTime)` is now `CreateTextStdoutWriter()`. +- Removed the old text formatting helpers: `BuildString`, `FormatTime`, `FormatTraceback`, and `FormatFullTraceback`. +- JSON `time` values are now formatted through the configured formatter and stored as strings in `LoggerJsonMessage`. + +## Added + +- `Formatter` for configurable message layouts, timestamp layouts, traceback layouts, separators, and color behavior. +- `DefaultTextFormatter` and `DefaultJsonFormatter` defaults for text and JSON writers. +- `LoggerTextWriter.SetFormatter` and `LoggerJsonWriter.SetFormatter`. +- Strftime-like timestamp format constants such as `RFC3339`, `Kitchen`, `DateTime`, `DateOnly`, and `TimeOnly`. +- Built-in color types and helpers: + - `FgColor`, `BgColor`, and `Attribute`. + - `ColorStringBuilder`. + - Level color getters/setters on `LogLevel`. +- Tests covering formatter defaults, empty traceback placeholders, literal `%` sequences in messages, color controls, and JSON newline semantics. + +## Changed + +- Text output defaults to `DefaultTextFormatter` instead of the old hard-coded string builder. +- JSON output defaults to `DefaultJsonFormatter` and can now format the JSON `message` field through `Formatter`. +- Newline semantics are normalized before text and JSON formatting, so trailing `\n` controls record termination without becoming part of the message. +- Text color output is enabled by default only for stdout/stderr. +- Example code now demonstrates custom formatters and color configuration. +- Go dependencies were simplified by removing: + - `github.com/fatih/color` + - `github.com/mattn/go-colorable` + - `github.com/mattn/go-isatty` + - `golang.org/x/sys` + +## Fixed + +- `NewFormatter()` now returns a fresh copy instead of mutating `DefaultTextFormatter`. +- `Formatter.FormatMessage` no longer panics when called with an empty traceback. +- Placeholder-looking text inside log messages, such as `%L` or `%s`, is preserved literally. +- `SetColorOutput(false)` disables color output. +- `SetColorOnlyStdout(false)` allows color output for non-stdout writers. + +## Migration + +Before: + +```go +import "git.scuroneko.dev/scuroneko/slog" + +logger := slog.CreateLogger(). + Prefix("API"). + Level(slog.DEBUG). + PrintTraceback(true) + +text := logger.CreateTextStdoutWriter() +``` + +After: + +```go +import sneklog "git.scuroneko.dev/scuroneko/slog/v2" + +logger := sneklog.CreateLogger(). + Prefix("API"). + Level(sneklog.DEBUG) + +formatter := sneklog.NewFormatter(). + SetFormat("%t [%L] %N: %m (%B)") + +text := logger.CreateTextStdoutWriter(). + SetFormatter(formatter) +``` + # v1.2.0 ## Highlights diff --git a/colors.go b/colors.go new file mode 100644 index 0000000..b3810b1 --- /dev/null +++ b/colors.go @@ -0,0 +1,181 @@ +package sneklog + +import ( + "fmt" + "strings" +) + +type FgColor uint8 +type BgColor uint8 +type Attribute uint8 + +const ( + Reset Attribute = iota + Bold + Faint + Italic + Underline + BlinkSlow + BlinkRapid + Invert + Conceal + CrossedOut +) +const ( + DoubleUnderline Attribute = iota + 21 // Can be disable bold on some terminals + DisableBold + DisableItalic + DisableUnderline + DisableBlink + DisableInvert + DisableConceal + DisableCrossedOut +) +const ( + FgBlack FgColor = iota + 30 + FgRed + FgGreen + FgYellow + FgBlue + FgMagenta + FgCyan + FgWhite + FgDefault FgColor = 39 +) +const ( + BgBlack BgColor = iota + 40 + BgRed + BgGreen + BgYellow + BgBlue + BgMagenta + BgCyan + BgWhite + BgDefault BgColor = 49 +) +const ( + FgHiBlack FgColor = iota + 90 + FgHiRed + FgHiGreen + FgHiYellow + FgHiBlue + FgHiMagenta + FgHiCyan + FgHiWhite +) +const ( + BgHiBlack BgColor = iota + 100 + BgHiRed + BgHiGreen + BgHiYellow + BgHiBlue + BgHiMagenta + BgHiCyan + BgHiWhite +) + +// This attributes rarely used and not supported by all terminals. +const ( + Border Attribute = iota + 51 + Outline + Upperline + DisableBorderOutline + DisableUpperline +) + +type ColorStringBuilder struct { + str string +} + +func NewColorStringBuilder() *ColorStringBuilder { + return &ColorStringBuilder{str: ""} +} +func (builder *ColorStringBuilder) AddAttribute(attr Attribute) *ColorStringBuilder { + builder.str += attr.String() + return builder +} +func (builder *ColorStringBuilder) AddFgColor(color FgColor) *ColorStringBuilder { + if color == 0 { + return builder + } + builder.str += color.String() + return builder +} +func (builder *ColorStringBuilder) AddBgColor(color BgColor) *ColorStringBuilder { + if color == 0 { + return builder + } + builder.str += color.String() + return builder +} +func (builder *ColorStringBuilder) AddColor256Fg(color uint8) *ColorStringBuilder { + builder.str += color256Fg(color) + return builder +} +func (builder *ColorStringBuilder) AddColor256Bg(color uint8) *ColorStringBuilder { + builder.str += color256Bg(color) + return builder +} +func (builder *ColorStringBuilder) AddColorRgbFg(r, g, b uint8) *ColorStringBuilder { + builder.str += colorRgbFg(r, g, b) + return builder +} +func (builder *ColorStringBuilder) AddColorRgbBg(r, g, b uint8) *ColorStringBuilder { + builder.str += colorRgbBg(r, g, b) + return builder +} +func (builder *ColorStringBuilder) AddReset() *ColorStringBuilder { + builder.str += "\x1b[0m" + return builder +} +func (builder *ColorStringBuilder) AddText(text string) *ColorStringBuilder { + builder.str += text + return builder +} +func (builder *ColorStringBuilder) String() string { + if strings.Contains(builder.str, "\x1b[") && !strings.HasSuffix(builder.str, "\x1b[0m") { + builder.AddReset() + } + return builder.str +} + +func (a Attribute) String() string { + return fmt.Sprintf("\x1b[%dm", a) +} +func (a Attribute) Uint8() uint8 { + return uint8(a) +} +func (a Attribute) Int() int { + return int(a) +} +func (c FgColor) String() string { + return fmt.Sprintf("\x1b[%dm", c) +} +func (c FgColor) Uint8() uint8 { + return uint8(c) +} +func (c FgColor) Int() int { + return int(c) +} +func (c BgColor) String() string { + return fmt.Sprintf("\x1b[%dm", c) +} +func (c BgColor) Uint8() uint8 { + return uint8(c) +} +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) +} diff --git a/doc.go b/doc.go index b4cfaa7..7657c17 100644 --- a/doc.go +++ b/doc.go @@ -1,4 +1,4 @@ -// Package slog provides a small structured logger that can write the same +// Package sneklog provides a small structured logger that can write the same // record to text and JSON outputs. // // A Logger can fan out records to stdout, files, or any external io.Writer. @@ -8,21 +8,20 @@ // CreateLogger uses the following defaults: // - prefix: "LOG" // - level: FATAL, which enables info, warn, error, and fatal records -// - text timestamps: enabled -// - text traceback: disabled // - pretty JSON: disabled +// - text formatter: DefaultTextFormatter +// - JSON formatter: DefaultJsonFormatter // // Call Level(DEBUG) to enable all records, including debug messages. // AddReplacer can be used to mask or normalize message text before records are -// passed to writers. +// passed to writers. Call SetFormatter on a writer to customize timestamps, +// traceback fields, colors, and message layout. // // Basic usage: // -// logger := slog.CreateLogger(). +// logger := sneklog.CreateLogger(). // Prefix("API"). -// Level(slog.DEBUG). -// PrintTraceback(true). -// JsonPretty(true). +// Level(sneklog.DEBUG). // AddReplacer("secret-token", "") // // text := logger.CreateTextStdoutWriter() @@ -42,4 +41,4 @@ // the logger and are closed by Logger.Close. Writers created from existing // io.Writer values through CreateTextWriter or CreateJsonWriter remain owned by // the caller and are not closed by the logger. -package slog +package sneklog diff --git a/examples/main.go b/examples/main.go index ff476c2..a43618f 100644 --- a/examples/main.go +++ b/examples/main.go @@ -4,19 +4,34 @@ import ( "bytes" "fmt" - "git.scuroneko.dev/scuroneko/slog" + "git.scuroneko.dev/scuroneko/slog/v2" ) func main() { - logger := slog.CreateLogger(). + logger := sneklog.CreateLogger(). Prefix("EXAMPLE"). - Level(slog.DEBUG). + Level(sneklog.DEBUG). JsonPretty(true). AddReplacer("SOME_SECRET", "") + sneklog.INFO.SetBgColor(sneklog.BgBlue).SetFgColor(sneklog.FgWhite) + textStdout := logger.CreateTextStdoutWriter() jsonStdout := logger.CreateJsonStdoutWriter() + formatter := sneklog.NewFormatter(). + SetFormat("[%t] [%L] [%N]: %m (%S)"). + SetTimeStampFormat(sneklog.Kitchen). + SetTraceBackFormat("%s %f:%n %p") + + jsonFormatter := sneklog.NewFormatter(). + SetFormat("[%L] [%N] %m"). + SetColorOutput(true). + SetTimeStampFormat(sneklog.Kitchen) + + textStdout.SetFormatter(formatter) + jsonStdout.SetFormatter(jsonFormatter) + textFile, err := logger.CreateTextFileWriter("logs/text.log") if err != nil { logger.Close() @@ -52,6 +67,14 @@ func main() { panic(err) } + s := sneklog.NewColorStringBuilder(). + AddColorRgbBg(31, 41, 40). + AddColorRgbFg(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()) } diff --git a/formatter.go b/formatter.go new file mode 100644 index 0000000..2d028a0 --- /dev/null +++ b/formatter.go @@ -0,0 +1,166 @@ +package sneklog + +import ( + "fmt" + "strings" + "time" +) + +// Formatter +// Format: %t - time, %N - name, %l - level, %L - level uppercase, %b - traceback, %B - full traceback, %m - message, +// %M - method, %f - filename, %n - line number, %s - method signature, %p - full path +// Example: "%t [%l] %N: %m (%f:%n %s)" +// +// TimeStampFormat: %Y - year, %m - month, %d - day, %H - hour, %M - minute, %S - second, +// %z - timezone(i.e. 0300), %Z - timezone(i.e. 03:00) +// Example: "%d.%m.%Y %H:%M:%S" +// +// TraceBackFormat: %M - method, %f - filename, %n - line number, %s - method signature, %p - full path +type Formatter struct { + Format string + MessageSeparator string + + // JSONTraceBackPath + // If true, in traceback will be path to file, otherwise only filename. + JSONTraceBackPath bool + TimeStampFormat string + TraceBackFormat string + TraceBackSeparator string + + ColorOutput bool + ColorOnlyStdout bool +} + +var DefaultTextFormatter = &Formatter{ + Format: "%t %l %N: %m", + MessageSeparator: " ", + TimeStampFormat: RFC3339, + TraceBackFormat: "%M (%f:%n %s)", + TraceBackSeparator: "->", + ColorOutput: true, + ColorOnlyStdout: true, +} +var DefaultJsonFormatter = &Formatter{ + Format: "%m", + MessageSeparator: " ", + TimeStampFormat: RFC3339, + ColorOutput: false, +} + +func NewFormatter() *Formatter { + return &Formatter{ + Format: DefaultTextFormatter.Format, + MessageSeparator: DefaultTextFormatter.MessageSeparator, + TimeStampFormat: DefaultTextFormatter.TimeStampFormat, + TraceBackFormat: DefaultTextFormatter.TraceBackFormat, + TraceBackSeparator: DefaultTextFormatter.TraceBackSeparator, + ColorOutput: DefaultTextFormatter.ColorOutput, + ColorOnlyStdout: DefaultTextFormatter.ColorOnlyStdout, + } +} +func (f *Formatter) SetFormat(format string) *Formatter { + f.Format = format + return f +} +func (f *Formatter) SetMessageSeparator(separator string) *Formatter { + f.MessageSeparator = separator + return f +} +func (f *Formatter) SetTimeStampFormat(format string) *Formatter { + f.TimeStampFormat = format + return f +} +func (f *Formatter) SetTraceBackFormat(format string) *Formatter { + f.TraceBackFormat = format + return f +} +func (f *Formatter) SetTraceBackSeparator(separator string) *Formatter { + f.TraceBackSeparator = separator + return f +} +func (f *Formatter) SetColorOutput(color bool) *Formatter { + f.ColorOutput = color + return f +} +func (f *Formatter) SetColorOnlyStdout(only bool) *Formatter { + f.ColorOnlyStdout = only + return f +} + +func (f *Formatter) FormatMessage(level LogLevel, prefix string, tb []*MethodTraceback, messages ...any) string { + if f == nil { + return fmt.Sprint(messages...) + } + output := f.Format + output = strings.ReplaceAll(output, "%t", f.FormatTime(time.Now())) + output = strings.ReplaceAll(output, "%N", prefix) + output = strings.ReplaceAll(output, "%l", level.GetName()) + output = strings.ReplaceAll(output, "%L", strings.ToUpper(level.GetName())) + + if len(tb) > 0 { + formattedTraceback := f.FormatTraceback(tb[0]) + output = strings.ReplaceAll(output, "%b", formattedTraceback) + + formattedTraceback = f.FormatTracebacks(tb) + output = strings.ReplaceAll(output, "%B", formattedTraceback) + } else { + output = strings.ReplaceAll(output, "%b", "") + output = strings.ReplaceAll(output, "%B", "") + } + + if len(tb) > 0 { + output = strings.ReplaceAll(output, "%M", tb[0].Method) + output = strings.ReplaceAll(output, "%f", tb[0].Filename) + output = strings.ReplaceAll(output, "%n", fmt.Sprintf("%d", tb[0].Line)) + output = strings.ReplaceAll(output, "%s", tb[0].Signature) + } else { + output = strings.ReplaceAll(output, "%M", "") + output = strings.ReplaceAll(output, "%f", "") + output = strings.ReplaceAll(output, "%n", "") + output = strings.ReplaceAll(output, "%s", "") + } + + if f.JSONTraceBackPath && len(tb) > 0 { + output = strings.ReplaceAll(output, "%p", tb[0].FullPath) + } else { + output = strings.ReplaceAll(output, "%p", "") + } + + message := Map(messages, func(m any) string { return fmt.Sprint(m) }) + output = strings.ReplaceAll(output, "%m", strings.Join(message, f.MessageSeparator)) + + return output +} + +func (f *Formatter) ColorizeString(s string, level LogLevel) string { + return NewColorStringBuilder(). + AddFgColor(level.fg).AddBgColor(level.bg). + AddText(s).AddReset().String() +} + +func (f *Formatter) FormatTime(t time.Time) string { + if f.TimeStampFormat == "" { + return t.Format(time.RFC3339) + } + return t.Format(StrftimeToGo(f.TimeStampFormat)) +} +func (f *Formatter) FormatTraceback(traceback *MethodTraceback) string { + formattedTraceback := f.TraceBackFormat + if f.JSONTraceBackPath { + formattedTraceback = strings.ReplaceAll(formattedTraceback, "%p", traceback.FullPath) + } else { + formattedTraceback = strings.ReplaceAll(formattedTraceback, "%p", "") + } + formattedTraceback = strings.ReplaceAll(formattedTraceback, "%M", traceback.Method) + formattedTraceback = strings.ReplaceAll(formattedTraceback, "%f", traceback.Filename) + formattedTraceback = strings.ReplaceAll(formattedTraceback, "%n", fmt.Sprintf("%d", traceback.Line)) + formattedTraceback = strings.ReplaceAll(formattedTraceback, "%s", traceback.Signature) + return formattedTraceback +} +func (f *Formatter) FormatTracebacks(traceback []*MethodTraceback) string { + var formattedTraceback []string + for _, frame := range traceback { + formattedTraceback = append(formattedTraceback, f.FormatTraceback(frame)) + } + return strings.Join(formattedTraceback, f.TraceBackSeparator) +} diff --git a/go.mod b/go.mod index 66abcd8..6d7624c 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,3 @@ -module git.scuroneko.dev/scuroneko/slog +module git.scuroneko.dev/scuroneko/slog/v2 go 1.26 - -require github.com/fatih/color v1.19.0 - -require ( - github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.21 // indirect - golang.org/x/sys v0.43.0 // indirect -) diff --git a/go.sum b/go.sum index f70bc1a..e69de29 100644 --- a/go.sum +++ b/go.sum @@ -1,15 +0,0 @@ -github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= -github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= -github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= -github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -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= -github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= -github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/io.go b/io.go index 1c80984..c364b4f 100644 --- a/io.go +++ b/io.go @@ -1,4 +1,4 @@ -package slog +package sneklog import ( "fmt" diff --git a/logger.go b/logger.go index 5ccad83..130d98c 100644 --- a/logger.go +++ b/logger.go @@ -1,13 +1,10 @@ -package slog +package sneklog import ( "errors" "fmt" "io" "strings" - "time" - - "github.com/fatih/color" ) type replacer struct { @@ -26,21 +23,28 @@ type Logger struct { writers []LoggerWriter replacers []replacer - printTraceback bool - printTime bool - jsonPretty bool + jsonPretty bool } // LogLevel describes a logging severity. type LogLevel struct { - n uint8 - t string - c color.Attribute + n uint8 + t string + fg FgColor + bg BgColor } // GetName returns the lowercase textual representation of the level. -func (l *LogLevel) GetName() string { - return l.t +func (l *LogLevel) GetName() string { return l.t } +func (l *LogLevel) GetFgColor() FgColor { return l.fg } +func (l *LogLevel) SetFgColor(color FgColor) *LogLevel { + l.fg = color + return l +} +func (l *LogLevel) GetBgColor() BgColor { return l.bg } +func (l *LogLevel) SetBgColor(color BgColor) *LogLevel { + l.bg = color + return l } // MethodTraceback describes a single stack frame attached to a log entry. @@ -54,20 +58,18 @@ type MethodTraceback struct { // Predefined log levels. var ( - INFO = LogLevel{n: 0, t: "info", c: color.FgWhite} - WARN = LogLevel{n: 1, t: "warn", c: color.FgHiYellow} - ERROR = LogLevel{n: 2, t: "error", c: color.FgHiRed} - FATAL = LogLevel{n: 3, t: "fatal", c: color.FgRed} - DEBUG = LogLevel{n: 4, t: "debug", c: color.FgGreen} + 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} ) // CreateLogger creates a logger with default settings. func CreateLogger() *Logger { return &Logger{ - prefix: "LOG", - level: FATAL, - printTraceback: false, - printTime: true, + prefix: "LOG", + level: FATAL, } } @@ -83,18 +85,6 @@ func (l *Logger) Level(level LogLevel) *Logger { return l } -// PrintTraceback enables traceback output for text writers. -func (l *Logger) PrintTraceback(b bool) *Logger { - l.printTraceback = b - return l -} - -// PrintTime enables timestamps for text writers. -func (l *Logger) PrintTime(b bool) *Logger { - l.printTime = b - return l -} - // JsonPretty enables indented JSON output for JSON writers. func (l *Logger) JsonPretty(b bool) *Logger { l.jsonPretty = b @@ -145,17 +135,17 @@ 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, l.printTraceback, l.printTime) + return CreateTextWriter(w) } // CreateTextStdoutWriter creates a non-owning text writer for os.Stdout. func (l *Logger) CreateTextStdoutWriter() *LoggerTextWriter { - return CreateTextStdoutWriter(l.printTraceback, l.printTime) + return CreateTextStdoutWriter() } // CreateTextFileWriter creates an owning text writer for a file. func (l *Logger) CreateTextFileWriter(filename string) (*LoggerTextWriter, error) { - return CreateTextFileWriter(filename, l.printTraceback, l.printTime) + return CreateTextFileWriter(filename) } // CreateJsonWriter wraps an external writer with the logger JSON settings. @@ -191,44 +181,3 @@ func (l *Logger) replaceAll(messages ...any) []any { } return out } - -// FormatTime converts time to the package text log timestamp format. -func FormatTime(t time.Time) string { - return fmt.Sprintf("%02d.%02d.%02d %02d:%02d:%02d", t.Day(), t.Month(), t.Year(), t.Hour(), t.Minute(), t.Second()) -} - -// FormatTraceback converts a traceback frame to a compact string. -func FormatTraceback(mt *MethodTraceback) string { - return fmt.Sprintf("%s:%s:%d", mt.Filename, mt.Method, mt.Line) -} - -// FormatFullTraceback joins multiple traceback frames into one string. -func FormatFullTraceback(tracebacks []*MethodTraceback) string { - formatted := make([]string, 0) - for _, tb := range tracebacks { - formatted = append(formatted, FormatTraceback(tb)) - } - return strings.Join(formatted, "->") -} - -// BuildString renders a text log record using the provided settings. -func BuildString(level LogLevel, prefix string, printTime, printTraceback bool, messages ...any) string { - args := []string{ - fmt.Sprintf("[%s]", prefix), - fmt.Sprintf("[%s]", strings.ToUpper(level.t)), - } - - if printTraceback { - args = append(args, fmt.Sprintf("[%s]", FormatTraceback(getTraceback()))) - } - - if printTime { - args = append(args, fmt.Sprintf("[%s]", FormatTime(time.Now()))) - } - - m := Map(messages, func(t any) string { - return fmt.Sprint(t) - }) - s := fmt.Sprintf("%s %s", strings.Join(args, " "), strings.Join(m, " ")) - return s -} diff --git a/logger_test.go b/logger_test.go index 06bc46f..ad76585 100644 --- a/logger_test.go +++ b/logger_test.go @@ -1,4 +1,4 @@ -package slog +package sneklog import ( "bytes" @@ -17,6 +17,9 @@ type stubLoggerWriter struct { messages []any } +func (w *stubLoggerWriter) Formatter() *Formatter { + return &Formatter{} +} func (w *stubLoggerWriter) Close() error { w.closeCalls++ return w.closeErr @@ -98,7 +101,7 @@ func TestLoggerPreservesMessageTypesWithoutReplacers(t *testing.T) { } func TestCreateTextWriterCloseOnNonCloserIsNoOp(t *testing.T) { - writer := CreateTextWriter(&bytes.Buffer{}, false, false) + writer := CreateTextWriter(&bytes.Buffer{}) if err := writer.Close(); err != nil { t.Fatalf("Close() error = %v", err) } @@ -113,7 +116,7 @@ func TestCreateTextWriterDoesNotCloseExternalCloser(t *testing.T) { _ = file.Close() }) - writer := CreateTextWriter(file, false, false) + writer := CreateTextWriter(file) if err := writer.Close(); err != nil { t.Fatalf("Close() error = %v", err) } @@ -186,10 +189,72 @@ func TestJsonWriterPrintPreservesTrailingNewlineSemantic(t *testing.T) { } } +func TestFormatterHandlesEmptyTracebackPlaceholders(t *testing.T) { + formatter := NewFormatter(). + SetFormat("%m|%b|%B|%M|%f|%n|%s|%p") + + got := formatter.FormatMessage(INFO, "TEST", nil, "hello") + if got != "hello|||||||" { + t.Fatalf("empty traceback placeholders should be empty, got %q", got) + } +} + +func TestFormatterDoesNotInterpretPlaceholdersInsideMessages(t *testing.T) { + formatter := NewFormatter(). + SetFormat("%L:%m") + + message := "literal %L %s %p" + got := formatter.FormatMessage(INFO, "TEST", nil, message) + if got != "INFO:literal %L %s %p" { + t.Fatalf("message placeholder-looking text should stay literal, got %q", got) + } +} + +func TestNewFormatterDoesNotMutateDefaultFormatter(t *testing.T) { + formatter := NewFormatter() + formatter.SetFormat("custom") + + if DefaultTextFormatter.Format == "custom" { + t.Fatal("NewFormatter() should return a copy, not mutate DefaultTextFormatter") + } +} + +func TestTextWriterColorOutputHonorsColorOnlyStdoutFalse(t *testing.T) { + var buf bytes.Buffer + formatter := NewFormatter(). + SetFormat("%m"). + SetColorOutput(true). + SetColorOnlyStdout(false) + writer := CreateTextWriter(&buf).SetFormatter(formatter) + + if err := writer.Print(INFO, "TEST", nil, "hello"); err != nil { + t.Fatalf("Print() error = %v", err) + } + if !strings.Contains(buf.String(), "\x1b[") { + t.Fatalf("ColorOnlyStdout(false) should allow color for external writers, got %q", buf.String()) + } +} + +func TestTextWriterColorOutputCanBeDisabled(t *testing.T) { + var buf bytes.Buffer + formatter := NewFormatter(). + SetFormat("%m"). + SetColorOutput(false). + SetColorOnlyStdout(false) + writer := CreateTextWriter(&buf).SetFormatter(formatter) + + if err := writer.Print(INFO, "TEST", nil, "hello"); err != nil { + t.Fatalf("Print() error = %v", err) + } + if strings.Contains(buf.String(), "\x1b[") { + t.Fatalf("SetColorOutput(false) should disable color output, got %q", buf.String()) + } +} + func TestCreateTextStdoutWriterDoesNotCloseStdout(t *testing.T) { stdoutFile := swapStdout(t) - writer := CreateTextStdoutWriter(false, false) + writer := CreateTextStdoutWriter() if err := writer.Close(); err != nil { t.Fatalf("Close() error = %v", err) } diff --git a/time_format.go b/time_format.go new file mode 100644 index 0000000..752ab75 --- /dev/null +++ b/time_format.go @@ -0,0 +1,161 @@ +package sneklog + +import "strings" + +const ( + Layout = "%m/%d %I:%M:%S%p '%y %z" + ANSIC = "%a %b %e %H:%M:%S %Y" + UnixDate = "%a %b %e %H:%M:%S %Z %Y" + RubyDate = "%a %b %d %H:%M:%S %z %Y" + + RFC822 = "%d %b %y %H:%M %Z" + RFC822Z = "%d %b %y %H:%M %z" + RFC850 = "%A, %d-%b-%y %H:%M:%S %Z" + RFC1123 = "%a, %d %b %Y %H:%M:%S %Z" + RFC1123Z = "%a, %d %b %Y %H:%M:%S %z" + RFC3339 = "%Y-%m-%dT%H:%M:%S%:z" + RFC3339Nano = "%Y-%m-%dT%H:%M:%S%#N%:z" + Kitchen = "%-I:%M%p" + + Stamp = "%b %e %H:%M:%S" + StampMilli = "%b %e %H:%M:%S.%L" + StampMicro = "%b %e %H:%M:%S.%f" + StampNano = "%b %e %H:%M:%S.%N" + DateTime = "%Y-%m-%d %H:%M:%S" + DateOnly = "%Y-%m-%d" + TimeOnly = "%H:%M:%S" +) + +type rule struct { + strf string + goLayout string +} + +var strftimeToGoRules = []rule{ + // Long/special tokens first. + {"%::z", "Z07:00:00"}, + {"%:::z", "Z07"}, + {"%:z", "Z07:00"}, + + {"%#N", ".999999999"}, + {"%#f", ".999999"}, + {"%#L", ".999"}, + + {"%-m", "1"}, + {"%#m", "1"}, + {"%-d", "2"}, + {"%#d", "2"}, + {"%-H", "15"}, + {"%#H", "15"}, + {"%-I", "3"}, + {"%#I", "3"}, + {"%-M", "4"}, + {"%#M", "4"}, + {"%-S", "5"}, + {"%#S", "5"}, + + {"%%", "%"}, + + {"%Y", "2006"}, + {"%y", "06"}, + + {"%B", "January"}, + {"%b", "Jan"}, + {"%h", "Jan"}, + {"%m", "01"}, + + {"%d", "02"}, + {"%e", "_2"}, + + {"%A", "Monday"}, + {"%a", "Mon"}, + + {"%H", "15"}, + {"%I", "03"}, + {"%M", "04"}, + {"%S", "05"}, + + {"%p", "PM"}, + + {"%z", "-0700"}, + {"%Z", "MST"}, + + {"%N", "000000000"}, + {"%f", "000000"}, + {"%L", "000"}, + + {"%F", "2006-01-02"}, + {"%T", "15:04:05"}, + {"%R", "15:04"}, + {"%D", "01/02/06"}, + {"%r", "03:04:05 PM"}, +} + +func StrftimeToGo(format string) string { + var out strings.Builder + + for i := 0; i < len(format); { + if format[i] != '%' { + out.WriteByte(format[i]) + i++ + continue + } + + matched := false + for _, r := range strftimeToGoRules { + if strings.HasPrefix(format[i:], r.strf) { + out.WriteString(r.goLayout) + i += len(r.strf) + matched = true + break + } + } + + if !matched { + // Можно заменить на ошибку, если хочешь strict mode. + out.WriteByte(format[i]) + i++ + } + } + + return out.String() +} + +func GoToStrftime(format string) string { + format = strings.ReplaceAll(format, "2006", "%Y") + format = strings.ReplaceAll(format, "06", "%y") + + format = strings.ReplaceAll(format, "15", "%H") + format = strings.ReplaceAll(format, "03", "%I") + format = strings.ReplaceAll(format, "3", "%-I") + format = strings.ReplaceAll(format, "3", "%#I") + + format = strings.ReplaceAll(format, "January", "%B") + format = strings.ReplaceAll(format, "Jan", "%b") + format = strings.ReplaceAll(format, "01", "%m") + format = strings.ReplaceAll(format, "1", "%-m") + + format = strings.ReplaceAll(format, "02", "%d") + format = strings.ReplaceAll(format, "_2", "%e") + format = strings.ReplaceAll(format, "2", "%-d") + + format = strings.ReplaceAll(format, "Monday", "%A") + format = strings.ReplaceAll(format, "Mon", "%a") + + format = strings.ReplaceAll(format, "04", "%M") + format = strings.ReplaceAll(format, "05", "%S") + format = strings.ReplaceAll(format, ".999999999", "%#N") + format = strings.ReplaceAll(format, "000000000", "%N") + format = strings.ReplaceAll(format, ".999999", "%#f") + format = strings.ReplaceAll(format, "000000", "%f") + format = strings.ReplaceAll(format, ".999", "%#L") + format = strings.ReplaceAll(format, "000", "%L") + format = strings.ReplaceAll(format, "PM", "%p") + + format = strings.ReplaceAll(format, "-0700", "%z") + format = strings.ReplaceAll(format, "MST", "%Z") + format = strings.ReplaceAll(format, "Z07:00", "%:z") + format = strings.ReplaceAll(format, "Z07:00:00", "%::z") + format = strings.ReplaceAll(format, "Z07", "%:::z") + return format +} diff --git a/traceback.go b/traceback.go index 2c218ec..30f850a 100644 --- a/traceback.go +++ b/traceback.go @@ -1,4 +1,4 @@ -package slog +package sneklog import ( "runtime" diff --git a/utils.go b/utils.go index e4831cf..a8b7e2b 100644 --- a/utils.go +++ b/utils.go @@ -1,4 +1,4 @@ -package slog +package sneklog // Map applies f to each element of s and returns the resulting slice. func Map[T, R any](s []T, f func(T) R) []R { diff --git a/writers.go b/writers.go index e96d37d..716e2c8 100644 --- a/writers.go +++ b/writers.go @@ -1,4 +1,4 @@ -package slog +package sneklog import ( "encoding/json" @@ -20,10 +20,20 @@ type LoggerWriter interface { // LoggerTextWriter writes human-readable log records to an io.Writer. type LoggerTextWriter struct { LoggerWriter - writer io.Writer - closer io.Closer - printTraceback bool - printTime bool + writer io.Writer + closer io.Closer + formatter *Formatter +} + +func (w *LoggerTextWriter) SetFormatter(formatter *Formatter) *LoggerTextWriter { + w.formatter = formatter + return w +} +func (w *LoggerTextWriter) Formatter() *Formatter { + if w.formatter == nil { + return DefaultTextFormatter + } + return w.formatter } // Write forwards raw bytes to the underlying writer. @@ -32,10 +42,32 @@ 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, _ []*MethodTraceback, messages ...any) error { - s := BuildString(level, prefix, w.printTime, w.printTraceback, messages...) +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) }) + + f := w.Formatter() + s := f.FormatMessage(level, prefix, traceback, messages...) + if f.ColorOutput && (!f.ColorOnlyStdout || w.writer == os.Stdout || w.writer == os.Stderr) { + s = f.ColorizeString(s, level) + } + _, err := w.Write([]byte(s)) - return err + if err != nil { + return err + } + if newline { + _, err = w.Write([]byte("\n")) + if err != nil { + return err + } + } + return nil } // Close closes the owned writer, if any. @@ -52,14 +84,28 @@ type LoggerJsonWriter struct { writer io.Writer closer io.Closer pretty bool + + formatter *Formatter +} + +func (w *LoggerJsonWriter) Formatter() *Formatter { + if w.formatter == nil { + return DefaultJsonFormatter + } + return w.formatter +} +func (w *LoggerJsonWriter) SetFormatter(f *Formatter) *LoggerJsonWriter { + w.formatter = f + return w } // LoggerJsonMessage is the JSON payload emitted by LoggerJsonWriter. type LoggerJsonMessage struct { - Time time.Time `json:"time"` - Level string `json:"level"` - Prefix string `json:"prefix"` - Message string `json:"message"` + Time string `json:"time"` + Level string `json:"level"` + Prefix string `json:"prefix"` + Message string `json:"message"` + Traceback []*MethodTraceback `json:"traceback"` } @@ -71,18 +117,24 @@ 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) }) + + f := w.Formatter() + s := f.FormatMessage(level, prefix, traceback, messages...) + if f.ColorOutput && (!f.ColorOnlyStdout || w.writer == os.Stdout || w.writer == os.Stderr) { + s = f.ColorizeString(s, level) + } m := LoggerJsonMessage{ - Time: time.Now(), + Time: f.FormatTime(time.Now()), Level: level.GetName(), Prefix: prefix, - Message: strings.TrimSpace(strings.Join(msg, " ")), + Message: s, Traceback: traceback, } var data []byte @@ -112,16 +164,16 @@ func (w *LoggerJsonWriter) Close() error { // CreateTextWriter wraps an external writer for text output without taking // ownership of it. -func CreateTextWriter(w io.Writer, printTraceback, printTime bool) *LoggerTextWriter { +func CreateTextWriter(w io.Writer) *LoggerTextWriter { writer := &LoggerTextWriter{ - writer: w, printTraceback: printTraceback, printTime: printTime, + writer: w, } return writer } // CreateTextFileWriter creates a text writer for path, creating parent // directories as needed. The returned writer owns the opened file. -func CreateTextFileWriter(path string, printTraceback, printTime bool) (*LoggerTextWriter, error) { +func CreateTextFileWriter(path string) (*LoggerTextWriter, error) { err := os.MkdirAll(filepath.Dir(path), os.ModePerm) if err != nil { return nil, err @@ -130,15 +182,15 @@ func CreateTextFileWriter(path string, printTraceback, printTime bool) (*LoggerT if err != nil { return nil, err } - writer := CreateTextWriter(file, printTraceback, printTime) + writer := CreateTextWriter(file) writer.closer = file return writer, nil } // CreateTextStdoutWriter creates a text writer for os.Stdout without taking // ownership of stdout. -func CreateTextStdoutWriter(printTraceback, printTime bool) *LoggerTextWriter { - writer := CreateTextWriter(os.Stdout, printTraceback, printTime) +func CreateTextStdoutWriter() *LoggerTextWriter { + writer := CreateTextWriter(os.Stdout) writer.closer = nil return writer }