slog: sync with log/slog

Bring this copy of slog up to date with the changes in the standard library
log/slog package.

Change-Id: I05d4de21388c4a4761b46b89fb74a1c7258ac06c
Reviewed-on: https://go-review.googlesource.com/c/exp/+/494179
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/attr.go b/slog/attr.go
index cd3bacc..a180d0e 100644
--- a/slog/attr.go
+++ b/slog/attr.go
@@ -58,14 +58,26 @@
 }
 
 // Group returns an Attr for a Group Value.
-// The caller must not subsequently mutate the
-// argument slice.
+// The first argument is the key; the remaining arguments
+// are converted to Attrs as in [Logger.Log].
 //
-// Use Group to collect several Attrs under a single
+// Use Group to collect several key-value pairs under a single
 // key on a log line, or as the result of LogValue
 // in order to log a single value as multiple Attrs.
-func Group(key string, as ...Attr) Attr {
-	return Attr{key, GroupValue(as...)}
+func Group(key string, args ...any) Attr {
+	return Attr{key, GroupValue(argsToAttrSlice(args)...)}
+}
+
+func argsToAttrSlice(args []any) []Attr {
+	var (
+		attr  Attr
+		attrs []Attr
+	)
+	for len(args) > 0 {
+		attr, args = argsToAttr(args)
+		attrs = append(attrs, attr)
+	}
+	return attrs
 }
 
 // Any returns an Attr for the supplied value.
diff --git a/slog/benchmarks/benchmarks_test.go b/slog/benchmarks/benchmarks_test.go
index ce82620..2e44707 100644
--- a/slog/benchmarks/benchmarks_test.go
+++ b/slog/benchmarks/benchmarks_test.go
@@ -31,8 +31,8 @@
 		{"disabled", disabledHandler{}},
 		{"async discard", newAsyncHandler()},
 		{"fastText discard", newFastTextHandler(io.Discard)},
-		{"Text discard", slog.NewTextHandler(io.Discard)},
-		{"JSON discard", slog.NewJSONHandler(io.Discard)},
+		{"Text discard", slog.NewTextHandler(io.Discard, nil)},
+		{"JSON discard", slog.NewJSONHandler(io.Discard, nil)},
 	} {
 		logger := slog.New(handler.h)
 		b.Run(handler.name, func(b *testing.B) {
diff --git a/slog/doc.go b/slog/doc.go
index 3b37eec..3b24259 100644
--- a/slog/doc.go
+++ b/slog/doc.go
@@ -44,7 +44,7 @@
 This statement uses [New] to create a new logger with a TextHandler
 that writes structured records in text form to standard error:
 
-	logger := slog.New(slog.NewTextHandler(os.Stderr))
+	logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
 
 [TextHandler] output is a sequence of key=value pairs, easily and unambiguously
 parsed by machine. This statement:
@@ -57,14 +57,14 @@
 
 The package also provides [JSONHandler], whose output is line-delimited JSON:
 
-	logger := slog.New(slog.NewJSONHandler(os.Stdout))
+	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
 	logger.Info("hello", "count", 3)
 
 produces this output:
 
 	{"time":"2022-11-08T15:28:26.000000000-05:00","level":"INFO","msg":"hello","count":3}
 
-Both [TextHandler] and [JSONHandler] can be configured with a [HandlerOptions].
+Both [TextHandler] and [JSONHandler] can be configured with [HandlerOptions].
 There are options for setting the minimum level (see Levels, below),
 displaying the source file and line of the log call, and
 modifying attributes before they are logged.
@@ -78,38 +78,6 @@
 so that existing applications that use [log.Printf] and related functions
 will send log records to the logger's handler without needing to be rewritten.
 
-# Attrs and Values
-
-An [Attr] is a key-value pair. The Logger output methods accept Attrs as well as
-alternating keys and values. The statement
-
-	slog.Info("hello", slog.Int("count", 3))
-
-behaves the same as
-
-	slog.Info("hello", "count", 3)
-
-There are convenience constructors for [Attr] such as [Int], [String], and [Bool]
-for common types, as well as the function [Any] for constructing Attrs of any
-type.
-
-The value part of an Attr is a type called [Value].
-Like an [any], a Value can hold any Go value,
-but it can represent typical values, including all numbers and strings,
-without an allocation.
-
-For the most efficient log output, use [Logger.LogAttrs].
-It is similar to [Logger.Log] but accepts only Attrs, not alternating
-keys and values; this allows it, too, to avoid allocation.
-
-The call
-
-	logger.LogAttrs(nil, slog.LevelInfo, "hello", slog.Int("count", 3))
-
-is the most efficient way to achieve the same output as
-
-	slog.Info("hello", "count", 3)
-
 Some attributes are common to many log calls.
 For example, you may wish to include the URL or trace identifier of a server request
 with all log events arising from the request.
@@ -149,7 +117,7 @@
 
 Then use the LevelVar to construct a handler, and make it the default:
 
-	h := slog.HandlerOptions{Level: programLevel}.NewJSONHandler(os.Stderr)
+	h := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: programLevel})
 	slog.SetDefault(slog.New(h))
 
 Now the program can change its logging level with a single statement:
@@ -164,11 +132,11 @@
 [TextHandler] separates the group and attribute names with a dot.
 [JSONHandler] treats each group as a separate JSON object, with the group name as the key.
 
-Use [Group] to create a Group Attr from a name and a list of Attrs:
+Use [Group] to create a Group attribute from a name and a list of key-value pairs:
 
 	slog.Group("request",
-	    slog.String("method", r.Method),
-	    slog.Any("url", r.URL))
+	    "method", r.Method,
+	    "url", r.URL)
 
 TextHandler would display this group as
 
