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  }