slog: use middle dot for TextHandler groups/scopes

Use a Unicode middle dot 'ยท' as the separator between group/scope names
and keys in the TextHandler, instead of an ordinary dot '.'.

Although both dot and middle-dot may appear in a user-supplied
attribute key, making unambiguous parsing into groups impossible,
middle-dot is expected to be much less common, thus reducing the
frequency of ambiguity. If a user wants to escape, they can use
ReplaceAttr.

Alternative encodings that unambiguously preserve the group/key
separation would make parsing more complicated than simply calling
strconv.Unquote.

Change-Id: I6951ced8bb64dcb340c16372e0c27f49c861d88c
Reviewed-on: https://go-review.googlesource.com/c/exp/+/442355
Run-TryBot: Jonathan Amsterdam <jba@google.com>
Reviewed-by: Alan Donovan <adonovan@google.com>
diff --git a/slog/handler.go b/slog/handler.go
index c00e5c9..d10d2d0 100644
--- a/slog/handler.go
+++ b/slog/handler.go
@@ -308,6 +308,11 @@
 	}
 }
 
+// Separator for group/scope names and keys.
+// We use the uncommon character middle-dot rather than an ordinary dot
+// to reduce the likelihood of ambiguous group structure.
+const keyComponentSep = "·" // Unicode middle dot
+
 // openGroup starts a new group of attributes
 // with the given name.
 // A group can arise from a scope, or from an Attr with a GroupKind value.
@@ -317,9 +322,8 @@
 		s.buf.WriteByte('{')
 		s.sep = ""
 	} else {
-		// TODO: fix escaping to make it easy to recover the original.
-		s.prefix.WriteString(escapeDots(name))
-		s.prefix.WriteByte('.')
+		s.prefix.WriteString(name)
+		s.prefix.WriteString(keyComponentSep)
 	}
 }
 
@@ -328,7 +332,7 @@
 	if s.h.json {
 		s.buf.WriteByte('}')
 	} else {
-		(*s.prefix) = (*s.prefix)[:len(*s.prefix)-len(name)-1]
+		(*s.prefix) = (*s.prefix)[:len(*s.prefix)-len(name)-len(keyComponentSep)]
 	}
 	s.sep = s.h.attrSep()
 }
@@ -364,11 +368,12 @@
 
 func (s *handleState) appendKey(key string) {
 	s.buf.WriteString(s.sep)
-	// TODO: make sure the entire prefix+key is quoted if any part of it needs to be.
 	if s.prefix != nil {
-		s.buf.Write(*s.prefix)
+		// TODO: optimize by avoiding allocation.
+		s.appendString(string(*s.prefix) + key)
+	} else {
+		s.appendString(key)
 	}
-	s.appendString(key)
 	if s.h.json {
 		s.buf.WriteByte(':')
 	} else {
diff --git a/slog/handler_test.go b/slog/handler_test.go
index 7275f9e..ae0d70c 100644
--- a/slog/handler_test.go
+++ b/slog/handler_test.go
@@ -49,13 +49,13 @@
 					Int("d", 4)),
 				Int("e", 5),
 			},
-			want: "INFO message a=1 g.b=2 g.h.c=3 g.d=4 e=5",
+			want: "INFO message a=1 g·b=2 g·h·c=3 g·d=4 e=5",
 		},
 		{
 			name:  "scope",
 			with:  func(h Handler) Handler { return h.With(preAttrs).WithScope("s") },
 			attrs: attrs,
-			want:  "INFO message pre=0 s.a=1 s.b=two",
+			want:  "INFO message pre=0 s·a=1 s·b=two",
 		},
 		{
 			name: "preformatted scopes",
@@ -66,7 +66,7 @@
 					WithScope("s2")
 			},
 			attrs: attrs,
-			want:  "INFO message p1=1 s1.p2=2 s1.s2.a=1 s1.s2.b=two",
+			want:  "INFO message p1=1 s1·p2=2 s1·s2·a=1 s1·s2·b=two",
 		},
 		{
 			name: "two scopes",
@@ -76,7 +76,7 @@
 					WithScope("s2")
 			},
 			attrs: attrs,