@@ -199,7 +167,7 @@
 
 Some handlers may wish to include information from the [context.Context] that is
 available at the call site. One example of such information
-is the identifier for the current span when tracing is is enabled.
+is the identifier for the current span when tracing is enabled.
 
 The [Logger.Log] and [Logger.LogAttrs] methods take a context as a first
 argument, as do their corresponding top-level functions.
@@ -212,6 +180,38 @@
 
 It is recommended to pass a context to an output method if one is available.
 
+# Attrs and Values
+
+An [Attr] is a key-value pair. The Logger output methods accept Attrs as well as
+alternating keys and values. The statement
+
+	slog.Info("hello", slog.Int("count", 3))
+
+behaves the same as
+
+	slog.Info("hello", "count", 3)
+
+There are convenience constructors for [Attr] such as [Int], [String], and [Bool]
+for common types, as well as the function [Any] for constructing Attrs of any
+type.
+
+The value part of an Attr is a type called [Value].
+Like an [any], a Value can hold any Go value,
+but it can represent typical values, including all numbers and strings,
+without an allocation.
+
+For the most efficient log output, use [Logger.LogAttrs].
+It is similar to [Logger.Log] but accepts only Attrs, not alternating
+keys and values; this allows it, too, to avoid allocation.
+
+The call
+
+	logger.LogAttrs(nil, slog.LevelInfo, "hello", slog.Int("count", 3))
+
+is the most efficient way to achieve the same output as
+
+	slog.Info("hello", "count", 3)
+
 # Customizing a type's logging behavior
 
 If a type implements the [LogValuer] interface, the [Value] returned from its LogValue
diff --git a/slog/example_custom_levels_test.go b/slog/example_custom_levels_test.go
index 40085e4..9422fc9 100644
--- a/slog/example_custom_levels_test.go
+++ b/slog/example_custom_levels_test.go
@@ -26,7 +26,7 @@
 		LevelEmergency = slog.Level(12)
 	)
 
-	th := slog.HandlerOptions{
+	th := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
 		// Set a custom level to show all log output. The default value is
 		// LevelInfo, which would drop Debug and Trace logs.
 		Level: LevelTrace,
@@ -70,7 +70,7 @@
 
 			return a
 		},
-	}.NewTextHandler(os.Stdout)
+	})
 
 	logger := slog.New(th)
 	logger.Log(nil, LevelEmergency, "missing pilots")
diff --git a/slog/example_level_handler_test.go b/slog/example_level_handler_test.go
index 7f82e19..6c7221d 100644
--- a/slog/example_level_handler_test.go
+++ b/slog/example_level_handler_test.go
@@ -64,7 +64,7 @@
 // Another typical use would be to decrease the log level (to LevelDebug, say)
 // during a part of the program that was suspected of containing a bug.
 func ExampleHandler_levelHandler() {
-	th := slog.HandlerOptions{ReplaceAttr: testutil.RemoveTime}.NewTextHandler(os.Stdout)
+	th := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ReplaceAttr: testutil.RemoveTime})
 	logger := slog.New(NewLevelHandler(slog.LevelWarn, th))
 	logger.Info("not printed")
 	logger.Warn("printed")
diff --git a/slog/example_logvaluer_group_test.go b/slog/example_logvaluer_group_test.go
index 85426d0..61892eb 100644
--- a/slog/example_logvaluer_group_test.go
+++ b/slog/example_logvaluer_group_test.go
@@ -4,9 +4,7 @@
 
 package slog_test
 
-import (
-	"golang.org/x/exp/slog"
-)
+import "golang.org/x/exp/slog"
 
 type Name struct {
 	First, Last string
diff --git a/slog/example_logvaluer_secret_test.go b/slog/example_logvaluer_secret_test.go
index f3e8fec..0d40519 100644
--- a/slog/example_logvaluer_secret_test.go
+++ b/slog/example_logvaluer_secret_test.go
@@ -24,8 +24,7 @@
 // with an alternative representation to avoid revealing secrets.
 func ExampleLogValuer_secret() {
 	t := Token("shhhh!")
-	logger := slog.New(slog.HandlerOptions{ReplaceAttr: testutil.RemoveTime}.
-		NewTextHandler(os.Stdout))
+	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ReplaceAttr: testutil.RemoveTime}))
 	logger.Info("permission granted", "user", "Perry", "token", t)
 
 	// Output:
diff --git a/slog/example_test.go b/slog/example_test.go
index e777f2d..ad5d000 100644
--- a/slog/example_test.go
+++ b/slog/example_test.go
@@ -17,7 +17,7 @@
 	r, _ := http.NewRequest("GET", "localhost", nil)
 	// ...
 
-	logger := slog.New(slog.HandlerOptions{ReplaceAttr: testutil.RemoveTime}.NewTextHandler(os.Stdout))
+	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ReplaceAttr: testutil.RemoveTime}))
 	slog.SetDefault(logger)
 
 	slog.Info("finished",
diff --git a/slog/example_wrap_test.go b/slog/example_wrap_test.go
index c7bfbb7..6feceef 100644
--- a/slog/example_wrap_test.go
+++ b/slog/example_wrap_test.go
@@ -35,13 +35,14 @@
 		}
 		// Remove the directory from the source's filename.
 		if a.Key == slog.SourceKey {
-			a.Value = slog.StringValue(filepath.Base(a.Value.String()))
+			source := a.Value.Any().(*slog.Source)
+			source.File = filepath.Base(source.File)
 		}
 		return a
 	}
