reflect/prototype: implement Format methods
Implement Format methods to provide the ability to pretty-print the descriptor
in a humanly readable fashion. While this functionality is not strictly necessary
for the operation of Go protobuf, it is a useful aid for humans.
Change-Id: I88807b38b5be713867f2f2aab5a0843fc147dc35
Reviewed-on: https://go-review.googlesource.com/131255
Reviewed-by: Herbie Ong <herbie@google.com>
diff --git a/reflect/prototype/stringer.go b/reflect/prototype/stringer.go
index 7541b8d..20805fa 100644
--- a/reflect/prototype/stringer.go
+++ b/reflect/prototype/stringer.go
@@ -6,21 +6,253 @@
import (
"fmt"
+ "io"
+ "math/rand"
+ "reflect"
+ "strconv"
+ "strings"
"google.golang.org/proto/internal/pragma"
pref "google.golang.org/proto/reflect/protoreflect"
)
-// TODO: This is useful for print descriptor types in a human readable way.
-// This is not strictly necessary.
-
-// list is an interface that matches any of the list interfaces defined in the
-// protoreflect package.
type list interface {
Len() int
pragma.DoNotImplement
}
-func formatList(s fmt.State, r rune, vs list) {}
+func formatList(s fmt.State, r rune, vs list) {
+ io.WriteString(s, formatListOpt(vs, true, r == 'v' && (s.Flag('+') || s.Flag('#'))))
+}
+func formatListOpt(vs list, isRoot, allowMulti bool) string {
+ start, end := "[", "]"
+ if isRoot {
+ var name string
+ switch vs.(type) {
+ case pref.FieldNumbers:
+ name = "FieldNumbers"
+ case pref.FieldRanges:
+ name = "FieldRanges"
+ case pref.FileImports:
+ name = "FileImports"
+ case pref.Descriptor:
+ name = reflect.ValueOf(vs).MethodByName("Get").Type().Out(0).Name() + "s"
+ }
+ start, end = name+randomSpace()+"{", "}"
+ }
-func formatDesc(s fmt.State, r rune, t pref.Descriptor) {}
+ var ss []string
+ switch vs := vs.(type) {
+ case pref.FieldNumbers:
+ for i := 0; i < vs.Len(); i++ {
+ ss = append(ss, fmt.Sprint(vs.Get(i)))
+ }
+ return start + joinStrings(ss, false) + end
+ case pref.FieldRanges:
+ for i := 0; i < vs.Len(); i++ {
+ r := vs.Get(i)
+ if r[0]+1 == r[1] {
+ ss = append(ss, fmt.Sprintf("%d", r[0]))
+ } else {
+ ss = append(ss, fmt.Sprintf("%d:%d", r[0], r[1]))
+ }
+ }
+ return start + joinStrings(ss, false) + end
+ case pref.FileImports:
+ for i := 0; i < vs.Len(); i++ {
+ var rs records
+ rs.Append(reflect.ValueOf(vs.Get(i)), "Path", "Package", "IsPublic", "IsWeak")
+ ss = append(ss, "{"+rs.Join()+"}")
+ }
+ return start + joinStrings(ss, allowMulti) + end
+ default:
+ _, isEnumValue := vs.(pref.EnumValueDescriptors)
+ for i := 0; i < vs.Len(); i++ {
+ m := reflect.ValueOf(vs).MethodByName("Get")
+ v := m.Call([]reflect.Value{reflect.ValueOf(i)})[0].Interface()
+ ss = append(ss, formatDescOpt(v.(pref.Descriptor), false, allowMulti && !isEnumValue))
+ }
+ return start + joinStrings(ss, allowMulti && isEnumValue) + end
+ }
+}
+
+// descriptorAccessors is a list of accessors to print for each descriptor.
+//
+// Do not print all accessors since some contain redundant information,
+// while others are pointers that we do not want to follow since the descriptor
+// is actually a cyclic graph.
+//
+// Using a list allows us to print the accessors in a sensible order.
+var descriptorAccessors = map[reflect.Type][]string{
+ reflect.TypeOf((*pref.FileDescriptor)(nil)).Elem(): {"Path", "Package", "Imports", "Messages", "Enums", "Extensions", "Services"},
+ reflect.TypeOf((*pref.MessageDescriptor)(nil)).Elem(): {"IsMapEntry", "Fields", "Oneofs", "RequiredNumbers", "ExtensionRanges", "Messages", "Enums", "Extensions"},
+ reflect.TypeOf((*pref.FieldDescriptor)(nil)).Elem(): {"Number", "Cardinality", "Kind", "JSONName", "IsPacked", "IsMap", "IsWeak", "Default", "OneofType", "ExtendedType", "MessageType", "EnumType"},
+ reflect.TypeOf((*pref.OneofDescriptor)(nil)).Elem(): {"Fields"}, // not directly used; must keep in sync with formatDescOpt
+ reflect.TypeOf((*pref.EnumDescriptor)(nil)).Elem(): {"Values"},
+ reflect.TypeOf((*pref.EnumValueDescriptor)(nil)).Elem(): {"Number"},
+ reflect.TypeOf((*pref.ServiceDescriptor)(nil)).Elem(): {"Methods"},
+ reflect.TypeOf((*pref.MethodDescriptor)(nil)).Elem(): {"InputType", "OutputType", "IsStreamingClient", "IsStreamingServer"},
+}
+
+func formatDesc(s fmt.State, r rune, t pref.Descriptor) {
+ io.WriteString(s, formatDescOpt(t, true, r == 'v' && (s.Flag('+') || s.Flag('#'))))
+}
+func formatDescOpt(t pref.Descriptor, isRoot, allowMulti bool) string {
+ rv := reflect.ValueOf(t)
+ rt := rv.MethodByName("ProtoType").Type().In(0)
+
+ start, end := "{", "}"
+ if isRoot {
+ start = rt.Name() + randomSpace() + "{"
+ }
+
+ _, isFile := t.(pref.FileDescriptor)
+ rs := records{allowMulti: allowMulti}
+ if t.IsPlaceholder() {
+ if isFile {
+ rs.Append(rv, "Path", "Package", "IsPlaceholder")
+ } else {
+ rs.Append(rv, "FullName", "IsPlaceholder")
+ }
+ } else {
+ switch {
+ case isFile:
+ rs.Append(rv, "Syntax")
+ case isRoot:
+ rs.Append(rv, "Syntax", "FullName")
+ default:
+ rs.Append(rv, "Name")
+ }
+ if t, ok := t.(pref.OneofDescriptor); ok {
+ var ss []string
+ fs := t.Fields()
+ for i := 0; i < fs.Len(); i++ {
+ ss = append(ss, string(fs.Get(i).Name()))
+ }
+ if len(ss) > 0 {
+ rs.recs = append(rs.recs, [2]string{"Fields", "[" + joinStrings(ss, false) + "]"})
+ }
+ } else {
+ rs.Append(rv, descriptorAccessors[rt]...)
+ }
+ // TODO: Print GoType
+ }
+ return start + rs.Join() + end
+}
+
+type records struct {
+ recs [][2]string
+ allowMulti bool
+}
+
+func (rs *records) Append(v reflect.Value, accessors ...string) {
+ for _, a := range accessors {
+ var rv reflect.Value
+ if m := v.MethodByName(a); m.IsValid() {
+ rv = m.Call(nil)[0]
+ }
+ if v.Kind() == reflect.Struct && !rv.IsValid() {
+ rv = v.FieldByName(a)
+ }
+ if !rv.IsValid() {
+ panic(fmt.Sprintf("unknown accessor: %v.%s", v.Type(), a))
+ }
+ if _, ok := rv.Interface().(pref.Value); ok {
+ rv = rv.MethodByName("Interface").Call(nil)[0]
+ if !rv.IsNil() {
+ rv = rv.Elem()
+ }
+ }
+
+ // Ignore zero values.
+ var isZero bool
+ switch rv.Kind() {
+ case reflect.Interface, reflect.Slice:
+ isZero = rv.IsNil()
+ case reflect.Bool:
+ isZero = rv.Bool() == false
+ case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
+ isZero = rv.Int() == 0
+ case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
+ isZero = rv.Uint() == 0
+ case reflect.String:
+ isZero = rv.String() == ""
+ }
+ if n, ok := rv.Interface().(list); ok {
+ isZero = n.Len() == 0
+ }
+ if isZero {
+ continue
+ }
+
+ // Format the value.
+ var s string
+ v := rv.Interface()
+ switch v := v.(type) {
+ case list:
+ s = formatListOpt(v, false, rs.allowMulti)
+ case pref.FieldDescriptor, pref.OneofDescriptor, pref.EnumValueDescriptor, pref.MethodDescriptor:
+ s = string(v.(pref.Descriptor).Name())
+ case pref.Descriptor:
+ s = string(v.FullName())
+ case string:
+ s = strconv.Quote(v)
+ case []byte:
+ s = fmt.Sprintf("%q", v)
+ default:
+ s = fmt.Sprint(v)
+ }
+ rs.recs = append(rs.recs, [2]string{a, s})
+ }
+}
+
+func (rs *records) Join() string {
+ var ss []string
+
+ // In single line mode, simply join all records with commas.
+ if !rs.allowMulti {
+ for _, r := range rs.recs {
+ ss = append(ss, r[0]+": "+r[1])
+ }
+ return joinStrings(ss, false)
+ }
+
+ // In allowMulti line mode, align single line records for more readable output.
+ var maxLen int
+ flush := func(i int) {
+ for _, r := range rs.recs[len(ss):i] {
+ padding := strings.Repeat(" ", maxLen-len(r[0]))
+ ss = append(ss, r[0]+": "+padding+r[1])
+ }
+ maxLen = 0
+ }
+ for i, r := range rs.recs {
+ if isMulti := strings.Contains(r[1], "\n"); isMulti {
+ flush(i)
+ ss = append(ss, r[0]+": "+strings.Join(strings.Split(r[1], "\n"), "\n\t"))
+ } else if maxLen < len(r[0]) {
+ maxLen = len(r[0])
+ }
+ }
+ flush(len(rs.recs))
+ return joinStrings(ss, true)
+}
+
+func joinStrings(ss []string, isMulti bool) string {
+ if len(ss) == 0 {
+ return ""
+ }
+ if isMulti {
+ return "\n\t" + strings.Join(ss, "\n\t") + "\n"
+ }
+ return strings.Join(ss, ", ")
+}
+
+// randomSpace randomly returns a string that is either empty or a single space.
+// This is done deliberately to ensure that the output is slightly non-stable.
+//
+// These makes it harder for people to depend on the debug string as stable
+// and provides us the flexibility to make changes.
+func randomSpace() string {
+ return " "[:rand.Intn(2)]
+}
diff --git a/reflect/prototype/type_test.go b/reflect/prototype/type_test.go
index 0c1d93f..00404ff 100644
--- a/reflect/prototype/type_test.go
+++ b/reflect/prototype/type_test.go
@@ -5,6 +5,7 @@
package prototype
import (
+ "fmt"
"reflect"
"strconv"
"strings"
@@ -63,6 +64,43 @@
}
}
+// TestDescriptorAccessors tests that descriptorAccessors is up-to-date.
+func TestDescriptorAccessors(t *testing.T) {
+ ignore := map[string]bool{"ProtoType": true, "DescriptorByName": true}
+ rt := reflect.TypeOf((*pref.Descriptor)(nil)).Elem()
+ for i := 0; i < rt.NumMethod(); i++ {
+ ignore[rt.Method(i).Name] = true
+ }
+
+ for rt, m := range descriptorAccessors {
+ got := map[string]bool{}
+ for _, s := range m {
+ got[s] = true
+ }
+ want := map[string]bool{}
+ for i := 0; i < rt.NumMethod(); i++ {
+ want[rt.Method(i).Name] = true
+ }
+
+ // Check if descriptorAccessors contains a non-existent accessor.
+ // If this test fails, remove the accessor from descriptorAccessors.
+ for s := range got {
+ if !want[s] && !ignore[s] {
+ t.Errorf("%v.%v does not exist", rt, s)
+ }
+ }
+
+ // Check if there are new protoreflect interface methods that are not
+ // handled by the formatter. If this fails, either add the method to
+ // ignore or add them to descriptorAccessors.
+ for s := range want {
+ if !got[s] && !ignore[s] {
+ t.Errorf("%v.%v is not called by formatter", rt, s)
+ }
+ }
+ }
+}
+
// TODO: Test NewFileFromDescriptorProto with imported files.
func TestFile(t *testing.T) {
@@ -127,18 +165,18 @@
Name: "field_six", // "test.B.field_six"
Number: 6,
Cardinality: pref.Required,
- Kind: pref.StringKind,
+ Kind: pref.BytesKind,
}},
Oneofs: []Oneof{
{Name: "O1"}, // "test.B.O1"
{Name: "O2"}, // "test.B.O2"
},
- ExtensionRanges: [][2]pref.FieldNumber{{1000, 2000}},
+ ExtensionRanges: [][2]pref.FieldNumber{{1000, 2000}, {3000, 3001}},
}, {
Name: "C", // "test.C"
Messages: []Message{{
Name: "A", // "test.C.A"
- Fields: []Field{{Name: "F", Number: 1, Cardinality: pref.Required, Kind: pref.BytesKind}},
+ Fields: []Field{{Name: "F", Number: 1, Cardinality: pref.Required, Kind: pref.BytesKind, Default: pref.ValueOf([]byte("dead\xbe\xef"))}},
}},
Enums: []Enum{{
Name: "E1", // "test.C.E1"
@@ -244,7 +282,7 @@
Name: protoV1.String("field_six"),
Number: protoV1.Int32(6),
Label: descriptorV1.FieldDescriptorProto_Label(pref.Required).Enum(),
- Type: descriptorV1.FieldDescriptorProto_Type(pref.StringKind).Enum(),
+ Type: descriptorV1.FieldDescriptorProto_Type(pref.BytesKind).Enum(),
}},
OneofDecl: []*descriptorV1.OneofDescriptorProto{
{Name: protoV1.String("O1")},
@@ -252,16 +290,18 @@
},
ExtensionRange: []*descriptorV1.DescriptorProto_ExtensionRange{
{Start: protoV1.Int32(1000), End: protoV1.Int32(2000)},
+ {Start: protoV1.Int32(3000), End: protoV1.Int32(3001)},
},
}, {
Name: protoV1.String("C"),
NestedType: []*descriptorV1.DescriptorProto{{
Name: protoV1.String("A"),
Field: []*descriptorV1.FieldDescriptorProto{{
- Name: protoV1.String("F"),
- Number: protoV1.Int32(1),
- Label: descriptorV1.FieldDescriptorProto_Label(pref.Required).Enum(),
- Type: descriptorV1.FieldDescriptorProto_Type(pref.BytesKind).Enum(),
+ Name: protoV1.String("F"),
+ Number: protoV1.Int32(1),
+ Label: descriptorV1.FieldDescriptorProto_Label(pref.Required).Enum(),
+ Type: descriptorV1.FieldDescriptorProto_Type(pref.BytesKind).Enum(),
+ DefaultValue: protoV1.String(`dead\276\357`),
}},
}},
EnumType: []*descriptorV1.EnumDescriptorProto{{
@@ -320,12 +360,14 @@
{"NewFileFromDescriptorProto", fd2},
}
for _, tt := range tests {
+ tt := tt
t.Run(tt.name, func(t *testing.T) {
- t.Run("Accessors", func(t *testing.T) {
- // Run sub-tests in parallel to induce potential races.
- t.Run("", func(t *testing.T) { t.Parallel(); testFileAccessors(t, tt.desc) })
- t.Run("", func(t *testing.T) { t.Parallel(); testFileAccessors(t, tt.desc) })
- })
+ // Run sub-tests in parallel to induce potential races.
+ for i := 0; i < 2; i++ {
+ t.Run("Accessors", func(t *testing.T) { t.Parallel(); testFileAccessors(t, tt.desc) })
+ t.Run("FormatCompact", func(t *testing.T) { t.Parallel(); testFileFormatCompact(t, tt.desc) })
+ t.Run("FormatMulti", func(t *testing.T) { t.Parallel(); testFileFormatMulti(t, tt.desc) })
+ }
})
}
}
@@ -442,7 +484,7 @@
},
"ByNumber:6": M{
"Cardinality": pref.Required,
- "Default": "",
+ "Default": []byte(nil),
"OneofType": nil,
},
},
@@ -474,13 +516,14 @@
"Has:6": true,
},
"ExtensionRanges": M{
- "Len": 1,
+ "Len": 2,
"Get:0": [2]pref.FieldNumber{1000, 2000},
"Has:999": false,
"Has:1000": true,
"Has:1500": true,
"Has:1999": true,
"Has:2000": false,
+ "Has:3000": true,
},
},
"Get:2": M{
@@ -576,7 +619,6 @@
}
checkAccessors(t, "", reflect.ValueOf(fd), want)
}
-
func checkAccessors(t *testing.T, p string, rv reflect.Value, want map[string]interface{}) {
if rv.Interface() == nil {
t.Errorf("%v is nil, want non-nil", p)
@@ -629,6 +671,159 @@
}
}
+func testFileFormatCompact(t *testing.T, fd pref.FileDescriptor) {
+ const want = `FileDescriptor{Syntax: proto2, Path: "path/to/file.proto", Package: test, Messages: [{Name: A, IsMapEntry: true, Fields: [{Name: key, Number: 1, Cardinality: optional, Kind: string, JSONName: "key"}, {Name: value, Number: 2, Cardinality: optional, Kind: message, JSONName: "value", MessageType: test.B}]}, {Name: B, Fields: [{Name: field_one, Number: 1, Cardinality: optional, Kind: string, JSONName: "fieldOne", Default: "hello", OneofType: O1}, {Name: field_two, Number: 2, Cardinality: optional, Kind: enum, JSONName: "Field2", Default: 1, OneofType: O2, EnumType: test.E1}, {Name: field_three, Number: 3, Cardinality: optional, Kind: message, JSONName: "fieldThree", OneofType: O2, MessageType: test.C}, {Name: field_four, Number: 4, Cardinality: repeated, Kind: message, JSONName: "Field4", IsMap: true, MessageType: test.A}, {Name: field_five, Number: 5, Cardinality: repeated, Kind: int32, JSONName: "fieldFive", IsPacked: true}, {Name: field_six, Number: 6, Cardinality: required, Kind: bytes, JSONName: "fieldSix"}], Oneofs: [{Name: O1, Fields: [field_one]}, {Name: O2, Fields: [field_two, field_three]}], RequiredNumbers: [6], ExtensionRanges: [1000:2000, 3000]}, {Name: C, Messages: [{Name: A, Fields: [{Name: F, Number: 1, Cardinality: required, Kind: bytes, JSONName: "F", Default: "dead\xbe\xef"}], RequiredNumbers: [1]}], Enums: [{Name: E1, Values: [{Name: FOO}, {Name: BAR, Number: 1}]}], Extensions: [{Name: X, Number: 1000, Cardinality: repeated, Kind: message, ExtendedType: test.B, MessageType: test.C}]}], Enums: [{Name: E1, Values: [{Name: FOO}, {Name: BAR, Number: 1}]}], Extensions: [{Name: X, Number: 1000, Cardinality: repeated, Kind: message, IsPacked: true, ExtendedType: test.B, MessageType: test.C}], Services: [{Name: S, Methods: [{Name: M, InputType: test.A, OutputType: test.C.A, IsStreamingClient: true, IsStreamingServer: true}]}]}`
+ got := fmt.Sprintf("%v", fd)
+ got = strings.Replace(got, "FileDescriptor ", "FileDescriptor", 1) // cleanup randomizer
+ if got != want {
+ t.Errorf("fmt.Sprintf(%q, fd):\ngot: %s\nwant: %s", "%v", got, want)
+ }
+}
+
+func testFileFormatMulti(t *testing.T, fd pref.FileDescriptor) {
+ const want = `FileDescriptor{
+ Syntax: proto2
+ Path: "path/to/file.proto"
+ Package: test
+ Messages: [{
+ Name: A
+ IsMapEntry: true
+ Fields: [{
+ Name: key
+ Number: 1
+ Cardinality: optional
+ Kind: string
+ JSONName: "key"
+ }, {
+ Name: value
+ Number: 2
+ Cardinality: optional
+ Kind: message
+ JSONName: "value"
+ MessageType: test.B
+ }]
+ }, {
+ Name: B
+ Fields: [{
+ Name: field_one
+ Number: 1
+ Cardinality: optional
+ Kind: string
+ JSONName: "fieldOne"
+ Default: "hello"
+ OneofType: O1
+ }, {
+ Name: field_two
+ Number: 2
+ Cardinality: optional
+ Kind: enum
+ JSONName: "Field2"
+ Default: 1
+ OneofType: O2
+ EnumType: test.E1
+ }, {
+ Name: field_three
+ Number: 3
+ Cardinality: optional
+ Kind: message
+ JSONName: "fieldThree"
+ OneofType: O2
+ MessageType: test.C
+ }, {
+ Name: field_four
+ Number: 4
+ Cardinality: repeated
+ Kind: message
+ JSONName: "Field4"
+ IsMap: true
+ MessageType: test.A
+ }, {
+ Name: field_five
+ Number: 5
+ Cardinality: repeated
+ Kind: int32
+ JSONName: "fieldFive"
+ IsPacked: true
+ }, {
+ Name: field_six
+ Number: 6
+ Cardinality: required
+ Kind: bytes
+ JSONName: "fieldSix"
+ }]
+ Oneofs: [{
+ Name: O1
+ Fields: [field_one]
+ }, {
+ Name: O2
+ Fields: [field_two, field_three]
+ }]
+ RequiredNumbers: [6]
+ ExtensionRanges: [1000:2000, 3000]
+ }, {
+ Name: C
+ Messages: [{
+ Name: A
+ Fields: [{
+ Name: F
+ Number: 1
+ Cardinality: required
+ Kind: bytes
+ JSONName: "F"
+ Default: "dead\xbe\xef"
+ }]
+ RequiredNumbers: [1]
+ }]
+ Enums: [{
+ Name: E1
+ Values: [
+ {Name: FOO}
+ {Name: BAR, Number: 1}
+ ]
+ }]
+ Extensions: [{
+ Name: X
+ Number: 1000
+ Cardinality: repeated
+ Kind: message
+ ExtendedType: test.B
+ MessageType: test.C
+ }]
+ }]
+ Enums: [{
+ Name: E1
+ Values: [
+ {Name: FOO}
+ {Name: BAR, Number: 1}
+ ]
+ }]
+ Extensions: [{
+ Name: X
+ Number: 1000
+ Cardinality: repeated
+ Kind: message
+ IsPacked: true
+ ExtendedType: test.B
+ MessageType: test.C
+ }]
+ Services: [{
+ Name: S
+ Methods: [{
+ Name: M
+ InputType: test.A
+ OutputType: test.C.A
+ IsStreamingClient: true
+ IsStreamingServer: true
+ }]
+ }]
+}`
+ got := fmt.Sprintf("%+v", fd)
+ got = strings.Replace(got, "FileDescriptor ", "FileDescriptor", 1) // cleanup randomizer
+ if got != want {
+ t.Errorf("fmt.Sprintf(%q, fd):\ngot: %s\nwant: %s", "%+v", got, want)
+ }
+}
+
func TestResolve(t *testing.T) {
f := &File{
Syntax: pref.Proto2,