diff --git a/bench_test.go b/bench_test.go index 88736ac..83479b8 100644 --- a/bench_test.go +++ b/bench_test.go @@ -36,6 +36,7 @@ var attrs = []slog.Attr{ slog.Any("err", errors.New("yo")), slog.Group("empty"), slog.Group("group", slog.String("bar", "baz")), + slog.String("multi", "foo\nbar"), } var attrsAny = func() (a []any) { diff --git a/buffer.go b/buffer.go index ddff9c3..ec86921 100644 --- a/buffer.go +++ b/buffer.go @@ -30,6 +30,10 @@ func (b *buffer) Cap() int { } func (b *buffer) WriteTo(dst io.Writer) (int64, error) { + if b == nil { + // for convenience, if receiver is nil, treat it like an empty buffer + return 0, nil + } l := len(*b) if l == 0 { return 0, nil diff --git a/buffer_test.go b/buffer_test.go index 5a4cdde..98b9ba6 100644 --- a/buffer_test.go +++ b/buffer_test.go @@ -46,6 +46,15 @@ func TestBuffer_WriteTo(t *testing.T) { AssertNoError(t, err) AssertEqual(t, "foobar", dest.String()) AssertZero(t, b.Len()) + + t.Run("nilbuffer", func(t *testing.T) { + // if receiver is nil, do nothing + dest.Reset() + c, err := (*buffer)(nil).WriteTo(&dest) + AssertNoError(t, err) + AssertZero(t, c) + AssertZero(t, dest.Len()) + }) } func TestBuffer_Clone(t *testing.T) { diff --git a/encoding.go b/encoding.go index 6088e65..0361dbc 100644 --- a/encoding.go +++ b/encoding.go @@ -114,6 +114,7 @@ func (e encoder) writeAttr(buf *buffer, a slog.Attr, group string) { } return } + buf.AppendByte(' ') e.withColor(buf, e.opts.Theme.AttrKey(), func() { if group != "" { diff --git a/handler.go b/handler.go index 82ce3ea..0cad596 100644 --- a/handler.go +++ b/handler.go @@ -1,6 +1,7 @@ package console import ( + "bytes" "context" "io" "log/slog" @@ -14,6 +15,17 @@ var bufferPool = &sync.Pool{ New: func() any { return new(buffer) }, } +func getBuf() *buffer { + return bufferPool.Get().(*buffer) +} + +func releaseBuf(buf *buffer) { + if buf != nil { + buf.Reset() + bufferPool.Put(buf) + } +} + var cwd, _ = os.Getwd() // HandlerOptions are options for a ConsoleHandler. @@ -82,7 +94,8 @@ func (h *Handler) Enabled(_ context.Context, l slog.Level) bool { // Handle implements slog.Handler. func (h *Handler) Handle(_ context.Context, rec slog.Record) error { - buf := bufferPool.Get().(*buffer) + buf := getBuf() + var multiLineBuf *buffer h.enc.writeTimestamp(buf, rec.Time) h.enc.writeLevel(buf, rec.Level) @@ -92,16 +105,40 @@ func (h *Handler) Handle(_ context.Context, rec slog.Record) error { h.enc.writeMessage(buf, rec.Level, rec.Message) buf.copy(&h.context) rec.Attrs(func(a slog.Attr) bool { + idx := buf.Len() h.enc.writeAttr(buf, a, h.group) + lastAttr := (*buf)[idx:] + if bytes.IndexByte(lastAttr, '\n') >= 0 { + if multiLineBuf == nil { + multiLineBuf = getBuf() + multiLineBuf.AppendByte(' ') + } + if k, v, ok := bytes.Cut(lastAttr, []byte("=")); ok { + multiLineBuf.Append(k[1:]) + multiLineBuf.AppendByte('=') + multiLineBuf.AppendByte('\n') + multiLineBuf.Append(v) + multiLineBuf.AppendByte('\n') + } else { + multiLineBuf.Append(lastAttr[1:]) + multiLineBuf.AppendByte('\n') + } + + *buf = (*buf)[:idx] + } return true }) - h.enc.NewLine(buf) + if multiLineBuf == nil { + h.enc.NewLine(buf) + } if _, err := buf.WriteTo(h.out); err != nil { - buf.Reset() - bufferPool.Put(buf) return err } - bufferPool.Put(buf) + if _, err := multiLineBuf.WriteTo(h.out); err != nil { + return err + } + releaseBuf(buf) + releaseBuf(multiLineBuf) return nil } diff --git a/handler_test.go b/handler_test.go index eb09629..a8e287d 100644 --- a/handler_test.go +++ b/handler_test.go @@ -106,6 +106,73 @@ func TestHandler_Attr(t *testing.T) { AssertEqual(t, expected, buf.String()) } +func TestHandler_AttrsWithNewlines(t *testing.T) { + tests := []struct { + name string + msg string + escapeNewlines bool + attrs []slog.Attr + want string + }{ + { + name: "single attr", + attrs: []slog.Attr{ + slog.String("foo", "line one\nline two"), + }, + want: "INF multiline attrs foo=\nline one\nline two\n", + }, + { + name: "multiple attrs", + attrs: []slog.Attr{ + slog.String("foo", "line one\nline two"), + slog.String("bar", "line three\nline four"), + }, + want: "INF multiline attrs foo=\nline one\nline two\nbar=\nline three\nline four\n", + }, + { + name: "sort multiline attrs to end", + attrs: []slog.Attr{ + slog.String("size", "big"), + slog.String("foo", "line one\nline two"), + slog.String("weight", "heavy"), + slog.String("bar", "line three\nline four"), + slog.String("color", "red"), + }, + want: "INF multiline attrs size=big weight=heavy color=red foo=\nline one\nline two\nbar=\nline three\nline four\n", + }, + { + name: "multiline message", + msg: "multiline\nmessage", + want: "INF multiline\nmessage\n", + }, + { + name: "preserve leading and trailing newlines", + attrs: []slog.Attr{ + slog.String("foo", "\nline one\nline two\n"), + }, + want: "INF multiline attrs foo=\n\nline one\nline two\n\n", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + buf := bytes.Buffer{} + h := NewHandler(&buf, &HandlerOptions{NoColor: true}) + + msg := test.msg + if msg == "" { + msg = "multiline attrs" + } + rec := slog.NewRecord(time.Time{}, slog.LevelInfo, msg, 0) + rec.AddAttrs(test.attrs...) + AssertNoError(t, h.Handle(context.Background(), rec)) + + AssertEqual(t, test.want, buf.String()) + }) + + } +} + // Handlers should not log groups (or subgroups) without fields. // '- If a group has no Attrs (even if it has a non-empty key), ignore it.' // https://pkg.go.dev/log/slog@master#Handler