diff --git a/.github/.release-please-config.json b/.github/.release-please-config.json index b253e91..d9573a8 100644 --- a/.github/.release-please-config.json +++ b/.github/.release-please-config.json @@ -27,6 +27,9 @@ }, "cmd/protoc-gen-elixir-grpc": { "component": "protoc-gen-elixir-grpc" + }, + "cmd/protoc-gen-pony": { + "component": "protoc-gen-pony" } }, "plugins": [ diff --git a/.github/.release-please-manifest.json b/.github/.release-please-manifest.json index 1adb373..ec07d14 100644 --- a/.github/.release-please-manifest.json +++ b/.github/.release-please-manifest.json @@ -1,4 +1,5 @@ { "cmd/protoc-gen-connect-go-servicestruct": "0.2.0", - "cmd/protoc-gen-elixir-grpc": "0.4.2" + "cmd/protoc-gen-elixir-grpc": "0.4.2", + "cmd/protoc-gen-pony": "0.0.1" } diff --git a/.github/goreleaser.yml b/.github/goreleaser.yml index bc17e7b..c6f366a 100644 --- a/.github/goreleaser.yml +++ b/.github/goreleaser.yml @@ -44,6 +44,25 @@ builds: - -X main.commit={{.Commit}} - -X main.date={{.Date}} + - id: protoc-gen-pony + main: ./cmd/protoc-gen-pony + binary: protoc-gen-pony + skip: '{{ ne .Env.BUILD_COMPONENT "protoc-gen-pony" }}' + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + ldflags: + - -s -w + - -X main.version={{.Version}} + - -X main.commit={{.Commit}} + - -X main.date={{.Date}} + archives: - id: default name_template: >- diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 461605e..c901dbc 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -76,12 +76,12 @@ jobs: # Validate component is known case "$COMPONENT" in - protoc-gen-elixir-grpc|protoc-gen-connect-go-servicestruct) + protoc-gen-elixir-grpc|protoc-gen-connect-go-servicestruct|protoc-gen-pony) echo "Valid component: ${COMPONENT}" ;; *) echo "ERROR: Unknown component: ${COMPONENT}" - echo "Valid components: protoc-gen-elixir-grpc, protoc-gen-connect-go-servicestruct" + echo "Valid components: protoc-gen-elixir-grpc, protoc-gen-connect-go-servicestruct, protoc-gen-pony" exit 1 ;; esac diff --git a/Taskfile.yml b/Taskfile.yml index ab01dc7..19c6a13 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -60,10 +60,7 @@ tasks: build-plugin: desc: Build all protoc plugin binaries - cmds: - - echo "Building protoc plugin binaries..." - - go build -o protoc-gen-connect-go-servicestruct ./cmd/protoc-gen-connect-go-servicestruct - - go build -o protoc-gen-elixir-grpc ./cmd/protoc-gen-elixir-grpc + deps: [build-plugin-go, build-plugin-elixir, build-plugin-pony] build-plugin-go: desc: Build the Go Connect protoc plugin binary @@ -77,6 +74,12 @@ tasks: - echo "Building Elixir gRPC protoc plugin binary..." - go build -o protoc-gen-elixir-grpc ./cmd/protoc-gen-elixir-grpc + build-plugin-pony: + desc: Build the Pony protoc plugin binary + cmds: + - echo "Building Pony protoc plugin binary..." + - go build -o protoc-gen-pony ./cmd/protoc-gen-pony + clean: desc: Clean build artifacts and coverage files cmds: diff --git a/cmd/protoc-gen-pony/CHANGELOG.md b/cmd/protoc-gen-pony/CHANGELOG.md new file mode 100644 index 0000000..cd93888 --- /dev/null +++ b/cmd/protoc-gen-pony/CHANGELOG.md @@ -0,0 +1,22 @@ +# Changelog + +## Unreleased + +### Features + +- All proto3 scalar types (bool, int32/64, uint32/64, sint32/64, fixed32/64, sfixed32/64, float, double, string, bytes) +- Enums: primitive per value, type alias union, `FromValue` dispatcher, `Raw` class for unknown values +- Singular and repeated embedded messages; sub-codec decode/encode +- Repeated scalar and enum fields (packed wire format) +- proto3 `optional` explicit presence (`(T | None)` type, match-on-None encode) +- Real `oneof` fields: wrapper class per member, union type alias, full decode/encode +- `map` fields: scalar, enum, and message values; `use "collections"` auto-emitted +- Cross-directory `use` directives (relative paths, deduplicated per directory) +- Well-known types (`google/protobuf/timestamp.proto`, `duration.proto`, `any.proto`, `wrappers.proto`, `field_mask.proto`, `empty.proto`, etc.) generate as regular proto3 messages +- Generated file header includes minimum required `protobuf-pony` runtime version + +### Known limitations + +- `google/protobuf/struct.proto`, `type.proto`, `api.proto`, `descriptor.proto` emit `TODO` comments — circular or JSON-only semantics +- JSON-specific WKT encoding (Timestamp as RFC 3339, etc.) is out of scope +- Services (gRPC stubs) are not generated diff --git a/cmd/protoc-gen-pony/README.md b/cmd/protoc-gen-pony/README.md new file mode 100644 index 0000000..239ec6a --- /dev/null +++ b/cmd/protoc-gen-pony/README.md @@ -0,0 +1,79 @@ +# protoc-gen-pony + +A Protobuf compiler plugin that generates Pony source code — `class val` +records plus sister `Codec` primitives that decode and encode against the +[`protobuf` Pony runtime library][runtime]. + +## Install + +```bash +go install github.com/TrogonStack/protoc-gen/cmd/protoc-gen-pony@latest +``` + +## Usage + +With `protoc`: + +```bash +protoc --pony_out=gen path/to/file.proto +``` + +With [buf]: + +```yaml +# buf.gen.yaml +version: v2 +plugins: + - local: protoc-gen-pony + out: gen +``` + +## Output + +For a `User` message in `acme/v1/user.proto`: + +```protobuf +syntax = "proto3"; +package acme.v1; + +message User { + int32 id = 1; + string name = 2; + bool active = 3; +} +``` + +The plugin writes `gen/acme/v1/user.pony` with a `class val User` record and a +`primitive UserCodec` exposing `decode(reader: WireReader ref): (User val | +WireError)` and `encode(writer: WireWriter ref, msg: User val)`. + +## Runtime requirement + +Generated code calls into the Pony `protobuf` package — `WireReader`, +`WireWriter`, `Tag`, `Scalar`, `WireType`, `WireError`. See [the runtime +sources][runtime]. + +## Coverage + +Supported (no `TODO` comments emitted): + +- All proto3 scalar types — bool, int32/64, uint32/64, sint32/64, + fixed32/64, sfixed32/64, float, double, string, bytes +- Enums (primitives + type alias + `FromValue` dispatcher + `Raw` fallback) +- Singular and repeated embedded messages +- proto3 `optional` explicit presence (`(T | None)` type) +- Real `oneof` fields (wrapper class per member, union type alias) +- `map` where V is a scalar, enum, or non-blocked message +- Cross-directory `use` directives (relative path, auto-deduped) +- Well-known types: Timestamp, Duration, Any, FieldMask, wrappers, Empty, etc. + generate as regular proto3 messages with no special treatment + +**Known limitations:** + +- `google/protobuf/struct.proto`, `type.proto`, `api.proto`, `descriptor.proto` + stay as `TODO` — circular or JSON-only types not representable in plain proto3 +- JSON-specific WKT encoding (Timestamp as RFC 3339, etc.) is out of scope +- Services (gRPC stubs) are not generated + +[buf]: https://buf.build +[runtime]: https://github.com/TrogonStack/protobuf-pony diff --git a/cmd/protoc-gen-pony/generate.go b/cmd/protoc-gen-pony/generate.go new file mode 100644 index 0000000..614fab7 --- /dev/null +++ b/cmd/protoc-gen-pony/generate.go @@ -0,0 +1,1275 @@ +package main + +import ( + "fmt" + "path" + "sort" + "strings" + + "google.golang.org/protobuf/compiler/protogen" + "google.golang.org/protobuf/reflect/protoreflect" +) + +// genCtx carries the plugin and file context through all emit functions so +// that same-file message/enum name resolution is available everywhere. +type genCtx struct { + plugin *protogen.Plugin + file *protogen.File + g *protogen.GeneratedFile +} + +// generateFile emits one `.pony` source per `.proto` input. Pony has no +// notion of a `package` keyword inside the file (packages are directories), +// so we just stream each top-level message in the file. Nested messages +// flatten with `_` (Outer_Inner) — see collectAndEmitMessages. +// +// Output path mirrors the proto file's path layout (e.g. +// `acme/users/v1/user.proto` → `acme/users/v1/user.pony`). We compute it +// from file.Desc.Path() directly because protogen's GeneratedFilenamePrefix +// is prefixed with the Go import path, which is irrelevant for Pony output. +func generateFile(plugin *protogen.Plugin, file *protogen.File) { + if len(file.Messages) == 0 && len(file.Enums) == 0 && len(file.Services) == 0 { + return + } + outPath := strings.TrimSuffix(file.Desc.Path(), ".proto") + ".pony" + g := plugin.NewGeneratedFile(outPath, protogen.GoImportPath("")) + + // Use `//` not `"""` — Pony allows only one package docstring per + // directory, and the runtime's protobuf.pony already owns it. + g.P(`// Generated by protoc-gen-pony. DO NOT EDIT.`) + g.P(`// Source: `, file.Desc.Path()) + g.P(`// Requires protobuf-pony runtime >= 0.1.0`) + g.P() + + ctx := &genCtx{plugin: plugin, file: file, g: g} + + useDirectives := ctx.crossDirUseDirectives() + if ctx.fileHasMaps() { + useDirectives = append(useDirectives, "collections") + sort.Strings(useDirectives) + } + if len(useDirectives) > 0 { + for _, u := range useDirectives { + g.P(`use "`, u, `"`) + } + g.P() + } + + ctx.collectAndEmitMessages(file.Messages, "") + for _, enum := range file.Enums { + ctx.emitEnum(enum, "") + } + for _, svc := range file.Services { + g.P(`// TODO protoc-gen-pony: service `, svc.Desc.Name(), ` (service)`) + } +} + +// collectAndEmitMessages walks a slice of messages depth-first, flattening +// nested types into their parent's namespace via `Outer_Inner` mangling. +// Messages are emitted in source order — protogen already gives us a +// deterministic walk, so we don't need to sort. +func (ctx *genCtx) collectAndEmitMessages(messages []*protogen.Message, namePrefix string) { + for _, msg := range messages { + if msg.Desc.IsMapEntry() { + continue // synthetic map-entry type — map fields emit TODO + } + flatName := namePrefix + string(msg.Desc.Name()) + ctx.emitMessage(msg, flatName) + ctx.collectAndEmitMessages(msg.Messages, flatName+"_") + for _, enum := range msg.Enums { + ctx.emitEnum(enum, flatName+"_") + } + } +} + +func (ctx *genCtx) emitMessage(msg *protogen.Message, className string) { + supported := ctx.supportedFields(msg.Fields) + oneofs := ctx.supportedRealOneofs(msg) + for _, oo := range oneofs { + ctx.emitOneofWrapperTypes(className, oo) + } + ctx.emitClass(className, msg.Fields, oneofs) + ctx.emitConstructor(supported, oneofs, className) + ctx.g.P() + ctx.emitCodec(className, supported, oneofs) + ctx.g.P() +} + +func (ctx *genCtx) emitClass(className string, all []*protogen.Field, oneofs []*protogen.Oneof) { + inSupportedOneof := func(oo *protogen.Oneof) bool { + for _, o := range oneofs { + if o == oo { + return true + } + } + return false + } + todo := func(f *protogen.Field) { + ctx.g.P(` // TODO protoc-gen-pony: field `, f.Desc.Name(), ` (`, fieldShape(f), `)`) + } + ctx.g.P(`class val `, className) + seenOneof := make(map[*protogen.Oneof]bool) + for _, field := range all { + if field.Oneof != nil && !field.Desc.HasOptionalKeyword() { + oo := field.Oneof + if inSupportedOneof(oo) { + if !seenOneof[oo] { + seenOneof[oo] = true + ctx.g.P(` let `, oo.Desc.Name(), `: `, oneofTypeName(className, oo)) + } + } else { + todo(field) + } + } else if ctx.isSupported(field) { + ctx.g.P(` let `, field.Desc.Name(), `: `, ctx.fieldPonyType(field)) + } else { + todo(field) + } + } + ctx.g.P() +} + +func (ctx *genCtx) emitConstructor(supported []*protogen.Field, oneofs []*protogen.Oneof, className string) { + if len(supported) == 0 && len(oneofs) == 0 { + ctx.g.P(` new val create() => None`) + return + } + total := len(supported) + len(oneofs) + suffix := func(i int) string { + if i == total { + return ")" + } + return "," + } + ctx.g.P(` new val create(`) + i := 0 + for _, field := range supported { + i++ + ctx.g.P(` `, field.Desc.Name(), `': `, ctx.fieldPonyType(field), ` = `, ctx.fieldPonyDefault(field), suffix(i)) + } + for _, oo := range oneofs { + i++ + ctx.g.P(` `, oo.Desc.Name(), `': `, oneofTypeName(className, oo), ` = None`, suffix(i)) + } + ctx.g.P(` =>`) + for _, field := range supported { + ctx.g.P(` `, field.Desc.Name(), ` = `, field.Desc.Name(), `'`) + } + for _, oo := range oneofs { + ctx.g.P(` `, oo.Desc.Name(), ` = `, oo.Desc.Name(), `'`) + } +} + +func (ctx *genCtx) emitCodec(className string, supported []*protogen.Field, oneofs []*protogen.Oneof) { + ctx.g.P(`primitive `, className, `Codec`) + ctx.g.P(` fun default(): `, className, ` val => `, className) + ctx.emitDecode(className, supported, oneofs) + ctx.g.P() + ctx.emitEncode(className, supported, oneofs) +} + +func (ctx *genCtx) emitDecode(className string, supported []*protogen.Field, oneofs []*protogen.Oneof) { + ctx.g.P(` fun decode(reader: WireReader ref): (`, className, ` val | WireError) =>`) + for _, field := range supported { + name := string(field.Desc.Name()) + if field.Desc.IsMap() { + kType := ctx.mapKeyPonyType(field.Message.Fields[0]) + vType := ctx.mapValuePonyType(field.Message.Fields[1]) + ctx.g.P(` var `, name, `: Map[`, kType, `, `, vType, `] trn = recover trn Map[`, kType, `, `, vType, `] end`) + } else if field.Desc.IsList() { + elem := ctx.elemPonyType(field) + ctx.g.P(` var `, name, `: Array[`, elem, `] trn = recover trn Array[`, elem, `] end`) + } else { + ctx.g.P(` var `, name, `: `, ctx.fieldPonyType(field), ` = `, ctx.fieldPonyDefault(field)) + } + } + for _, oo := range oneofs { + ctx.g.P(` var `, oo.Desc.Name(), `: `, oneofTypeName(className, oo), ` = None`) + } + ctx.g.P(` while not reader.at_end() do`) + ctx.g.P(` match reader.read_tag()`) + ctx.g.P(` | let t: Tag =>`) + ctx.g.P(` match (t.field_number, t.wire_type)`) + for _, field := range supported { + ctx.emitDecodeArm(field) + } + for _, oo := range oneofs { + for _, f := range oo.Fields { + ctx.emitOneofDecodeArm(f, string(oo.Desc.Name()), oneofCaseName(className, oo, f)) + } + } + ctx.g.P(` else`) + ctx.g.P(` match reader.skip(t.wire_type)`) + ctx.g.P(` | None => None`) + ctx.g.P(` | let e: WireError => return e`) + ctx.g.P(` end`) + ctx.g.P(` end`) + ctx.g.P(` | let e: WireError => return e`) + ctx.g.P(` end`) + ctx.g.P(` end`) + ctx.emitConstructorCall(className, supported, oneofs) +} + +func (ctx *genCtx) emitDecodeArm(field *protogen.Field) { + name := string(field.Desc.Name()) + num := field.Desc.Number() + + switch { + case field.Desc.IsMap(): + ctx.emitMapDecodeArm(field, name, num) + case field.Desc.IsList() && field.Desc.Kind() == protoreflect.MessageKind: + // non-packed: one tag + len-delim per element + codec := ponyMessageClassName(field.Message) + "Codec" + elemType := ponyMessageClassName(field.Message) + " val" + ctx.g.P(` | (`, num, `, WireLenDelim) =>`) + ctx.g.P(` match reader.read_len_delim()`) + ctx.g.P(` | let b: Array[U8] val =>`) + ctx.g.P(` match `, codec, `.decode(WireReader(b))`) + ctx.g.P(` | let v: `, elemType, ` => `, name, `.push(v)`) + ctx.g.P(` | let e: WireError => return e`) + ctx.g.P(` end`) + ctx.g.P(` | let e: WireError => return e`) + ctx.g.P(` end`) + + case field.Desc.IsList() && field.Desc.Kind() == protoreflect.StringKind: + // non-packed: string/bytes are never packable; one len-delim per element + ctx.g.P(` | (`, num, `, WireLenDelim) =>`) + ctx.g.P(` match reader.read_string()`) + ctx.g.P(` | let v: String val => `, name, `.push(v)`) + ctx.g.P(` | let e: WireError => return e`) + ctx.g.P(` end`) + + case field.Desc.IsList() && field.Desc.Kind() == protoreflect.BytesKind: + ctx.g.P(` | (`, num, `, WireLenDelim) =>`) + ctx.g.P(` match reader.read_len_delim()`) + ctx.g.P(` | let v: Array[U8] val => `, name, `.push(v)`) + ctx.g.P(` | let e: WireError => return e`) + ctx.g.P(` end`) + + case field.Desc.IsList() && field.Desc.Kind() == protoreflect.EnumKind: + // packed primary arm + unpacked arm (proto3 decoders must accept both) + fromValue := ponyEnumFromValueName(field.Enum) + ctx.g.P(` | (`, num, `, WireLenDelim) =>`) + ctx.g.P(` match reader.read_len_delim()`) + ctx.g.P(` | let b: Array[U8] val =>`) + ctx.g.P(` let sub = WireReader(b)`) + ctx.g.P(` while not sub.at_end() do`) + ctx.g.P(` match Scalar.read_int32(sub)`) + ctx.g.P(` | let v: I32 => `, name, `.push(`, fromValue, `(v))`) + ctx.g.P(` | let e: WireError => return e`) + ctx.g.P(` end`) + ctx.g.P(` end`) + ctx.g.P(` | let e: WireError => return e`) + ctx.g.P(` end`) + ctx.g.P(` | (`, num, `, WireVarint) =>`) + ctx.g.P(` match Scalar.read_int32(reader)`) + ctx.g.P(` | let v: I32 => `, name, `.push(`, fromValue, `(v))`) + ctx.g.P(` | let e: WireError => return e`) + ctx.g.P(` end`) + + case field.Desc.IsList(): + // packable numeric scalar: packed arm + unpacked arm + spec := scalarSpecs[field.Desc.Kind()] + readExprSub := strings.Replace(spec.readExpr, "reader", "sub", 1) + ctx.g.P(` | (`, num, `, WireLenDelim) =>`) + ctx.g.P(` match reader.read_len_delim()`) + ctx.g.P(` | let b: Array[U8] val =>`) + ctx.g.P(` let sub = WireReader(b)`) + ctx.g.P(` while not sub.at_end() do`) + ctx.g.P(` match `, readExprSub) + ctx.g.P(` | let v: `, spec.ponyType, ` => `, name, `.push(v)`) + ctx.g.P(` | let e: WireError => return e`) + ctx.g.P(` end`) + ctx.g.P(` end`) + ctx.g.P(` | let e: WireError => return e`) + ctx.g.P(` end`) + ctx.g.P(` | (`, num, `, `, spec.wireType, `) =>`) + ctx.g.P(` match `, spec.readExpr) + ctx.g.P(` | let v: `, spec.ponyType, ` => `, name, `.push(v)`) + ctx.g.P(` | let e: WireError => return e`) + ctx.g.P(` end`) + + case field.Desc.Kind() == protoreflect.MessageKind: + codec := ponyMessageClassName(field.Message) + "Codec" + msgType := ponyMessageClassName(field.Message) + " val" + ctx.g.P(` | (`, num, `, WireLenDelim) =>`) + ctx.g.P(` match reader.read_len_delim()`) + ctx.g.P(` | let b: Array[U8] val =>`) + ctx.g.P(` match `, codec, `.decode(WireReader(b))`) + ctx.g.P(` | let v: `, msgType, ` => `, name, ` = v`) + ctx.g.P(` | let e: WireError => return e`) + ctx.g.P(` end`) + ctx.g.P(` | let e: WireError => return e`) + ctx.g.P(` end`) + + case field.Desc.Kind() == protoreflect.EnumKind: + fromValue := ponyEnumFromValueName(field.Enum) + ctx.g.P(` | (`, num, `, WireVarint) =>`) + ctx.g.P(` match Scalar.read_int32(reader)`) + ctx.g.P(` | let v: I32 => `, name, ` = `, fromValue, `(v)`) + ctx.g.P(` | let e: WireError => return e`) + ctx.g.P(` end`) + + default: + spec := scalarSpecs[field.Desc.Kind()] + ctx.g.P(` | (`, num, `, `, spec.wireType, `) =>`) + ctx.g.P(` match `, spec.readExpr) + ctx.g.P(` | let v: `, spec.ponyType, ` => `, name, ` = v`) + ctx.g.P(` | let e: WireError => return e`) + ctx.g.P(` end`) + } +} + +func (ctx *genCtx) emitMapDecodeArm(field *protogen.Field, name string, num protoreflect.FieldNumber) { + keyField := field.Message.Fields[0] + valField := field.Message.Fields[1] + keySpec := scalarSpecs[keyField.Desc.Kind()] + kType := ctx.mapKeyPonyType(keyField) + vType := ctx.mapValuePonyType(valField) + keyReadExpr := strings.Replace(keySpec.readExpr, "reader", "entry_sub", 1) + + ctx.g.P(` | (`, num, `, WireLenDelim) =>`) + ctx.g.P(` match reader.read_len_delim()`) + ctx.g.P(` | let b: Array[U8] val =>`) + ctx.g.P(` let entry_sub = WireReader(b)`) + ctx.g.P(` var entry_k: `, kType, ` = `, keySpec.ponyDefault) + ctx.g.P(` var entry_v: `, vType, ` = `, ctx.mapValueDefault(valField)) + ctx.g.P(` while not entry_sub.at_end() do`) + ctx.g.P(` match entry_sub.read_tag()`) + ctx.g.P(` | let t: Tag =>`) + ctx.g.P(` match (t.field_number, t.wire_type)`) + ctx.g.P(` | (1, `, keySpec.wireType, `) =>`) + ctx.g.P(` match `, keyReadExpr) + ctx.g.P(` | let kk: `, kType, ` => entry_k = kk`) + ctx.g.P(` | let e: WireError => return e`) + ctx.g.P(` end`) + switch valField.Desc.Kind() { + case protoreflect.MessageKind: + codec := ponyMessageClassName(valField.Message) + "Codec" + msgType := ponyMessageClassName(valField.Message) + " val" + ctx.g.P(` | (2, WireLenDelim) =>`) + ctx.g.P(` match entry_sub.read_len_delim()`) + ctx.g.P(` | let vb: Array[U8] val =>`) + ctx.g.P(` match `, codec, `.decode(WireReader(vb))`) + ctx.g.P(` | let vv: `, msgType, ` => entry_v = vv`) + ctx.g.P(` | let e: WireError => return e`) + ctx.g.P(` end`) + ctx.g.P(` | let e: WireError => return e`) + ctx.g.P(` end`) + case protoreflect.EnumKind: + fromValue := ponyEnumFromValueName(valField.Enum) + ctx.g.P(` | (2, WireVarint) =>`) + ctx.g.P(` match Scalar.read_int32(entry_sub)`) + ctx.g.P(` | let vv: I32 => entry_v = `, fromValue, `(vv)`) + ctx.g.P(` | let e: WireError => return e`) + ctx.g.P(` end`) + default: + valSpec := scalarSpecs[valField.Desc.Kind()] + valReadExpr := strings.Replace(valSpec.readExpr, "reader", "entry_sub", 1) + ctx.g.P(` | (2, `, valSpec.wireType, `) =>`) + ctx.g.P(` match `, valReadExpr) + ctx.g.P(` | let vv: `, valSpec.ponyType, ` => entry_v = vv`) + ctx.g.P(` | let e: WireError => return e`) + ctx.g.P(` end`) + } + ctx.g.P(` else`) + ctx.g.P(` match entry_sub.skip(t.wire_type)`) + ctx.g.P(` | None => None`) + ctx.g.P(` | let e: WireError => return e`) + ctx.g.P(` end`) + ctx.g.P(` end`) + ctx.g.P(` | let e: WireError => return e`) + ctx.g.P(` end`) + ctx.g.P(` end`) + ctx.g.P(` `, name, `(entry_k) = entry_v`) + ctx.g.P(` | let e: WireError => return e`) + ctx.g.P(` end`) +} + +func (ctx *genCtx) emitConstructorCall(className string, supported []*protogen.Field, oneofs []*protogen.Oneof) { + if len(supported) == 0 && len(oneofs) == 0 { + ctx.g.P(` `, className) + return + } + parts := make([]string, 0, len(supported)+len(oneofs)) + for _, field := range supported { + if field.Desc.IsList() || field.Desc.IsMap() { + parts = append(parts, "consume "+string(field.Desc.Name())) + } else { + parts = append(parts, string(field.Desc.Name())) + } + } + for _, oo := range oneofs { + parts = append(parts, string(oo.Desc.Name())) + } + ctx.g.P(` `, className, `(`, strings.Join(parts, ", "), `)`) +} + +func (ctx *genCtx) emitEncode(className string, supported []*protogen.Field, oneofs []*protogen.Oneof) { + ctx.g.P(` fun encode(writer: WireWriter ref, msg: `, className, ` val) =>`) + if len(supported) == 0 && len(oneofs) == 0 { + ctx.g.P(` None`) + return + } + for _, field := range supported { + ctx.emitEncodeField(field) + } + for _, oo := range oneofs { + ctx.emitOneofEncodeBlock(className, oo) + } +} + +func (ctx *genCtx) emitEncodeField(field *protogen.Field) { + ref := "msg." + string(field.Desc.Name()) + num := field.Desc.Number() + + switch { + case field.Desc.IsMap(): + ctx.emitMapEncodeField(field, ref, num) + case field.Desc.IsList() && field.Desc.Kind() == protoreflect.MessageKind: + // non-packed: one tag + len-delim per element (proto3 repeated message) + codec := ponyMessageClassName(field.Message) + "Codec" + ctx.g.P(` for v in `, ref, `.values() do`) + ctx.g.P(` let sub = WireWriter`) + ctx.g.P(` `, codec, `.encode(sub, v)`) + ctx.g.P(` writer.write_tag(Tag(`, num, `, WireLenDelim))`) + ctx.g.P(` writer.write_len_delim(sub.done())`) + ctx.g.P(` end`) + + case field.Desc.IsList() && field.Desc.Kind() == protoreflect.EnumKind: + ctx.emitPackedEncode(ref, num, "Scalar.write_int32(sub, v.value())") + + case field.Desc.IsList() && field.Desc.Kind() == protoreflect.StringKind: + // non-packed: string is never packable; one tag + value per element + ctx.g.P(` for v in `, ref, `.values() do`) + ctx.g.P(` writer.write_tag(Tag(`, num, `, WireLenDelim))`) + ctx.g.P(` writer.write_string(v)`) + ctx.g.P(` end`) + + case field.Desc.IsList() && field.Desc.Kind() == protoreflect.BytesKind: + ctx.g.P(` for v in `, ref, `.values() do`) + ctx.g.P(` writer.write_tag(Tag(`, num, `, WireLenDelim))`) + ctx.g.P(` writer.write_len_delim(v)`) + ctx.g.P(` end`) + + case field.Desc.IsList(): + spec := scalarSpecs[field.Desc.Kind()] + ctx.emitPackedEncode(ref, num, strings.Replace(fmt.Sprintf(spec.writeFmt, "v"), "writer", "sub", 1)) + + case field.Desc.Kind() == protoreflect.MessageKind: + codec := ponyMessageClassName(field.Message) + "Codec" + msgType := ponyMessageClassName(field.Message) + " val" + ctx.g.P(` match `, ref) + ctx.g.P(` | let v: `, msgType, ` =>`) + ctx.g.P(` let sub = WireWriter`) + ctx.g.P(` `, codec, `.encode(sub, v)`) + ctx.g.P(` writer.write_tag(Tag(`, num, `, WireLenDelim))`) + ctx.g.P(` writer.write_len_delim(sub.done())`) + ctx.g.P(` end`) + + case !field.Desc.IsList() && field.Desc.HasOptionalKeyword(): + // optional field: explicit presence — emit match on None (never skip zero values) + ctx.emitOptionalEncodeField(field) + + case field.Desc.Kind() == protoreflect.EnumKind: + // singular enum: skip zero value (implicit absence) + ctx.g.P(` if `, ref, `.value() != 0 then`) + ctx.g.P(` writer.write_tag(Tag(`, num, `, WireVarint))`) + ctx.g.P(` Scalar.write_int32(writer, `, ref, `.value())`) + ctx.g.P(` end`) + + case field.Desc.Kind() == protoreflect.StringKind: + // write_string_field handles empty-string skip internally + ctx.g.P(` writer.write_string_field(`, num, `, `, ref, `)`) + + default: + spec := scalarSpecs[field.Desc.Kind()] + ctx.g.P(` if `, fmt.Sprintf(spec.presence, ref), ` then`) + ctx.g.P(` writer.write_tag(Tag(`, num, `, `, spec.wireType, `))`) + ctx.g.P(` `, fmt.Sprintf(spec.writeFmt, ref)) + ctx.g.P(` end`) + } +} + +func (ctx *genCtx) emitMapEncodeField(field *protogen.Field, ref string, num protoreflect.FieldNumber) { + keyField := field.Message.Fields[0] + valField := field.Message.Fields[1] + keySpec := scalarSpecs[keyField.Desc.Kind()] + + ctx.g.P(` for (k, v) in `, ref, `.pairs() do`) + ctx.g.P(` let sub = WireWriter`) + if keyField.Desc.Kind() == protoreflect.StringKind { + ctx.g.P(` sub.write_tag(Tag(1, WireLenDelim))`) + ctx.g.P(` sub.write_string(k)`) + } else { + ctx.g.P(` sub.write_tag(Tag(1, `, keySpec.wireType, `))`) + ctx.g.P(` `, fmt.Sprintf(strings.Replace(keySpec.writeFmt, "writer", "sub", 1), "k")) + } + switch valField.Desc.Kind() { + case protoreflect.MessageKind: + codec := ponyMessageClassName(valField.Message) + "Codec" + ctx.g.P(` let vsub = WireWriter`) + ctx.g.P(` `, codec, `.encode(vsub, v)`) + ctx.g.P(` sub.write_tag(Tag(2, WireLenDelim))`) + ctx.g.P(` sub.write_len_delim(vsub.done())`) + case protoreflect.EnumKind: + ctx.g.P(` sub.write_tag(Tag(2, WireVarint))`) + ctx.g.P(` Scalar.write_int32(sub, v.value())`) + case protoreflect.StringKind: + ctx.g.P(` sub.write_tag(Tag(2, WireLenDelim))`) + ctx.g.P(` sub.write_string(v)`) + default: + valSpec := scalarSpecs[valField.Desc.Kind()] + ctx.g.P(` sub.write_tag(Tag(2, `, valSpec.wireType, `))`) + ctx.g.P(` `, fmt.Sprintf(strings.Replace(valSpec.writeFmt, "writer", "sub", 1), "v")) + } + ctx.g.P(` writer.write_tag(Tag(`, num, `, WireLenDelim))`) + ctx.g.P(` writer.write_len_delim(sub.done())`) + ctx.g.P(` end`) +} + +func (ctx *genCtx) emitPackedEncode(ref string, num protoreflect.FieldNumber, writeOp string) { + ctx.g.P(` if `, ref, `.size() > 0 then`) + ctx.g.P(` let sub = WireWriter`) + ctx.g.P(` for v in `, ref, `.values() do`) + ctx.g.P(` `, writeOp) + ctx.g.P(` end`) + ctx.g.P(` writer.write_tag(Tag(`, num, `, WireLenDelim))`) + ctx.g.P(` writer.write_len_delim(sub.done())`) + ctx.g.P(` end`) +} + +func (ctx *genCtx) emitOptionalEncodeField(field *protogen.Field) { + ref := "msg." + string(field.Desc.Name()) + num := field.Desc.Number() + switch field.Desc.Kind() { + case protoreflect.EnumKind: + enumType := ponyEnumTypeName(field.Enum) + ctx.g.P(` match `, ref) + ctx.g.P(` | let v: `, enumType, ` =>`) + ctx.g.P(` writer.write_tag(Tag(`, num, `, WireVarint))`) + ctx.g.P(` Scalar.write_int32(writer, v.value())`) + ctx.g.P(` end`) + case protoreflect.StringKind: + ctx.g.P(` match `, ref) + ctx.g.P(` | let v: String val =>`) + ctx.g.P(` writer.write_tag(Tag(`, num, `, WireLenDelim))`) + ctx.g.P(` writer.write_string(v)`) + ctx.g.P(` end`) + case protoreflect.BytesKind: + ctx.g.P(` match `, ref) + ctx.g.P(` | let v: Array[U8] val =>`) + ctx.g.P(` writer.write_tag(Tag(`, num, `, WireLenDelim))`) + ctx.g.P(` writer.write_len_delim(v)`) + ctx.g.P(` end`) + default: + spec := scalarSpecs[field.Desc.Kind()] + ctx.g.P(` match `, ref) + ctx.g.P(` | let v: `, spec.ponyType, ` =>`) + ctx.g.P(` writer.write_tag(Tag(`, num, `, `, spec.wireType, `))`) + ctx.g.P(` `, fmt.Sprintf(spec.writeFmt, "v")) + ctx.g.P(` end`) + } +} + +func (ctx *genCtx) emitOneofWrapperTypes(className string, oo *protogen.Oneof) { + caseNames := make([]string, 0, len(oo.Fields)) + for _, f := range oo.Fields { + caseName := oneofCaseName(className, oo, f) + valType := ctx.oneofMemberPonyType(f) + valDefault := ctx.oneofMemberDefault(f) + ctx.g.P(`class val `, caseName) + ctx.g.P(` let value: `, valType) + if valDefault != "" { + ctx.g.P(` new val create(value': `, valType, ` = `, valDefault, `) => value = value'`) + } else { + ctx.g.P(` new val create(value': `, valType, `) => value = value'`) + } + ctx.g.P() + caseNames = append(caseNames, caseName) + } + ctx.g.P(`type `, oneofTypeName(className, oo), ` is (`, strings.Join(append(caseNames, "None"), " | "), `)`) + ctx.g.P() +} + +// emitOneofDecodeArm emits a match arm that reads a single oneof member and +// assigns `varName = CaseClass(decoded_value)`. +func (ctx *genCtx) emitOneofDecodeArm(field *protogen.Field, varName, caseName string) { + num := field.Desc.Number() + switch field.Desc.Kind() { + case protoreflect.MessageKind: + codec := ponyMessageClassName(field.Message) + "Codec" + msgType := ponyMessageClassName(field.Message) + " val" + ctx.g.P(` | (`, num, `, WireLenDelim) =>`) + ctx.g.P(` match reader.read_len_delim()`) + ctx.g.P(` | let b: Array[U8] val =>`) + ctx.g.P(` match `, codec, `.decode(WireReader(b))`) + ctx.g.P(` | let v: `, msgType, ` => `, varName, ` = `, caseName, `(v)`) + ctx.g.P(` | let e: WireError => return e`) + ctx.g.P(` end`) + ctx.g.P(` | let e: WireError => return e`) + ctx.g.P(` end`) + case protoreflect.EnumKind: + fromValue := ponyEnumFromValueName(field.Enum) + ctx.g.P(` | (`, num, `, WireVarint) =>`) + ctx.g.P(` match Scalar.read_int32(reader)`) + ctx.g.P(` | let v: I32 => `, varName, ` = `, caseName, `(`, fromValue, `(v))`) + ctx.g.P(` | let e: WireError => return e`) + ctx.g.P(` end`) + case protoreflect.StringKind: + ctx.g.P(` | (`, num, `, WireLenDelim) =>`) + ctx.g.P(` match reader.read_string()`) + ctx.g.P(` | let v: String val => `, varName, ` = `, caseName, `(v)`) + ctx.g.P(` | let e: WireError => return e`) + ctx.g.P(` end`) + case protoreflect.BytesKind: + ctx.g.P(` | (`, num, `, WireLenDelim) =>`) + ctx.g.P(` match reader.read_len_delim()`) + ctx.g.P(` | let v: Array[U8] val => `, varName, ` = `, caseName, `(v)`) + ctx.g.P(` | let e: WireError => return e`) + ctx.g.P(` end`) + default: + spec := scalarSpecs[field.Desc.Kind()] + ctx.g.P(` | (`, num, `, `, spec.wireType, `) =>`) + ctx.g.P(` match `, spec.readExpr) + ctx.g.P(` | let v: `, spec.ponyType, ` => `, varName, ` = `, caseName, `(v)`) + ctx.g.P(` | let e: WireError => return e`) + ctx.g.P(` end`) + } +} + +// emitOneofEncodeBlock emits `match msg.kind | let v: CaseA => ... | None => None end`. +func (ctx *genCtx) emitOneofEncodeBlock(className string, oo *protogen.Oneof) { + ctx.g.P(` match msg.`, oo.Desc.Name()) + for _, f := range oo.Fields { + caseName := oneofCaseName(className, oo, f) + ctx.g.P(` | let v: `, caseName, ` =>`) + ctx.emitOneofCaseEncode(f) + } + ctx.g.P(` | None => None`) + ctx.g.P(` end`) +} + +func (ctx *genCtx) emitOneofCaseEncode(field *protogen.Field) { + num := field.Desc.Number() + switch field.Desc.Kind() { + case protoreflect.MessageKind: + codec := ponyMessageClassName(field.Message) + "Codec" + ctx.g.P(` let sub = WireWriter`) + ctx.g.P(` `, codec, `.encode(sub, v.value)`) + ctx.g.P(` writer.write_tag(Tag(`, num, `, WireLenDelim))`) + ctx.g.P(` writer.write_len_delim(sub.done())`) + case protoreflect.EnumKind: + ctx.g.P(` writer.write_tag(Tag(`, num, `, WireVarint))`) + ctx.g.P(` Scalar.write_int32(writer, v.value.value())`) + case protoreflect.StringKind: + ctx.g.P(` writer.write_tag(Tag(`, num, `, WireLenDelim))`) + ctx.g.P(` writer.write_string(v.value)`) + case protoreflect.BytesKind: + ctx.g.P(` writer.write_tag(Tag(`, num, `, WireLenDelim))`) + ctx.g.P(` writer.write_len_delim(v.value)`) + default: + spec := scalarSpecs[field.Desc.Kind()] + ctx.g.P(` writer.write_tag(Tag(`, num, `, `, spec.wireType, `))`) + ctx.g.P(` `, fmt.Sprintf(spec.writeFmt, "v.value")) + } +} + +// supportedRealOneofs returns the non-synthetic oneofs in msg whose every +// member passes isSupportedOneofMember. If any member is unsupported (WKT, +// group, etc.), the whole oneof emits TODO comments instead. +func (ctx *genCtx) supportedRealOneofs(msg *protogen.Message) []*protogen.Oneof { + var result []*protogen.Oneof + for _, oo := range msg.Oneofs { + if oo.Desc.IsSynthetic() { + continue + } + ok := true + for _, f := range oo.Fields { + if !ctx.isSupportedOneofMember(f) { + ok = false + break + } + } + if ok { + result = append(result, oo) + } + } + return result +} + +// isSupportedMapField returns true for map fields whose value kind we can +// generate — scalar, non-WKT enum, non-WKT message (codec.default() supplies +// the zero value for missing entries on the wire). +func (ctx *genCtx) isSupportedMapField(field *protogen.Field) bool { + if field.Message == nil || len(field.Message.Fields) != 2 { + return false + } + valField := field.Message.Fields[1] + switch valField.Desc.Kind() { + case protoreflect.GroupKind: + return false + case protoreflect.MessageKind: + if valField.Message == nil || valField.Message.Desc.IsMapEntry() { + return false + } + return !isWKT(valField.Message.Desc.ParentFile()) + case protoreflect.EnumKind: + if valField.Enum == nil { + return false + } + return !isWKT(valField.Enum.Desc.ParentFile()) + } + return true +} + +// isSupportedOneofMember applies the field-kind checks to a oneof member +// without the real-oneof gate that isSupported enforces. +func (ctx *genCtx) isSupportedOneofMember(field *protogen.Field) bool { + switch field.Desc.Kind() { + case protoreflect.GroupKind: + return false + case protoreflect.MessageKind: + if field.Message == nil || field.Message.Desc.IsMapEntry() { + return false + } + return !isWKT(field.Message.Desc.ParentFile()) + case protoreflect.EnumKind: + if field.Enum == nil { + return false + } + return !isWKT(field.Enum.Desc.ParentFile()) + } + return true +} + +// oneofMemberPonyType returns the raw Pony type for a oneof member value — +// no None wrapper, since None comes from the outer union type alias. +func (ctx *genCtx) oneofMemberPonyType(field *protogen.Field) string { + switch field.Desc.Kind() { + case protoreflect.MessageKind: + return ponyMessageClassName(field.Message) + " val" + case protoreflect.EnumKind: + return ponyEnumTypeName(field.Enum) + } + return scalarSpecs[field.Desc.Kind()].ponyType +} + +// oneofMemberDefault returns the constructor default for a oneof member's +// value field. Returns "" for message kinds (no sensible zero value exists). +func (ctx *genCtx) oneofMemberDefault(field *protogen.Field) string { + switch field.Desc.Kind() { + case protoreflect.MessageKind: + return "" + case protoreflect.EnumKind: + return ponyEnumZeroValuePrimitive(field.Enum) + } + return scalarSpecs[field.Desc.Kind()].ponyDefault +} + +func (ctx *genCtx) mapKeyPonyType(field *protogen.Field) string { + return scalarSpecs[field.Desc.Kind()].ponyType +} + +func (ctx *genCtx) mapValuePonyType(field *protogen.Field) string { + switch field.Desc.Kind() { + case protoreflect.MessageKind: + return ponyMessageClassName(field.Message) + " val" + case protoreflect.EnumKind: + return ponyEnumTypeName(field.Enum) + } + return scalarSpecs[field.Desc.Kind()].ponyType +} + +func (ctx *genCtx) mapValueDefault(field *protogen.Field) string { + switch field.Desc.Kind() { + case protoreflect.MessageKind: + return ponyMessageClassName(field.Message) + "Codec.default()" + case protoreflect.EnumKind: + return ponyEnumZeroValuePrimitive(field.Enum) + } + return scalarSpecs[field.Desc.Kind()].ponyDefault +} + +// oneofTypeName builds the Pony type alias name for a real oneof. +// e.g. className="Zoo", oneof="kind" → "ZooKind" +func oneofTypeName(className string, oo *protogen.Oneof) string { + return className + snakeToPascal(string(oo.Desc.Name())) +} + +// oneofCaseName builds the Pony wrapper class name for one oneof member. +// e.g. className="Zoo", oneof="kind", field="type_a" → "ZooKindTypeA" +func oneofCaseName(className string, oo *protogen.Oneof, field *protogen.Field) string { + return className + snakeToPascal(string(oo.Desc.Name())) + snakeToPascal(string(field.Desc.Name())) +} + +// snakeToPascal converts snake_case to PascalCase: "type_a" → "TypeA". +func snakeToPascal(s string) string { + parts := strings.Split(s, "_") + var b strings.Builder + for _, p := range parts { + if len(p) > 0 { + b.WriteString(strings.ToUpper(p[:1]) + p[1:]) + } + } + return b.String() +} + +func (ctx *genCtx) emitEnum(enum *protogen.Enum, namePrefix string) { + enumTypeName := namePrefix + string(enum.Desc.Name()) + fromValueName := enumTypeName + "FromValue" + rawName := enumTypeName + "Raw" + + primNames := make([]string, 0, len(enum.Values)) + for _, v := range enum.Values { + prim := namePrefix + screamingToPascal(string(v.Desc.Name())) + ctx.g.P(`primitive `, prim, ` fun value(): I32 => `, v.Desc.Number()) + primNames = append(primNames, prim) + } + + // Raw class preserves unknown numeric values across decode/re-encode (proto3 + // forward-compat: a peer may send values not yet in this schema). + ctx.g.P() + ctx.g.P(`class val `, rawName) + ctx.g.P(` let _v: I32`) + ctx.g.P(` new val create(v: I32) => _v = v`) + ctx.g.P(` fun value(): I32 => _v`) + + ctx.g.P() + ctx.g.P(`type `, enumTypeName, ` is (`, strings.Join(append(primNames, rawName), " | "), `)`) + ctx.g.P() + ctx.g.P(`primitive `, fromValueName) + ctx.g.P(` fun apply(v: I32): `, enumTypeName, ` =>`) + ctx.g.P(` match v`) + for _, v := range enum.Values { + prim := namePrefix + screamingToPascal(string(v.Desc.Name())) + ctx.g.P(` | `, v.Desc.Number(), ` => `, prim) + } + ctx.g.P(` else `, rawName, `(v)`) + ctx.g.P(` end`) + ctx.g.P() +} + +// isSupported returns true for field shapes we generate code for. +// Out: real oneofs, groups, WKT message/enum refs, map (no zero value). +// In: scalars (singular + repeated), messages (singular + repeated, any dir), +// enums (singular + repeated, any dir), proto3 optional, map. +func (ctx *genCtx) isSupported(field *protogen.Field) bool { + if field.Desc.IsMap() { + return ctx.isSupportedMapField(field) + } + if field.Oneof != nil && !field.Desc.HasOptionalKeyword() { + return false + } + return ctx.isSupportedOneofMember(field) +} + +// wktBlocklist holds the google/protobuf/ files that can't be generated as +// plain proto3 messages: struct.proto has a circular Value↔ListValue↔Struct +// oneof, type.proto and api.proto depend on it, and descriptor.proto is +// descriptor-only. Everything else (Timestamp, Duration, Any, wrappers, etc.) +// generates naturally as regular proto3 messages. +var wktBlocklist = map[string]bool{ + "google/protobuf/struct.proto": true, + "google/protobuf/type.proto": true, + "google/protobuf/api.proto": true, + "google/protobuf/descriptor.proto": true, +} + +// isWKT reports whether f is a blocked well-known-type file. Most WKTs +// generate as regular proto3 messages; only the circular/JSON-only ones stay TODO. +func isWKT(f protoreflect.FileDescriptor) bool { + return wktBlocklist[f.Path()] +} + +func (ctx *genCtx) fileHasMaps() bool { + var check func([]*protogen.Message) bool + check = func(msgs []*protogen.Message) bool { + for _, msg := range msgs { + if msg.Desc.IsMapEntry() { + continue + } + for _, f := range msg.Fields { + if f.Desc.IsMap() && ctx.isSupported(f) { + return true + } + } + if check(msg.Messages) { + return true + } + } + return false + } + return check(ctx.file.Messages) +} + +// crossDirUseDirectives returns sorted relative-path strings for Pony `use` +// directives needed to reference types from other proto packages. Same- +// directory refs are already in the same Pony package and need no import. +func (ctx *genCtx) crossDirUseDirectives() []string { + thisDir := path.Dir(ctx.file.Desc.Path()) + dirToRel := make(map[string]string) + + addDepPath := func(depPath string) { + if depPath == "" { + return + } + depDir := path.Dir(depPath) + if depDir == thisDir { + return + } + if _, ok := dirToRel[depDir]; !ok { + dirToRel[depDir] = protoRelDir(thisDir, depDir) + } + } + + checkField := func(f *protogen.Field) { + if f.Desc.IsMap() { + // Map entry message is always co-located; check value for cross-dir deps. + if f.Message != nil && len(f.Message.Fields) == 2 { + valField := f.Message.Fields[1] + switch valField.Desc.Kind() { + case protoreflect.MessageKind: + if valField.Message != nil { + addDepPath(valField.Message.Desc.ParentFile().Path()) + } + case protoreflect.EnumKind: + if valField.Enum != nil { + addDepPath(valField.Enum.Desc.ParentFile().Path()) + } + } + } + return + } + switch f.Desc.Kind() { + case protoreflect.MessageKind: + if f.Message != nil { + addDepPath(f.Message.Desc.ParentFile().Path()) + } + case protoreflect.EnumKind: + if f.Enum != nil { + addDepPath(f.Enum.Desc.ParentFile().Path()) + } + } + } + + var walkMessages func([]*protogen.Message) + walkMessages = func(msgs []*protogen.Message) { + for _, msg := range msgs { + if msg.Desc.IsMapEntry() { + continue + } + // Collect cross-dir deps from regular supported fields. + for _, f := range msg.Fields { + if ctx.isSupported(f) { + checkField(f) + } + } + // Also collect from oneof members in supported real oneofs. + for _, oo := range ctx.supportedRealOneofs(msg) { + for _, f := range oo.Fields { + checkField(f) + } + } + walkMessages(msg.Messages) + } + } + walkMessages(ctx.file.Messages) + + result := make([]string, 0, len(dirToRel)) + for _, rel := range dirToRel { + result = append(result, rel) + } + sort.Strings(result) + return result +} + +// protoRelDir returns the relative path from fromDir to toDir using +// slash-separated proto path components (e.g. "geo" → "../common"). +// If the result doesn't start with "..", it is prefixed with "./" to +// distinguish it from a package-name lookup. +func protoRelDir(fromDir, toDir string) string { + from := strings.Split(path.Clean(fromDir), "/") + to := strings.Split(path.Clean(toDir), "/") + // trim common prefix + i := 0 + for i < len(from) && i < len(to) && from[i] == to[i] { + i++ + } + var parts []string + for range from[i:] { + parts = append(parts, "..") + } + parts = append(parts, to[i:]...) + rel := strings.Join(parts, "/") + if !strings.HasPrefix(rel, "..") { + rel = "./" + rel + } + return rel +} + +func (ctx *genCtx) supportedFields(fields []*protogen.Field) []*protogen.Field { + var out []*protogen.Field + for _, f := range fields { + if ctx.isSupported(f) { + out = append(out, f) + } + } + return out +} + +// fieldPonyType returns the Pony type declaration for a field. +// Map fields become Map[K, V] val. Repeated fields become Array[elem] val. +// Singular message fields become (ChildMsg val | None). +// Singular enum fields become the enum type alias. Scalars use scalarSpecs. +func (ctx *genCtx) fieldPonyType(field *protogen.Field) string { + if field.Desc.IsMap() { + kType := ctx.mapKeyPonyType(field.Message.Fields[0]) + vType := ctx.mapValuePonyType(field.Message.Fields[1]) + return "Map[" + kType + ", " + vType + "] val" + } + if field.Desc.IsList() { + return "Array[" + ctx.elemPonyType(field) + "] val" + } + switch field.Desc.Kind() { + case protoreflect.MessageKind: + return "(" + ponyMessageClassName(field.Message) + " val | None)" + case protoreflect.EnumKind: + if field.Desc.HasOptionalKeyword() { + return "(" + ponyEnumTypeName(field.Enum) + " | None)" + } + return ponyEnumTypeName(field.Enum) + } + if field.Desc.HasOptionalKeyword() { + return "(" + scalarSpecs[field.Desc.Kind()].ponyType + " | None)" + } + return scalarSpecs[field.Desc.Kind()].ponyType +} + +// elemPonyType returns the element type for repeated fields (no None wrapper +// for messages — each element is always present). +func (ctx *genCtx) elemPonyType(field *protogen.Field) string { + switch field.Desc.Kind() { + case protoreflect.MessageKind: + return ponyMessageClassName(field.Message) + " val" + case protoreflect.EnumKind: + return ponyEnumTypeName(field.Enum) + } + return scalarSpecs[field.Desc.Kind()].ponyType +} + +// fieldPonyDefault returns the default value expression for a field. +func (ctx *genCtx) fieldPonyDefault(field *protogen.Field) string { + if field.Desc.IsMap() { + kType := ctx.mapKeyPonyType(field.Message.Fields[0]) + vType := ctx.mapValuePonyType(field.Message.Fields[1]) + return "recover val Map[" + kType + ", " + vType + "] end" + } + if field.Desc.IsList() { + return "recover val Array[" + ctx.elemPonyType(field) + "] end" + } + switch field.Desc.Kind() { + case protoreflect.MessageKind: + return "None" + case protoreflect.EnumKind: + if field.Desc.HasOptionalKeyword() { + return "None" + } + return ponyEnumZeroValuePrimitive(field.Enum) + } + if field.Desc.HasOptionalKeyword() { + return "None" + } + return scalarSpecs[field.Desc.Kind()].ponyDefault +} + +// ── Name helpers ───────────────────────────────────────────────────────────── + +// screamingToPascal converts SCREAMING_SNAKE_CASE to PascalCase. +// STATUS_UNKNOWN → StatusUnknown, ACTIVE → Active. +func screamingToPascal(s string) string { + return snakeToPascal(strings.ToLower(s)) +} + +// ponyMessageClassName builds the flat Pony class name for a message by +// walking the parent chain (Outer.Inner → Outer_Inner). +func ponyMessageClassName(msg *protogen.Message) string { + var ancestors []string + parent := msg.Desc.Parent() + for { + parentMsg, ok := parent.(protoreflect.MessageDescriptor) + if !ok { + break + } + ancestors = append(ancestors, string(parentMsg.Name())) + parent = parentMsg.Parent() + } + // ancestors are innermost-first; reverse to outermost-first + for i, j := 0, len(ancestors)-1; i < j; i, j = i+1, j-1 { + ancestors[i], ancestors[j] = ancestors[j], ancestors[i] + } + return strings.Join(append(ancestors, string(msg.Desc.Name())), "_") +} + +// enumNamePrefix returns the message-hierarchy prefix (e.g. "Zoo_") for +// an enum nested inside messages. Top-level enums return "". +func enumNamePrefix(enum *protogen.Enum) string { + var ancestors []string + parent := enum.Desc.Parent() + for { + parentMsg, ok := parent.(protoreflect.MessageDescriptor) + if !ok { + break + } + ancestors = append(ancestors, string(parentMsg.Name())) + parent = parentMsg.Parent() + } + if len(ancestors) == 0 { + return "" + } + for i, j := 0, len(ancestors)-1; i < j; i, j = i+1, j-1 { + ancestors[i], ancestors[j] = ancestors[j], ancestors[i] + } + return strings.Join(ancestors, "_") + "_" +} + +// ponyEnumTypeName returns the Pony type alias name for an enum. +func ponyEnumTypeName(enum *protogen.Enum) string { + return enumNamePrefix(enum) + string(enum.Desc.Name()) +} + +func ponyEnumFromValueName(enum *protogen.Enum) string { + return ponyEnumTypeName(enum) + "FromValue" +} + +// ponyEnumZeroValuePrimitive returns the Pony primitive name for the proto3 +// zero value (number == 0) of an enum. +func ponyEnumZeroValuePrimitive(enum *protogen.Enum) string { + prefix := enumNamePrefix(enum) + for _, v := range enum.Values { + if v.Desc.Number() == 0 { + return prefix + screamingToPascal(string(v.Desc.Name())) + } + } + if len(enum.Values) > 0 { + return prefix + screamingToPascal(string(enum.Values[0].Desc.Name())) + } + return ponyEnumTypeName(enum) + "Zero" +} + +// ── fieldShape ──────────────────────────────────────────────────────────────── + +// fieldShape returns a short string describing why a field is unsupported, +// for the TODO comment. +func fieldShape(field *protogen.Field) string { + var parts []string + if field.Desc.IsMap() { + parts = append(parts, "map") + } else if field.Desc.IsList() { + parts = append(parts, "repeated") + } + if field.Desc.HasOptionalKeyword() { + parts = append(parts, "optional") + } + if field.Oneof != nil { + parts = append(parts, "oneof") + } + parts = append(parts, field.Desc.Kind().String()) + return strings.Join(parts, " ") +} + +// ── scalarSpecs ─────────────────────────────────────────────────────────────── + +// scalarSpec lookups for Pony codegen. Single source of truth for the +// FieldKind → (Pony type, default, wire type, read expr, write fmt, +// presence-check fmt) mapping. writeFmt and presence are fmt.Sprintf +// patterns with one `%s` for the value reference; an empty writeFmt means +// the kind is special-cased in emitEncodeField (StringKind goes through +// write_string_field). Adding a new Kind requires editing one entry. +type scalarSpec struct { + ponyType string + ponyDefault string + wireType string + readExpr string + writeFmt string + presence string +} + +var scalarSpecs = map[protoreflect.Kind]scalarSpec{ + protoreflect.BoolKind: { + ponyType: "Bool", ponyDefault: "false", wireType: "WireVarint", + readExpr: "Scalar.read_bool(reader)", writeFmt: "Scalar.write_bool(writer, %s)", + presence: "%s", + }, + protoreflect.Int32Kind: { + ponyType: "I32", ponyDefault: "0", wireType: "WireVarint", + readExpr: "Scalar.read_int32(reader)", writeFmt: "Scalar.write_int32(writer, %s)", + presence: "%s != 0", + }, + protoreflect.Int64Kind: { + ponyType: "I64", ponyDefault: "0", wireType: "WireVarint", + readExpr: "Scalar.read_int64(reader)", writeFmt: "Scalar.write_int64(writer, %s)", + presence: "%s != 0", + }, + protoreflect.Uint32Kind: { + ponyType: "U32", ponyDefault: "0", wireType: "WireVarint", + readExpr: "Scalar.read_uint32(reader)", writeFmt: "Scalar.write_uint32(writer, %s)", + presence: "%s != 0", + }, + protoreflect.Uint64Kind: { + ponyType: "U64", ponyDefault: "0", wireType: "WireVarint", + readExpr: "Scalar.read_uint64(reader)", writeFmt: "Scalar.write_uint64(writer, %s)", + presence: "%s != 0", + }, + protoreflect.Sint32Kind: { + ponyType: "I32", ponyDefault: "0", wireType: "WireVarint", + readExpr: "Scalar.read_sint32(reader)", writeFmt: "Scalar.write_sint32(writer, %s)", + presence: "%s != 0", + }, + protoreflect.Sint64Kind: { + ponyType: "I64", ponyDefault: "0", wireType: "WireVarint", + readExpr: "Scalar.read_sint64(reader)", writeFmt: "Scalar.write_sint64(writer, %s)", + presence: "%s != 0", + }, + protoreflect.Fixed32Kind: { + ponyType: "U32", ponyDefault: "0", wireType: "WireFixed32", + readExpr: "Scalar.read_fixed32(reader)", writeFmt: "Scalar.write_fixed32(writer, %s)", + presence: "%s != 0", + }, + protoreflect.Fixed64Kind: { + ponyType: "U64", ponyDefault: "0", wireType: "WireFixed64", + readExpr: "Scalar.read_fixed64(reader)", writeFmt: "Scalar.write_fixed64(writer, %s)", + presence: "%s != 0", + }, + protoreflect.Sfixed32Kind: { + ponyType: "I32", ponyDefault: "0", wireType: "WireFixed32", + readExpr: "Scalar.read_sfixed32(reader)", writeFmt: "Scalar.write_sfixed32(writer, %s)", + presence: "%s != 0", + }, + protoreflect.Sfixed64Kind: { + ponyType: "I64", ponyDefault: "0", wireType: "WireFixed64", + readExpr: "Scalar.read_sfixed64(reader)", writeFmt: "Scalar.write_sfixed64(writer, %s)", + presence: "%s != 0", + }, + protoreflect.FloatKind: { + ponyType: "F32", ponyDefault: "0.0", wireType: "WireFixed32", + readExpr: "Scalar.read_float(reader)", writeFmt: "Scalar.write_float(writer, %s)", + presence: "%s != 0.0", + }, + protoreflect.DoubleKind: { + ponyType: "F64", ponyDefault: "0.0", wireType: "WireFixed64", + readExpr: "Scalar.read_double(reader)", writeFmt: "Scalar.write_double(writer, %s)", + presence: "%s != 0.0", + }, + protoreflect.StringKind: { + ponyType: "String val", ponyDefault: `""`, wireType: "WireLenDelim", + readExpr: "reader.read_string()", + // writeFmt empty: emitEncodeField special-cases StringKind through + // write_string_field. presence empty for the same reason. + }, + protoreflect.BytesKind: { + ponyType: "Array[U8] val", ponyDefault: "recover val Array[U8] end", wireType: "WireLenDelim", + readExpr: "reader.read_len_delim()", writeFmt: "writer.write_len_delim(%s)", + presence: "%s.size() > 0", + }, +} diff --git a/cmd/protoc-gen-pony/main.go b/cmd/protoc-gen-pony/main.go new file mode 100644 index 0000000..ca30c99 --- /dev/null +++ b/cmd/protoc-gen-pony/main.go @@ -0,0 +1,122 @@ +// protoc-gen-pony is a plugin for the Protobuf compiler that generates +// Pony code (class val records + sister Codec primitives that decode/encode +// against the Pony `protobuf` runtime library). To use it, build this +// program and make it available on your PATH as protoc-gen-pony. +// +// With protoc: +// +// protoc --pony_out=gen path/to/file.proto +// +// With [buf], your buf.gen.yaml will look like this: +// +// version: v2 +// plugins: +// - local: protoc-gen-pony +// out: gen +// +// Generated files import the `protobuf` runtime library — see +// https://github.com/TrogonStack/protobuf-pony for the Pony source. The +// runtime exposes WireReader/WireWriter, Tag, Scalar, the WireType union, +// and the WireError typed-error union. +// +// [buf]: https://buf.build +package main + +import ( + "flag" + "fmt" + "io" + "os" + "strings" + + "google.golang.org/protobuf/compiler/protogen" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/pluginpb" +) + +var ( + // Set by ldflags during build time. + version = "dev" + commit = "unknown" + date = "unknown" +) + +const usage = "\n\nFlags:\n -h, --help\tPrint this help and exit.\n --version\tPrint the version and exit." + +// goImportStub keeps protogen happy on non-Go targets. protogen.Options.New +// errors out if any input file lacks a Go import path; users targeting Pony +// shouldn't have to set go_package or M-mappings just for that. We prepend +// a stub M-entry for every file before calling protogen — user-provided +// params still win because they appear later in the Parameter string. +const goImportStub = "protoc-gen-pony/stub" + +func main() { + if len(os.Args) == 2 && os.Args[1] == "--version" { + fmt.Printf("protoc-gen-pony %s (commit: %s, built: %s)\n", version, commit, date) + os.Exit(0) + } + if len(os.Args) == 2 && (os.Args[1] == "-h" || os.Args[1] == "--help") { + if _, err := fmt.Fprintln(os.Stdout, usage); err != nil { + os.Exit(1) + } + os.Exit(0) + } + if len(os.Args) != 1 { + if _, err := fmt.Fprintln(os.Stderr, usage); err != nil { + os.Exit(1) + } + os.Exit(1) + } + + in, err := io.ReadAll(os.Stdin) + if err != nil { + fmt.Fprintf(os.Stderr, "protoc-gen-pony: read stdin: %v\n", err) + os.Exit(1) + } + var req pluginpb.CodeGeneratorRequest + if err := proto.Unmarshal(in, &req); err != nil { + fmt.Fprintf(os.Stderr, "protoc-gen-pony: unmarshal request: %v\n", err) + os.Exit(1) + } + injectGoImportStubs(&req) + + var flagSet flag.FlagSet + plugin, err := protogen.Options{ParamFunc: flagSet.Set}.New(&req) + if err != nil { + fmt.Fprintf(os.Stderr, "protoc-gen-pony: %v\n", err) + os.Exit(1) + } + // Advertise no features — the v1 generator emits TODO comments for + // explicit-presence/oneof/map/embedded/enum fields rather than + // handling them. Advertising features we don't implement risks + // protoc passing input we'd silently miscompile. + + for _, file := range plugin.Files { + if file.Generate { + generateFile(plugin, file) + } + } + + resp := plugin.Response() + out, err := proto.Marshal(resp) + if err != nil { + fmt.Fprintf(os.Stderr, "protoc-gen-pony: marshal response: %v\n", err) + os.Exit(1) + } + if _, err := os.Stdout.Write(out); err != nil { + fmt.Fprintf(os.Stderr, "protoc-gen-pony: write stdout: %v\n", err) + os.Exit(1) + } +} + +func injectGoImportStubs(req *pluginpb.CodeGeneratorRequest) { + parts := make([]string, 0, len(req.GetProtoFile())+1) + for _, file := range req.GetProtoFile() { + parts = append(parts, "M"+file.GetName()+"="+goImportStub) + } + if existing := req.GetParameter(); existing != "" { + parts = append(parts, existing) + } + combined := strings.Join(parts, ",") + req.Parameter = &combined +} diff --git a/cmd/protoc-gen-pony/main_test.go b/cmd/protoc-gen-pony/main_test.go new file mode 100644 index 0000000..3c9dce4 --- /dev/null +++ b/cmd/protoc-gen-pony/main_test.go @@ -0,0 +1,1062 @@ +package main + +import ( + "path" + "strings" + "testing" + "testing/quick" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/compiler/protogen" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/descriptorpb" + "google.golang.org/protobuf/types/pluginpb" +) + +// runPlugin builds a synthetic plugin invocation around the supplied +// FileDescriptorProtos and returns the generated content for the file +// matching `wantFilename`. Goes through injectGoImportStubs so that helper +// gets exercised by the test suite. +func runPlugin(t *testing.T, files []*descriptorpb.FileDescriptorProto, wantFilename string) string { + t.Helper() + toGenerate := make([]string, 0, len(files)) + for _, f := range files { + toGenerate = append(toGenerate, f.GetName()) + } + req := &pluginpb.CodeGeneratorRequest{ + FileToGenerate: toGenerate, + ProtoFile: files, + } + injectGoImportStubs(req) + plugin, err := protogen.Options{}.New(req) + require.NoError(t, err) + for _, f := range plugin.Files { + if f.Generate { + generateFile(plugin, f) + } + } + resp := plugin.Response() + require.Empty(t, resp.GetError(), "plugin reported error") + for _, f := range resp.GetFile() { + if f.GetName() == wantFilename { + return f.GetContent() + } + } + t.Fatalf("expected output file %q not in plugin response (got %v)", wantFilename, fileNames(resp.GetFile())) + return "" +} + +func fileNames(files []*pluginpb.CodeGeneratorResponse_File) []string { + names := make([]string, len(files)) + for i, f := range files { + names[i] = f.GetName() + } + return names +} + +// field is a fixture builder for FieldDescriptorProto that defaults Label +// to OPTIONAL — the singular-presence shape the v1 plugin generates code +// for. Tests that need a different label build the descriptor inline. +func field(name string, num int32, kind descriptorpb.FieldDescriptorProto_Type) *descriptorpb.FieldDescriptorProto { + return &descriptorpb.FieldDescriptorProto{ + Name: proto.String(name), + Number: proto.Int32(num), + Label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum(), + Type: kind.Enum(), + JsonName: proto.String(name), + } +} + +// scalarMessageProto returns a FileDescriptor with one User message +// containing one int32, one string, and one bool field. +func scalarMessageProto() *descriptorpb.FileDescriptorProto { + return &descriptorpb.FileDescriptorProto{ + Name: proto.String("user.proto"), + Package: proto.String("acme.v1"), + Syntax: proto.String("proto3"), + MessageType: []*descriptorpb.DescriptorProto{ + { + Name: proto.String("User"), + Field: []*descriptorpb.FieldDescriptorProto{ + field("id", 1, descriptorpb.FieldDescriptorProto_TYPE_INT32), + field("name", 2, descriptorpb.FieldDescriptorProto_TYPE_STRING), + field("active", 3, descriptorpb.FieldDescriptorProto_TYPE_BOOL), + }, + }, + }, + } +} + +func TestScalarMessage_ClassDecl(t *testing.T) { + t.Parallel() + out := runPlugin(t, []*descriptorpb.FileDescriptorProto{scalarMessageProto()}, "user.pony") + assert.Contains(t, out, "class val User") + assert.Contains(t, out, "let id: I32") + assert.Contains(t, out, "let name: String val") + assert.Contains(t, out, "let active: Bool") +} + +func TestScalarMessage_Constructor(t *testing.T) { + t.Parallel() + out := runPlugin(t, []*descriptorpb.FileDescriptorProto{scalarMessageProto()}, "user.pony") + assert.Contains(t, out, "new val create(") + assert.Contains(t, out, "id': I32 = 0") + assert.Contains(t, out, `name': String val = ""`) + assert.Contains(t, out, "active': Bool = false") +} + +func TestScalarMessage_Codec(t *testing.T) { + t.Parallel() + out := runPlugin(t, []*descriptorpb.FileDescriptorProto{scalarMessageProto()}, "user.pony") + assert.Contains(t, out, "primitive UserCodec") + assert.Contains(t, out, "fun decode(reader: WireReader ref): (User val | WireError)") + assert.Contains(t, out, "fun encode(writer: WireWriter ref, msg: User val)") +} + +func TestScalarMessage_DecodeDispatch(t *testing.T) { + t.Parallel() + out := runPlugin(t, []*descriptorpb.FileDescriptorProto{scalarMessageProto()}, "user.pony") + assert.Contains(t, out, "(1, WireVarint)") + assert.Contains(t, out, "(2, WireLenDelim)") + assert.Contains(t, out, "(3, WireVarint)") + assert.Contains(t, out, "Scalar.read_int32(reader)") + assert.Contains(t, out, "reader.read_string()") + assert.Contains(t, out, "Scalar.read_bool(reader)") +} + +func TestScalarMessage_EncodePresence(t *testing.T) { + t.Parallel() + out := runPlugin(t, []*descriptorpb.FileDescriptorProto{scalarMessageProto()}, "user.pony") + // String fields go through write_string_field (handles empty check). + assert.Contains(t, out, "writer.write_string_field(2, msg.name)") + // Numeric fields gate emission on != 0. + assert.Contains(t, out, "if msg.id != 0 then") + // Bool fields gate on the value itself. + assert.Contains(t, out, "if msg.active then") +} + +func TestEmptyMessage(t *testing.T) { + t.Parallel() + file := &descriptorpb.FileDescriptorProto{ + Name: proto.String("empty.proto"), + Syntax: proto.String("proto3"), + MessageType: []*descriptorpb.DescriptorProto{ + {Name: proto.String("Empty")}, + }, + } + out := runPlugin(t, []*descriptorpb.FileDescriptorProto{file}, "empty.pony") + assert.Contains(t, out, "class val Empty") + assert.Contains(t, out, "new val create() => None") + assert.Contains(t, out, "primitive EmptyCodec") +} + +// zooFileProto is a shared fixture for tests that need same-file message, +// enum, repeated, and unsupported shapes together. +func zooFileProto() *descriptorpb.FileDescriptorProto { + tags := field("tags", 2, descriptorpb.FieldDescriptorProto_TYPE_STRING) + tags.Label = descriptorpb.FieldDescriptorProto_LABEL_REPEATED.Enum() + + // Synthetic oneofs (proto3 `optional`) must come AFTER real oneofs in + // OneofDecl, so the real "kind" oneof is declared first at index 0. + typeA := field("type_a", 4, descriptorpb.FieldDescriptorProto_TYPE_STRING) + typeA.OneofIndex = proto.Int32(0) + typeB := field("type_b", 5, descriptorpb.FieldDescriptorProto_TYPE_INT32) + typeB.OneofIndex = proto.Int32(0) + + // proto3 explicit `optional` — synthesized into a single-field oneof + // at OneofIndex 1. + optCount := field("count", 3, descriptorpb.FieldDescriptorProto_TYPE_INT32) + optCount.Proto3Optional = proto.Bool(true) + optCount.OneofIndex = proto.Int32(1) + + parent := field("parent", 6, descriptorpb.FieldDescriptorProto_TYPE_MESSAGE) + parent.TypeName = proto.String(".zoo.Parent") + + status := field("status", 7, descriptorpb.FieldDescriptorProto_TYPE_ENUM) + status.TypeName = proto.String(".zoo.Status") + + // map — modeled in descriptors as a repeated MESSAGE + // field pointing at a synthetic nested MapEntry type with + // MessageOptions.map_entry=true. + metadata := field("metadata", 8, descriptorpb.FieldDescriptorProto_TYPE_MESSAGE) + metadata.Label = descriptorpb.FieldDescriptorProto_LABEL_REPEATED.Enum() + metadata.TypeName = proto.String(".zoo.Zoo.MetadataEntry") + mapEntry := &descriptorpb.DescriptorProto{ + Name: proto.String("MetadataEntry"), + Field: []*descriptorpb.FieldDescriptorProto{ + field("key", 1, descriptorpb.FieldDescriptorProto_TYPE_STRING), + field("value", 2, descriptorpb.FieldDescriptorProto_TYPE_INT32), + }, + Options: &descriptorpb.MessageOptions{MapEntry: proto.Bool(true)}, + } + + return &descriptorpb.FileDescriptorProto{ + Name: proto.String("zoo.proto"), + Package: proto.String("zoo"), + Syntax: proto.String("proto3"), + MessageType: []*descriptorpb.DescriptorProto{ + { + Name: proto.String("Zoo"), + Field: []*descriptorpb.FieldDescriptorProto{ + field("id", 1, descriptorpb.FieldDescriptorProto_TYPE_INT32), + tags, optCount, typeA, typeB, parent, status, metadata, + }, + NestedType: []*descriptorpb.DescriptorProto{mapEntry}, + OneofDecl: []*descriptorpb.OneofDescriptorProto{ + {Name: proto.String("kind")}, + {Name: proto.String("_count")}, + }, + }, + {Name: proto.String("Parent")}, + }, + EnumType: []*descriptorpb.EnumDescriptorProto{ + { + Name: proto.String("Status"), + Value: []*descriptorpb.EnumValueDescriptorProto{ + {Name: proto.String("UNKNOWN"), Number: proto.Int32(0)}, + {Name: proto.String("ACTIVE"), Number: proto.Int32(1)}, + }, + }, + }, + } +} + +func TestUnsupportedShapesEmitTodo(t *testing.T) { + t.Parallel() + out := runPlugin(t, []*descriptorpb.FileDescriptorProto{zooFileProto()}, "zoo.pony") + + assert.Contains(t, out, "let id: I32") + + // These shapes are now generated — confirm they are NOT TODO comments. + assert.Contains(t, out, "let tags: Array[String val] val") + assert.Contains(t, out, "let parent: (Parent val | None)") + assert.Contains(t, out, "let status: Status") + assert.Contains(t, out, "let count: (I32 | None)") + + // The `kind` oneof (type_a + type_b) is now supported via oneof codegen. + assert.Contains(t, out, "let kind: ZooKind") + assert.NotContains(t, out, "TODO protoc-gen-pony: field type_a") + assert.NotContains(t, out, "TODO protoc-gen-pony: field type_b") + + // map is now generated. + assert.Contains(t, out, "let metadata: Map[String val, I32] val") + assert.NotContains(t, out, "TODO protoc-gen-pony: field metadata") +} + +func TestEnumGeneration(t *testing.T) { + t.Parallel() + out := runPlugin(t, []*descriptorpb.FileDescriptorProto{zooFileProto()}, "zoo.pony") + + // Primitives for each enum value. + assert.Contains(t, out, "primitive Unknown fun value(): I32 => 0") + assert.Contains(t, out, "primitive Active fun value(): I32 => 1") + + // Raw class val preserves unknown numeric values (proto3 forward-compat). + assert.Contains(t, out, "class val StatusRaw") + assert.Contains(t, out, "fun value(): I32 => _v") + + // Type alias union includes the Raw fallback. + assert.Contains(t, out, "type Status is (Unknown | Active | StatusRaw)") + + // FromValue: all known values explicit, unknowns go to Raw(v). + assert.Contains(t, out, "primitive StatusFromValue") + assert.Contains(t, out, "| 0 => Unknown") + assert.Contains(t, out, "| 1 => Active") + assert.Contains(t, out, "else StatusRaw(v)") +} + +func TestEnumField_ClassAndCodec(t *testing.T) { + t.Parallel() + out := runPlugin(t, []*descriptorpb.FileDescriptorProto{zooFileProto()}, "zoo.pony") + + // Class declaration and constructor default. + assert.Contains(t, out, "let status: Status") + assert.Contains(t, out, "status': Status = Unknown") + + // Decode: reads an I32 and applies FromValue. + assert.Contains(t, out, "status = StatusFromValue(v)") + + // Encode: skips zero value. + assert.Contains(t, out, "if msg.status.value() != 0 then") + assert.Contains(t, out, "Scalar.write_int32(writer, msg.status.value())") +} + +func TestMessageField_ClassAndCodec(t *testing.T) { + t.Parallel() + out := runPlugin(t, []*descriptorpb.FileDescriptorProto{zooFileProto()}, "zoo.pony") + + // Class declaration and constructor default. + assert.Contains(t, out, "let parent: (Parent val | None)") + assert.Contains(t, out, "parent': (Parent val | None) = None") + + // Decode: reads len-delim bytes, hands to sub-codec. + assert.Contains(t, out, "match ParentCodec.decode(WireReader(b))") + assert.Contains(t, out, "| let v: Parent val => parent = v") + + // Encode: match on None, emit sub-writer only when present. + assert.Contains(t, out, "match msg.parent") + assert.Contains(t, out, "| let v: Parent val =>") + assert.Contains(t, out, "ParentCodec.encode(sub, v)") +} + +func TestRepeatedScalarField(t *testing.T) { + t.Parallel() + out := runPlugin(t, []*descriptorpb.FileDescriptorProto{zooFileProto()}, "zoo.pony") + + // Class field and constructor default. + assert.Contains(t, out, "let tags: Array[String val] val") + assert.Contains(t, out, "tags': Array[String val] val = recover val Array[String val] end") + + // Decode: trn accumulator + per-element read (string is never packed). + assert.Contains(t, out, "var tags: Array[String val] trn = recover trn Array[String val] end") + assert.Contains(t, out, "reader.read_string()") + assert.Contains(t, out, "| let v: String val => tags.push(v)") + + // Constructor call consumes the trn. + assert.Contains(t, out, "consume tags") + + // Encode: per-element (no packed blob for strings). + assert.Contains(t, out, "for v in msg.tags.values() do") + assert.Contains(t, out, "writer.write_tag(Tag(2, WireLenDelim))") + assert.Contains(t, out, "writer.write_string(v)") +} + +func TestRepeatedMessageField(t *testing.T) { + t.Parallel() + + item := field("item", 2, descriptorpb.FieldDescriptorProto_TYPE_MESSAGE) + item.Label = descriptorpb.FieldDescriptorProto_LABEL_REPEATED.Enum() + item.TypeName = proto.String(".pkg.Item") + + file := &descriptorpb.FileDescriptorProto{ + Name: proto.String("pkg.proto"), + Package: proto.String("pkg"), + Syntax: proto.String("proto3"), + MessageType: []*descriptorpb.DescriptorProto{ + { + Name: proto.String("Container"), + Field: []*descriptorpb.FieldDescriptorProto{item}, + }, + {Name: proto.String("Item")}, + }, + } + out := runPlugin(t, []*descriptorpb.FileDescriptorProto{file}, "pkg.pony") + + // Class field and default. + assert.Contains(t, out, "let item: Array[Item val] val") + assert.Contains(t, out, "item': Array[Item val] val = recover val Array[Item val] end") + + // Decode: trn accumulator, per-entry sub-codec. + assert.Contains(t, out, "var item: Array[Item val] trn = recover trn Array[Item val] end") + assert.Contains(t, out, "match ItemCodec.decode(WireReader(b))") + assert.Contains(t, out, "| let v: Item val => item.push(v)") + assert.Contains(t, out, "consume item") + + // Encode: one tag+len-delim per element. + assert.Contains(t, out, "for v in msg.item.values() do") + assert.Contains(t, out, "ItemCodec.encode(sub, v)") + assert.Contains(t, out, "writer.write_tag(Tag(2, WireLenDelim))") +} + +func TestNestedMessageFlatNaming(t *testing.T) { + t.Parallel() + file := &descriptorpb.FileDescriptorProto{ + Name: proto.String("nested.proto"), + Syntax: proto.String("proto3"), + MessageType: []*descriptorpb.DescriptorProto{ + { + Name: proto.String("Outer"), + NestedType: []*descriptorpb.DescriptorProto{ + { + Name: proto.String("Inner"), + Field: []*descriptorpb.FieldDescriptorProto{ + field("value", 1, descriptorpb.FieldDescriptorProto_TYPE_INT64), + }, + }, + }, + }, + }, + } + out := runPlugin(t, []*descriptorpb.FileDescriptorProto{file}, "nested.pony") + assert.Contains(t, out, "class val Outer") + assert.Contains(t, out, "class val Outer_Inner") + assert.Contains(t, out, "primitive Outer_InnerCodec") +} + +func TestFileHeaderHasSourceComment(t *testing.T) { + t.Parallel() + out := runPlugin(t, []*descriptorpb.FileDescriptorProto{scalarMessageProto()}, "user.pony") + assert.Contains(t, out, "// Generated by protoc-gen-pony. DO NOT EDIT.") + assert.Contains(t, out, "// Source: user.proto") +} + +func TestRepeatedNumericField(t *testing.T) { + t.Parallel() + + scores := field("scores", 2, descriptorpb.FieldDescriptorProto_TYPE_INT32) + scores.Label = descriptorpb.FieldDescriptorProto_LABEL_REPEATED.Enum() + + file := &descriptorpb.FileDescriptorProto{ + Name: proto.String("game.proto"), + Syntax: proto.String("proto3"), + MessageType: []*descriptorpb.DescriptorProto{ + {Name: proto.String("Game"), Field: []*descriptorpb.FieldDescriptorProto{scores}}, + }, + } + out := runPlugin(t, []*descriptorpb.FileDescriptorProto{file}, "game.pony") + + // Both packed (WireLenDelim) and unpacked (WireVarint) arms must be emitted. + assert.Contains(t, out, "(2, WireLenDelim)") + assert.Contains(t, out, "(2, WireVarint)") + // Packed arm uses sub-reader; unpacked arm reads directly. + assert.Contains(t, out, "Scalar.read_int32(sub)") + assert.Contains(t, out, "Scalar.read_int32(reader)") + + // Encode uses packed format. + assert.Contains(t, out, "if msg.scores.size() > 0 then") + assert.Contains(t, out, "Scalar.write_int32(sub, v)") +} + +func TestServiceEmitsTodo(t *testing.T) { + t.Parallel() + + file := &descriptorpb.FileDescriptorProto{ + Name: proto.String("svc.proto"), + Syntax: proto.String("proto3"), + Service: []*descriptorpb.ServiceDescriptorProto{ + {Name: proto.String("GreeterService")}, + }, + } + out := runPlugin(t, []*descriptorpb.FileDescriptorProto{file}, "svc.pony") + assert.Contains(t, out, "// TODO protoc-gen-pony: service GreeterService (service)") +} + +// "User-provided params still win" (main.go:50) relies on existing entries +// appearing verbatim after the injected stubs — protogen's later-wins +// semantics depend on it. Lock in: nothing dropped or reordered, including +// empty values and duplicates. +func TestOptionalScalarField(t *testing.T) { + t.Parallel() + + score := field("score", 2, descriptorpb.FieldDescriptorProto_TYPE_INT32) + score.Proto3Optional = proto.Bool(true) + score.OneofIndex = proto.Int32(0) + + file := &descriptorpb.FileDescriptorProto{ + Name: proto.String("player.proto"), + Syntax: proto.String("proto3"), + MessageType: []*descriptorpb.DescriptorProto{ + { + Name: proto.String("Player"), + Field: []*descriptorpb.FieldDescriptorProto{score}, + OneofDecl: []*descriptorpb.OneofDescriptorProto{ + {Name: proto.String("_score")}, + }, + }, + }, + } + out := runPlugin(t, []*descriptorpb.FileDescriptorProto{file}, "player.pony") + + // Class field and constructor default. + assert.Contains(t, out, "let score: (I32 | None)") + assert.Contains(t, out, "score': (I32 | None) = None") + + // Decode var: (I32 | None) + assert.Contains(t, out, "var score: (I32 | None) = None") + + // Encode: match on None (explicit presence — zero is emitted when set). + assert.Contains(t, out, "match msg.score") + assert.Contains(t, out, "| let v: I32 =>") + assert.NotContains(t, out, "if msg.score != 0") +} + +func TestOptionalEnumField(t *testing.T) { + t.Parallel() + + status := field("status", 1, descriptorpb.FieldDescriptorProto_TYPE_ENUM) + status.TypeName = proto.String(".opt_test.Color") + status.Proto3Optional = proto.Bool(true) + status.OneofIndex = proto.Int32(0) + + file := &descriptorpb.FileDescriptorProto{ + Name: proto.String("opt_test.proto"), + Package: proto.String("opt_test"), + Syntax: proto.String("proto3"), + MessageType: []*descriptorpb.DescriptorProto{ + { + Name: proto.String("Palette"), + Field: []*descriptorpb.FieldDescriptorProto{status}, + OneofDecl: []*descriptorpb.OneofDescriptorProto{ + {Name: proto.String("_status")}, + }, + }, + }, + EnumType: []*descriptorpb.EnumDescriptorProto{ + { + Name: proto.String("Color"), + Value: []*descriptorpb.EnumValueDescriptorProto{ + {Name: proto.String("RED"), Number: proto.Int32(0)}, + {Name: proto.String("BLUE"), Number: proto.Int32(1)}, + }, + }, + }, + } + out := runPlugin(t, []*descriptorpb.FileDescriptorProto{file}, "opt_test.pony") + + // Class field: (Color | None), default None. + assert.Contains(t, out, "let status: (Color | None)") + assert.Contains(t, out, "status': (Color | None) = None") + + // Encode: match on None (not zero-check). + assert.Contains(t, out, "match msg.status") + assert.Contains(t, out, "| let v: Color =>") + assert.NotContains(t, out, "if msg.status.value() != 0") +} + +func TestCrossFileSameDirectoryRef(t *testing.T) { + t.Parallel() + + addrField := field("address", 2, descriptorpb.FieldDescriptorProto_TYPE_MESSAGE) + addrField.TypeName = proto.String(".geo.Address") + + personFile := &descriptorpb.FileDescriptorProto{ + Name: proto.String("geo/person.proto"), + Package: proto.String("geo"), + Syntax: proto.String("proto3"), + Dependency: []string{"geo/address.proto"}, + MessageType: []*descriptorpb.DescriptorProto{ + { + Name: proto.String("Person"), + Field: []*descriptorpb.FieldDescriptorProto{addrField}, + }, + }, + } + addressFile := &descriptorpb.FileDescriptorProto{ + Name: proto.String("geo/address.proto"), + Package: proto.String("geo"), + Syntax: proto.String("proto3"), + MessageType: []*descriptorpb.DescriptorProto{ + {Name: proto.String("Address")}, + }, + } + out := runPlugin(t, []*descriptorpb.FileDescriptorProto{addressFile, personFile}, "geo/person.pony") + + // Cross-file same-directory ref should be generated, not TODO. + assert.Contains(t, out, "let address: (Address val | None)") + assert.NotContains(t, out, "TODO protoc-gen-pony: field address") + + // Sub-codec calls present. + assert.Contains(t, out, "AddressCodec.decode(WireReader(b))") + assert.Contains(t, out, "AddressCodec.encode(sub, v)") +} + +func TestOneofWrapperTypes(t *testing.T) { + t.Parallel() + out := runPlugin(t, []*descriptorpb.FileDescriptorProto{zooFileProto()}, "zoo.pony") + + // Wrapper classes for each oneof member. + assert.Contains(t, out, "class val ZooKindTypeA") + assert.Contains(t, out, "let value: String val") + assert.Contains(t, out, "new val create(value': String val = \"\") => value = value'") + + assert.Contains(t, out, "class val ZooKindTypeB") + assert.Contains(t, out, "let value: I32") + assert.Contains(t, out, "new val create(value': I32 = 0) => value = value'") + + // Type alias union includes None. + assert.Contains(t, out, "type ZooKind is (ZooKindTypeA | ZooKindTypeB | None)") +} + +func TestOneofConstructorParam(t *testing.T) { + t.Parallel() + out := runPlugin(t, []*descriptorpb.FileDescriptorProto{zooFileProto()}, "zoo.pony") + assert.Contains(t, out, "kind': ZooKind = None") + assert.Contains(t, out, "kind = kind'") +} + +func TestOneofDecode(t *testing.T) { + t.Parallel() + out := runPlugin(t, []*descriptorpb.FileDescriptorProto{zooFileProto()}, "zoo.pony") + + // Declare the oneof var. + assert.Contains(t, out, "var kind: ZooKind = None") + + // String arm (type_a = field 4) wraps in ZooKindTypeA. + assert.Contains(t, out, "(4, WireLenDelim)") + assert.Contains(t, out, "| let v: String val => kind = ZooKindTypeA(v)") + + // Int32 arm (type_b = field 5) wraps in ZooKindTypeB. + assert.Contains(t, out, "(5, WireVarint)") + assert.Contains(t, out, "| let v: I32 => kind = ZooKindTypeB(v)") + + // Constructor call includes kind. + assert.Contains(t, out, "kind)") +} + +func TestOneofEncode(t *testing.T) { + t.Parallel() + out := runPlugin(t, []*descriptorpb.FileDescriptorProto{zooFileProto()}, "zoo.pony") + + assert.Contains(t, out, "match msg.kind") + assert.Contains(t, out, "| let v: ZooKindTypeA =>") + assert.Contains(t, out, "writer.write_tag(Tag(4, WireLenDelim))") + assert.Contains(t, out, "writer.write_string(v.value)") + assert.Contains(t, out, "| let v: ZooKindTypeB =>") + assert.Contains(t, out, "writer.write_tag(Tag(5, WireVarint))") + assert.Contains(t, out, "Scalar.write_int32(writer, v.value)") + assert.Contains(t, out, "| None => None") +} + +func TestOneofWithMessageMember(t *testing.T) { + t.Parallel() + + childField := field("child", 2, descriptorpb.FieldDescriptorProto_TYPE_MESSAGE) + childField.TypeName = proto.String(".pkg.Child") + childField.OneofIndex = proto.Int32(0) + + numField := field("num", 3, descriptorpb.FieldDescriptorProto_TYPE_INT64) + numField.OneofIndex = proto.Int32(0) + + file := &descriptorpb.FileDescriptorProto{ + Name: proto.String("pkg.proto"), + Package: proto.String("pkg"), + Syntax: proto.String("proto3"), + MessageType: []*descriptorpb.DescriptorProto{ + { + Name: proto.String("Parent"), + Field: []*descriptorpb.FieldDescriptorProto{childField, numField}, + OneofDecl: []*descriptorpb.OneofDescriptorProto{ + {Name: proto.String("payload")}, + }, + }, + {Name: proto.String("Child")}, + }, + } + out := runPlugin(t, []*descriptorpb.FileDescriptorProto{file}, "pkg.pony") + + // Message member: no default in wrapper constructor (no = value). + assert.Contains(t, out, "class val ParentPayloadChild") + assert.Contains(t, out, "let value: Child val") + assert.Contains(t, out, "new val create(value': Child val) => value = value'") + assert.NotContains(t, out, "new val create(value': Child val =") + + // Int64 member has default. + assert.Contains(t, out, "class val ParentPayloadNum") + assert.Contains(t, out, "new val create(value': I64 = 0)") + + // Type alias. + assert.Contains(t, out, "type ParentPayload is (ParentPayloadChild | ParentPayloadNum | None)") + + // Decode: message arm reads sub-codec. + assert.Contains(t, out, "match ChildCodec.decode(WireReader(b))") + assert.Contains(t, out, "| let v: Child val => payload = ParentPayloadChild(v)") + + // Encode: message arm uses sub-writer. + assert.Contains(t, out, "| let v: ParentPayloadChild =>") + assert.Contains(t, out, "ChildCodec.encode(sub, v.value)") +} + +func TestOneofUnsupportedWhenMemberIsWKT(t *testing.T) { + t.Parallel() + + // google/protobuf/struct.proto is in the blocklist — a Value oneof member + // keeps the whole oneof as TODO. + valueField := field("payload", 2, descriptorpb.FieldDescriptorProto_TYPE_MESSAGE) + valueField.TypeName = proto.String(".google.protobuf.Value") + valueField.OneofIndex = proto.Int32(0) + strField := field("label", 3, descriptorpb.FieldDescriptorProto_TYPE_STRING) + strField.OneofIndex = proto.Int32(0) + + structFile := &descriptorpb.FileDescriptorProto{ + Name: proto.String("google/protobuf/struct.proto"), + Package: proto.String("google.protobuf"), + Syntax: proto.String("proto3"), + MessageType: []*descriptorpb.DescriptorProto{{Name: proto.String("Value")}}, + } + eventFile := &descriptorpb.FileDescriptorProto{ + Name: proto.String("event.proto"), + Package: proto.String("event"), + Syntax: proto.String("proto3"), + Dependency: []string{"google/protobuf/struct.proto"}, + MessageType: []*descriptorpb.DescriptorProto{ + { + Name: proto.String("Event"), + Field: []*descriptorpb.FieldDescriptorProto{valueField, strField}, + OneofDecl: []*descriptorpb.OneofDescriptorProto{ + {Name: proto.String("when")}, + }, + }, + }, + } + out := runPlugin(t, []*descriptorpb.FileDescriptorProto{structFile, eventFile}, "event.pony") + + // Whole oneof stays TODO because one member (Value from struct.proto) is blocked. + assert.Contains(t, out, "TODO protoc-gen-pony: field payload") + assert.Contains(t, out, "TODO protoc-gen-pony: field label") + assert.NotContains(t, out, "type EventWhen") +} + +func TestCrossDirectoryRef(t *testing.T) { + t.Parallel() + + addrField := field("address", 2, descriptorpb.FieldDescriptorProto_TYPE_MESSAGE) + addrField.TypeName = proto.String(".common.Address") + + personFile := &descriptorpb.FileDescriptorProto{ + Name: proto.String("geo/person.proto"), + Package: proto.String("geo"), + Syntax: proto.String("proto3"), + Dependency: []string{"common/address.proto"}, + MessageType: []*descriptorpb.DescriptorProto{ + { + Name: proto.String("Person"), + Field: []*descriptorpb.FieldDescriptorProto{addrField}, + }, + }, + } + addressFile := &descriptorpb.FileDescriptorProto{ + Name: proto.String("common/address.proto"), + Package: proto.String("common"), + Syntax: proto.String("proto3"), + MessageType: []*descriptorpb.DescriptorProto{ + {Name: proto.String("Address")}, + }, + } + out := runPlugin(t, []*descriptorpb.FileDescriptorProto{addressFile, personFile}, "geo/person.pony") + + // use directive for the cross-directory dep. + assert.Contains(t, out, `use "../common"`) + + // Field generated (not TODO). + assert.Contains(t, out, "let address: (Address val | None)") + assert.NotContains(t, out, "TODO protoc-gen-pony: field address") + + // Codec calls generated. + assert.Contains(t, out, "AddressCodec.decode(WireReader(b))") + assert.Contains(t, out, "AddressCodec.encode(sub, v)") +} + +func TestCrossDirectoryDedupedUse(t *testing.T) { + t.Parallel() + + cityField := field("city", 2, descriptorpb.FieldDescriptorProto_TYPE_MESSAGE) + cityField.TypeName = proto.String(".common.City") + countryField := field("country", 3, descriptorpb.FieldDescriptorProto_TYPE_MESSAGE) + countryField.TypeName = proto.String(".common.Country") + + personFile := &descriptorpb.FileDescriptorProto{ + Name: proto.String("geo/person.proto"), + Package: proto.String("geo"), + Syntax: proto.String("proto3"), + Dependency: []string{"common/city.proto", "common/country.proto"}, + MessageType: []*descriptorpb.DescriptorProto{ + { + Name: proto.String("Person"), + Field: []*descriptorpb.FieldDescriptorProto{cityField, countryField}, + }, + }, + } + cityFile := &descriptorpb.FileDescriptorProto{ + Name: proto.String("common/city.proto"), + Package: proto.String("common"), + Syntax: proto.String("proto3"), + MessageType: []*descriptorpb.DescriptorProto{ + {Name: proto.String("City")}, + }, + } + countryFile := &descriptorpb.FileDescriptorProto{ + Name: proto.String("common/country.proto"), + Package: proto.String("common"), + Syntax: proto.String("proto3"), + MessageType: []*descriptorpb.DescriptorProto{ + {Name: proto.String("Country")}, + }, + } + out := runPlugin(t, []*descriptorpb.FileDescriptorProto{cityFile, countryFile, personFile}, "geo/person.pony") + + // Only one use directive for common/ even though two deps come from there. + assert.Equal(t, 1, strings.Count(out, `use "../common"`)) +} + +func TestWKTRefEmitsTodo(t *testing.T) { + t.Parallel() + + // google/protobuf/struct.proto is in the blocklist (circular Value type). + valueField := field("payload", 2, descriptorpb.FieldDescriptorProto_TYPE_MESSAGE) + valueField.TypeName = proto.String(".google.protobuf.Value") + + structFile := &descriptorpb.FileDescriptorProto{ + Name: proto.String("google/protobuf/struct.proto"), + Package: proto.String("google.protobuf"), + Syntax: proto.String("proto3"), + MessageType: []*descriptorpb.DescriptorProto{ + {Name: proto.String("Value")}, + }, + } + eventFile := &descriptorpb.FileDescriptorProto{ + Name: proto.String("events/event.proto"), + Package: proto.String("events"), + Syntax: proto.String("proto3"), + Dependency: []string{"google/protobuf/struct.proto"}, + MessageType: []*descriptorpb.DescriptorProto{ + { + Name: proto.String("Event"), + Field: []*descriptorpb.FieldDescriptorProto{valueField}, + }, + }, + } + out := runPlugin(t, []*descriptorpb.FileDescriptorProto{structFile, eventFile}, "events/event.pony") + + // Blocked WKT field stays as TODO. + assert.Contains(t, out, "TODO protoc-gen-pony: field payload") + + // No use directive for blocked WKT. + assert.NotContains(t, out, `use "../google/protobuf"`) +} + +func TestWKT_TimestampGenerates(t *testing.T) { + t.Parallel() + + tsField := field("created_at", 2, descriptorpb.FieldDescriptorProto_TYPE_MESSAGE) + tsField.TypeName = proto.String(".google.protobuf.Timestamp") + + tsFile := &descriptorpb.FileDescriptorProto{ + Name: proto.String("google/protobuf/timestamp.proto"), + Package: proto.String("google.protobuf"), + Syntax: proto.String("proto3"), + MessageType: []*descriptorpb.DescriptorProto{ + { + Name: proto.String("Timestamp"), + Field: []*descriptorpb.FieldDescriptorProto{ + field("seconds", 1, descriptorpb.FieldDescriptorProto_TYPE_INT64), + field("nanos", 2, descriptorpb.FieldDescriptorProto_TYPE_INT32), + }, + }, + }, + } + eventFile := &descriptorpb.FileDescriptorProto{ + Name: proto.String("events/event.proto"), + Package: proto.String("events"), + Syntax: proto.String("proto3"), + Dependency: []string{"google/protobuf/timestamp.proto"}, + MessageType: []*descriptorpb.DescriptorProto{ + { + Name: proto.String("Event"), + Field: []*descriptorpb.FieldDescriptorProto{tsField}, + }, + }, + } + out := runPlugin(t, []*descriptorpb.FileDescriptorProto{tsFile, eventFile}, "events/event.pony") + + // Timestamp generates as a real class (not TODO). + assert.Contains(t, out, "let created_at: (Timestamp val | None)") + assert.NotContains(t, out, "TODO protoc-gen-pony: field created_at") + + // Cross-dir use directive emitted. + assert.Contains(t, out, `use "../google/protobuf"`) +} + +// cleanDirSegs filters an arbitrary []string down to a valid proto directory +// path (slash-separated ASCII alphanumeric components). Returns ("", false) +// when no valid segments remain. +func cleanDirSegs(segs []string) (string, bool) { + var out []string + for _, s := range segs { + clean := strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { + return r + } + return -1 + }, s) + if clean != "" { + out = append(out, clean) + } + } + if len(out) == 0 { + return "", false + } + return strings.Join(out, "/"), true +} + +// TestProtoRelDir_Inverse verifies that joining `from` with the result of +// protoRelDir always resolves back to `to`. +func TestProtoRelDir_Inverse(t *testing.T) { + f := func(fromSegs, toSegs []string) bool { + from, ok1 := cleanDirSegs(fromSegs) + to, ok2 := cleanDirSegs(toSegs) + if !ok1 || !ok2 || from == to { + return true // skip: preconditions not met + } + return path.Join(from, protoRelDir(from, to)) == to + } + if err := quick.Check(f, &quick.Config{MaxCount: 2000}); err != nil { + t.Error(err) + } +} + +// TestSnakeToPascal_NeverContainsUnderscore verifies that snakeToPascal removes +// all underscores regardless of the input string. +func TestSnakeToPascal_NeverContainsUnderscore(t *testing.T) { + f := func(s string) bool { + return !strings.Contains(snakeToPascal(s), "_") + } + if err := quick.Check(f, &quick.Config{MaxCount: 2000}); err != nil { + t.Error(err) + } +} + +func TestMapField_ClassAndCodec(t *testing.T) { + t.Parallel() + out := runPlugin(t, []*descriptorpb.FileDescriptorProto{zooFileProto()}, "zoo.pony") + + // Class field and constructor default. + assert.Contains(t, out, "let metadata: Map[String val, I32] val") + assert.Contains(t, out, "metadata': Map[String val, I32] val = recover val Map[String val, I32] end") + + // Decode: trn accumulator, entry sub-reader, key/value arms, final assign. + assert.Contains(t, out, "var metadata: Map[String val, I32] trn = recover trn Map[String val, I32] end") + assert.Contains(t, out, "let entry_sub = WireReader(b)") + assert.Contains(t, out, "var entry_k: String val") + assert.Contains(t, out, "var entry_v: I32") + assert.Contains(t, out, "metadata(entry_k) = entry_v") + assert.Contains(t, out, "consume metadata") + + // Encode: pairs() loop, always-write key + value. + assert.Contains(t, out, "for (k, v) in msg.metadata.pairs() do") + assert.Contains(t, out, "sub.write_string(k)") + assert.Contains(t, out, "Scalar.write_int32(sub, v)") +} + +func TestMapField_UseCollections(t *testing.T) { + t.Parallel() + out := runPlugin(t, []*descriptorpb.FileDescriptorProto{zooFileProto()}, "zoo.pony") + assert.Contains(t, out, `use "collections"`) +} + +func TestMapField_MessageValue(t *testing.T) { + t.Parallel() + + entryField := field("items", 1, descriptorpb.FieldDescriptorProto_TYPE_MESSAGE) + entryField.Label = descriptorpb.FieldDescriptorProto_LABEL_REPEATED.Enum() + entryField.TypeName = proto.String(".pkg.Container.ItemsEntry") + mapEntry := &descriptorpb.DescriptorProto{ + Name: proto.String("ItemsEntry"), + Field: []*descriptorpb.FieldDescriptorProto{ + field("key", 1, descriptorpb.FieldDescriptorProto_TYPE_STRING), + { + Name: proto.String("value"), + Number: proto.Int32(2), + Label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum(), + Type: descriptorpb.FieldDescriptorProto_TYPE_MESSAGE.Enum(), + TypeName: proto.String(".pkg.Item"), + JsonName: proto.String("value"), + }, + }, + Options: &descriptorpb.MessageOptions{MapEntry: proto.Bool(true)}, + } + file := &descriptorpb.FileDescriptorProto{ + Name: proto.String("pkg.proto"), + Package: proto.String("pkg"), + Syntax: proto.String("proto3"), + MessageType: []*descriptorpb.DescriptorProto{ + { + Name: proto.String("Container"), + Field: []*descriptorpb.FieldDescriptorProto{entryField}, + NestedType: []*descriptorpb.DescriptorProto{mapEntry}, + }, + {Name: proto.String("Item")}, + }, + } + out := runPlugin(t, []*descriptorpb.FileDescriptorProto{file}, "pkg.pony") + + // map now generates. + assert.Contains(t, out, "let items: Map[String val, Item val] val") + assert.NotContains(t, out, "TODO protoc-gen-pony: field items") + + // codec.default() emitted for Item and Container. + assert.Contains(t, out, "fun default(): Item val => Item") + assert.Contains(t, out, "fun default(): Container val => Container") + + // Decode: sub-codec with ItemCodec.default() as initial value. + assert.Contains(t, out, "var entry_v: Item val = ItemCodec.default()") + assert.Contains(t, out, "match ItemCodec.decode(WireReader(vb))") + + // Encode: sub-writer for value. + assert.Contains(t, out, "ItemCodec.encode(vsub, v)") + + assert.Contains(t, out, `use "collections"`) +} + +func TestMapField_EnumValue(t *testing.T) { + t.Parallel() + + entryField := field("by_status", 1, descriptorpb.FieldDescriptorProto_TYPE_MESSAGE) + entryField.Label = descriptorpb.FieldDescriptorProto_LABEL_REPEATED.Enum() + entryField.TypeName = proto.String(".pkg.Lookup.ByStatusEntry") + mapEntry := &descriptorpb.DescriptorProto{ + Name: proto.String("ByStatusEntry"), + Field: []*descriptorpb.FieldDescriptorProto{ + field("key", 1, descriptorpb.FieldDescriptorProto_TYPE_STRING), + { + Name: proto.String("value"), + Number: proto.Int32(2), + Label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum(), + Type: descriptorpb.FieldDescriptorProto_TYPE_ENUM.Enum(), + TypeName: proto.String(".pkg.Color"), + JsonName: proto.String("value"), + }, + }, + Options: &descriptorpb.MessageOptions{MapEntry: proto.Bool(true)}, + } + file := &descriptorpb.FileDescriptorProto{ + Name: proto.String("pkg.proto"), + Package: proto.String("pkg"), + Syntax: proto.String("proto3"), + MessageType: []*descriptorpb.DescriptorProto{ + { + Name: proto.String("Lookup"), + Field: []*descriptorpb.FieldDescriptorProto{entryField}, + NestedType: []*descriptorpb.DescriptorProto{mapEntry}, + }, + }, + EnumType: []*descriptorpb.EnumDescriptorProto{ + { + Name: proto.String("Color"), + Value: []*descriptorpb.EnumValueDescriptorProto{ + {Name: proto.String("RED"), Number: proto.Int32(0)}, + {Name: proto.String("BLUE"), Number: proto.Int32(1)}, + }, + }, + }, + } + out := runPlugin(t, []*descriptorpb.FileDescriptorProto{file}, "pkg.pony") + + // Class field uses enum type as map value. + assert.Contains(t, out, "let by_status: Map[String val, Color] val") + + // Decode: FromValue applied to I32. + assert.Contains(t, out, "| let vv: I32 => entry_v = ColorFromValue(vv)") + + // Encode: .value() call on enum. + assert.Contains(t, out, "Scalar.write_int32(sub, v.value())") + + // use "collections" emitted. + assert.Contains(t, out, `use "collections"`) +} + +func TestInjectGoImportStubs_PreservesExistingParameters(t *testing.T) { + t.Parallel() + const existing = "foo=1,bar=,foo=2" + req := &pluginpb.CodeGeneratorRequest{ + ProtoFile: []*descriptorpb.FileDescriptorProto{ + {Name: proto.String("user.proto")}, + {Name: proto.String("admin.proto")}, + }, + Parameter: proto.String(existing), + } + injectGoImportStubs(req) + assert.Equal(t, + "Muser.proto=protoc-gen-pony/stub,Madmin.proto=protoc-gen-pony/stub,"+existing, + req.GetParameter()) +}