go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/common/proto/textpb/textpb.go (about) 1 // Copyright 2021 The LUCI Authors. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 // Package textpb can reformat text protos to be prettier. 16 // 17 // It is needed because "google.golang.org/protobuf/encoding/prototext" 18 // intentionally produces unstable output (inserting spaces in random places) 19 // and it very zealously escapes JSON-valued fields making them unreadable. 20 package textpb 21 22 import ( 23 "bytes" 24 "encoding/json" 25 "fmt" 26 "strconv" 27 "strings" 28 29 "google.golang.org/protobuf/encoding/prototext" 30 "google.golang.org/protobuf/proto" 31 "google.golang.org/protobuf/reflect/protoreflect" 32 "google.golang.org/protobuf/types/descriptorpb" 33 34 "github.com/protocolbuffers/txtpbfmt/ast" 35 "github.com/protocolbuffers/txtpbfmt/parser" 36 "github.com/protocolbuffers/txtpbfmt/unquote" 37 38 "go.chromium.org/luci/common/errors" 39 luciproto "go.chromium.org/luci/common/proto" 40 ) 41 42 // Format reformats a text proto of the given type to be prettier. 43 // 44 // Normalizes whitespaces and converts JSON-valued fields to be multi-line 45 // string literals instead of one giant string with "\n" inside. A string field 46 // can be annotated as containing JSON via field options: 47 // 48 // import "go.chromium.org/luci/common/proto/options.proto"; 49 // 50 // message MyMessage { 51 // string my_field = 1 [(luci.text_pb_format) = JSON]; 52 // } 53 func Format(blob []byte, desc protoreflect.MessageDescriptor) ([]byte, error) { 54 nodes, err := parser.ParseWithConfig(blob, parser.Config{ 55 SkipAllColons: true, 56 }) 57 if err != nil { 58 return nil, err 59 } 60 if err := transformTextPBAst(nodes, desc); err != nil { 61 return nil, err 62 } 63 return []byte(parser.Pretty(nodes, 0)), nil 64 } 65 66 var marshalOpts = prototext.MarshalOptions{AllowPartial: true, Indent: " "} 67 68 // Marshal marshals the message into a pretty textproto. 69 // 70 // Uses the global protoregistry.GlobalTypes resolved. If you need to use 71 // a custom one, use "google.golang.org/protobuf/encoding/prototext" to marshal 72 // the message into a non-pretty form and the pass the result through Format. 73 func Marshal(m proto.Message) ([]byte, error) { 74 blob, err := marshalOpts.Marshal(m) 75 if err != nil { 76 return nil, err 77 } 78 return Format(blob, m.ProtoReflect().Descriptor()) 79 } 80 81 func transformTextPBAst(nodes []*ast.Node, desc protoreflect.MessageDescriptor) error { 82 for _, node := range nodes { 83 if err := transformTextPBNode(node, desc, ""); err != nil { 84 return err 85 } 86 } 87 return nil 88 } 89 90 func transformTextPBNode(node *ast.Node, desc protoreflect.MessageDescriptor, parentName string) error { 91 fDesc := desc.Fields().ByName(protoreflect.Name(node.Name)) 92 if fDesc == nil { 93 return errors.Reason("could not find field %q", node.Name).Err() 94 } 95 var format luciproto.TextPBFieldFormat 96 if opts, ok := fDesc.Options().(*descriptorpb.FieldOptions); ok { 97 format = proto.GetExtension(opts, luciproto.E_TextPbFormat).(luciproto.TextPBFieldFormat) 98 } 99 switch format { 100 case luciproto.TextPBFieldFormat_JSON: 101 if err := jsonTransformTextPBNode(node, parentName); err != nil { 102 return err 103 } 104 } 105 for _, child := range node.Children { 106 if err := transformTextPBNode(child, fDesc.Message(), name(parentName, node)); err != nil { 107 return err 108 } 109 } 110 return nil 111 } 112 113 var quoteSwapper = strings.NewReplacer("'", "\"", "\"", "'") 114 115 func jsonTransformTextPBNode(node *ast.Node, parentName string) error { 116 for _, value := range node.Values { 117 if !isString(value.Value) { 118 return nil 119 } 120 } 121 s, err := unquote.Unquote(node) 122 if err != nil { 123 return errors.Annotate(err, "internal error: could not parse value for '%s' as string", name(parentName, node)).Err() 124 } 125 buf := &bytes.Buffer{} 126 if err := json.Indent(buf, []byte(s), "", " "); err != nil { 127 return errors.Annotate(err, "value for '%s' must be valid JSON, got value '%s'", name(parentName, node), s).Err() 128 } 129 lines := strings.Split(buf.String(), "\n") 130 values := make([]*ast.Value, 0, len(lines)) 131 for _, line := range lines { 132 // Using single quotes for each string reduces the line noise by 133 // preventing the double quotes (of which there are many) from 134 // having to be escaped. There isn't a function to get a 135 // single-quoted string, so swap the single and double quotes, 136 // quote the string and then swap the quotes back 137 line = quoteSwapper.Replace(strconv.Quote(quoteSwapper.Replace(line))) 138 values = append(values, &ast.Value{Value: line}) 139 } 140 node.Values = values 141 return nil 142 } 143 144 func isString(s string) bool { 145 return len(s) >= 2 && 146 (s[0] == '"' || s[0] == '\'') && 147 s[len(s)-1] == s[0] 148 } 149 150 func name(parentName string, node *ast.Node) string { 151 if parentName == "" { 152 return node.Name 153 } 154 return fmt.Sprintf("%s.%s", parentName, node.Name) 155 }