slog: add Level.MarshalJSON

Add a MarshalJSON method to Level so that it
marshals as a string.

This lets us remove special cases for Level in the handlers.

It also ensures that others who write handlers that output JSON will
get strings instead of numbers; the numbers are obscure,
uninformative, and could be confused with the values of other
level-numbering schemes.

Also, improve handler tests to check replacement.

Change-Id: Iedc7aa797469922fb9cfcc3b5d804806f3deb5d8
Reviewed-on: https://go-review.googlesource.com/c/exp/+/432183
Run-TryBot: Jonathan Amsterdam <jba@google.com>
Reviewed-by: Alan Donovan <adonovan@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
diff --git a/slog/handler.go b/slog/handler.go
index a180e47..0cee928 100644
--- a/slog/handler.go
+++ b/slog/handler.go
@@ -138,7 +138,9 @@
 		a = rep(a)
 		if a.Key() != "" {
 			app.appendKey(a.Key())
-			app.appendAttrValue(a)
+			if err := app.appendAttrValue(a); err != nil {
+				app.appendString(fmt.Sprintf("!ERROR:%v", err))
+			}
 		}
 	}
 
@@ -163,17 +165,7 @@
 			app.appendKey(key)
 			app.appendString(val.String())
 		} else {
-			// Don't use replace: we want to output Levels as strings
-			// to match the above.
-			a := rep(Any(key, val))
-			if a.Key() != "" {
-				app.appendKey(a.Key())
-				if l, ok := a.any.(Level); ok {
-					app.appendString(l.String())
-				} else {
-					app.appendAttrValue(a)
-				}
-			}
+			replace(Any(key, val))
 		}
 		app.appendSep()
 	}
diff --git a/slog/handler_test.go b/slog/handler_test.go
index a3e3ec7..9800ef7 100644
--- a/slog/handler_test.go
+++ b/slog/handler_test.go
@@ -51,11 +51,7 @@
 		{
 			name: "cap keys",
 			h: &commonHandler{
-				opts: HandlerOptions{
-					ReplaceAttr: func(a Attr) Attr {
-						return a.WithKey(strings.ToUpper(a.Key()))
-					},
-				},
+				opts: HandlerOptions{ReplaceAttr: upperCaseKey},
 			},
 			want: "(TIME=2022-09-18T08:26:33.000Z;LEVEL=INFO;MSG=message;A=one;B=2)",
 		},
@@ -87,6 +83,10 @@
 	}
 }
 
+func upperCaseKey(a Attr) Attr {
+	return a.WithKey(strings.ToUpper(a.Key()))
+}
+
 type testAppender struct {
 	buf *buffer.Buffer
 }
diff --git a/slog/json_handler.go b/slog/json_handler.go
index 4abe4a4..b473011 100644
--- a/slog/json_handler.go
+++ b/slog/json_handler.go
@@ -149,10 +149,7 @@
 			return err
 		}
 	case AnyKind:
-		v := a.Value()
-		if l, ok := v.(Level); ok {
-			ap.appendString(l.String())
-		} else if err := ap.appendJSONMarshal(v); err != nil {
+		if err := ap.appendJSONMarshal(a.Value()); err != nil {
 			return err
 		}
 	default:
diff --git a/slog/json_handler_test.go b/slog/json_handler_test.go
index 48e82c0..2d47b66 100644
--- a/slog/json_handler_test.go
+++ b/slog/json_handler_test.go
@@ -12,6 +12,7 @@
 	"io"
 	"math"
 	"os"
+	"strings"
 	"testing"
 	"time"
 
@@ -19,18 +20,35 @@
 )
 
 func TestJSONHandler(t *testing.T) {
-	var buf bytes.Buffer
-	h := NewJSONHandler(&buf)
-	r := NewRecord(testTime, InfoLevel, "m", 0)
-	r.AddAttrs(Int("a", 1))
-	if err := h.Handle(r); err != nil {
-		t.Fatal(err)
-	}
-	got := buf.String()
-	got = got[:len(got)-1] // Remove final newline.
-	want := `{"time":"2000-01-02T03:04:05Z","level":"INFO","msg":"m","a":1}`
-	if got != want {
-		t.Errorf("\ngot  %s\nwant %s", got, want)
+	for _, test := range []struct {
+		name string
+		opts HandlerOptions
+		want string
+	}{
+		{
+			"none",
+			HandlerOptions{},
+			`{"time":"2000-01-02T03:04:05Z","level":"INFO","msg":"m","a":1,"m":{"b":2}}`,
+		},
+		{
+			"replace",
+			HandlerOptions{ReplaceAttr: upperCaseKey},
+			`{"TIME":"2000-01-02T03:04:05Z","LEVEL":"INFO","MSG":"m","A":1,"M":{"b":2}}`,
+		},
+	} {
+		t.Run(test.name, func(t *testing.T) {
+			var buf bytes.Buffer
+			h := test.opts.NewJSONHandler(&buf)
+			r := NewRecord(testTime, InfoLevel, "m", 0)
+			r.AddAttrs(Int("a", 1), Any("m", map[string]int{"b": 2}))
+			if err := h.Handle(r); err != nil {
+				t.Fatal(err)
+			}
+			got := strings.TrimSuffix(buf.String(), "\n")
+			if got != test.want {
+				t.Errorf("\ngot  %s\nwant %s", got, test.want)
+			}
+		})
 	}
 }
 
diff --git a/slog/level.go b/slog/level.go
index 1664e55..5ac3efa 100644
--- a/slog/level.go
+++ b/slog/level.go
@@ -7,6 +7,7 @@
 import (
 	"fmt"
 	"math"
+	"strconv"
 	"sync/atomic"
 )
 
@@ -78,6 +79,13 @@
 	}
 }
 
