blob: 443c57854832fbfeef3c02d9e5b3a5d89d96b2c8 [file] [log] [blame]
// Copyright 2021 The LUCI Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package textpb can reformat text protos to be prettier.
//
// It is needed because "google.golang.org/protobuf/encoding/prototext"
// intentionally produces unstable output (inserting spaces in random places)
// and it very zealously escapes JSON-valued fields making them unreadable.
package textpb
import (
"bytes"
"encoding/json"
"fmt"
"strconv"
"strings"
"google.golang.org/protobuf/encoding/prototext"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protoreflect"
"google.golang.org/protobuf/types/descriptorpb"
"github.com/protocolbuffers/txtpbfmt/ast"
"github.com/protocolbuffers/txtpbfmt/parser"
"github.com/protocolbuffers/txtpbfmt/unquote"
"go.chromium.org/luci/common/errors"
luciproto "go.chromium.org/luci/common/proto"
)
// Format reformats a text proto of the given type to be prettier.
//
// Normalizes whitespaces and converts JSON-valued fields to be multi-line
// string literals instead of one giant string with "\n" inside. A string field
// can be annotated as containing JSON via field options:
//
// import "go.chromium.org/luci/common/proto/options.proto";
//
// message MyMessage {
// string my_field = 1 [(luci.text_pb_format) = JSON];
// }
func Format(blob []byte, desc protoreflect.MessageDescriptor) ([]byte, error) {
nodes, err := parser.ParseWithConfig(blob, parser.Config{
SkipAllColons: true,
})
if err != nil {
return nil, err
}
if err := transformTextPBAst(nodes, desc); err != nil {
return nil, err
}
return []byte(parser.Pretty(nodes, 0)), nil
}
var marshalOpts = prototext.MarshalOptions{AllowPartial: true, Indent: " "}
// Marshal marshals the message into a pretty textproto.
//
// Uses the global protoregistry.GlobalTypes resolved. If you need to use
// a custom one, use "google.golang.org/protobuf/encoding/prototext" to marshal
// the message into a non-pretty form and the pass the result through Format.
func Marshal(m proto.Message) ([]byte, error) {
blob, err := marshalOpts.Marshal(m)
if err != nil {
return nil, err
}
return Format(blob, m.ProtoReflect().Descriptor())
}
func transformTextPBAst(nodes []*ast.Node, desc protoreflect.MessageDescriptor) error {
for _, node := range nodes {
if err := transformTextPBNode(node, desc, ""); err != nil {
return err
}
}
return nil
}
func transformTextPBNode(node *ast.Node, desc protoreflect.MessageDescriptor, parentName string) error {
fDesc := desc.Fields().ByName(protoreflect.Name(node.Name))
if fDesc == nil {
return errors.Reason("could not find field %q", node.Name).Err()
}
var format luciproto.TextPBFieldFormat
if opts, ok := fDesc.Options().(*descriptorpb.FieldOptions); ok {
format = proto.GetExtension(opts, luciproto.E_TextPbFormat).(luciproto.TextPBFieldFormat)
}
switch format {
case luciproto.TextPBFieldFormat_JSON:
if err := jsonTransformTextPBNode(node, parentName); err != nil {
return err
}
}
for _, child := range node.Children {
if err := transformTextPBNode(child, fDesc.Message(), name(parentName, node)); err != nil {
return err
}
}
return nil
}
var quoteSwapper = strings.NewReplacer("'", "\"", "\"", "'")
func jsonTransformTextPBNode(node *ast.Node, parentName string) error {
for _, value := range node.Values {
if !isString(value.Value) {
return nil
}
}
s, err := unquote.Unquote(node)
if err != nil {
return errors.Annotate(err, "internal error: could not parse value for '%s' as string", name(parentName, node)).Err()
}
buf := &bytes.Buffer{}
if err := json.Indent(buf, []byte(s), "", " "); err != nil {
return errors.Annotate(err, "value for '%s' must be valid JSON, got value '%s'", name(parentName, node), s).Err()
}
lines := strings.Split(buf.String(), "\n")
values := make([]*ast.Value, 0, len(lines))
for _, line := range lines {
// Using single quotes for each string reduces the line noise by
// preventing the double quotes (of which there are many) from
// having to be escaped. There isn't a function to get a
// single-quoted string, so swap the single and double quotes,
// quote the string and then swap the quotes back
line = quoteSwapper.Replace(strconv.Quote(quoteSwapper.Replace(line)))
values = append(values, &ast.Value{Value: line})
}
node.Values = values
return nil
}
func isString(s string) bool {
return len(s) >= 2 &&
(s[0] == '"' || s[0] == '\'') &&
s[len(s)-1] == s[0]
}
func name(parentName string, node *ast.Node) string {
if parentName == "" {
return node.Name
}
return fmt.Sprintf("%s.%s", parentName, node.Name)
}