-			want:  "INFO message p1=1 s1.s2.a=1 s1.s2.b=two",
+			want:  "INFO message p1=1 s1·s2·a=1 s1·s2·b=two",
 		},
 	} {
 		t.Run(test.name, func(t *testing.T) {
@@ -204,17 +204,29 @@
 					Int("d", 4)),
 				Int("e", 5),
 			},
-			wantText: "msg=message a=1 g.b=2 g.h.c=3 g.d=4 e=5",
+			wantText: "msg=message a=1 g·b=2 g·h·c=3 g·d=4 e=5",
 			wantJSON: `{"msg":"message","a":1,"g":{"b":2,"h":{"c":3},"d":4},"e":5}`,
 		},
 		{
 			name:     "empty group",
 			replace:  removeKeys(timeKey, levelKey),
 			attrs:    []Attr{Group("g"), Group("h", Int("a", 1))},
-			wantText: "msg=message h.a=1",
+			wantText: "msg=message h·a=1",
 			wantJSON: `{"msg":"message","g":{},"h":{"a":1}}`,
 		},
 		{
+			name:    "escapes",
+			replace: removeKeys(timeKey, levelKey),
+			attrs: []Attr{
+				String("a b", "x\t\n\000y"),
+				Group(" b.c=\"\\x2E\t",
+					String("d=e", "f.g\""),
+					Int("m·d", 1)), // middle dot is not escaped
+			},
+			wantText: `msg=message "a b"="x\t\n\x00y" " b.c=\"\\x2E\t·d=e"="f.g\"" " b.c=\"\\x2E\t·m·d"=1`,
+			wantJSON: `{"msg":"message","a b":"x\t\n\u0000y"," b.c=\"\\x2E\t":{"d=e":"f.g\"","m·d":1}}`,
+		},
+		{
 			name:    "LogValuer",
 			replace: removeKeys(timeKey, levelKey),
 			attrs: []Attr{
@@ -222,7 +234,7 @@
 				Any("name", logValueName{"Ren", "Hoek"}),
 				Int("b", 2),
 			},
-			wantText: "msg=message a=1 name.first=Ren name.last=Hoek b=2",
+			wantText: "msg=message a=1 name·first=Ren name·last=Hoek b=2",
 			wantJSON: `{"msg":"message","a":1,"name":{"first":"Ren","last":"Hoek"},"b":2}`,
 		},
 		{
@@ -230,7 +242,7 @@
 			replace:  removeKeys(timeKey, levelKey),
 			with:     func(h Handler) Handler { return h.With(preAttrs).WithScope("s") },
 			attrs:    attrs,
-			wantText: "msg=message pre=3 x=y s.a=one s.b=2",
+			wantText: "msg=message pre=3 x=y s·a=one s·b=2",
 			wantJSON: `{"msg":"message","pre":3,"x":"y","s":{"a":"one","b":2}}`,
 		},
 		{
@@ -243,7 +255,7 @@
 					WithScope("s2")
 			},
 			attrs:    attrs,
-			wantText: "msg=message p1=1 s1.p2=2 s1.s2.a=one s1.s2.b=2",
+			wantText: "msg=message p1=1 s1·p2=2 s1·s2·a=one s1·s2·b=2",
 			wantJSON: `{"msg":"message","p1":1,"s1":{"p2":2,"s2":{"a":"one","b":2}}}`,
 		},
 		{
@@ -255,7 +267,7 @@
 					WithScope("s2")
 			},
 			attrs:    attrs,
-			wantText: "msg=message p1=1 s1.s2.a=one s1.s2.b=2",
+			wantText: "msg=message p1=1 s1·s2·a=one s1·s2·b=2",
 			wantJSON: `{"msg":"message","p1":1,"s1":{"s2":{"a":"one","b":2}}}`,
 		},
 	} {
diff --git a/slog/text_handler.go b/slog/text_handler.go
index 2268f76..152445a 100644
--- a/slog/text_handler.go
+++ b/slog/text_handler.go
@@ -8,7 +8,6 @@
 	"encoding"
 	"fmt"
 	"io"
-	"strings"
 	"unicode"
 	"unicode/utf8"
 )
@@ -71,6 +70,12 @@
 // Keys and values are quoted if they contain Unicode space characters,
 // non-printing characters, '"' or '='.
 //
+// Keys inside groups and scopes consist of components (keys or scope names)
+// separated by the Unicode middle dot character, '·'. No further escaping is
+// performed. If it is necessary to reconstruct the group structure of a key
+// even in the presence of middle dots inside components, use
+// [HandlerOptions.ReplaceAttr] to escape the keys.
+//
 // Each call to Handle results in a single serialized call to
 // io.Writer.Write.
 func (h *TextHandler) Handle(r Record) error {
@@ -100,13 +105,6 @@
 	return nil
 }
 
-// escapeDots replaces all '.' runes in s with the equivalent hex escape code.
-// This allows the scope/group structure to be retrieved from the dot-separated
-// components of a key.
-func escapeDots(s string) string {
-	return strings.ReplaceAll(s, ".", `\x2E`)
-}
-
 func needsQuoting(s string) bool {
 	for i := 0; i < len(s); {
 		b := s[i]