+func (l Level) MarshalJSON() ([]byte, error) {
+	// AppendQuote is sufficient for JSON-encoding all Level strings.
+	// They don't contain any runes that would produce invalid JSON
+	// when escaped.
+	return strconv.AppendQuote(nil, l.String()), nil
+}
+
 // An AtomicLevel is Level that can be read and written safely by multiple
 // goroutines.
 // Use NewAtomicLevel to create one.
diff --git a/slog/text_handler_test.go b/slog/text_handler_test.go
index 055652b..f9f4f30 100644
--- a/slog/text_handler_test.go
+++ b/slog/text_handler_test.go
@@ -21,50 +21,72 @@
 
 func TestTextHandler(t *testing.T) {
 	for _, test := range []struct {
-		name string
-		attr Attr
-		want string
+		name             string
+		attr             Attr
+		wantKey, wantVal string
 	}{
 		{
 			"unquoted",
 			Int("a", 1),
-			"a=1",
+			"a", "1",
 		},
 		{
 			"quoted",
 			String("x = y", `qu"o`),
-			`"x = y"="qu\"o"`,
+			`"x = y"`, `"qu\"o"`,
 		},
 		{
 			"Sprint",
 			Any("name", name{"Ren", "Hoek"}),
-			`name="Hoek, Ren"`,
+			`name`, `"Hoek, Ren"`,
 		},
 		{
 			"TextMarshaler",
 			Any("t", text{"abc"}),
-			`t="text{\"abc\"}"`,
+			`t`, `"text{\"abc\"}"`,
 		},
 		{
 			"TextMarshaler error",
 			Any("t", text{""}),
-			`t="!ERROR:text: empty string"`,
+			`t`, `"!ERROR:text: empty string"`,
 		},
 	} {
 		t.Run(test.name, func(t *testing.T) {
-			var buf bytes.Buffer
-			h := NewTextHandler(&buf)
-			r := NewRecord(testTime, InfoLevel, "a message", 0)
-			r.AddAttrs(test.attr)
-			if err := h.Handle(r); err != nil {
-				t.Fatal(err)
-			}
-			got := buf.String()
-			// Remove final newline.
-			got = got[:len(got)-1]
-			want := `time=2000-01-02T03:04:05.000Z level=INFO msg="a message" ` + test.want
-			if got != want {
-				t.Errorf("\ngot  %s\nwant %s", got, want)
+			for _, opts := range []struct {
+				name       string
+				opts       HandlerOptions
+				wantPrefix string
+				modKey     func(string) string
+			}{
+				{
+					"none",
+					HandlerOptions{},
+					`time=2000-01-02T03:04:05.000Z level=INFO msg="a message"`,
+					func(s string) string { return s },
+				},
+				{
+					"replace",
+					HandlerOptions{ReplaceAttr: upperCaseKey},
+					`TIME=2000-01-02T03:04:05.000Z LEVEL=INFO MSG="a message"`,
+					strings.ToUpper,
+				},
+			} {
+				t.Run(opts.name, func(t *testing.T) {
+					var buf bytes.Buffer
+					h := opts.opts.NewTextHandler(&buf)
+					r := NewRecord(testTime, InfoLevel, "a message", 0)
+					r.AddAttrs(test.attr)
+					if err := h.Handle(r); err != nil {
+						t.Fatal(err)
+					}
+					got := buf.String()
+					// Remove final newline.
+					got = got[:len(got)-1]
+					want := opts.wantPrefix + " " + opts.modKey(test.wantKey) + "=" + test.wantVal
+					if got != want {
+						t.Errorf("\ngot  %s\nwant %s", got, want)
+					}
+				})
 			}
 		})
 	}