-	logger := slog.New(slog.HandlerOptions{AddSource: true, ReplaceAttr: replace}.NewTextHandler(os.Stdout))
+	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{AddSource: true, ReplaceAttr: replace}))
 	Infof(logger, "message, %s", "formatted")
 
 	// Output:
-	// level=INFO source=example_wrap_test.go:43 msg="message, formatted"
+	// level=INFO source=example_wrap_test.go:44 msg="message, formatted"
 }
diff --git a/slog/handler.go b/slog/handler.go
index 862fb3a..74f8873 100644
--- a/slog/handler.go
+++ b/slog/handler.go
@@ -41,7 +41,7 @@
 	Enabled(context.Context, Level) bool
 
 	// Handle handles the Record.
-	// It will only be called Enabled returns true.
+	// It will only be called when Enabled returns true.
 	// The Context argument is as for Enabled.
 	// It is present solely to provide Handlers access to the context's values.
 	// Canceling the context should not affect record processing.
@@ -130,10 +130,8 @@
 // HandlerOptions are options for a TextHandler or JSONHandler.
 // A zero HandlerOptions consists entirely of default values.
 type HandlerOptions struct {
-	// When AddSource is true, the handler adds a ("source", "file:line")
-	// attribute to the output indicating the source code position of the log
-	// statement. AddSource is false by default to skip the cost of computing
-	// this information.
+	// AddSource causes the handler to compute the source code position
+	// of the log statement and add a SourceKey attribute to the output.
 	AddSource bool
 
 	// Level reports the minimum record level that will be logged.
@@ -285,22 +283,7 @@
 	}
 	// source
 	if h.opts.AddSource {
-		frame := r.frame()
-		if frame.File != "" {
-			key := SourceKey
-			if rep == nil {
-				state.appendKey(key)
-				state.appendSource(frame.File, frame.Line)
-			} else {
-				buf := buffer.New()
-				buf.WriteString(frame.File) // TODO: escape?
-				buf.WriteByte(':')
-				buf.WritePosInt(frame.Line)
-				s := buf.String()
-				buf.Free()
-				state.appendAttr(String(key, s))
-			}
-		}
+		state.appendAttr(Any(SourceKey, r.source()))
 	}
 	key = MessageKey
 	msg := r.Message
@@ -421,7 +404,6 @@
 	if s.groups != nil {
 		*s.groups = append(*s.groups, name)
 	}
-
 }
 
 // closeGroup ends the group with the given name.
@@ -455,6 +437,16 @@
 	if a.isEmpty() {
 		return
 	}
+	// Special case: Source.
+	if v := a.Value; v.Kind() == KindAny {
+		if src, ok := v.Any().(*Source); ok {
+			if s.h.json {
+				a.Value = src.group()
+			} else {
+				a.Value = StringValue(fmt.Sprintf("%s:%d", src.File, src.Line))
+			}
+		}
+	}
 	if a.Value.Kind() == KindGroup {
 		attrs := a.Value.Group()
 		// Output only non-empty groups.
@@ -496,26 +488,6 @@
 	s.sep = s.h.attrSep()
 }
 
