encoding/protobuf/jsonpb: add encoder

Issue #606

Change-Id: I3bf6cbc1ecd5e83c94b5a7d97c845a35f4b84878
Reviewed-on: https://cue-review.googlesource.com/c/cue/+/9371
Reviewed-by: CUE cueckoo <cueckoo@gmail.com>
Reviewed-by: Marcel van Lohuizen <mpvl@golang.org>
diff --git a/encoding/protobuf/jsonpb/decoder_test.go b/encoding/protobuf/jsonpb/decoder_test.go
index 779c5ef..45326e0 100644
--- a/encoding/protobuf/jsonpb/decoder_test.go
+++ b/encoding/protobuf/jsonpb/decoder_test.go
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package jsonpb
+package jsonpb_test
 
 import (
 	"strings"
@@ -25,6 +25,7 @@
 	"cuelang.org/go/cue/format"
 	"cuelang.org/go/cue/parser"
 	"cuelang.org/go/encoding/json"
+	"cuelang.org/go/encoding/protobuf/jsonpb"
 	"cuelang.org/go/encoding/yaml"
 	"cuelang.org/go/internal/cuetest"
 	"cuelang.org/go/internal/cuetxtar"
@@ -85,7 +86,7 @@
 			}
 
 			w := t.Writer(f.Name)
-			err := NewDecoder(schema).RewriteFile(file)
+			err := jsonpb.NewDecoder(schema).RewriteFile(file)
 			if err != nil {
 				errors.Print(w, err, nil)
 				continue
@@ -121,7 +122,7 @@
 		t.Fatal(err)
 	}
 
