From 655fadbf275d4942bc15e40efc35a691daf27c0a Mon Sep 17 00:00:00 2001 From: Peter Aronoff Date: Sun, 13 Aug 2023 20:16:15 -0400 Subject: [PATCH] feat: update for Go 1.21 --- .github/workflows/go.yml | 2 +- .golangci.yml | 16 ++++++ CHANGES.md | 14 +++++ TODO.md | 7 ++- benchmark_test.go | 6 +-- doc.go | 6 +-- docs/README.md | 80 +++++++++++++--------------- go.mod | 4 +- go.sum | 4 -- humane.go | 61 +++++++++++----------- humane_test.go | 50 ++++++++++-------- internal/withsupport/withsupport.go | 74 -------------------------- slogtest_test.go | 81 +++++++++++++++++++++++++++++ 13 files changed, 219 insertions(+), 186 deletions(-) create mode 100644 CHANGES.md delete mode 100644 internal/withsupport/withsupport.go create mode 100644 slogtest_test.go diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index ed548c4..55c35b5 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -13,7 +13,7 @@ jobs: - ubuntu-latest - macos-latest - windows-latest - go: ['1.20'] + go: ['1.21'] name: humane test (using go ${{ matrix.go }} on ${{ matrix.os }}) steps: - uses: actions/checkout@v3 diff --git a/.golangci.yml b/.golangci.yml index 0023aa5..0d9e97c 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,4 +1,18 @@ linters-settings: + depguard: + rules: + main: + files: + - $all + deny: + - pkg: reflect + desc: "avoid reflect" + test: + files: + - $all + deny: + - pkg: reflect + desc: "avoid reflect" errcheck: check-type-assertions: true check-blank: true @@ -9,6 +23,8 @@ linters-settings: - fmt.Fprintln - (*github.com/telemachus/humane/internal/buffer.Buffer).WriteByte - (*github.com/telemachus/humane/internal/buffer.Buffer).WriteString + exhaustive: + default-signifies-exhaustive: true goconst: min-len: 2 min-occurrences: 3 diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 0000000..ac52a16 --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,14 @@ +# humane version history + +# v0.5.0 + ++ Switch from `exp/slog` to `log/slog` and from `exp/slices` to `slices` now + that Go 1.21 has been released. ++ Adjust the `NewHandler` function to match the latest `slog` API. ++ Use a pointer to `sync.Mutex` rather than `sync.Mutex`. See this discussion + in the guide to writing `slog` handlers for why: https://bit.ly/3s2KrOG. ++ Add `testing/slogtest`. ++ Fix a bug (found thanks to `testing/slogtest`): move the test for + `Attr.Empty` to catch all empty attrs. ++ Fix a bug (found thanks to `testing/slogtest`): make sure to call `Resolve` + on all attribute values. diff --git a/TODO.md b/TODO.md index e7bbbb9..39c4f5b 100644 --- a/TODO.md +++ b/TODO.md @@ -1,3 +1,6 @@ -# To do list +# TODO list -+ Switch from strings.Builder to sync.Pool and a custom buffer as in slog? ++ Try preformatting of groups and attributes, as discussed [here][howto]. ++ Simplify appendAttr. + +[howto]: https://github.com/golang/example/blob/master/slog-handler-guide/README.md#with-pre-formatting diff --git a/benchmark_test.go b/benchmark_test.go index 11524ae..7765323 100644 --- a/benchmark_test.go +++ b/benchmark_test.go @@ -4,11 +4,11 @@ import ( "context" "errors" "io" + "log/slog" "testing" "time" "github.com/telemachus/humane" - "golang.org/x/exp/slog" ) var ( @@ -30,7 +30,7 @@ var slogAttrs = []slog.Attr{ } func BenchmarkSlog(b *testing.B) { - logger := slog.New(slog.NewTextHandler(io.Discard)) + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) b.ResetTimer() b.ReportAllocs() for i := 0; i < b.N; i++ { @@ -44,7 +44,7 @@ func BenchmarkSlog(b *testing.B) { } func BenchmarkHumane(b *testing.B) { - logger := slog.New(humane.NewHandler(io.Discard)) + logger := slog.New(humane.NewHandler(io.Discard, nil)) b.ResetTimer() b.ReportAllocs() for i := 0; i < b.N; i++ { diff --git a/doc.go b/doc.go index 5251808..ee69efd 100644 --- a/doc.go +++ b/doc.go @@ -6,7 +6,7 @@ // // 1. Get a slog logger using humane's handler with default options: // -// logger := slog.New(humane.NewHandler(os.Stdout)) +// logger := slog.New(humane.NewHandler(os.Stdout, nil)) // logger.Info("Message", "foo", "bar", "bizz", "buzz") // // 2. Get a slog logger using humane's handler with customized options: @@ -22,13 +22,13 @@ // } // // func main() { -// ho := humane.Options{ +// opts := &humane.Options{ // Level: slog.LevelError, // ReplaceAttr: trimSource, // TimeFormat: time.Kitchen, // AddSource: true, // } -// logger := slog.New(ho.NewHandler(os.Stderr)) +// logger := slog.New(humane.NewHandler(os.Stderr, opts)) // // ... later // logger.Error("Message", "error", err, "response", respStatus) // } diff --git a/docs/README.md b/docs/README.md index 645cd19..b90f424 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,14 +10,6 @@ not his.) [logfmt]: https://brandur.org/logfmt [details]: https://brandur.org/logfmt#human -## Warning - -[slog][slogdiscussion] has been accepted for Go 1.21, but it is still under -development. This handler is new and also still being tweaked. Let me know -if you have trouble with it, but be aware that the API may change. - -[slogdiscussion]: https://github.com/golang/go/issues/56345 - ## The Format Briefly, the format is as follows. @@ -30,7 +22,7 @@ The level and message Attrs appear as is without `key=value` structure or quoting. Then the rest of the Attrs appear as `key=value` pairs. A time Attr will be added by default to the third section. (See below for how to change the format of this Attr or omit it entirely.) The three sections of the log -message are separated by a pipe character (`|`). The pipes should make it easy +line are separated by a pipe character (`|`). The pipes should make it easy to parse out the sections of the message with (e.g.) `cut` or `awk`, but no attempt is made to check for that character anywhere else in the log. Thus, if pipes appear elsewhere, all bets are off. (This seems like a reasonable @@ -41,7 +33,7 @@ probably use JSON or another format.) ## Installation ``` -go install github.com/telemachus/humane +go get github.com/telemachus/humane ``` ## Usage @@ -49,7 +41,7 @@ go install github.com/telemachus/humane ```go // Create a logger with default options. See below for more on available // options. -logger := slog.New(humane.NewHandler(os.Stdout)) +logger := slog.New(humane.NewHandler(os.Stdout, nil)) logger.Info("My informative message", "foo", "bar", "bizz", "buzz") logger.Error("Ooops", slog.Any("error", err)) // Output: @@ -57,38 +49,39 @@ logger.Error("Ooops", slog.Any("error", err)) // ERROR | Ooops | error="error message" time="2023-04-02T10:50.09 EDT" // You can also set options. Again, see the next section for more details. -ho := humane.Options{ +opts := humane.Options{ Level: slog.LevelError, TimeFormat: time.RFC3339 } -logger := slog.New(ho.NewHandler(os.Stderr)) +logger := slog.New(humane.NewHandler(os.Stderr, opts)) logger.Info("This message will not be written") ``` ## Options + `Level slog.Leveler`: Level defaults to slog.Info. You can use - a [slog.Level](https://pkg.go.dev/golang.org/x/exp/slog#Level) to change - the default. If you want something more complex, you can also implement - a [slog.Leveler](https://pkg.go.dev/golang.org/x/exp/slog#Leveler). + a [slog.Level](https://pkg.go.dev/log/slog#Level) to change the default. If + you want something more complex, you can also implement + a [slog.Leveler](https://pkg.go.dev/log/slog#Leveler). + `ReplaceAttr func(groups []string, a slog.Attr)`: As in slog itself, this function is applied to each Attr in a given Record during handling. This allows you to, e.g., omit or edit Attrs in creative ways. See [slog's - documentation and tests for further examples](slog). Note that the - ReplaceAttr function is **not** applied to the level or message Attrs since - they receive specific formatting by this handler. (However, I am open to - reconsidering that. Please open an [issue][issue] to discuss it.) In order - to make the time and source Attrs easier to test for, they use constants - defined by slog for their keys: `slog.TimeKey` and `slog.SourceKey`. + documentation and tests for further examples](https://pkg.go.dev/log/slog). + Note that the ReplaceAttr function is **not** applied to the level or + message Attrs since they receive specific formatting by this handler. + (However, I am open to reconsidering that. Please open an [issue][issue] to + discuss it.) In order to make the time and source Attrs easier to test for, + they use constants defined by slog for their keys: `slog.TimeKey` and + `slog.SourceKey`. + `TimeFormat string`: The time format defaults to "2006-01-02T03:04.05 MST". You can use this option to set some other time format. (You can also tweak - the time Attr via a ReplaceAttr function, but this is easier for simple - format changes.) The time Attr uses `slog.TimeKey` as its key value by - default. + the time format via a ReplaceAttr function, but setting this option is + easier for simple format changes.) The time Attr uses `slog.TimeKey` as its + key value by default. + `AddSource bool`: This option defaults to false. If you set it to true, then an Attr containing `source=/path/to/source:line` will be added to each - record. If source Attr is present, it uses `slog.SourceKey` as its default - key value. + record. If a source Attr is present, it uses `slog.SourceKey` as its + default key value. A common need (e.g., for testing) is to remove the time Attr altogether. Here's a simple way to do that. @@ -100,11 +93,11 @@ func removeTime(_ []string, a slog.Attr) slog.Attr { } return a } -ho := humane.Options{ReplaceAttr: removeTime} -logger := slog.New(ho.NewHandler(os.Stdout)) +opts := humane.Options{ReplaceAttr: removeTime} +logger := slog.New(humane.NewHandler(os.Stdout, opts)) ``` -[slog]: https://pkg.go.dev/golang.org/x/exp/slog +[slog]: https://pkg.go.dev/log/slog [issue]: https://github.com/telemachus/humane/issues ## Bugs and Limitations @@ -114,31 +107,30 @@ know][issue] if you find any. One limitation concerns the source Attr. If you use the logger in a helper function or a wrapper, then the source information will likely be wrong. See -[slog's documentation][sourceproblem] for a discussion and workaround. There -is also [an open issue][sourceissue] that proposes more support in slog for -writing helper functions. (There's another [open issue][pcissue] that -proposes other ways to give users of slog more help with the source Attr.) +[slog's documentation][sourceproblem] for a discussion and workaround. -[sourceproblem]: https://pkg.go.dev/golang.org/x/exp/slog#hdr-Wrapping_output_methods -[sourceissue]: https://github.com/golang/go/issues/59145 -[pcissue]: https://github.com/golang/go/issues/59280 +[sourceproblem]: https://pkg.go.dev/log/slog#hdr-Wrapping_output_methods - -## Acknowledgements +## Acknowledgments I'm using quite a lot of code from slog itself as well as from the [slog -extras repository][slogextras]. Thanks to Jonathan Amsterdam for both. I've -also taken ideas and code from sources on [Go's wiki][wiki] as well as several -blog posts about slog. See below for a list of resources. (Note that some of -the resources are more or less out of date since slog and its API have changed -over time.) +extras repository][slogextras]. The [guide to writing `slog` handlers][guide] +was also very useful. Thanks to Jonathan Amsterdam for for all three of these. +I've also taken ideas and code from sources on [Go's wiki][wiki] as well as +several blog posts about slog. See below for a list of resources. (Note that +some of the resources are more or less out of date since slog and its API have +changed over time.) + + ++ [A Guide to Writing `slog` Handlers][guide] + [Proposal: Structured Logging][proposal] + [`slog`: Golang's official structured logging package][sobyte] + [Structured logging in Go][mrkaran] + [A Comprehensive Guide to Structured Logging in Go][betterstack] [slogextras]: https://github.com/jba/slog +[guide]: https://github.com/golang/example/tree/master/slog-handler-guide [wiki]: https://github.com/golang/go/wiki/Resources-for-slog [proposal]: https://go.googlesource.com/proposal/+/master/design/56345-structured-logging.md [sobyte]: https://www.sobyte.net/post/2022-10/go-slog/ diff --git a/go.mod b/go.mod index a44301c..fd62b0b 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,3 @@ module github.com/telemachus/humane -go 1.20 - -require golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53 +go 1.21 diff --git a/go.sum b/go.sum index 161cf7b..e69de29 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +0,0 @@ -golang.org/x/exp v0.0.0-20230321023759-10a507213a29 h1:ooxPy7fPvB4kwsA2h+iBNHkAbp/4JxTSwCmvdjEYmug= -golang.org/x/exp v0.0.0-20230321023759-10a507213a29/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= -golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53 h1:5llv2sWeaMSnA3w2kS57ouQQ4pudlXrR0dCgw51QK9o= -golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= diff --git a/humane.go b/humane.go index 57248e5..e941ab4 100644 --- a/humane.go +++ b/humane.go @@ -5,7 +5,9 @@ import ( "encoding" "fmt" "io" + "log/slog" "runtime" + "slices" "strconv" "strings" "sync" @@ -13,8 +15,6 @@ import ( "unicode/utf8" "github.com/telemachus/humane/internal/buffer" - "golang.org/x/exp/slices" - "golang.org/x/exp/slog" ) var ( @@ -30,7 +30,7 @@ var ( type handler struct { w io.Writer - mu sync.Mutex + mu *sync.Mutex level slog.Leveler groups []string attrs string @@ -66,21 +66,20 @@ type Options struct { AddSource bool } -// NewHandler returns a [log/slog.Handler] using default options. -func NewHandler(w io.Writer) slog.Handler { - return Options{}.NewHandler(w) -} - // NewHandler returns a [log/slog.Handler] using the receiver's options. -func (opts Options) NewHandler(w io.Writer) slog.Handler { +// Default options are used if opts is nil. +func NewHandler(w io.Writer, opts *Options) slog.Handler { + if opts == nil { + opts = &Options{} + } h := &handler{ w: w, + mu: &sync.Mutex{}, level: opts.Level, timeFormat: opts.TimeFormat, replaceAttr: opts.ReplaceAttr, addSource: opts.AddSource, } - // TODO: should this also use sync.Pool? h.groups = make([]string, 0, 10) if opts.Level == nil { h.level = defaultLevel @@ -165,6 +164,7 @@ func (h *handler) WithGroup(name string) slog.Handler { func (h *handler) clone() *handler { return &handler{ w: h.w, + mu: h.mu, level: h.level, groups: slices.Clip(h.groups), attrs: h.attrs, @@ -185,25 +185,29 @@ func (h *handler) appendLevel(buf *buffer.Buffer) { } func (h *handler) appendAttr(buf *buffer.Buffer, a slog.Attr) { - if a.Value.Kind() != slog.KindGroup { - if h.replaceAttr != nil { - a = h.replaceAttr(h.groups, a) - if a.Equal(slog.Attr{}) { - return - } + a.Value = a.Value.Resolve() + if a.Value.Kind() == slog.KindGroup { + attrs := a.Value.Group() + if len(attrs) == 0 { + return + } + if a.Key != "" { + h.groups = append(h.groups, a.Key) + } + for _, a := range attrs { + h.appendAttr(buf, a) + } + if a.Key != "" { + h.groups = h.groups[:len(h.groups)-1] } - appendKey(buf, h.groups, a.Key) - h.appendVal(buf, a.Value) return } - if a.Key != "" { - h.groups = append(h.groups, a.Key) - } - for _, a := range a.Value.Group() { - h.appendAttr(buf, a) + if h.replaceAttr != nil { + a = h.replaceAttr(h.groups, a) } - if a.Key != "" { - h.groups = h.groups[:len(h.groups)-1] + if !a.Equal(slog.Attr{}) { + appendKey(buf, h.groups, a.Key) + h.appendVal(buf, a.Value) } } @@ -238,7 +242,7 @@ func (h *handler) appendVal(buf *buffer.Buffer, val slog.Value) { // If fmt contains any quote characters, this won't // properly quote it. But alternative versions run far slower. // If the user must have a time with quotes, they can use - // ReplaceAttr to change the King to slog.String. + // ReplaceAttr to change the Kind to slog.String. quoteTime := needsQuoting(h.timeFormat) if quoteTime { buf.WriteByte('"') @@ -251,16 +255,13 @@ func (h *handler) appendVal(buf *buffer.Buffer, val slog.Value) { if tm, ok := val.Any().(encoding.TextMarshaler); ok { data, err := tm.MarshalText() if err != nil { - // TODO: append an error? + // TODO: should this append an error? return } appendString(buf, string(data)) return } appendString(buf, fmt.Sprint(val.Any())) - default: - // TODO: should this return instead of calling panic? - panic(fmt.Sprintf("bad kind: %s", val.Kind())) } } diff --git a/humane_test.go b/humane_test.go index 8cc12cc..1b11713 100644 --- a/humane_test.go +++ b/humane_test.go @@ -3,12 +3,12 @@ package humane_test import ( "bytes" "fmt" + "log/slog" "path/filepath" "testing" "time" "github.com/telemachus/humane" - "golang.org/x/exp/slog" ) func removeTime(groups []string, a slog.Attr) slog.Attr { @@ -29,11 +29,17 @@ func removeTimeTrimSource(_ []string, a slog.Attr) slog.Attr { } } +func TestHumaneNilOpts(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + slog.New(humane.NewHandler(&buf, nil)) +} + func TestHumaneBasic(t *testing.T) { t.Parallel() var buf bytes.Buffer - ho := humane.Options{ReplaceAttr: removeTime} - logger := slog.New(ho.NewHandler(&buf)) + opts := &humane.Options{ReplaceAttr: removeTime} + logger := slog.New(humane.NewHandler(&buf, opts)) logger.Info("foo") got := buf.String() want := " INFO | foo |\n" @@ -45,8 +51,8 @@ func TestHumaneBasic(t *testing.T) { func TestKeepTimeKeyInGroup(t *testing.T) { t.Parallel() var buf bytes.Buffer - ho := humane.Options{ReplaceAttr: removeTime} - logger := slog.New(ho.NewHandler(&buf)) + opts := &humane.Options{ReplaceAttr: removeTime} + logger := slog.New(humane.NewHandler(&buf, opts)) logger.WithGroup("request").Info("foo", slog.String("time", "3:00pm")) got := buf.String() want := " INFO | foo | request.time=3:00pm\n" @@ -58,8 +64,8 @@ func TestKeepTimeKeyInGroup(t *testing.T) { func TestHumaneCustomLevel(t *testing.T) { t.Parallel() var buf bytes.Buffer - ho := humane.Options{ReplaceAttr: removeTime, Level: slog.LevelError} - logger := slog.New(ho.NewHandler(&buf)) + opts := &humane.Options{ReplaceAttr: removeTime, Level: slog.LevelError} + logger := slog.New(humane.NewHandler(&buf, opts)) logger.Info("wtf?") got := buf.String() want := "" @@ -71,11 +77,11 @@ func TestHumaneCustomLevel(t *testing.T) { func TestHumaneAddSource(t *testing.T) { t.Parallel() var buf bytes.Buffer - ho := humane.Options{ReplaceAttr: removeTimeTrimSource, AddSource: true} - logger := slog.New(ho.NewHandler(&buf)) + opts := &humane.Options{ReplaceAttr: removeTimeTrimSource, AddSource: true} + logger := slog.New(humane.NewHandler(&buf, opts)) logger.Info("foo") got := buf.String() - want := " INFO | foo | source=humane_test.go:76\n" + want := " INFO | foo | source=humane_test.go:82\n" if got != want { t.Errorf(`logger.Info("foo") = %q; want %q`, got, want) } @@ -85,8 +91,8 @@ func TestHumaneCustomTimeFormat(t *testing.T) { t.Parallel() var buf bytes.Buffer timeFormat := "2006-01-02" - ho := humane.Options{TimeFormat: timeFormat} - logger := slog.New(ho.NewHandler(&buf)) + opts := &humane.Options{TimeFormat: timeFormat} + logger := slog.New(humane.NewHandler(&buf, opts)) logger.Info("foo") got := buf.String() want := fmt.Sprintf( @@ -102,8 +108,8 @@ func TestHumaneCustomTimeFormat(t *testing.T) { func TestHumaneSlogGroup(t *testing.T) { t.Parallel() var buf bytes.Buffer - ho := humane.Options{ReplaceAttr: removeTime} - logger := slog.New(ho.NewHandler(&buf)) + opts := &humane.Options{ReplaceAttr: removeTime} + logger := slog.New(humane.NewHandler(&buf, opts)) logger.Info("message", slog.Group( "foo", @@ -125,8 +131,8 @@ func TestHumaneSlogGroup(t *testing.T) { func TestHumaneWithGroup(t *testing.T) { t.Parallel() var buf bytes.Buffer - ho := humane.Options{ReplaceAttr: removeTime} - logger := slog.New(ho.NewHandler(&buf).WithGroup("GROUP")) + opts := &humane.Options{ReplaceAttr: removeTime} + logger := slog.New(humane.NewHandler(&buf, opts).WithGroup("GROUP")) logger.Info("message", slog.Group( "foo", @@ -152,9 +158,9 @@ func TestHumaneWithGroup(t *testing.T) { func TestHumaneWithAttrs(t *testing.T) { t.Parallel() var buf bytes.Buffer - ho := humane.Options{ReplaceAttr: removeTime} + opts := &humane.Options{ReplaceAttr: removeTime} logger := slog.New( - ho.NewHandler(&buf).WithAttrs( + humane.NewHandler(&buf, opts).WithAttrs( []slog.Attr{slog.Int("c", 3), slog.String("foo", "bar")}, ), ) @@ -176,8 +182,8 @@ func TestHumaneWithAttrs(t *testing.T) { func TestHumaneWithGroupWithAttrs(t *testing.T) { t.Parallel() var buf bytes.Buffer - ho := humane.Options{ReplaceAttr: removeTime} - logger := slog.New(ho.NewHandler(&buf)) + opts := &humane.Options{ReplaceAttr: removeTime} + logger := slog.New(humane.NewHandler(&buf, opts)) logger = logger.WithGroup("g").With("a", 1).WithGroup("h").With("b", 2) logger.Info("message") got := buf.String() @@ -237,8 +243,8 @@ func TestHumaneNeedsQuoting(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() var buf bytes.Buffer - ho := humane.Options{ReplaceAttr: removeTime} - logger := slog.New(ho.NewHandler(&buf)) + opts := &humane.Options{ReplaceAttr: removeTime} + logger := slog.New(humane.NewHandler(&buf, opts)) logger.Info("message", tc.args...) got := buf.String() if got != tc.want { diff --git a/internal/withsupport/withsupport.go b/internal/withsupport/withsupport.go deleted file mode 100644 index 202443f..0000000 --- a/internal/withsupport/withsupport.go +++ /dev/null @@ -1,74 +0,0 @@ -// Package withsupport provides support for Handler.WithAttr and -// Handler.WithGroup. -package withsupport - -import "golang.org/x/exp/slog" - -// GroupOrAttrs holds either a group name or a list of slog.Attrs. -type GroupOrAttrs struct { - Group string // group name if non-empty - Attrs []slog.Attr // attrs if non-empty - Next *GroupOrAttrs -} - -// WithGroup returns a GroupOrAttrs that includes the given group. -func (g *GroupOrAttrs) WithGroup(name string) *GroupOrAttrs { - if name == "" { - return g - } - return &GroupOrAttrs{ - Group: name, - Next: g, - } -} - -// WithAttrs returns a GroupOrAttrs that includes the given attrs. -func (g *GroupOrAttrs) WithAttrs(attrs []slog.Attr) *GroupOrAttrs { - if len(attrs) == 0 { - return g - } - return &GroupOrAttrs{ - Attrs: attrs, - Next: g, - } -} - -// Apply calls f on each Attr in g. The first argument to f is the list -// of groups that precede the Attr. -// Apply returns the complete list of groups. -func (g *GroupOrAttrs) Apply(f func(groups []string, a slog.Attr)) []string { - var groups []string - - var rec func(*GroupOrAttrs) - rec = func(g *GroupOrAttrs) { - if g == nil { - return - } - rec(g.Next) - if g.Group != "" { - groups = append(groups, g.Group) - } else { - for _, a := range g.Attrs { - f(groups, a) - } - } - } - rec(g) - - return groups -} - -// Collect returns a slice of the GroupOrAttrs in reverse order. -func (g *GroupOrAttrs) Collect() []*GroupOrAttrs { - n := 0 - for ga := g; ga != nil; ga = ga.Next { - n++ - } - res := make([]*GroupOrAttrs, n) - i := 0 - for ga := g; ga != nil; ga = ga.Next { - res[len(res)-i-1] = ga - i++ - } - return res -} diff --git a/slogtest_test.go b/slogtest_test.go new file mode 100644 index 0000000..9eeebd6 --- /dev/null +++ b/slogtest_test.go @@ -0,0 +1,81 @@ +package humane_test + +import ( + "bytes" + "fmt" + "log/slog" + "strings" + "testing" + "testing/slogtest" + "time" + + "github.com/telemachus/humane" +) + +// This code is (very lightly) adapted from examples in slog and slogtest. +// Thanks to Jonathan Amsterdam for both. +func TestSlogtest(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + h := humane.NewHandler(&buf, &humane.Options{TimeFormat: time.RFC3339}) + results := func() []map[string]any { + ms := []map[string]any{} + for _, line := range bytes.Split(buf.Bytes(), []byte{'\n'}) { + if len(line) == 0 { + continue + } + m, err := parseHumane(line) + if err != nil { + t.Fatal(err) + } + ms = append(ms, m) + } + return ms + } + if err := slogtest.TestHandler(h, results); err != nil { + t.Error(err) + } +} + +func parseHumane(bs []byte) (map[string]any, error) { + top := map[string]any{} + s := string(bytes.TrimSpace(bs)) + // First, we need to divide each line into three parts (level, message, + // kv pairs). Humane delimits these three parts by " | ". + // Then we need to create proper key-value pairs for level and message. + pieces := strings.Split(s, " | ") + top[slog.LevelKey] = strings.TrimSpace(pieces[0]) + top[slog.MessageKey] = strings.TrimSpace(pieces[1]) + // The rest of the line contains kv pairs that we can (roughly) divide + // by spaces. This is crude since it will split a quoted key or value + // that contains a space. For this test, however, this will work---as + // long as I make sure to set a time format without whitespace. + s = pieces[2] + for len(s) > 0 { + kv, rest, _ := strings.Cut(s, " ") + k, value, found := strings.Cut(kv, "=") + if !found { + return nil, fmt.Errorf("no '=' in %q", kv) + } + keys := strings.Split(k, ".") + // Populate a tree of maps for a dotted path such as "a.b.c=x". + m := top + for _, key := range keys[:len(keys)-1] { + x, ok := m[key] + var m2 map[string]any + if !ok { + m2 = map[string]any{} + m[key] = m2 + } else { + m2, ok = x.(map[string]any) + if !ok { + return nil, fmt.Errorf("value for %q in composite key %q is not map[string]any", key, k) + } + } + m = m2 + } + m[keys[len(keys)-1]] = value + s = rest + } + return top, nil +}