-func (s *handleState) appendSource(file string, line int) {
-	if s.h.json {
-		s.buf.WriteByte('"')
-		*s.buf = appendEscapedJSONString(*s.buf, file)
-		s.buf.WriteByte(':')
-		s.buf.WritePosInt(line)
-		s.buf.WriteByte('"')
-	} else {
-		// text
-		if needsQuoting(file) {
-			s.appendString(file + ":" + strconv.Itoa(line))
-		} else {
-			// common case: no quoting needed.
-			s.appendString(file)
-			s.buf.WriteByte(':')
-			s.buf.WritePosInt(line)
-		}
-	}
-}
-
 func (s *handleState) appendString(str string) {
 	if s.h.json {
 		s.buf.WriteByte('"')
diff --git a/slog/handler_test.go b/slog/handler_test.go
index 6c39025..4b2f085 100644
--- a/slog/handler_test.go
+++ b/slog/handler_test.go
@@ -11,6 +11,8 @@
 	"context"
 	"encoding/json"
 	"io"
+	"path/filepath"
+	"strconv"
 	"strings"
 	"testing"
 	"time"
@@ -116,13 +118,14 @@
 	preAttrs := []Attr{Int("pre", 3), String("x", "y")}
 
 	for _, test := range []struct {
-		name     string
-		replace  func([]string, Attr) Attr
-		with     func(Handler) Handler
-		preAttrs []Attr
-		attrs    []Attr
-		wantText string
-		wantJSON string
+		name      string
+		replace   func([]string, Attr) Attr
+		addSource bool
+		with      func(Handler) Handler
+		preAttrs  []Attr
+		attrs     []Attr
+		wantText  string
+		wantJSON  string
 	}{
 		{
 			name:     "basic",
@@ -310,19 +313,34 @@
 			wantText: `msg=message a=1 b=2 c=3 d=4`,
 			wantJSON: `{"msg":"message","a":1,"b":2,"c":3,"d":4}`,
 		},
+		{
+			name: "Source",
+			replace: func(gs []string, a Attr) Attr {
+				if a.Key == SourceKey {
+					s := a.Value.Any().(*Source)
+					s.File = filepath.Base(s.File)
+					return Any(a.Key, s)
+				}
+				return removeKeys(TimeKey, LevelKey)(gs, a)
+			},
+			addSource: true,
+			wantText:  `source=handler_test.go:$LINE msg=message`,
+			wantJSON:  `{"source":{"function":"golang.org/x/exp/slog.TestJSONAndTextHandlers","file":"handler_test.go","line":$LINE},"msg":"message"}`,
+		},
 	} {
-		r := NewRecord(testTime, LevelInfo, "message", 0)
+		r := NewRecord(testTime, LevelInfo, "message", callerPC(2))
+		line := strconv.Itoa(r.source().Line)
 		r.AddAttrs(test.attrs...)
 		var buf bytes.Buffer
-		opts := HandlerOptions{ReplaceAttr: test.replace}
+		opts := HandlerOptions{ReplaceAttr: test.replace, AddSource: test.addSource}
 		t.Run(test.name, func(t *testing.T) {
 			for _, handler := range []struct {
 				name string
 				h    Handler
 				want string
 			}{
-				{"text", opts.NewTextHandler(&buf), test.wantText},
-				{"json", opts.NewJSONHandler(&buf), test.wantJSON},
+				{"text", NewTextHandler(&buf, &opts), test.wantText},
+				{"json", NewJSONHandler(&buf, &opts), test.wantJSON},
 			} {
 				t.Run(handler.name, func(t *testing.T) {
 					h := handler.h
@@ -333,9 +351,10 @@
 					if err := h.Handle(ctx, r); err != nil {
 						t.Fatal(err)
 					}
+					want := strings.ReplaceAll(handler.want, "$LINE", line)
 					got := strings.TrimSuffix(buf.String(), "\n")
-					if got != handler.want {
-						t.Errorf("\ngot  %s\nwant %s\n", got, handler.want)
+					if got != want {
+						t.Errorf("\ngot  %s\nwant %s\n", got, want)
 					}
 				})
 			}
@@ -397,38 +416,11 @@
 	}
 }
 
-func TestAppendSource(t *testing.T) {
-	for _, test := range []struct {
-		file               string
-		wantText, wantJSON string
-	}{
-		{"a/b.go", "a/b.go:1", `"a/b.go:1"`},
-		{"a b.go", `"a b.go:1"`, `"a b.go:1"`},
-		{`C:\windows\b.go`, `C:\windows\b.go:1`, `"C:\\windows\\b.go:1"`},
-	} {
-		check := func(json bool, want string) {
-			t.Helper()
-			var buf []byte
-			state := handleState{
-				h:   &commonHandler{json: json},
-				buf: (*buffer.Buffer)(&buf),
-			}
-			state.appendSource(test.file, 1)
-			got := string(buf)
-			if got != want {
-				t.Errorf("%s, json=%t:\ngot  %s\nwant %s", test.file, json, got, want)
-			}
-		}
-		check(false, test.wantText)
-		check(true, test.wantJSON)
-	}
-}
-
 func TestSecondWith(t *testing.T) {
 	// Verify that a second call to Logger.With does not corrupt
 	// the original.
 	var buf bytes.Buffer
-	h := HandlerOptions{ReplaceAttr: removeKeys(TimeKey)}.NewTextHandler(&buf)
+	h := NewTextHandler(&buf, &HandlerOptions{ReplaceAttr: removeKeys(TimeKey)})
 	logger := New(h).With(
 		String("app", "playground"),
 		String("role", "tester"),
@@ -454,14 +446,14 @@
 
 	var got []ga
 
-	h := HandlerOptions{ReplaceAttr: func(gs []string, a Attr) Attr {
+	h := NewTextHandler(io.Discard, &HandlerOptions{ReplaceAttr: func(gs []string, a Attr) Attr {
 		v := a.Value.String()
 		if a.Key == TimeKey {
 			v = "<now>"
 		}
 		got = append(got, ga{strings.Join(gs, ","), a.Key, v})
 		return a
-	}}.NewTextHandler(io.Discard)
+	}})
 	New(h).
 		With(Int("a", 1)).
 		WithGroup("g1").
diff --git a/slog/json_handler.go b/slog/json_handler.go
index 047cb74..157ada8 100644
--- a/slog/json_handler.go
+++ b/slog/json_handler.go
@@ -11,7 +11,6 @@
 	"errors"
 	"fmt"
 	"io"
-	"math"
 	"strconv"
 	"time"
 	"unicode/utf8"
@@ -26,18 +25,17 @@
 }
 
 // NewJSONHandler creates a JSONHandler that writes to w,
-// using the default options.
-func NewJSONHandler(w io.Writer) *JSONHandler {
-	return (HandlerOptions{}).NewJSONHandler(w)
-}
-
-// NewJSONHandler creates a JSONHandler with the given options that writes to w.
-func (opts HandlerOptions) NewJSONHandler(w io.Writer) *JSONHandler {
+// using the given options.
+// If opts is nil, the default options are used.
+func NewJSONHandler(w io.Writer, opts *HandlerOptions) *JSONHandler {
+	if opts == nil {
+		opts = &HandlerOptions{}
+	}
 	return &JSONHandler{
 		&commonHandler{
 			json: true,
 			w:    w,
-			opts: opts,
+			opts: *opts,
 		},
 	}
 }
@@ -77,12 +75,16 @@
 // To modify these or other attributes, or remove them from the output, use
 // [HandlerOptions.ReplaceAttr].
 //
-// Values are formatted as with encoding/json.Marshal, with the following
-// exceptions:
-//   - Floating-point NaNs and infinities are formatted as one of the strings
-//     "NaN", "Infinity" or "-Infinity".
-//   - Levels are formatted as with Level.String.
-//   - HTML characters are not escaped.
+// Values are formatted as with an [encoding/json.Encoder] with SetEscapeHTML(false),
+// with two exceptions.
+//
+// First, an Attr whose Value is of type error is formatted as a string, by
+// calling its Error method. Only errors in Attrs receive this special treatment,
+// not errors embedded in structs, slices, maps or other data structures that
+// are processed by the encoding/json package.
+//
+// Second, an encoding failure does not cause Handle to return an error.
+// Instead, the error message is formatted as a string.
 //
 // Each call to Handle results in a single serialized call to io.Writer.Write.
 func (h *JSONHandler) Handle(_ context.Context, r Record) error {
@@ -110,22 +112,11 @@
 	case KindUint64:
 		*s.buf = strconv.AppendUint(*s.buf, v.Uint64(), 10)
 	case KindFloat64:
-		f := v.Float64()
-		// json.Marshal fails on special floats, so handle them here.
-		switch {
-		case math.IsInf(f, 1):
-			s.buf.WriteString(`"Infinity"`)
-		case math.IsInf(f, -1):
-			s.buf.WriteString(`"-Infinity"`)
-		case math.IsNaN(f):
-			s.buf.WriteString(`"NaN"`)
-		default:
-			// json.Marshal is funny about floats; it doesn't
-			// always match strconv.AppendFloat. So just call it.
-			// That's expensive, but floats are rare.
-			if err := appendJSONMarshal(s.buf, f); err != nil {
-				return err
-			}
+		// json.Marshal is funny about floats; it doesn't
+		// always match strconv.AppendFloat. So just call it.
+		// That's expensive, but floats are rare.
+		if err := appendJSONMarshal(s.buf, v.Float64()); err != nil {
+			return err
 		}
 	case KindBool:
 		*s.buf = strconv.AppendBool(*s.buf, v.Bool())
@@ -165,9 +156,7 @@
 // It does not surround the string in quotation marks.
 //
 // Modified from encoding/json/encode.go:encodeState.string,
-// with escapeHTML set to true.
-//
-// TODO: review whether HTML escaping is necessary.
+// with escapeHTML set to false.
 func appendEscapedJSONString(buf []byte, s string) []byte {
 	char := func(b byte) { buf = append(buf, b) }
 	str := func(s string) { buf = append(buf, s...) }
diff --git a/slog/json_handler_test.go b/slog/json_handler_test.go
index fb93713..9f27ffd 100644
--- a/slog/json_handler_test.go
+++ b/slog/json_handler_test.go
@@ -40,7 +40,7 @@
 	} {
 		t.Run(test.name, func(t *testing.T) {
 			var buf bytes.Buffer
-			h := test.opts.NewJSONHandler(&buf)
+			h := NewJSONHandler(&buf, &test.opts)
 			r := NewRecord(testTime, LevelInfo, "m", 0)
 			r.AddAttrs(Int("a", 1), Any("m", map[string]int{"b": 2}))
 			if err := h.Handle(context.Background(), r); err != nil {
@@ -75,7 +75,7 @@
 func (jsonMarshalerError) Error() string { return "oops" }
 
 func TestAppendJSONValue(t *testing.T) {
-	// On most values, jsonAppendAttrValue should agree with json.Marshal.
+	// jsonAppendAttrValue should always agree with json.Marshal.
 	for _, value := range []any{
 		"hello",
 		`"[{escape}]"`,
@@ -90,8 +90,9 @@
 		testTime,
 		jsonMarshaler{"xyz"},
 		jsonMarshalerError{jsonMarshaler{"pqr"}},
+		LevelWarn,
 	} {
-		got := jsonValueString(t, AnyValue(value))
+		got := jsonValueString(AnyValue(value))
 		want, err := marshalJSON(value)
 		if err != nil {
 			t.Fatal(err)
@@ -118,24 +119,23 @@
 		value any
 		want  string
 	}{
-		{math.NaN(), `"NaN"`},
-		{math.Inf(+1), `"Infinity"`},
-		{math.Inf(-1), `"-Infinity"`},
-		{LevelWarn, `"WARN"`},
+		{math.NaN(), `"!ERROR:json: unsupported value: NaN"`},
+		{math.Inf(+1), `"!ERROR:json: unsupported value: +Inf"`},
+		{math.Inf(-1), `"!ERROR:json: unsupported value: -Inf"`},
+		{io.EOF, `"EOF"`},
 	} {
-		got := jsonValueString(t, AnyValue(test.value))
+		got := jsonValueString(AnyValue(test.value))
 		if got != test.want {
 			t.Errorf("%v: got %s, want %s", test.value, got, test.want)
 		}
 	}
 }
 
-func jsonValueString(t *testing.T, v Value) string {
-	t.Helper()
+func jsonValueString(v Value) string {
 	var buf []byte
 	s := &handleState{h: &commonHandler{json: true}, buf: (*buffer.Buffer)(&buf)}
 	if err := appendJSONValue(s, v); err != nil {
-		t.Fatal(err)
+		s.appendError(err)
 	}
 	return string(buf)
 }
@@ -172,7 +172,7 @@
 		}},
 	} {
 		b.Run(bench.name, func(b *testing.B) {
-			l := New(bench.opts.NewJSONHandler(io.Discard)).With(
+			l := New(NewJSONHandler(io.Discard, &bench.opts)).With(
 				String("program", "my-test-program"),
 				String("package", "log/slog"),
 				String("traceID", "2039232309232309"),
@@ -237,7 +237,7 @@
 		{"struct file", outFile, structAttrs},
 	} {
 		b.Run(bench.name, func(b *testing.B) {
-			l := New(NewJSONHandler(bench.wc)).With(bench.attrs...)
+			l := New(NewJSONHandler(bench.wc, nil)).With(bench.attrs...)
 			b.ReportAllocs()
 			b.ResetTimer()
 			for i := 0; i < b.N; i++ {
diff --git a/slog/logger.go b/slog/logger.go
index 173b34f..6ad93bf 100644
--- a/slog/logger.go
+++ b/slog/logger.go
@@ -95,16 +95,8 @@
 // The new Logger's handler is the result of calling WithAttrs on the receiver's
 // handler.
 func (l *Logger) With(args ...any) *Logger {
-	var (
-		attr  Attr
-		attrs []Attr
-	)
-	for len(args) > 0 {
-		attr, args = argsToAttr(args)
-		attrs = append(attrs, attr)
-	}
 	c := l.clone()
-	c.handler = l.handler.WithAttrs(attrs)
+	c.handler = l.handler.WithAttrs(argsToAttrSlice(args))
 	return c
 }
 
diff --git a/slog/logger_test.go b/slog/logger_test.go
index 9694b54..2839e05 100644
--- a/slog/logger_test.go
+++ b/slog/logger_test.go
@@ -25,7 +25,7 @@
 func TestLogTextHandler(t *testing.T) {
 	var buf bytes.Buffer
 
-	l := New(NewTextHandler(&buf))
+	l := New(NewTextHandler(&buf, nil))
 
 	check := func(want string) {
 		t.Helper()
@@ -110,13 +110,13 @@
 
 	// Once slog.SetDefault is called, the direction is reversed: the default
 	// log.Logger's output goes through the handler.
-	SetDefault(New(HandlerOptions{AddSource: true}.NewTextHandler(&slogbuf)))
+	SetDefault(New(NewTextHandler(&slogbuf, &HandlerOptions{AddSource: true})))
 	log.Print("msg2")
 	checkLogOutput(t, slogbuf.String(), "time="+timeRE+` level=INFO source=.*logger_test.go:\d{3} msg=msg2`)
 
 	// The default log.Logger always outputs at Info level.
 	slogbuf.Reset()
-	SetDefault(New(HandlerOptions{Level: LevelWarn}.NewTextHandler(&slogbuf)))
+	SetDefault(New(NewTextHandler(&slogbuf, &HandlerOptions{Level: LevelWarn})))
 	log.Print("should not appear")
 	if got := slogbuf.String(); got != "" {
 		t.Errorf("got %q, want empty", got)
@@ -161,23 +161,20 @@
 	check(attrsSlice(h.r), Int("c", 3))
 }
 
-func sourceLine(r Record) (string, int) {
-	f := r.frame()
-	return f.File, f.Line
-}
-
 func TestCallDepth(t *testing.T) {
 	h := &captureHandler{}
 	var startLine int
 
 	check := func(count int) {
 		t.Helper()
+		const wantFunc = "golang.org/x/exp/slog.TestCallDepth"
 		const wantFile = "logger_test.go"
 		wantLine := startLine + count*2
-		gotFile, gotLine := sourceLine(h.r)
-		gotFile = filepath.Base(gotFile)
-		if gotFile != wantFile || gotLine != wantLine {
-			t.Errorf("got (%s, %d), want (%s, %d)", gotFile, gotLine, wantFile, wantLine)
+		got := h.r.source()
+		gotFile := filepath.Base(got.File)
+		if got.Function != wantFunc || gotFile != wantFile || got.Line != wantLine {
+			t.Errorf("got (%s, %s, %d), want (%s, %s, %d)",
+				got.Function, gotFile, got.Line, wantFunc, wantFile, wantLine)
 		}
 	}
 
@@ -361,7 +358,7 @@
 		}
 		return a
 	}
-	l := New(HandlerOptions{ReplaceAttr: removeTime}.NewTextHandler(&buf))
+	l := New(NewTextHandler(&buf, &HandlerOptions{ReplaceAttr: removeTime}))
 	l.Error("msg", "err", io.EOF, "a", 1)
 	checkLogOutput(t, buf.String(), `level=ERROR msg=msg err=EOF a=1`)
 	buf.Reset()
@@ -371,7 +368,7 @@
 
 func TestNewLogLogger(t *testing.T) {
 	var buf bytes.Buffer
-	h := NewTextHandler(&buf)
+	h := NewTextHandler(&buf, nil)
 	ll := NewLogLogger(h, LevelWarn)
 	ll.Print("hello")
 	checkLogOutput(t, buf.String(), "time="+timeRE+` level=WARN msg=hello`)
diff --git a/slog/record.go b/slog/record.go
index e791b2e..38b3440 100644
--- a/slog/record.go
+++ b/slog/record.go
@@ -64,15 +64,6 @@
 	}
 }
 
-// frame returns the runtime.Frame of the log event.
-// If the Record was created without the necessary information,
-// or if the location is unavailable, it returns a zero Frame.
-func (r Record) frame() runtime.Frame {
-	fs := runtime.CallersFrames([]uintptr{r.PC})
-	f, _ := fs.Next()
-	return f
-}
-
 // Clone returns a copy of the record with no shared state.
 // The original record and the clone can both be modified
 // without interfering with each other.
@@ -170,3 +161,47 @@
 		return Any(badKey, x), args[1:]
 	}
 }
+
+// Source describes the location of a line of source code.
+type Source struct {
+	// Function is the package path-qualified function name containing the
+	// source line. If non-empty, this string uniquely identifies a single
+	// function in the program. This may be the empty string if not known.
+	Function string `json:"function"`
+	// File and Line are the file name and line number (1-based) of the source
+	// line. These may be the empty string and zero, respectively, if not known.
+	File string `json:"file"`
+	Line int    `json:"line"`
+}
+
+// attrs returns the non-zero fields of s as a slice of attrs.
+// It is similar to a LogValue method, but we don't want Source
+// to implement LogValuer because it would be resolved before
+// the ReplaceAttr function was called.
+func (s *Source) group() Value {
+	var as []Attr
+	if s.Function != "" {
+		as = append(as, String("function", s.Function))
+	}
+	if s.File != "" {
+		as = append(as, String("file", s.File))
+	}
+	if s.Line != 0 {
+		as = append(as, Int("line", s.Line))
+	}
+	return GroupValue(as...)
+}
+
+// source returns a Source for the log event.
+// If the Record was created without the necessary information,
+// or if the location is unavailable, it returns a non-nil *Source
+// with zero fields.
+func (r Record) source() *Source {
+	fs := runtime.CallersFrames([]uintptr{r.PC})
+	f, _ := fs.Next()
+	return &Source{
+		Function: f.Function,
+		File:     f.File,
+		Line:     f.Line,
+	}
+}
diff --git a/slog/record_test.go b/slog/record_test.go
index fa907f3..f68af75 100644
--- a/slog/record_test.go
+++ b/slog/record_test.go
@@ -11,7 +11,6 @@
 	"time"
 
 	"golang.org/x/exp/slices"
-	"golang.org/x/exp/slog/internal/buffer"
 )
 
 func TestRecordAttrs(t *testing.T) {
@@ -37,30 +36,33 @@
 	}
 }
 
-func TestRecordSourceLine(t *testing.T) {
-	// Zero call depth => empty file/line
+func TestRecordSource(t *testing.T) {
+	// Zero call depth => empty *Source.
 	for _, test := range []struct {
 		depth            int
+		wantFunction     string
 		wantFile         string
 		wantLinePositive bool
 	}{
-		{0, "", false},
-		{-16, "", false},
-		{1, "record_test.go", true}, // 1: caller of NewRecord
-		{2, "testing.go", true},
+		{0, "", "", false},
+		{-16, "", "", false},
+		{1, "golang.org/x/exp/slog.TestRecordSource", "record_test.go", true}, // 1: caller of NewRecord
+		{2, "testing.tRunner", "testing.go", true},
 	} {
 		var pc uintptr
 		if test.depth > 0 {
 			pc = callerPC(test.depth + 1)
 		}
 		r := NewRecord(time.Time{}, 0, "", pc)
-		gotFile, gotLine := sourceLine(r)
-		if i := strings.LastIndexByte(gotFile, '/'); i >= 0 {
-			gotFile = gotFile[i+1:]
+		got := r.source()
+		if i := strings.LastIndexByte(got.File, '/'); i >= 0 {
+			got.File = got.File[i+1:]
 		}
-		if gotFile != test.wantFile || (gotLine > 0) != test.wantLinePositive {
-			t.Errorf("depth %d: got (%q, %d), want (%q, %t)",
-				test.depth, gotFile, gotLine, test.wantFile, test.wantLinePositive)
+		if got.Function != test.wantFunction || got.File != test.wantFile || (got.Line > 0) != test.wantLinePositive {
+			t.Errorf("depth %d: got (%q, %q, %d), want (%q, %q, %t)",
+				test.depth,
+				got.Function, got.File, got.Line,
+				test.wantFunction, test.wantFile, test.wantLinePositive)
 		}
 	}
 }
@@ -137,29 +139,6 @@
 	}
 }
 
-func BenchmarkSourceLine(b *testing.B) {
-	r := NewRecord(time.Now(), LevelInfo, "", 5)
-	b.Run("alone", func(b *testing.B) {
-		for i := 0; i < b.N; i++ {
-			file, line := sourceLine(r)
-			_ = file
-			_ = line
-		}
-	})
-	b.Run("stringifying", func(b *testing.B) {
-		for i := 0; i < b.N; i++ {
-			file, line := sourceLine(r)
-			buf := buffer.New()
-			buf.WriteString(file)
-			buf.WriteByte(':')
-			buf.WritePosInt(line)
-			s := buf.String()
-			buf.Free()
-			_ = s
-		}
-	})
-}
-
 func BenchmarkRecord(b *testing.B) {
 	const nAttrs = nAttrsInline * 10
 	var a Attr
diff --git a/slog/slogtest/example_test.go b/slog/slogtest/example_test.go
index 1439353..b56e167 100644
--- a/slog/slogtest/example_test.go
+++ b/slog/slogtest/example_test.go
@@ -16,7 +16,7 @@
 // format when given a pointer to a map[string]any.
 func Example_parsing() {
 	var buf bytes.Buffer
-	h := slog.NewJSONHandler(&buf)
+	h := slog.NewJSONHandler(&buf, nil)
 
 	results := func() []map[string]any {
 		var ms []map[string]any
diff --git a/slog/slogtest_test.go b/slog/slogtest_test.go
index f799bed..2eaf980 100644
--- a/slog/slogtest_test.go
+++ b/slog/slogtest_test.go
@@ -1,3 +1,7 @@
+// Copyright 2023 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
 package slog_test
 
 import (
@@ -18,8 +22,8 @@
 		new   func(io.Writer) slog.Handler
 		parse func([]byte) (map[string]any, error)
 	}{
-		{"JSON", func(w io.Writer) slog.Handler { return slog.NewJSONHandler(w) }, parseJSON},
-		{"Text", func(w io.Writer) slog.Handler { return slog.NewTextHandler(w) }, parseText},
+		{"JSON", func(w io.Writer) slog.Handler { return slog.NewJSONHandler(w, nil) }, parseJSON},
+		{"Text", func(w io.Writer) slog.Handler { return slog.NewTextHandler(w, nil) }, parseText},
 	} {
 		t.Run(test.name, func(t *testing.T) {
 			var buf bytes.Buffer
@@ -38,9 +42,9 @@
 	}
 }
 
-func parseLines(bs []byte, parse func([]byte) (map[string]any, error)) ([]map[string]any, error) {
-	var ms []map[string]any
-	for _, line := range bytes.Split(bs, []byte{'\n'}) {
+func parseLines(src []byte, parse func([]byte) (map[string]any, error)) ([]map[string]any, error) {
+	var records []map[string]any
+	for _, line := range bytes.Split(src, []byte{'\n'}) {
 		if len(line) == 0 {
 			continue
 		}
@@ -48,9 +52,9 @@
 		if err != nil {
 			return nil, fmt.Errorf("%s: %w", string(line), err)
 		}
-		ms = append(ms, m)
+		records = append(records, m)
 	}
-	return ms, nil
+	return records, nil
 }
 
 func parseJSON(bs []byte) (map[string]any, error) {
@@ -71,12 +75,13 @@
 	top := map[string]any{}
 	s := string(bytes.TrimSpace(bs))
 	for len(s) > 0 {
-		kv, rest, _ := strings.Cut(s, " ")
+		kv, rest, _ := strings.Cut(s, " ") // assumes exactly one space between attrs
 		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]
diff --git a/slog/text_handler.go b/slog/text_handler.go
index 4981eb6..75b66b7 100644
--- a/slog/text_handler.go
+++ b/slog/text_handler.go
@@ -22,18 +22,17 @@
 }
 
 // NewTextHandler creates a TextHandler that writes to w,
-// using the default options.
-func NewTextHandler(w io.Writer) *TextHandler {
-	return (HandlerOptions{}).NewTextHandler(w)
-}
-
-// NewTextHandler creates a TextHandler with the given options that writes to w.
-func (opts HandlerOptions) NewTextHandler(w io.Writer) *TextHandler {
+// using the given options.
+// If opts is nil, the default options are used.
+func NewTextHandler(w io.Writer, opts *HandlerOptions) *TextHandler {
+	if opts == nil {
+		opts = &HandlerOptions{}
+	}
 	return &TextHandler{
 		&commonHandler{
 			json: false,
 			w:    w,
-			opts: opts,
+			opts: *opts,
 		},
 	}
 }
diff --git a/slog/text_handler_test.go b/slog/text_handler_test.go
index 6dbe402..2d674bb 100644
--- a/slog/text_handler_test.go
+++ b/slog/text_handler_test.go
@@ -10,7 +10,6 @@
 	"errors"
 	"fmt"
 	"io"
-	"regexp"
 	"strings"
 	"testing"
 	"time"
@@ -82,7 +81,7 @@
 			} {
 				t.Run(opts.name, func(t *testing.T) {
 					var buf bytes.Buffer
-					h := opts.opts.NewTextHandler(&buf)
+					h := NewTextHandler(&buf, &opts.opts)
 					r := NewRecord(testTime, LevelInfo, "a message", 0)
 					r.AddAttrs(test.attr)
 					if err := h.Handle(context.Background(), r); err != nil {
@@ -122,35 +121,9 @@
 	return []byte(fmt.Sprintf("text{%q}", t.s)), nil
 }
 
-func TestTextHandlerSource(t *testing.T) {
-	var buf bytes.Buffer
-	h := HandlerOptions{AddSource: true}.NewTextHandler(&buf)
-	r := NewRecord(testTime, LevelInfo, "m", callerPC(2))
-	if err := h.Handle(context.Background(), r); err != nil {
-		t.Fatal(err)
-	}
-	if got := buf.String(); !sourceRegexp.MatchString(got) {
-		t.Errorf("got\n%q\nwanted to match %s", got, sourceRegexp)
-	}
-}
-
-var sourceRegexp = regexp.MustCompile(`source="?([A-Z]:)?[^:]+text_handler_test\.go:\d+"? msg`)
-
-func TestSourceRegexp(t *testing.T) {
-	for _, s := range []string{
-		`source=/tmp/path/to/text_handler_test.go:23 msg=m`,
-		`source=C:\windows\path\text_handler_test.go:23 msg=m"`,
-		`source="/tmp/tmp.XcGZ9cG9Xb/with spaces/exp/slog/text_handler_test.go:95" msg=m`,
-	} {
-		if !sourceRegexp.MatchString(s) {
-			t.Errorf("failed to match %s", s)
-		}
-	}
-}
-
 func TestTextHandlerPreformatted(t *testing.T) {
 	var buf bytes.Buffer
-	var h Handler = NewTextHandler(&buf)
+	var h Handler = NewTextHandler(&buf, nil)
 	h = h.WithAttrs([]Attr{Duration("dur", time.Minute), Bool("b", true)})
 	// Also test omitting time.
 	r := NewRecord(time.Time{}, 0 /* 0 Level is INFO */, "m", 0)
@@ -170,7 +143,7 @@
 	for i := 0; i < 10; i++ {
 		r.AddAttrs(Int("x = y", i))
 	}
-	var h Handler = NewTextHandler(io.Discard)
+	var h Handler = NewTextHandler(io.Discard, nil)
 	wantAllocs(t, 0, func() { h.Handle(context.Background(), r) })
 
 	h = h.WithGroup("s")