-	if err := NewDecoder(inst.Value()).RewriteFile(file); err != nil {
+	if err := jsonpb.NewDecoder(inst.Value()).RewriteFile(file); err != nil {
 		t.Fatal(err)
 	}
 
diff --git a/encoding/protobuf/jsonpb/encoder.go b/encoding/protobuf/jsonpb/encoder.go
new file mode 100644
index 0000000..462e9bb
--- /dev/null
+++ b/encoding/protobuf/jsonpb/encoder.go
@@ -0,0 +1,168 @@
+// Copyright 2021 CUE 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 jsonpb
+
+import (
+	"strconv"
+
+	"cuelang.org/go/cue"
+	"cuelang.org/go/cue/ast"
+	"cuelang.org/go/cue/errors"
+	"cuelang.org/go/cue/literal"
+	"cuelang.org/go/cue/token"
+	"cuelang.org/go/encoding/protobuf/pbinternal"
+)
+
+// TODO: Options:
+// - Convert integer strings.
+// - URL encoder
+// - URL decoder
+
+// An Encoder rewrites CUE values according to the Protobuf to JSON mappings,
+// based on a given CUE schema.
+//
+// It bases the mapping on the underlying CUE type, without consulting Protobuf
+// attributes.
+//
+// Mappings per CUE type:
+//  for any CUE type:
+//  int:       if the expression value is an integer and the schema value is
+//             an int64, it is converted to a string.
+//  {}:        JSON objects representing any values will be left as is.
+//             If the CUE type corresponding to the URL can be determined within
+//             the module context it will be unified.
+//  _:         Adds a `@type` URL (TODO).
+//
+type Encoder struct {
+	schema cue.Value
+}
+
+// NewEncoder creates an Encoder for the given schema.
+func NewEncoder(schema cue.Value, options ...Option) *Encoder {
+	return &Encoder{schema: schema}
+}
+
+// RewriteFile modifies file, modifying it to conform to the Protocol buffer
+// to JSON mapping it in terms of the given schema.
+//
+// RewriteFile is idempotent, calling it multiples times on an expression gives
+// the same result.
+func (e *Encoder) RewriteFile(file *ast.File) error {
+	var enc encoder
+	enc.rewriteDecls(e.schema, file.Decls)
+	return enc.errs
+}
+
+// RewriteExpr modifies file, modifying it to conform to the Protocol buffer
+// to JSON mapping it in terms of the given schema.
+//
+// RewriteExpr is idempotent, calling it multiples times on an expression gives
+// the same result.
+func (e *Encoder) RewriteExpr(expr ast.Expr) (ast.Expr, error) {
+	var enc encoder
+	x := enc.rewrite(e.schema, expr)
+	return x, enc.errs
+}
+
+type encoder struct {
+	errs errors.Error
+}
+
+func (e *encoder) addErr(err errors.Error) {
+	e.errs = errors.Append(e.errs, err)
+}
+
+func (e *encoder) addErrf(p token.Pos, schema cue.Value, format string, args ...interface{}) {
+	format = "%s: " + format
+	args = append([]interface{}{schema.Path()}, args...)
+	e.addErr(errors.Newf(p, format, args...))
+}
+
+func (e *encoder) rewriteDecls(schema cue.Value, decls []ast.Decl) {
+	for _, f := range decls {
+		field, ok := f.(*ast.Field)
+		if !ok {
+			continue
+		}
+		sel := cue.Label(field.Label)
+		if !sel.IsString() {
+			continue
+		}
+
+		v := schema.LookupPath(cue.MakePath(sel.Optional()))
+		if !v.Exists() {
+			continue
+		}
+
+		field.Value = e.rewrite(v, field.Value)
+	}
+}
+
+func (e *encoder) rewrite(schema cue.Value, expr ast.Expr) (x ast.Expr) {
+	switch x := expr.(type) {
+	case *ast.ListLit:
+		for i, elem := range x.Elts {
+			v := schema.LookupPath(cue.MakePath(cue.Index(i).Optional()))
+			if !v.Exists() {
+				break
+			}
+			x.Elts[i] = e.rewrite(v, elem)
+		}
+		return expr
+
+	case *ast.StructLit:
+		e.rewriteDecls(schema, x.Elts)
+		return expr
+
+	case *ast.BasicLit:
+		if x.Kind != token.INT {
+			break
+		}
+
+		info, err := pbinternal.FromValue("", schema)
+		if err != nil {
+			break
+		}
+
+		switch info.Type {
+		case "int64", "fixed64", "sfixed64", "uint64":
+			b, ok := expr.(*ast.BasicLit)
+			if schema.IncompleteKind() == cue.IntKind && ok && b.Kind == token.INT {
+				b.Kind = token.STRING
+				b.Value = literal.String.Quote(b.Value)
+			}
+
+		case "int32", "fixed32", "sfixed32", "uint32", "float", "double":
+		case "varint":
+
+		default:
+			if !info.IsEnum {
+				break
+			}
+
+			i, err := strconv.ParseInt(x.Value, 10, 32)
+			if err != nil {
+				break
+			}
+
+			if s := pbinternal.MatchByInt(schema, i); s != "" {
+				x.Kind = token.STRING
+				x.Value = literal.String.Quote(s)
+			}
+		}
+	}
+
+	return expr
+}
diff --git a/encoding/protobuf/jsonpb/encoder_test.go b/encoding/protobuf/jsonpb/encoder_test.go
new file mode 100644
index 0000000..440ce50
--- /dev/null
+++ b/encoding/protobuf/jsonpb/encoder_test.go
@@ -0,0 +1,84 @@
+// Copyright 2021 CUE 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 jsonpb_test
+
+import (
+	"testing"
+
+	"cuelang.org/go/cue"
+	"cuelang.org/go/cue/ast"
+	"cuelang.org/go/cue/errors"
+	"cuelang.org/go/cue/format"
+	"cuelang.org/go/cue/parser"
+	"cuelang.org/go/encoding/protobuf/jsonpb"
+	"cuelang.org/go/internal/cuetest"
+	"cuelang.org/go/internal/cuetxtar"
+)
+
+func TestEncoder(t *testing.T) {
+	test := cuetxtar.TxTarTest{
+		Root:   "./testdata/encoder",
+		Name:   "jsonpb",
+		Update: cuetest.UpdateGoldenFiles,
+	}
+
+	r := cue.Runtime{}
+
+	test.Run(t, func(t *cuetxtar.Test) {
+		// TODO: use high-level API.
+
+		var schema cue.Value
+		var file *ast.File
+
+		for _, f := range t.Archive.Files {
+			switch {
+			case f.Name == "schema.cue":
+				inst, err := r.Compile(f.Name, f.Data)
+				if err != nil {
+					t.WriteErrors(errors.Promote(err, "test"))
+					return
+				}
+				schema = inst.Value()
+
+			case f.Name == "value.cue":
+				f, err := parser.ParseFile(f.Name, f.Data, parser.ParseComments)
+				if err != nil {
+					t.WriteErrors(errors.Promote(err, "test"))
+					return
+				}
+				file = f
+			}
+		}
+
+		if !schema.Exists() {
+			inst, err := r.CompileFile(file)
+			if err != nil {
+				t.WriteErrors(errors.Promote(err, "test"))
+			}
+			schema = inst.Value()
+		}
+
+		err := jsonpb.NewEncoder(schema).RewriteFile(file)
+		if err != nil {
+			errors.Print(t, err, nil)
+		}
+
+		b, err := format.Node(file)
+		if err != nil {
+			t.Fatal(err)
+		}
+		_, _ = t.Write(b)
+	})
+}
diff --git a/encoding/protobuf/jsonpb/jsonpb.go b/encoding/protobuf/jsonpb/jsonpb.go
index df07a38..74f8424 100644
--- a/encoding/protobuf/jsonpb/jsonpb.go
+++ b/encoding/protobuf/jsonpb/jsonpb.go
@@ -14,4 +14,6 @@
 
 // Package jsonpb rewrites a CUE expression based upon the Protobuf
 // interpretation of JSON.
+//
+// API Status: DRAFT: API may change without notice.
 package jsonpb
diff --git a/encoding/protobuf/jsonpb/testdata/encoder/enums.txtar b/encoding/protobuf/jsonpb/testdata/encoder/enums.txtar
new file mode 100644
index 0000000..427a3dd
--- /dev/null
+++ b/encoding/protobuf/jsonpb/testdata/encoder/enums.txtar
@@ -0,0 +1,68 @@
+-- schema.cue --
+enum: [string]:
+    { "foo", #enumValue: 1 } |
+    { "bar", #enumValue: 2 } @protobuf(1,Enum)
+
+defEnum: [string]: #foo | #bar @protobuf(2,Enum)
+
+#foo: 1
+#bar: 2
+
+typeEnum: [string]: #Enum @protobuf(3,Enum)
+
+#Enum: #foo | #bar
+
+// TODO: consider supporting @symbol(foo) or @json(,symbol=foo)
+// symbolEnum: [string]:
+//     { 1, @symbol(foo) } |
+//     { 2, @symbol(bar) }
+
+
+singleEnum: #single @protobuf(3,Enum)
+
+#single: 1
+
+badEnum: { string, #enumValue: 1 } | { "two", #enumValue: 2 }
+
+
+-- value.cue --
+enum: asIs: "foo"
+enum: asIsUnknown: "foobar"
+
+// Convert integers to strings
+defEnum: foo: 1
+defEnum: bar: 2
+defEnum: baz: 3
+
+
+typeEnum: foo: 1
+typeEnum: bar: 2
+typeEnum: baz: 3
+
+
+// TODO: consider supporting @symbol(foo) or @json(,symbol=foo)
+// symbolEnum: foo: "foo"
+// symbolEnum: bar: "bar"
+// symbolEnum: baz: "baz"
+
+singleEnum: 1
+
+-- out/jsonpb --
+enum: asIs:        "foo"
+enum: asIsUnknown: "foobar"
+
+// Convert integers to strings
+defEnum: foo: "foo"
+defEnum: bar: "bar"
+defEnum: baz: 3
+
+typeEnum: foo: "foo"
+typeEnum: bar: "bar"
+typeEnum: baz: 3
+
+// TODO: consider supporting @symbol(foo) or @json(,symbol=foo)
+// symbolEnum: foo: "foo"
+// symbolEnum: bar: "bar"
+// symbolEnum: baz: "baz"
+
+singleEnum: 1
diff --git a/encoding/protobuf/jsonpb/testdata/encoder/list.txtar b/encoding/protobuf/jsonpb/testdata/encoder/list.txtar
new file mode 100644
index 0000000..403fcd6
--- /dev/null
+++ b/encoding/protobuf/jsonpb/testdata/encoder/list.txtar
@@ -0,0 +1,27 @@
+-- schema.cue --
+a: [...#D]
+
+#D: {
+    a: int @protobuf(1,int64)
+}
+
+b: [1, ...] // Don't include schema fields if not in value
+
+c: [{a: 1}, ...]
+c: [...#D]
+
+-- value.cue --
+// Hello
+a: [
+    {a: 1},
+]
+
+c: [{a: 1}, {a: 2}]
+
+-- out/jsonpb --
+// Hello
+a: [
+	{a: "1"},
+]
+
+c: [{a: "1"}, {a: "2"}]
diff --git a/encoding/protobuf/jsonpb/testdata/encoder/simple.txtar b/encoding/protobuf/jsonpb/testdata/encoder/simple.txtar
new file mode 100644
index 0000000..6f608ae
--- /dev/null
+++ b/encoding/protobuf/jsonpb/testdata/encoder/simple.txtar
@@ -0,0 +1,27 @@
+-- value.cue --
+a: 1 @protobuf(1, int64)
+b: 2 @protobuf(1, int32)
+c: 3.4 @protobuf(1, int64)
+
+d: "foo\u1234"
+e: '\000'
+
+f: false
+// Doc comment
+t: true
+
+notConcrete: string
+
+-- out/jsonpb --
+a: "1" @protobuf(1, int64)
+b: 2   @protobuf(1, int32)
+c: 3.4 @protobuf(1, int64)
+
+d: "foo\u1234"
+e: '\000'
+
+f: false
+// Doc comment
+t: true
+
+notConcrete: string
diff --git a/encoding/protobuf/jsonpb/testdata/encoder/struct.txtar b/encoding/protobuf/jsonpb/testdata/encoder/struct.txtar
new file mode 100644
index 0000000..4aa6d4b
--- /dev/null
+++ b/encoding/protobuf/jsonpb/testdata/encoder/struct.txtar
@@ -0,0 +1,28 @@
+-- schema.cue --
+a: {
+    {b: int @protobuf(1,int64)}
+
+    c: int @protobuf(1,int64)
+
+    {d: int @protobuf(1,int32)}
+
+    e: int @protobuf(1,int32)
+
+}
+-- value.cue --
+// Hello
+a: {
+    b: 1
+    c: 2
+    d: 3
+    e: 4
+}
+
+-- out/jsonpb --
+// Hello
+a: {
+	b: "1"
+	c: "2"
+	d: 3
+	e: 4
+}