diff --git a/protoc-gen-kaja/NELSON.md b/protoc-gen-kaja/NELSON.md index 59aa7186..4e297891 100644 --- a/protoc-gen-kaja/NELSON.md +++ b/protoc-gen-kaja/NELSON.md @@ -30,3 +30,157 @@ You are running inside an automated loop. **Each invocation is stateless** — y - **Keep Notes as an attack playbook.** Good: "Boolean map keys — Go returns 'boolean', TS returns 'string'. Tested in 300_bool_map_key." Bad: "Good progress finding bugs." ## Notes + +### Successfully exploited +- **WKT-typed custom field options** — When a custom field option uses a Well-Known Type (e.g. `google.protobuf.Duration`, `google.protobuf.Timestamp`) as its message type, the Go plugin drops the option entirely. Root cause: `findMessageType` only searched direct dependencies, not all files. Fixed by RALPH. Tested in `239_wkt_custom_option`. +- **Hyphenated json_name in custom option messages** — When a message used as a custom option value has fields with `json_name` containing non-identifier characters (hyphens, spaces, etc.), the Go plugin emits the key unquoted (`my-value: ...`) while TS quotes it (`"my-value": ...`). Root cause: `formatCustomOptions` only quotes keys containing `.` or starting with a digit, but doesn't check for other special chars. The TS `typescriptLiteralFromValue` uses regex `/^(?![0-9])[a-zA-Z0-9$_]+$/` to decide quoting. Tested in `240_custom_option_hyphen_json_name`. +- **Control characters in custom option strings** — The Go plugin's `formatCustomOptions` only escapes `\`, `"`, `\n`, `\r`, `\t` in string values. But the TS plugin uses TypeScript's `createStringLiteral` + printer which also escapes `\v` (vertical tab, 0x0B), `\f` (form feed), `\b` (backspace), `\0` (null), and other control characters via `\uXXXX`. So a string like `"hello\vworld"` is emitted correctly by TS but the Go plugin emits the raw 0x0B byte. Root cause: incomplete string escaping in `formatCustomOptions`. Tested in `241_custom_option_string_vtab`. +- **Integer map key ordering in custom options** — When a custom option message has a `map` field, the TS plugin uses `type.toJson(type.fromBinary(...))` which creates a JavaScript object. JS engines sort integer-index keys (valid array indices 0..2^32-2) in ascending numeric order regardless of insertion order. So keys added as 10, 1 become `{"1": ..., "10": ...}`. The Go plugin preserves wire order, so the same entries stay as `{"10": ..., "1": ...}`. Root cause: `mergeRepeatedOptions` preserves wire order; needs to sort integer-like map keys numerically. Tested in `242_custom_map_int_key_order`. + +- **Single-element repeated field in custom option** — When a custom option message has a `repeated string` field set with a single value, the TS plugin emits it as an array `["solo"]` (via `type.toJson()` which always wraps repeated fields in arrays). The Go plugin's `mergeRepeatedOptions` only creates arrays when there are MULTIPLE entries with the same key; a single entry is left unwrapped as `"solo"`. Root cause: Go doesn't distinguish between singular and repeated fields — it relies on merge count, not field cardinality. Tested in `243_custom_option_repeated_single`. + +- **U+2028/U+2029 LINE/PARAGRAPH SEPARATOR in custom option strings** — TypeScript's printer escapes U+2028 (LINE SEPARATOR) and U+2029 (PARAGRAPH SEPARATOR) as `\u2028` and `\u2029` because they are not valid unescaped in JS string literals (pre-ES2019). The Go plugin's `escapeStringForJS` only escapes characters < U+0020, so it emits the raw UTF-8 bytes for these characters. Same issue applies to U+0085 (NEXT LINE), U+00A0 (NO-BREAK SPACE), and U+FEFF (BOM). Root cause: `escapeStringForJS` doesn't escape non-ASCII characters that TypeScript's printer escapes. Tested in `244_custom_option_string_linesep`. + +- **Single-element repeated top-level extension** — When a `repeated string` top-level extension (extending e.g. `MessageOptions`) has a single value, the TS plugin emits `["solo"]` (array) but the Go plugin emits `"solo"` (bare string). Root cause: `parseCustomOptions` calls `mergeRepeatedOptions` which only creates arrays on duplicate keys, but unlike `parseMessageValue`, it has NO post-merge wrapping logic to force single repeated values into arrays. The fix in `parseMessageValue` (lines 1066-1084) checks `fd.GetLabel() == LABEL_REPEATED` and wraps, but `parseCustomOptions` doesn't have access to the field label or doesn't apply the same check. Tested in `245_repeated_extension_single`. + +- **String map key escaping in custom options** — When a `map` custom option has keys containing special characters (backslash, double quotes), the Go plugin wraps the key in quotes via `needsQuoteAsPropertyKey` but does NOT escape the content. `formatCustomOptions` line 1177 does `fmt.Sprintf("\"%s\"", opt.key)` which inserts the raw string. So a key `back\slash` becomes `"back\slash"` (where `\s` is interpreted as an escape in JS) instead of `"back\\slash"`. Same issue with double quotes: key `has"quote` becomes `"has"quote"` (broken string literal) instead of `"has\"quote"`. Root cause: `formatCustomOptions` needs to call `escapeStringForJS` on map keys before quoting. Tested in `246_custom_map_string_key_escape`. + +- **U+0085 NEXT LINE in custom option strings** — TypeScript's compiler `escapeString` regex explicitly includes `\u0085`: `/[\\\"\u0000-\u001f\t\v\f\b\r\n\u2028\u2029\u0085]/g`. The escape map has `"\u0085": "\\u0085"`. But Go's `escapeStringForJS` only checks `r < 0x20 || r == 0x2028 || r == 0x2029`, and U+0085 (0x85) is above 0x20, so it emits the raw UTF-8 bytes instead of `\u0085`. Root cause: missing U+0085 check in `escapeStringForJS`. Tested in `247_custom_option_string_nextline`. + +- **Null byte followed by digit in custom option strings** — TypeScript's `getReplacement` function in `escapeString` has special handling: when `\0` (null byte) is followed by a digit character (0-9), it emits `\x00` instead of `\0` to avoid creating an octal escape sequence. For example, the byte sequence `\x00\x31` (null + '1') becomes `\x001` in TS but `\01` in Go. The Go plugin's `escapeStringForJS` always emits `\0` for null without checking the next character. Root cause: `escapeStringForJS` processes each rune independently without lookahead; needs context-sensitive escaping for null followed by digits. Tested in `248_custom_option_string_null_digit`. + +- **Cross-file extension ordering in custom options** — When multiple extensions targeting the same options type (e.g. `FieldOptions`) are defined in different files, the TS plugin collects them in registry iteration order (= file processing order, i.e. the order files appear in the `CodeGeneratorRequest.proto_file` array). The Go plugin reads extensions from wire bytes which are in **field-number order** (protoc serializes fields by number). So if `a_options.proto` defines extension at field 50002 and `b_options.proto` defines one at field 50001, and `a_options.proto` is imported first: TS outputs `alpha_tag, beta_tag` (file order) while Go outputs `beta_tag, alpha_tag` (field-number order). Root cause: `parseCustomOptions` iterates wire bytes in field-number order, but the TS plugin's interpreter iterates extensions in registry order (file-processing order). Fix requires building the extension map with ordering info from the protoc descriptor's file ordering, then emitting options in that order instead of wire order. Tested in `249_custom_option_cross_file_order`. + +- **Custom option message field declaration order** — When a message used as a custom option has fields declared in a different order than their field numbers (e.g. `string beta = 2; string alpha = 1;`), the TS plugin emits JSON keys in **declaration order** (beta, alpha) because `ReflectionJsonWriter.write()` iterates `this.fields` which is built from `buildFieldInfos(descriptor.fields)` preserving .proto declaration order. The Go plugin emits keys in **field-number order** (alpha, beta) because `parseMessageValue` reads wire bytes sequentially and protoc serializes fields by field number. Root cause: `parseMessageValue` outputs fields in wire order (= field number order) instead of .proto declaration order. Fix requires reordering `parseMessageValue` output to match the field declaration order from the descriptor. Tested in `250_custom_option_field_order`. + +- **DEL and C1 control codes over-escaped in custom option strings** — The Go plugin's `escapeStringForJS` escapes characters in range `0x7F-0x9F` (DEL + C1 control codes) and `U+FEFF` (BOM) as `\uXXXX`. But the TS compiler's `escapeString` regex only covers `\u0000-\u001F`, `\u2028`, `\u2029`, and `\u0085`. It does NOT cover DEL (0x7F), C1 codes 0x80-0x84, 0x86-0x9F, or U+FEFF. So the TS plugin leaves these as raw UTF-8 bytes while the Go plugin escapes them. Root cause: `escapeStringForJS` condition `r >= 0x7F && r <= 0x9F` is too broad — should only match `r == 0x0085`. And `r == 0xFEFF` should be removed entirely. Tested in `251_custom_option_string_del`. + +- **Lowercase hex digits in \uXXXX escapes** — The Go plugin's `escapeStringForJS` uses `fmt.Fprintf(&b, "\u%04x", r)` which produces lowercase hex digits (e.g. `\ufeff`). The TS compiler uses uppercase hex digits (e.g. `\uFEFF`). This only manifests for characters whose codepoints contain A-F digits — characters like U+0085, U+2028, U+2029 have no A-F digits so pass unnoticed. U+FEFF (BOM) makes it obvious: Go emits `\ufeff`, TS emits `\uFEFF`. Root cause: `%04x` in fmt format string should be `%04X`. Tested in `252_custom_option_string_bom`. + +- **Non-ASCII characters unescaped in custom option strings** — The TS compiler's printer uses `escapeNonAsciiString` (not `escapeString`) for string literals. This first runs `escapeString` (handling C0 controls, `\u2028`, `\u2029`, `\u0085`), then additionally escapes ALL characters above U+007F as `\uXXXX` via `/[^\u0000-\u007F]/g`. So any non-ASCII character like `é` (U+00E9), `ñ` (U+00F1), `中` (U+4E2D) gets escaped. The Go plugin's `escapeStringForJS` only escapes specific ranges (C0, C1, line/para separators, BOM) and leaves regular non-ASCII characters as raw UTF-8. For example, `"café"` → TS: `"caf\u00E9"`, Go: `"café"`. Root cause: `escapeStringForJS` needs to escape ALL non-ASCII characters (charCode > 127), not just specific ranges. Tested in `253_custom_option_string_nonascii`. + +- **Supplementary plane characters (emoji) use wrong escape syntax** — For characters above U+FFFF (like emoji 🎉 U+1F389), the Go plugin's `escapeStringForJS` uses ES2015 `\u{1F389}` syntax. But the TS compiler's `escapeNonAsciiString` processes JavaScript strings as UTF-16 code units — `"🎉".charCodeAt(0)` returns 0xD83C and `"🎉".charCodeAt(1)` returns 0xDF89 — so the regex matches each surrogate individually, producing `\uD83C\uDF89`. Root cause: Go iterates runes (full codepoints) and emits `\u{...}` for >0xFFFF, but TS works with UTF-16 code units and emits surrogate pairs. Fix: for runes > 0xFFFF, compute the UTF-16 surrogate pair (hi = 0xD800 + ((r-0x10000)>>10), lo = 0xDC00 + ((r-0x10000)&0x3FF)) and emit `\uHHHH\uLLLL`. Tested in `254_custom_option_string_emoji`. + +- **Proto2 GROUP field causes field options indexing mismatch** — The TS plugin's interpreter builds `type.fields` via `buildFieldInfos(descriptor.fields)` which SKIPS GROUP fields. But the option-assignment loop `for (let i = 0; i < type.fields.length; i++) { fi.options = this.readOptions(descriptor.fields[i], ...) }` uses `descriptor.fields[i]` (which INCLUDES GROUP fields) indexed by `i` from `type.fields` (which EXCLUDES them). When a GROUP field exists between other fields, the indices go out of sync: fields after the GROUP get the WRONG field's options (or no options at all). The Go plugin reads each field's options directly from its own `FieldDescriptorProto.Options`, so it produces correct output. Result: TS drops custom options on fields that follow a GROUP field; Go includes them correctly. Tested in `255_group_field_options`. + +- **Enum alias resolution in custom option values** — When a custom option message has an enum field whose enum uses `allow_alias` (multiple names for the same numeric value), the TS plugin uses the LAST alias name while the Go plugin uses the FIRST. Root cause: TS `RuntimeEnumBuilder.build()` does `object[v.number] = v.name` in a loop — JS object key assignment overwrites, so the last value added for a given number wins. Then `toJson()` uses `enumObject[numericValue]` to get the name, producing the last alias. Go's `resolveEnumValueName` iterates `enum.Value` and returns on the FIRST match. Fix: `resolveEnumValueName` should iterate in REVERSE order (or track the last matching value) to match JS object overwrite semantics. Tested in `256_custom_option_enum_alias`. + +- **Proto2 required fields with default values in custom options** — When a custom option message (proto2) has `required` fields and the option sets them to their default values (0 for int32, "" for string), the TS plugin omits them from the output while the Go plugin includes them. Root cause: The TS plugin's `ReflectionJsonWriter.scalar()` checks `ed = emitDefaultValues || optional`. For required fields, `field.opt = false` (since `buildFieldInfos` only sets `opt = true` for proto2 OPTIONAL and proto3 optional fields). With `emitDefaultValues = false` (default) and `optional = false`, `ed = false`, so `value === 0` returns `undefined` → field is omitted from JSON. But protoc DOES serialize required fields even with default values in the binary wire data. The Go plugin's `parseMessageValue` reads ALL wire data without checking for defaults, so it includes `count: 0, label: ""`. Fix: `parseMessageValue` should check if a field is required with a default value and skip it, OR replicate the TS `toJson()` behavior of omitting default non-optional values. Tested in `257_required_default_option`. + +- **Proto2 optional fields with default values in custom options** — When a custom option message (proto2) has `optional` fields set to their default values (0 for int32, "" for string), the TS plugin INCLUDES them because `field.opt = true` for proto2 optional fields → `ed = emitDefaultValues || optional = false || true = true` → value IS emitted. But the Go plugin's `isDefaultValue` function doesn't distinguish between required and optional — it unconditionally filters all default values. So `{ count: 0, label: "" }` is emitted by TS but becomes `{}` in Go. Root cause: `isDefaultValue` needs to check whether the field is proto2 optional (or proto3 explicit optional) before filtering defaults. For optional/explicit-optional fields, defaults should NOT be filtered. Only proto3 implicit non-optional fields and proto2 required fields should have defaults omitted. Fix: `isDefaultValue` (or its caller in `parseMessageValue`) must look at `fd.GetLabel()` and the file syntax to decide whether to skip defaults. Tested in `258_optional_default_option`. + +- **google.protobuf.NullValue enum in custom option message** — The TS runtime's `ReflectionJsonWriter.enum()` has special handling for `google.protobuf.NullValue`: when `type[0] == 'google.protobuf.NullValue'`, it returns JSON `null` (not the enum value name string). So a proto2 optional `NullValue` field set to `NULL_VALUE` (value 0) outputs `nullField: null` in TS. The Go plugin has no NullValue-specific logic in `resolveEnumValueName` or `formatCustomOptions` — it resolves the enum name "NULL_VALUE" and outputs it as a quoted string `nullField: "NULL_VALUE"`. Root cause: `parseMessageValue` and `formatCustomOptions` don't know about NullValue's special JSON semantics. Fix: when an enum field's type is `.google.protobuf.NullValue`, emit `null` instead of the resolved enum name string. Tested in `259_custom_option_null_value`. + +- **Map field with int64 value and jstype = JS_NORMAL** — When a `map` field has `[jstype = JS_NORMAL]`, the TS plugin propagates the jstype to the map value: (1) TypeScript type becomes `bigint` instead of `string`, (2) field info V object gets `L: 0 /*LongType.BIGINT*/`, (3) binary read uses `.toBigInt()` instead of `.toString()`, (4) default value uses `0n` instead of `"0"`. The Go plugin ignores jstype on map fields entirely — `getBaseTypescriptType` for map value fields doesn't check the parent field's jstype, and the field info generation for maps doesn't add `L` to the V object. Root cause: multiple places in the Go plugin handle int64 jstype for regular scalar fields but skip map value fields: `getBaseTypescriptType` (called for map values via `getTypescriptType`), field info generation (line 4338-4341), binary read map helper, and binary write map helper. Fix: when a map field has a jstype option and the value is an int64 type, propagate the jstype to the value type in all code paths. Tested in `260_map_int64_jstype`. + +- **Proto3 oneof scalar/enum fields set to default values in custom options** — The TS plugin's `ReflectionJsonWriter.write()` forces `emitDefaultValues: true` for the selected scalar/enum oneof member: `const opt = field.kind == 'scalar' || field.kind == 'enum' ? { ...options, emitDefaultValues: true } : options;`. So when a oneof string is set to `""`, int32 to `0`, or bool to `false`, the value IS included in the JSON output. The Go plugin's `isDefaultValue` filtering checks `hasPresence = fd.GetProto3Optional()` for proto3, which returns `false` for oneof fields (they're not proto3_optional). So it filters out the default-valued oneof field, producing `{}` instead of `{ text: "" }`. Root cause: `parseMessageValue`'s default-value filtering doesn't account for proto3 oneof fields needing their defaults emitted. Fix: when checking `hasPresence`, also check if the field is part of a oneof (`fd.OneofIndex != nil` and not synthetic/proto3_optional) — oneof members with explicit values always have presence. Tested in `261_custom_option_oneof_default`. + +- **Proto3 optional field named `constructor` crashes TS plugin** — When a proto3 `optional string constructor = 1` field exists, the TS plugin's `InternalBinaryWrite.scalar()` creates a synthetic MessageType and calls `.create()` to get the default value for write conditions. The `create()` method skips optional fields (`if (fi.opt) continue;`), so `message["constructor"]` reads `Object.prototype.constructor` (the built-in `Object` function). Then `typescriptLiteralFromValue(Object)` throws "Unexpected object: function Object() { [native code] }". The Go plugin handles this correctly — it hardcodes write conditions without runtime evaluation. Same crash affects proto2 optional `constructor` fields but NOT `constructor` in a oneof (different code path). Also applies to other Object.prototype properties like `hasOwnProperty`, `valueOf` when used as optional field names. Root cause: TS plugin bug in `InternalBinaryWrite.scalar()` line 110 — runtime evaluation of default values exposes prototype pollution. Fix: RALPH should replicate the TS crash by returning an error for optional fields named `constructor` (or other Object.prototype properties), OR find a way to generate compatible output. Tested in `263_constructor_optional_field`. + +- **create() property order for colliding localNames** — When two fields have different proto names but identical `lowerCamelCase` results (e.g., `x_1_y` and `x1y` both become `x1Y`), the `create()` method's property assignment order differs. The TS plugin's `reflectionCreate` sets properties in field number order; `Object.entries(defaultMessage)` returns the property at its FIRST creation position (from field 1) even though its value was overwritten by field 3. So the order is `x1Y, middle`. The Go plugin's dedup logic (lines 4693-4710) iterates in reverse and keeps the LAST occurrence, preserving the last item's position. So it outputs `middle, x1Y`. Root cause: The dedup keeps the LAST occurrence at the LAST position, but JS `Object.entries` returns a property at its FIRST creation position. Fix: After dedup, the property should be placed at the position of the FIRST field that created it, not the last. Or: build initItems without dedup, iterate in order, track which fieldNames have been seen, and output each fieldName at the FIRST occurrence (with the LAST occurrence's value). Tested in `264_create_property_collision`. + +- **`ts.client` service option not excluded from service options** — The TS plugin's `getServiceType()` hardcodes `excludeOptions.concat("ts.client")` so the protobuf-ts-specific `ts.client` ServiceOptions extension (field 777701 from `protobuf-ts.proto`) is ALWAYS excluded from the generated service options output, even when explicitly set. The Go plugin has no knowledge of this special exclusion — it discovers the extension normally via `buildExtensionMap` and includes it in the output. So when a service has `option (ts.client) = GENERIC_CLIENT`, TS outputs `new ServiceType("test.MySvc", [...])` (no service options) while Go outputs `new ServiceType("test.MySvc", [...], { "ts.client": ["GENERIC_CLIENT"] })`. Root cause: Go plugin's `getCustomServiceOptions` doesn't filter out protobuf-ts internal extensions. Fix: either hardcode exclusion of extensions from the `ts` package, or skip extensions with field numbers in the protobuf-ts reserved range (777701+), or check if the extension's package is `ts`. Tested in `265_ts_client_service_option`. + +- **`ts.exclude_options` file option not implemented** — The TS plugin's `getMessageType()` reads `ts.exclude_options` from the file's `FileOptions` (extension field 777701 in package `ts`) and passes the values to `readOptions()`, which filters out matching custom option keys from field info and message options. The Go plugin has no knowledge of `ts.exclude_options` — it includes all custom options unconditionally. So when a file has `option (ts.exclude_options) = "test.my_tag"` and a field has `[(my_tag) = "important"]`, TS omits the option from the field info while Go includes `options: { "test.my_tag": "important" }`. Root cause: Go plugin never reads `ts.exclude_options` from `FileOptions` and never filters custom options by it. Fix: read `ts.exclude_options` from the file's unknown fields (same technique as `buildExtensionMap`), then filter custom options in `getCustomFieldOptions`, `getCustomMessageOptions`, `getCustomMethodOptions`, and `getCustomServiceOptions` by matching keys against the exclude list (supporting both literal matches and wildcard patterns). Tested in `266_ts_exclude_options`. + +- **`ts.exclude_options` wildcard uses substring match, not prefix match** — The TS plugin's `readOptions()` converts wildcard patterns (e.g., `test.*`) to regex (`test\..*`) and uses `key.match(re)` which performs **substring matching**. So pattern `test.*` excludes key `other.test.foo` because the regex finds `test.foo` as a substring. The Go plugin's `filterExcludedOptions` uses `strings.HasPrefix(opt.key, prefix)` which only matches at the **start** of the key. So `other.test.foo` with pattern `test.*` is excluded by TS but NOT by Go. Root cause: `filterExcludedOptions` uses prefix matching but the TS plugin uses unanchored regex matching. Fix: either use `strings.Contains` instead of `strings.HasPrefix`, or compile the pattern as a regex and use `regexp.MatchString` for full compatibility. Tested in `267_exclude_options_wildcard_substring`. + +- **`ts.exclude_options` literal pattern uses exact match, not substring** — The TS plugin's `readOptions()` splits exclude patterns into literals (no `*`) and wildcards (has `*`). For literals, it uses `key === pattern` (exact equality). For wildcards, it uses `key.match(re)` (regex substring match). The Go plugin's `filterExcludedOptions` converts ALL patterns to regex and uses `re.MatchString()` (substring match) regardless of whether the pattern has `*`. So a literal pattern `"test.tag"` becomes regex `test\.tag` which matches `"prefix.test.tag"` as a substring. The TS plugin would NOT exclude `"prefix.test.tag"` because `"test.tag" === "prefix.test.tag"` is false. Root cause: `filterExcludedOptions` doesn't distinguish literals from wildcards — it applies regex substring matching to all patterns. Fix: for patterns without `*`, use exact string equality (`opt.key == pattern`) instead of regex. Tested in `268_exclude_options_literal_exact`. + +- **`ts.server` service option NOT excluded by TS plugin** — The TS plugin's `getServiceType()` only hardcode-excludes `"ts.client"` from service options (`.concat("ts.client")`). It does NOT exclude `"ts.server"` (field 777702). So when a service has `option (ts.server) = GRPC1_SERVER`, the TS plugin includes `"ts.server": ["GRPC1_SERVER"]` in the generated `ServiceType` options. The Go plugin's `getCustomServiceOptions` at line 1242 excludes BOTH `ts.client` AND `ts.server`. Root cause: RALPH's fix for test 265 over-corrected by also excluding `ts.server`, but the TS plugin only excludes `ts.client`. Fix: remove `|| opt.key == "ts.server"` from line 1242. Additionally, when `ts.server` is set, the TS plugin generates a `test.grpc-server.ts` file that the Go plugin doesn't generate. Tested in `269_ts_server_service_option`. + +- **GENERIC_SERVER generates .server.ts file** — When a service has `option (ts.server) = GENERIC_SERVER` (enum value 1), the TS plugin generates a `.server.ts` file containing a generic server interface (`export interface IMySvc`). The Go plugin has NO support for `GENERIC_SERVER` at all — it only handles `GRPC1_SERVER` (value 2) via `serviceNeedsGrpc1Server()` and `generateGrpcServerFile()`. There is no `generateGenericServerFile()` function in the Go plugin. Root cause: The Go plugin was only ported for GRPC1_SERVER support. The entire generic server code path (`service-server-generator-generic.js` in the TS plugin) was never implemented. Fix: implement a `generateGenericServerFile()` function that generates the `.server.ts` file with a generic interface mirroring the TS plugin's `ServiceServerGeneratorGeneric.generateInterface()` method. The interface takes a type parameter `T = ServerCallContext` and has a method for each RPC (unary returns `Promise`, server-streaming returns `ServerStreamingCall`, etc.). Tested in `270_generic_server_option`. + +- **GENERIC_SERVER streaming import ordering** — When a GENERIC_SERVER service has streaming RPCs, the `.server.ts` file needs `RpcInputStream` and `RpcOutputStream` imports from `@protobuf-ts/runtime-rpc`. The TS plugin's import system prepends each import at the TOP of the file as it's encountered during code generation. Order of encounter: (1) `ServerCallContext` at start of `generateInterface`, (2) message type imports (`Req`, `Resp`) from first method's `createUnary`, (3) `RpcInputStream` from `createServerStreaming`, (4) `RpcOutputStream` from `createClientStreaming`. Since each is prepended, the final file order is reversed: `RpcOutputStream, RpcInputStream, Resp, Req, ServerCallContext`. The Go plugin collects message type imports first (in reverse method order), then emits runtime-rpc streaming imports, then `ServerCallContext` — producing: `Resp, Req, RpcOutputStream, RpcInputStream, ServerCallContext`. Root cause: `generateGenericServerFile` emits imports in a fixed order (message types → streaming types → ServerCallContext) instead of matching the TS plugin's prepend-as-encountered order. Fix: emit runtime-rpc streaming imports BEFORE message type imports: `RpcOutputStream, RpcInputStream, Resp, Req, ServerCallContext`. Tested in `271_generic_server_streaming`. + +- **GRPC1_CLIENT server-streaming/bidi parameter signatures differ** — For server-streaming and bidi methods in the `.grpc-client.ts` file, the TS plugin generates optional parameters in both interface overloads and implementation: (1) Interface: `metadata?: grpc.Metadata` (optional `?`), Go: `metadata: grpc.Metadata` (required). (2) Implementation: `metadata?: grpc.Metadata | grpc.CallOptions` (optional `?`), Go: `metadata: grpc.Metadata | grpc.CallOptions | undefined` (union with undefined instead of optional). (3) Return statement: TS passes `options` without cast, Go wraps as `(options as any)`. Root cause: The TS plugin's `createServerStreamingSignatures()` uses `ts.createToken(ts.SyntaxKind.QuestionToken)` on the metadata parameter to make it optional. The TS printer renders this as `metadata?:` with the type being just `grpc.Metadata`. The Go plugin generates `metadata: grpc.Metadata` (no `?`, no `| undefined`). Similarly for the implementation signature, TS uses an optional parameter (`?:`) with a union type, while Go uses a required parameter with `| undefined` added to the union. And the TS `createServerStreaming()` and `createDuplexStreaming()` methods pass `options` directly (no `as any` cast) while the Go plugin adds `(options as any)`. Fix: In `generateGrpcClientFile`, for server-streaming interface overload, add `?` to metadata parameter. For implementation signatures, use `?:` instead of adding `| undefined`. For server-streaming/bidi return statements, pass `options` directly instead of `(options as any)`. Tested in `276_grpc1_client_streaming`. + +- **GRPC1_CLIENT bidi streaming implementation not fixed** — RALPH fixed the server-streaming code path (test 276) but the bidi streaming implementation has the exact same two bugs: (1) `metadata: grpc.Metadata | grpc.CallOptions | undefined` instead of `metadata?: grpc.Metadata | grpc.CallOptions` (optional `?`), (2) `(options as any)` instead of bare `options` in `makeBidiStreamRequest`. Root cause: the bidi streaming block in `generateGrpcClientFile` (around line 9450) was not updated when RALPH fixed the server-streaming block. Fix: apply the same changes to the `cs && ss` (bidi) code path: use `metadata?:` with the base union type, and pass `options` without `(... as any)`. Tested in `277_grpc1_client_bidi`. + +- **GRPC1_CLIENT client-streaming import order** — For a client-streaming method `rpc CS(stream Req) returns (Resp)`, the TS plugin encounters `Resp` first (in the callback type `(err: ..., value?: Resp) => void`) then `Req` (in the return type `ClientWritableStream`). Since imports are prepended, `Req` ends up above `Resp`. The Go plugin's import collection loop always processes `resType` before `reqType` regardless of streaming type, producing `Resp` above `Req`. For unary methods this happens to match (TS encounters `Req` input first, then `Resp` callback → prepend gives `Resp, Req`). But client streaming reverses the encounter order. Root cause: `generateGrpcClientFile`'s import loop uses a fixed `resType, reqType` order for all method types. For client-streaming methods, the order should be `reqType, resType` to match the TS prepend pattern. Fix: check `cs && !ss` and swap the append order for client streaming. Tested in `279_grpc1_client_cs`. + +- **Service deprecation incorrectly propagated to method JSDoc in extra files** — When a service has `option deprecated = true` but an individual method does NOT have `option deprecated = true`, the TS plugin only adds `@deprecated` to the SERVICE-level JSDoc (interface and class declarations), NOT to individual method JSDoc blocks. The Go plugin's `generateGrpcClientFile`, `generateGrpcServerFile`, and `generateGenericServerFile` all check `service.GetOptions().GetDeprecated() || g.isFileDeprecated()` in the method JSDoc generation code, incorrectly adding `@deprecated` to every method in a deprecated service. Root cause: the Go plugin conflates service-level deprecation with method-level deprecation. The TS plugin's `comments.addCommentsForDescriptor(signature, descMethod, ...)` only checks the method descriptor's own deprecated flag. Fix: remove the `service.GetOptions().GetDeprecated()` and `g.isFileDeprecated()` checks from method-level JSDoc in all three extra file generators — only check `method.GetOptions().GetDeprecated()`. Tested in `280_grpc_client_deprecated_service`. + +- **GRPC1_CLIENT cross-method type import ordering with swapped types** — When two unary methods use the same cross-package types in swapped input/output positions (e.g., `rpc GetTime(Empty) returns (Timestamp)` and `rpc SetTime(Timestamp) returns (Empty)`), the type import order differs. The TS plugin processes methods forward with prepend: GetTime encounters `Empty` (input) then `Timestamp` (callback output), prepending gives `Timestamp` above `Empty`. The Go plugin iterates methods in reverse and appends `resType` before `reqType`: SetTime (processed first in reverse) appends `Empty` (output) then `Timestamp` (input), putting `Empty` above `Timestamp` — the opposite of TS. Root cause: the reverse-iterate-and-append strategy correctly simulates prepend for types introduced within a single method, but breaks when the same types are introduced across different methods because the first-seen method differs between forward (TS) and reverse (Go) iteration. Fix: the Go plugin needs to track which types have already been seen and match the TS encounter order, possibly by simulating forward iteration with prepend. Tested in `282_grpc_client_import_order`. + +- **GRPC1_SERVER cross-method type import ordering with swapped types** — Same bug as test 282 but in the grpc-server file. RALPH fixed the grpc-client import loop (test 282) by switching to a `prepend`-based approach with forward iteration. But the grpc-server file (`generateGrpcServerFile`, lines 8878-8905) still uses the old reverse-iterate-and-append pattern: `for i := len(service.Method) - 1; i >= 0; i--` with `imports = append(imports, ...)`. When two methods swap the same cross-package types (e.g., `rpc GetTime(Empty) returns (Timestamp)` and `rpc SetTime(Timestamp) returns (Empty)`), Go outputs `Empty` above `Timestamp` while TS outputs `Timestamp` above `Empty`. Root cause: RALPH only applied the prepend fix to grpc-client, not grpc-server. Fix: apply the same forward-iterate-with-prepend pattern to `generateGrpcServerFile`'s import collection loop. Tested in `283_grpc_server_import_order`. + +- **Cross-package type alias import ordering in main file** — When two message types from different packages have the same base name (e.g., `alpha.Response` and `beta.Response`), `precomputeImportAliases` correctly assigns an alias (`Response$`) to the second one. The TS plugin's import system prepends imports as encountered, and when it processes the service's methods forward, it encounters `alpha.Response` first and `beta.Response` second. Since each prepend goes to the TOP, the final order is `Response$` (beta) above `Response` (alpha). The Go plugin's main file import logic emits `Response` (alpha) first, then `Response$` (beta) — the opposite order. Root cause: The Go plugin's message type import ordering in the main `test.ts` file doesn't match the TS plugin's prepend-as-encountered pattern when aliased cross-package types are involved. The aliased type should appear ABOVE the non-aliased type (since it's prepended later). Tested in `284_grpc_server_alias_import`. + +- **Generic client file includes GRPC1_CLIENT-only services** — When a file has two services — one with `ts.client = GRPC1_CLIENT` and one with default GENERIC_CLIENT — the TS plugin's `.client.ts` file only includes the service with GENERIC_CLIENT style (the other goes only to `.grpc-client.ts`). The Go plugin's `generateClientFile` iterates ALL services at line 7218 (`for _, service := range file.Service`) without checking if each service needs a generic client. It also collects imports from ALL services (line 6633) without filtering. Root cause: `generateClientFile` lacks a per-service `serviceNeedsGenericClient(service)` check analogous to the `serviceNeedsGrpc1Client(service)` check at line 9253 in `generateGrpcClientFile`. Fix: add a `serviceNeedsGenericClient` function and filter services in the iteration loop at line 7218, as well as in all import-collection loops in `generateClientFile`. Tested in `285_client_service_filtering`. + +### Areas thoroughly tested with NO difference found +- All 15 scalar types, maps, enums, oneofs, groups, nested messages, services (all streaming types) +- Custom options: scalar, enum, bool, bytes (base64), repeated, nested message, NaN/Infinity floats, negative int32 +- Proto2: required fields, defaults (string escapes, NaN, inf, bytes hex/octal), extension ranges, groups in oneof +- Proto3: optional fields, proto3_optional +- Comments: unicode, empty, whitespace-only, trailing, detached +- Field names: JS keywords, digit edges, double underscores, SCREAMING_SNAKE, MixedCase, leading underscore +- json_name: custom, uppercase, with special chars +- WKTs as field types (not options): Any, Struct, Value, ListValue +- Property collisions: __proto__, toString, oneofKind +- Import ordering, cross-file types, no-package files +- Multiple custom extensions on same field (ordering) +- Service/method options (non-WKT types) + +- **GENERIC_SERVER streaming import interleave with different types** — When a GENERIC_SERVER service has multiple server-streaming (or client-streaming) methods that use DIFFERENT request/response types, the TS plugin interleaves `RpcInputStream`/`RpcOutputStream` imports with message type imports based on first-encounter order. For example, with `rpc StreamA(Alpha) returns (stream Beta)` and `rpc StreamB(Gamma) returns (stream Delta)`, the TS plugin processes methods in forward order: StreamA encounters Alpha, Beta, then RpcInputStream (prepended → sits above Beta/Alpha). StreamB encounters Gamma, Delta (prepended above RpcInputStream). Final order: `Delta, Gamma, RpcInputStream, Beta, Alpha, ServerCallContext`. The Go plugin always emits streaming imports at the top before ALL message types: `RpcInputStream, Delta, Gamma, Beta, Alpha, ServerCallContext`. This only manifests when different methods use different types AND there are streaming methods — if all methods share the same types (like test 271), the streaming import happens to end up at the same position. Root cause: `generateGenericServerFile` collects all message type imports in one pass, then unconditionally emits streaming imports before them. Fix: track when each streaming import is first needed (during which method's processing) and interleave it with message type imports accordingly. Tested in `272_generic_server_import_interleave`. + +- **GENERIC_SERVER bidi streaming import order** — For a bidi streaming method (`rpc Chat(stream Alpha) returns (stream Beta)`), the TS plugin's `createBidi()` encounters `RpcOutputStream` (for the `requests` parameter) BEFORE `RpcInputStream` (for the `responses` parameter). Since imports are prepended, the final order is `RpcInputStream` above `RpcOutputStream`. The Go plugin's import loop (lines 9107-9114) checks `ss` before `cs`, so it prepends `RpcInputStream` first, then `RpcOutputStream` on top — resulting in `RpcOutputStream` above `RpcInputStream` (reversed). Root cause: the `ss`/`cs` check order in `generateGenericServerFile` doesn't match the TS plugin's encounter order in `createBidi()`. Fix: swap the `ss` and `cs` blocks (check `cs` first for `RpcOutputStream`, then `ss` for `RpcInputStream`). Tested in `273_generic_server_bidi`. + +- **`ts.client = NO_CLIENT` not suppressing client file generation** — When a service has `option (ts.client) = NO_CLIENT;` (enum value 0), the TS plugin reads the `ClientStyle` repeated option and sees `[NO_CLIENT]`, so it does NOT generate a `.client.ts` file. The Go plugin has no logic to read the `ts.client` service option value — it unconditionally generates a `.client.ts` for every proto file with services (line 389: `generateClientFile` called without checking client style). Root cause: Go plugin never reads field 777701 from `ServiceOptions` to determine which client styles to generate. It only knows about `ts.client` for the purpose of EXCLUDING it from the service options output (line 1426). Fix: add a `getServiceClientStyles()` function (similar to `getServiceServerStyles()`) that reads field 777701 from `ServiceOptions`, then check if `NO_CLIENT` is the only style (or if the list is non-empty and doesn't contain `GENERIC_CLIENT`) before generating the client file. Also need to handle `GRPC1_CLIENT` (value 4) which generates a `.grpc-client.ts` instead of `.client.ts`. Tested in `274_no_client_service_option`. + +- **`ts.client = GRPC1_CLIENT` generates `.grpc-client.ts` not `.client.ts`** — When a service has `option (ts.client) = GRPC1_CLIENT;` (value 4), the TS plugin generates a `.grpc-client.ts` file (using `@grpc/grpc-js` types, extending `grpc.Client`). The Go plugin's `fileNeedsClient` treats any non-NO_CLIENT (non-0) style as needing a generic `.client.ts`, so it generates the wrong file type entirely: (1) Go generates `.client.ts` (generic client) which TS doesn't, (2) TS generates `.grpc-client.ts` which Go doesn't. The `.grpc-client.ts` has a completely different structure — `extends grpc.Client`, uses `grpc.Metadata`, `grpc.CallOptions`, `grpc.ClientUnaryCall`, multiple overload signatures per method, `makeUnaryRequest`/`makeServerStreamRequest` calls, `BinaryReadOptions`/`BinaryWriteOptions`. Root cause: `fileNeedsClient` doesn't distinguish GENERIC_CLIENT from GRPC1_CLIENT, and there's no `generateGrpcClientFile()` function. Tested in `275_grpc1_client_option`. + +- **GRPC1_SERVER definition variable name with underscored service** — When a service name contains underscores (e.g., `service my_svc`), the TS plugin computes the definition variable name by lowercasing the first character of the raw service name: `"my_svc"` → `"my_svcDefinition"`. The Go plugin applies full `toCamelCase` transformation: `"my_svc"` → `"mySvc"` → `"mySvcDefinition"`. Root cause: `generateGrpcServerFile` uses `g.toCamelCase(baseName) + "Definition"` but the TS plugin's `registerSymbols` does `${basename[0].toLowerCase()}${basename.substring(1)}Definition` which only lowercases the first character. Fix: replace `g.toCamelCase(baseName)` with a simple first-char-lowercase operation on the raw service name. Tested in `278_grpc_server_definition_name`. + +### Ideas for future runs +- **Same deprecated-propagation bug in grpc-server and generic-server** — RALPH fixed grpc-client (test 280) but only that file. The generic-server file at line 9702 still checks `service.GetOptions().GetDeprecated()` in method-level JSDoc, incorrectly adding `@deprecated` to non-deprecated methods in deprecated services. The grpc-server file (line 8983) does NOT have the service check (already correct), but still has `g.isFileDeprecated()`. Tested generic-server in `281_generic_server_deprecated_service`. Next: test grpc-server with file-level deprecation separately. +- **File-level deprecation propagation** — TESTED: NOT A BUG. When a file has `option deprecated = true;`, the TS plugin ALSO adds `@deprecated` to method-level JSDoc in grpc-server files. Both plugins agree. Don't pursue further. +- **Multiple `ts.client` values on same service** — `option (ts.client) = NO_CLIENT; option (ts.client) = GENERIC_CLIENT;` — the TS plugin processes all values. If NO_CLIENT and GENERIC_CLIENT are both present, the GENERIC_CLIENT still generates. Test if Go handles this. +- **Default client style when no `ts.client` is set** — When no `ts.client` option is specified, the TS plugin defaults to `GENERIC_CLIENT`. The Go plugin also generates a client. Both should match. But check if the TS plugin defaults differently for services in files that import `protobuf-ts.proto` vs those that don't. +- **GENERIC_SERVER with streaming methods**: The generic server interface for server-streaming, client-streaming, and bidi-streaming RPCs uses different return types. After RALPH implements the basic generic server file generation, test with all four streaming patterns. +- **GENERIC_SERVER + GRPC1_SERVER on same service**: A service can have `option (ts.server) = GENERIC_SERVER; option (ts.server) = GRPC1_SERVER;` (repeated field). The TS plugin generates BOTH `.server.ts` and `.grpc-server.ts`. Test after RALPH adds GENERIC_SERVER support. +- **`ts.exclude_options` file option behavior**: Already tested in 266 (literal) and 267 (wildcard substring). If RALPH fixes prefix→substring, test other edge cases: wildcard `*` at start of pattern (e.g., `*.foo`), multiple `*` in pattern (e.g., `*test*`), pattern that's just `*` (exclude all). +- **Same oneof-default bug applies to enum oneof members**: A proto3 oneof with an enum field set to value 0 (default) would also be filtered. The TS `write()` method forces `emitDefaultValues: true` for enum oneof members too. Also applies to NullValue enum in a oneof. +- **Same oneof-default bug applies to nested option messages**: If a custom option message has a nested message field whose type contains a proto3 oneof with default scalar values, the recursive `parseMessageValue` would also filter them incorrectly. +- **Message-typed oneof member set to empty message**: The TS `write()` does NOT force `emitDefaultValues: true` for message-typed oneof members (only scalar/enum). So an empty message in a oneof might be handled differently. Check if protoc serializes an empty message in a oneof and how both plugins handle it. +- **Same map jstype bug applies to JS_NUMBER and JS_STRING**: `map` with `[jstype = JS_NUMBER]` would also differ — Go ignores jstype for map values entirely. Also applies to `map`, `map`, `map`, `map` — all 64-bit map value types with explicit jstype. +- **Map value jstype also affects binary write**: The `internalBinaryWrite` helper for maps needs to use the correct write method based on jstype. With JS_NORMAL, bigint values need different serialization than string values. +- **Non-ASCII escaping applies EVERYWHERE strings appear**: The `escapeNonAsciiString` is used by the TS printer for ALL string literals, not just custom options. This means `json_name` values (line 4285: `escapeStringForJS(actualJsonName)`) with non-ASCII chars would also differ. Same for proto2 string defaults, map key strings, nested message string values, etc. After RALPH fixes `escapeStringForJS` to escape all non-ASCII chars, this entire class disappears. +- **Characters above U+FFFF (surrogate pairs)**: The TS printer's `encodeUtf16EscapeSequence(c.charCodeAt(0))` handles individual UTF-16 code units. For characters above U+FFFF (e.g., emoji 🎉 U+1F389), JavaScript strings use surrogate pairs, and the regex matches each surrogate individually, producing `\uD83C\uDF89`. Go's `escapeStringForJS` iterates runes (Unicode codepoints), so it would need to split into surrogates and escape each. This is a subtlety of the fix. +- Same repeated-extension-single bug likely applies to repeated enum, repeated int32, repeated bytes, repeated message top-level extensions with a single value — all go through `parseCustomOptions` which lacks wrapping. +- Same integer-key ordering issue applies to `map`, `map`, `map`, etc. — all numeric map key types would have JS Object.keys() reordering. But RALPH will likely fix all at once. +- Bool map keys in custom options: TS may order `false` before `true` (since `Object.keys` on `{true: ..., false: ...}` preserves insertion order for non-integer keys). Check if wire order `true, false` matches TS order. +- Other control chars in custom option strings: `\b` (backspace 0x08), `\f` (form feed 0x0C), `\0` (null 0x00) — same root cause as vtab, likely all broken +- Control chars in nested message field string values (same escaping code path in `parseMessageValue`) +- U+00A0 (NBSP) and U+FEFF (BOM) ARE escaped by TS printer (contrary to earlier notes). Tested U+0090 — both plugins escape it as `\u0090`. Tested U+FEFF — both escape it, but TS uses `\uFEFF` (uppercase) and Go uses `\ufeff` (lowercase). The earlier "don't test" advice was wrong. +- U+0085 NEXT LINE — confirmed different. TS escapes it via regex `\u0085` in `doubleQuoteEscapedCharsRegExp`. Go misses it because 0x85 > 0x20. Fix: add `r == 0x0085` check in `escapeStringForJS`. +- **Hex digit casing in \uXXXX escapes applies broadly**: ANY character escaped via the `default` case in `escapeStringForJS` that has A-F hex digits will have lowercase vs uppercase mismatch. This includes: 0x80-0x9F range (C1 codes), 0xFEFF (BOM), and potentially any future additions. After RALPH fixes `%04x` → `%04X`, this class of bugs goes away. But also check `\x00` — does Go use `\x00` (lowercase) while TS uses `\x00` (also lowercase)? Verified: both use lowercase for `\x` escapes, so no issue there. +- Wrapper types (Int32Value, BoolValue, etc.) as custom option types — tested, NO difference (TS plugin uses generic MessageType not WKT-aware toJson for options) +- Custom options with `google.protobuf.Any`, `google.protobuf.Struct`, `google.protobuf.Value` as the option type — Struct/Value fields would use generic reflection toJson in TS (NOT WKT-specific), so no special handling difference. But NullValue IS handled specially by TS runtime (see exploit above). Any other WKTs that `ReflectionJsonWriter` gives special treatment? Check `google.protobuf.Duration`, `google.protobuf.Timestamp`, `google.protobuf.FieldMask` — the `message()` method calls `type.internalJsonWrite()` which may use WKT-specific formatting if the MessageType was set up with WKT extensions. But the interpreter creates plain MessageTypes without WKT extensions, so probably no difference. +- Custom options with `google.protobuf.FieldMask`, `google.protobuf.Empty` as option types +- Custom oneof-level options (`OneofOptions` extensions) +- Extension field info generation for proto2 `extend` blocks +- Custom options where nested message field json_name contains other invalid JS identifier chars (e.g., spaces, `@`, `+`) +- Top-level extension key quoting for non-identifier characters (currently only dot and digit-start checked) +- Custom enum options (`EnumOptions` extensions) — tested, no difference (neither plugin emits them) +- Custom `EnumValueOptions` extensions — untested +- `toCamelCase` vs `lowerCamelCase` — thoroughly compared, no differences found for any common pattern +- Map ordering for string keys that look like integers — tested, Go's `sortMapEntriesJSOrder` already handles these (applied to ALL `[]customOption` values including nested messages, not just map entries). No diff. +- json_name that looks like an integer-index in nested option message — tested, Go correctly reorders via `sortMapEntriesJSOrder` on nested `[]customOption` values. No diff. +- Cross-file extension ordering also likely affects MESSAGE options and SERVICE options, not just FIELD options — same `parseCustomOptions` code path, same bug. +- The cross-file ordering issue may also appear in method options when multiple files define extensions for `MethodOptions`. +- Null byte NOT followed by a digit should still use `\0` (not `\x00`). Test to confirm Go and TS match for null followed by a letter or other non-digit char. +- Same null-before-digit issue likely occurs in map key strings, nested message string values — same `escapeStringForJS` function used everywhere. +- Same declaration-order vs field-number-order issue applies to NESTED messages within option messages — if a deeply nested message has out-of-order fields, Go would emit them by field number while TS by declaration order. Also applies to map value messages. +- Oneof fields within option messages — the TS plugin's `toJson()` emits the selected oneof field at its declaration position. Go reads it at its wire position (field number). If the oneof field has a high field number but is declared early, order differs. +- DEL/C1 codes 0x80-0x9F ARE escaped by TS (contrary to earlier notes — tested U+0090, both plugins produce `\u0090`). The over-escaping bug in test 251 was specifically about 0x7F (DEL) which TS does NOT escape. C1 range escaping is correct in Go, but the hex CASING is wrong (lowercase vs uppercase). BOM (0xFEFF) same issue — Go correctly escapes it but uses wrong case. +- The over-escaping of 0x7F-0x9F characters likely also affects `json_name` values (line 4285: `escapeStringForJS(actualJsonName)`) if a json_name contains these characters. +- **Same required-field-default bug applies to other types**: proto2 required `bool` set to `false`, required `float` set to `0.0`, required `enum` set to `0` — all would be serialized by protoc but omitted by TS's `toJson()`. Same root cause as test 257. +- **Proto2 optional fields set to default**: Confirmed DIFFERENT — Go's `isDefaultValue` unconditionally filters defaults. TS includes them because `field.opt = true` → `ed = true`. Go's fix for required defaults (test 257) over-corrected by not distinguishing required vs optional. Tested in `258_optional_default_option`. +- **Same proto2-optional-default bug applies to other types**: proto2 `optional bool` set to `false`, `optional float` set to `0.0`, `optional enum` set to first value (0), `optional bytes` set to empty — all would be filtered by Go's `isDefaultValue` but included by TS. Same root cause. The fix needs to check `fd.GetLabel() == LABEL_OPTIONAL` (for proto2) or `Proto3Optional` before deciding whether to filter. +- **Proto3 explicit optional fields set to default**: In protobuf-ts, proto3 explicit optional fields have `field.opt = true` → `ed = true` → defaults included. Go's `isDefaultValue` would also filter these incorrectly. But proto3 explicit optional in option messages is unusual. +- **Nested required-field messages in custom options**: If a custom option has a nested message with required fields set to defaults, same bug at nested level. +- **Same cross-method import ordering bug in grpc-server and generic-server** — The import ordering bug in test 282 (swapped types across methods) likely also affects grpc-server and generic-server files, which use similar reverse-iterate-and-append strategies. CONFIRMED for grpc-server in test 283. Generic-server may also have the same bug — check after RALPH fixes grpc-server. +- **Custom EnumValueOptions extensions** — TESTED: NO DIFFERENCE. The TS plugin doesn't emit custom enum value options in generated code. Both plugins ignore them. +- **GRPC1_CLIENT import ordering with streaming + unary mixed** — The same cross-method import ordering bug could manifest differently when mixing streaming and unary methods, since streaming methods have different encounter orders for types. diff --git a/protoc-gen-kaja/RALPH.md b/protoc-gen-kaja/RALPH.md index 06f6fe5d..2d0a88fd 100644 --- a/protoc-gen-kaja/RALPH.md +++ b/protoc-gen-kaja/RALPH.md @@ -27,8 +27,185 @@ You are running inside an automated loop. **Each invocation is stateless** — y ## Plan +- [x] Fix custom options with WKT message types (test 239_wkt_custom_option) + - Fixed `findMessageType` to search all files, not just direct deps (transitive deps like Duration used as option value types) + - Added `isWKTFileUsed` filter to only generate WKT .ts files whose types are actually used as field types or service method types (matching protoc-gen-ts behavior) +- [x] Fix custom option property keys with hyphens (test 240_custom_option_hyphen_json_name) + - Added `needsQuoteAsPropertyKey()` in `formatCustomOptions` to quote keys that aren't valid JS identifiers (e.g. `my-value` → `"my-value"`) + - Must skip already-quoted keys (numeric map keys like `"1"` are pre-quoted) +- [x] Fix string escaping for control characters (test 241_custom_option_string_vtab) + - Created `escapeStringForJS()` helper matching TypeScript compiler's `escapeString` behavior + - Handles `\v`, `\f`, `\b`, `\0`, and other control chars via `\uXXXX` + - Replaced duplicated escaping code in `formatCustomOptions`, `formatCustomOptionArray`, and jsonName escaping +- [x] Fix integer map key ordering in custom options (test 242_custom_map_int_key_order) + - Added `sortMapEntriesJSOrder()` to sort `[]customOption` map entries after merging in `mergeRepeatedOptions` + - Matches JavaScript Object.keys() enumeration: array-index keys (0..2^32-2) first in ascending numeric order, then non-integer keys in insertion order + - Strips quotes from keys before checking `isArrayIndex()` since numeric map keys are stored pre-quoted (e.g. `"1"`, `"10"`) +- [x] Fix single-element repeated fields in custom options (test 243_custom_option_repeated_single) + - After `mergeRepeatedOptions` in `parseMessageValue`, check `msgDesc.Field` for `LABEL_REPEATED` fields + - Wrap any non-array values in `[]interface{}` for repeated fields (skip map entries) + - Matches protobuf-ts `toJson()` which always emits arrays for repeated fields +- [x] Fix U+2028/U+2029 escaping in JS string literals (test 244_custom_option_string_linesep) + - Added `r == 0x2028 || r == 0x2029` check in `escapeStringForJS()` to escape LINE SEPARATOR and PARAGRAPH SEPARATOR + - These chars are not valid unescaped in JS string literals (pre-ES2019), TypeScript's printer escapes them +- [x] Fix single-element repeated extension fields (test 245_repeated_extension_single) + - Added array-wrapping logic in `parseCustomOptions` (not just `parseMessageValue`) for top-level repeated extensions + - Builds a `repeatedExts` set from `extensionMap` entries with `LABEL_REPEATED`, skipping map entries + - After `mergeRepeatedOptions`, wraps non-array values in `[]interface{}` for repeated extensions +- [x] Fix string map key escaping in custom options (test 246_custom_map_string_key_escape) + - Called `escapeStringForJS()` on map keys before quoting them in `formatCustomOptions` + - Fixes backslash and double-quote characters in string map keys (e.g. `back\slash` → `"back\\slash"`) +- [x] Fix C1 control character escaping in JS strings (test 247_custom_option_string_nextline) + - Added `(r >= 0x7F && r <= 0x9F)` and `r == 0xFEFF` checks in `escapeStringForJS()` + - U+0085 (NEXT LINE / NEL) is a C1 control character that TypeScript's printer escapes as `\u0085` + - Also covers DEL (0x7F), other C1 chars, and BOM (0xFEFF) to match TypeScript's `escapeString` +- [x] Fix null byte followed by digit escaping (test 248_custom_option_string_null_digit) + - When `\0` is followed by a digit (0-9), use `\x00` instead to avoid ambiguous octal escape + - Changed `escapeStringForJS()` to iterate over `[]rune` slice so we can peek at the next character +- [x] Fix custom option cross-file ordering (test 249_custom_option_cross_file_order) + - Added `registryOrder` field to `extInfo` to track discovery order of extensions across files + - After merging, sort custom options by registry order (file processing order) instead of wire order (field number) + - TS plugin uses registration order (order extensions are encountered during file processing), not field number order +- [x] Fix custom option field order within message values (test 250_custom_option_field_order) + - Added `sort.SliceStable` in `parseMessageValue` after merging to reorder fields by message descriptor declaration order + - protoc serializes by field number, but protobuf-ts `toJson()` emits in declaration order (order fields appear in the .proto file) +- [x] Fix DEL character escaping in JS strings (test 251_custom_option_string_del) + - Changed C1 range from `r >= 0x7F` to `r >= 0x80` — DEL (0x7F) is NOT escaped by TypeScript's printer + - C1 control characters are 0x80–0x9F; DEL is technically a control char but TS passes it through literally +- [x] Fix non-ASCII character escaping in JS strings (test 253_custom_option_string_nonascii) + - Changed condition from specific ranges `(r >= 0x80 && r <= 0x9F) || r == 0x2028 || r == 0x2029 || r == 0xFEFF` to `r >= 0x80` + - TypeScript's printer uses `escapeNonAsciiString` which escapes ALL chars outside 0x0000-0x007F range + - Regex `/[^\u0000-\u007F]/g` means 0x7F (DEL) is NOT escaped but 0x80+ ALL are + - Added `\u{X}` format for supplementary chars (> U+FFFF) to match TS behavior +- [x] Fix supplementary character escaping to use surrogate pairs (test 254_custom_option_string_emoji) + - Changed `\u{X}` format to surrogate pair `\uHHHH\uHHHH` for chars > U+FFFF + - TypeScript's `escapeString` uses `\uHHHH\uHHHH` surrogate pairs, not ES6 `\u{X}` syntax +- [x] Fix group field custom options index-shift bug (test 255_group_field_options) + - protobuf-ts has a bug in `getMessageType()`: it reads custom options using array index alignment between original descriptor fields (includes groups) and filtered fields (no groups), causing options to shift + - Added `customOptionsSource` field to `fieldInfo` struct; for filtered field at index `i`, use `msg.Field[i].Options` + - This replicates the bug where fields after a group get the wrong (or no) custom options +- [x] Fix enum alias resolution in custom options (test 256_custom_option_enum_alias) + - When enum has `allow_alias`, use the LAST value with matching number (JS object overwrite behavior) + - Changed `resolveEnumValueName` and `findEnumInMessageWithPrefix` to iterate all values and keep last match +- [x] Fix default value omission in custom option messages (test 257_required_default_option) + - Added `isDefaultValue()` helper to check if a field value equals its proto3 JSON default (0, "", false, "0", etc.) + - Added filtering step in `parseMessageValue` after merge: removes fields with default values (matching protobuf-ts `toJson()` behavior) + - Skips map entry messages — key/value fields are always meaningful even when they equal defaults (e.g., bool key `false`) +- [x] Fix proto2 optional fields keeping defaults in custom options (test 258_optional_default_option) + - Added `findFileSyntaxForMessageType()` to look up file syntax for a message type + - Changed `parseMessageValue` to accept `msgTypeName` parameter for syntax lookup + - Proto2 optional fields have explicit presence (opt=true in protobuf-ts) → defaults are NOT filtered + - Proto3 explicit optional fields also have presence → defaults kept + - Only proto2 required and proto3 implicit fields have defaults filtered + - Note: `GetSyntax()` returns "" for proto2 files (not "proto2"), so check `syntax == "proto2" || syntax == ""` +- [x] Fix google.protobuf.NullValue rendering as `null` in custom options (test 259_custom_option_null_value) + - protobuf-ts `ReflectionJsonWriter.enum()` special-cases NullValue to emit JSON `null` instead of enum name + - At all 3 enum resolution sites in custom options, check if typeName is `.google.protobuf.NullValue` → store `nil` instead of enum name string + - Added `nil` case in `formatCustomOptions` and `formatCustomOptionArray` → outputs `"null"` literal + - Updated `isDefaultValue` to handle NullValue: `nil` is the default value (NullValue only has value 0) +- [x] Fix map field jstype propagation to value field (test 260_map_int64_jstype) + - Added `mapValueWithJstype()` helper that copies jstype from outer map field to synthetic value field + - Applied at 4 locations: interface type, createDefault type, binary read method, map value default + - Added jstype-aware L parameter in field info V part for map scalar values + - Added jstype checks in `getMapValueDefault` for `0n` (BIGINT) and `0` (NUMBER) defaults +- [x] Fix proto3 oneof scalar fields keeping defaults in custom options (test 261_custom_option_oneof_default) + - TS plugin's `ReflectionJsonWriter.write()` forces `emitDefaultValues=true` for scalar/enum oneof members + - Added `fd.OneofIndex != nil` check to `hasPresence` in proto3 branch of default-value filtering + - Oneof members always have presence semantics, so their default values should not be filtered +- [x] Fix create() property collision ordering (test 264_create_property_collision) + - When two fields collide on localName (e.g. `x_1_y` and `x1y` both → `x1Y`), the property must appear at the position of the FIRST occurrence (JS Object.entries semantics) but with the LAST occurrence's value + - Changed dedup in create() from reverse-iterate to forward-iterate with index tracking: first occurrence sets position, later occurrences overwrite value in-place +- [x] Fix ts.client service option not excluded (test 265_ts_client_service_option) + - protobuf-ts hardcodes exclusion of only `ts.client` from service options output (NOT `ts.server`) + - Added filtering in `getCustomServiceOptions` to skip options with key `ts.client` +- [x] Implement ts.exclude_options file option (test 266_ts_exclude_options) + - Added `getExcludeOptions()` to read field 777701 (ts.exclude_options) from FileOptions unknown fields + - Added `filterExcludedOptions()` helper supporting exact match and trailing wildcard patterns + - Applied filtering in all four `getCustom*Options` methods (field, message, method, service) +- [x] Fix ts.exclude_options wildcard substring matching (test 267_exclude_options_wildcard_substring) + - TS plugin converts patterns to regex (dots escaped, `*` → `.*`) and uses `String.match()` (substring, not anchored) + - Changed `filterExcludedOptions` from prefix-based matching to regex substring matching + - Pattern `test.*` now correctly matches `other.test.foo` (finds "test.foo" as substring) +- [x] Fix ts.exclude_options literal exact match (test 268_exclude_options_literal_exact) + - Literal patterns (no `*`) must use exact match (`key === pattern`), not regex substring + - Split `filterExcludedOptions` into two paths: literals use `==`, wildcards use regex substring match + - Pattern `test.tag` now only excludes key `test.tag`, not `prefix.test.tag` +- [x] Implement gRPC server file generation (test 269_ts_server_service_option) + - Only `ts.client` is excluded from service options (NOT `ts.server` — fixed previous incorrect exclusion) + - Added `getServiceServerStyles()` to read `ts.server` (field 777702) from ServiceOptions unknown fields + - Handles packed repeated encoding: protoc sends repeated enums as BytesType containing packed varints + - Added `isVarintFieldType()` and `parseVarintValue()` helpers for packed repeated support in `parseCustomOptions` + - Implemented `generateGrpcServerFile()` producing `.grpc-server.ts` with: + - Interface `I{ServiceName}` extending `grpc.UntypedServiceImplementation` + - Service definition const `{camelServiceName}Definition: grpc.ServiceDefinition` + - Method entries with path, originalName, stream flags, and serialize/deserialize functions + - Triggered when any service has `ts.server = GRPC1_SERVER` (value 2) +- [x] Implement generic server file generation (test 270_generic_server_option) + - Added `generateGenericServerFile()` producing `.server.ts` with: + - Interface `I{ServiceName}` with method signatures + - Unary: `method(request: I, context: T): Promise` + - Server streaming: `method(request: I, responses: RpcInputStream, context: T): Promise` + - Client streaming: `method(requests: RpcOutputStream, context: T): Promise` + - Bidi: `method(requests: RpcOutputStream, responses: RpcInputStream, context: T): Promise` + - Triggered when any service has `ts.server = GENERIC_SERVER` (value 1) + - Imports: message types (value imports, reverse method order) then ServerCallContext from runtime-rpc +- [x] Fix generic server import interleaving (test 272_generic_server_import_interleave) + - TS plugin prepends imports as encountered per-method, so RpcInputStream/RpcOutputStream are interleaved with message imports + - Changed from collecting all message imports + emitting streaming at top, to simulating prepend-as-encountered per method + - For each method (forward order): prepend input type, output type, then RpcInputStream/RpcOutputStream if needed +- [x] Fix bidi streaming import order (test 273_generic_server_bidi) + - For bidi, TS plugin's createBidi() encounters RpcOutputStream (requests) before RpcInputStream (responses) + - Since imports are prepended, must prepend RpcOutputStream first, then RpcInputStream goes on top + - Swapped cs/ss check order in the import loop +- [x] Suppress client file when ts.client = NO_CLIENT (test 274_no_client_service_option) + - Added `getServiceClientStyles()` to read field 777701 from ServiceOptions unknown fields (same pattern as server styles) + - Added `fileNeedsClient()`: returns false only if ALL services explicitly set NO_CLIENT (0); default (no option) is GENERIC_CLIENT + - Changed client file generation guard from `len(file.Service) > 0` to also check `fileNeedsClient(file)` +- [x] Implement gRPC client file generation (test 275_grpc1_client_option) + - Added `serviceNeedsGrpc1Client()`, `fileNeedsGrpc1Client()`, `getGrpcClientOutputFileName()` helpers + - Changed `fileNeedsClient()` to only return true for GENERIC_CLIENT (1), not GRPC1_CLIENT (4) + - Implemented `generateGrpcClientFile()` producing `.grpc-client.ts` with: + - Interface `I{ServiceName}Client` with method overloads for each streaming type + - Class `{ServiceName}Client` extending `grpc.Client` with `_binaryOptions`, constructor, and method implementations + - Unary: 4 overloads (metadata+options+callback, metadata+callback, options+callback, callback) + - Uses `makeUnaryRequest`, `makeServerStreamRequest`, `makeClientStreamRequest`, `makeBidiStreamRequest` + - Callback types wrapped in parens `((...) => void)` in union positions in implementation signatures + - Imports: service value, BinaryWriteOptions/BinaryReadOptions type, message types, `import * as grpc` +- [x] Fix gRPC client server streaming method signatures (test 276_grpc1_client_streaming) + - Interface: metadata is optional (`metadata?: grpc.Metadata`), not required + - Implementation: metadata is optional union (`metadata?: grpc.Metadata | grpc.CallOptions`), not `metadata: ... | undefined` + - Implementation body: `options` passed directly (no `(options as any)` cast) for server streaming +- [x] Fix gRPC client bidi streaming method signatures (test 277_grpc1_client_bidi) + - Same two bugs as server streaming: `metadata` should be optional (`?:`) not `| undefined`, `options` passed directly not `(options as any)` + - Applied same fix to the `cs && ss` (bidi) code path in `generateGrpcClientFile` +- [x] Fix generic server method @deprecated inheriting from service deprecation (test 281_generic_server_deprecated_service) + - Removed `service.GetOptions().GetDeprecated()` check from method deprecation in `generateGenericServerFile` + - Service-level deprecation should NOT propagate to individual methods in the server interface + - Only method-level `deprecated = true` and file-level deprecation should add `@deprecated` to methods +- [x] Fix gRPC client import ordering (test 282_grpc_client_import_order) + - Switched from backward-iterate+append to forward-iterate+prepend strategy (matching TS plugin behavior) + - TS plugin processes methods forward, prepending imports as types are encountered + - For unary/server-stream/bidi: prepend input first, then output (output ends up above input) + - For client-stream: prepend output first, then input (input ends up above output) +- [x] Fix gRPC server import ordering (test 283_grpc_server_import_order) + - Same forward-iterate+prepend fix as generic server and gRPC client + - For each method: prepend input first, then output (output ends up above input) + - Previous reverse-iterate+append approach failed when two methods share types in swapped positions +- [x] Fix service-only import ordering when file has messages (test 284_grpc_server_alias_import) + - Removed `len(g.file.MessageType) == 0` guard from per-method-pair reversal of serviceTypes + - Service-only external imports always need prepend ordering (last method's types first), regardless of whether the file has messages + - The guard was incorrect: it caused service-only imports to be emitted in forward order when messages existed +- [x] Filter non-generic-client services from .client.ts generation (test 285_client_service_filtering) + - Added `serviceNeedsGenericClient()` function: returns true if service has no ts.client option (default) or GENERIC_CLIENT (1) + - Built `clientServices` filtered slice in `generateClientFile`, used throughout all service loops + - Replaced all `file.Service` references in import collection, collision detection, and client generation with `clientServices` + - Services with GRPC1_CLIENT only appear in .grpc-client.ts, not in .client.ts + ## Notes - Run tests with `protoc-gen-kaja/scripts/test --summary`. Full output without `--summary`. - Use `protoc-gen-kaja/scripts/diff ` to inspect specific failures. - Results are in `protoc-gen-kaja/results//`. Each has `expected/`, `actual/`, `result.txt`, and optionally `failure.txt`. +- `findMessageType` now searches `g.allFiles` (not just current file + direct deps). This is needed because option extension types can be defined in transitive dependencies (e.g., `google.protobuf.Duration` used as an option value type). +- WKT file generation now matches protoc-gen-ts: only emit WKT files whose types are used as field types (message/enum) or service method input/output in ANY generated file (including self-references within the WKT file itself). This correctly filters out e.g. `duration.ts` when Duration is only used as a custom option value type. +- String escaping: use `escapeStringForJS()` helper for all JS string literals. It handles `\v`, `\f`, `\b`, `\0`, other control chars via `\uXXXX`, plus the standard `\\`, `\"`, `\n`, `\r`, `\t`. ALL non-ASCII chars (>= 0x80) are escaped as `\uXXXX` (or surrogate pairs `\uHHHH\uHHHH` for supplementary chars > U+FFFF), matching TypeScript's `escapeNonAsciiString`. DEL (0x7F) is NOT escaped. diff --git a/protoc-gen-kaja/main.go b/protoc-gen-kaja/main.go index 65ee8e63..5124207f 100644 --- a/protoc-gen-kaja/main.go +++ b/protoc-gen-kaja/main.go @@ -6,6 +6,7 @@ import ( "io" "math" "os" + "regexp" "sort" "path/filepath" "strconv" @@ -131,6 +132,65 @@ func collectTransitiveWKTDeps(fileToGenerate []string, allFiles []*descriptorpb. return result } +// isWKTFileUsed checks if any type defined in wktFile is used as a field type +// (message or enum) or service method input/output in any file in the generated set. +// This includes self-references (types within the same file referencing each other). +func isWKTFileUsed(wktFile *descriptorpb.FileDescriptorProto, allGenFiles []*descriptorpb.FileDescriptorProto) bool { + // Collect all type names defined in the WKT file + wktTypes := make(map[string]bool) + wktPkg := wktFile.GetPackage() + var collectTypes func(msg *descriptorpb.DescriptorProto, prefix string) + collectTypes = func(msg *descriptorpb.DescriptorProto, prefix string) { + fullName := "." + prefix + msg.GetName() + wktTypes[fullName] = true + for _, nested := range msg.NestedType { + collectTypes(nested, prefix+msg.GetName()+".") + } + for _, enum := range msg.EnumType { + wktTypes["."+prefix+enum.GetName()] = true + } + } + prefix := "" + if wktPkg != "" { + prefix = wktPkg + "." + } + for _, msg := range wktFile.MessageType { + collectTypes(msg, prefix) + } + for _, enum := range wktFile.EnumType { + wktTypes["."+prefix+enum.GetName()] = true + } + + // Check if any type is used as a field type or service method type + for _, f := range allGenFiles { + var checkMessages func(msgs []*descriptorpb.DescriptorProto) bool + checkMessages = func(msgs []*descriptorpb.DescriptorProto) bool { + for _, msg := range msgs { + for _, field := range msg.Field { + if wktTypes[field.GetTypeName()] { + return true + } + } + if checkMessages(msg.NestedType) { + return true + } + } + return false + } + if checkMessages(f.MessageType) { + return true + } + for _, svc := range f.Service { + for _, method := range svc.Method { + if wktTypes[method.GetInputType()] || wktTypes[method.GetOutputType()] { + return true + } + } + } + } + return false +} + func getOutputFileName(protoFile string) string { base := strings.TrimSuffix(protoFile, ".proto") return base + ".ts" @@ -141,6 +201,222 @@ func getClientOutputFileName(protoFile string) string { return base + ".client.ts" } +func getServerOutputFileName(protoFile string) string { + base := strings.TrimSuffix(protoFile, ".proto") + return base + ".grpc-server.ts" +} + +func getGenericServerOutputFileName(protoFile string) string { + base := strings.TrimSuffix(protoFile, ".proto") + return base + ".server.ts" +} + +func getGrpcClientOutputFileName(protoFile string) string { + base := strings.TrimSuffix(protoFile, ".proto") + return base + ".grpc-client.ts" +} + +// getServiceClientStyles reads the ts.client option (field 777701) from ServiceOptions. +// Returns the list of ClientStyle enum values (as int32). +// ClientStyle: NO_CLIENT=0, GENERIC_CLIENT=1, GRPC1_CLIENT=4 +func getServiceClientStyles(svc *descriptorpb.ServiceDescriptorProto) []int32 { + if svc.Options == nil { + return nil + } + unknown := svc.Options.ProtoReflect().GetUnknown() + var styles []int32 + for len(unknown) > 0 { + num, typ, n := protowire.ConsumeTag(unknown) + if n < 0 { + break + } + unknown = unknown[n:] + switch typ { + case protowire.VarintType: + val, n := protowire.ConsumeVarint(unknown) + if n < 0 { + return styles + } + unknown = unknown[n:] + if num == 777701 { + styles = append(styles, int32(val)) + } + case protowire.BytesType: + val, n := protowire.ConsumeBytes(unknown) + if n < 0 { + return styles + } + unknown = unknown[n:] + if num == 777701 { + packed := val + for len(packed) > 0 { + v, vn := protowire.ConsumeVarint(packed) + if vn < 0 { + break + } + packed = packed[vn:] + styles = append(styles, int32(v)) + } + } + case protowire.Fixed32Type: + unknown = unknown[4:] + case protowire.Fixed64Type: + unknown = unknown[8:] + default: + return styles + } + } + return styles +} + +// fileNeedsClient checks if any service in a file needs a generic client file generated. +// Default (no ts.client option) is GENERIC_CLIENT. NO_CLIENT (0) suppresses generation. +// GRPC1_CLIENT (4) generates a separate .grpc-client.ts, not .client.ts. +func fileNeedsClient(file *descriptorpb.FileDescriptorProto) bool { + for _, svc := range file.Service { + styles := getServiceClientStyles(svc) + if len(styles) == 0 { + // No ts.client option → default is GENERIC_CLIENT + return true + } + for _, style := range styles { + if style == 1 { // GENERIC_CLIENT + return true + } + } + } + return false +} + +// serviceNeedsGenericClient checks if a service needs a generic client (GENERIC_CLIENT). +// Default (no ts.client option) is GENERIC_CLIENT. Returns false for NO_CLIENT or GRPC1_CLIENT only. +func serviceNeedsGenericClient(svc *descriptorpb.ServiceDescriptorProto) bool { + styles := getServiceClientStyles(svc) + if len(styles) == 0 { + return true // default is GENERIC_CLIENT + } + for _, style := range styles { + if style == 1 { // GENERIC_CLIENT + return true + } + } + return false +} + +// serviceNeedsGrpc1Client checks if a service has ts.client = GRPC1_CLIENT (4) +func serviceNeedsGrpc1Client(svc *descriptorpb.ServiceDescriptorProto) bool { + for _, style := range getServiceClientStyles(svc) { + if style == 4 { // GRPC1_CLIENT + return true + } + } + return false +} + +// fileNeedsGrpc1Client checks if any service in a file needs GRPC1_CLIENT generation +func fileNeedsGrpc1Client(file *descriptorpb.FileDescriptorProto) bool { + for _, svc := range file.Service { + if serviceNeedsGrpc1Client(svc) { + return true + } + } + return false +} + +// getServiceServerStyles reads the ts.server option (field 777702) from ServiceOptions. +// Returns the list of ServerStyle enum values (as int32). +// ServerStyle: NO_SERVER=0, GENERIC_SERVER=1, GRPC1_SERVER=2 +func getServiceServerStyles(svc *descriptorpb.ServiceDescriptorProto) []int32 { + if svc.Options == nil { + return nil + } + unknown := svc.Options.ProtoReflect().GetUnknown() + var styles []int32 + for len(unknown) > 0 { + num, typ, n := protowire.ConsumeTag(unknown) + if n < 0 { + break + } + unknown = unknown[n:] + switch typ { + case protowire.VarintType: + val, n := protowire.ConsumeVarint(unknown) + if n < 0 { + return styles + } + unknown = unknown[n:] + if num == 777702 { + styles = append(styles, int32(val)) + } + case protowire.BytesType: + val, n := protowire.ConsumeBytes(unknown) + if n < 0 { + return styles + } + unknown = unknown[n:] + // Repeated enum fields may be packed: bytes contain sequence of varints + if num == 777702 { + packed := val + for len(packed) > 0 { + v, vn := protowire.ConsumeVarint(packed) + if vn < 0 { + break + } + packed = packed[vn:] + styles = append(styles, int32(v)) + } + } + case protowire.Fixed32Type: + unknown = unknown[4:] + case protowire.Fixed64Type: + unknown = unknown[8:] + default: + return styles + } + } + return styles +} + +// serviceNeedsGrpc1Server checks if any service in the file has ts.server = GRPC1_SERVER (2) +func serviceNeedsGrpc1Server(svc *descriptorpb.ServiceDescriptorProto) bool { + for _, style := range getServiceServerStyles(svc) { + if style == 2 { // GRPC1_SERVER + return true + } + } + return false +} + +// fileNeedsGrpc1Server checks if any service in a file needs GRPC1_SERVER generation +func fileNeedsGrpc1Server(file *descriptorpb.FileDescriptorProto) bool { + for _, svc := range file.Service { + if serviceNeedsGrpc1Server(svc) { + return true + } + } + return false +} + +// serviceNeedsGenericServer checks if a service has ts.server = GENERIC_SERVER (1) +func serviceNeedsGenericServer(svc *descriptorpb.ServiceDescriptorProto) bool { + for _, style := range getServiceServerStyles(svc) { + if style == 1 { // GENERIC_SERVER + return true + } + } + return false +} + +// fileNeedsGenericServer checks if any service in a file needs GENERIC_SERVER generation +func fileNeedsGenericServer(file *descriptorpb.FileDescriptorProto) bool { + for _, svc := range file.Service { + if serviceNeedsGenericServer(svc) { + return true + } + } + return false +} + func generate(req *pluginpb.CodeGeneratorRequest) *pluginpb.CodeGeneratorResponse { resp := &pluginpb.CodeGeneratorResponse{} resp.SupportedFeatures = proto.Uint64(uint64(pluginpb.CodeGeneratorResponse_FEATURE_PROTO3_OPTIONAL)) @@ -220,8 +496,8 @@ func generate(req *pluginpb.CodeGeneratorRequest) *pluginpb.CodeGeneratorRespons Content: proto.String(content), }) - // Generate client file if there are services - if len(file.Service) > 0 { + // Generate client file if any service needs a client + if len(file.Service) > 0 && fileNeedsClient(file) { clientContent := generateClientFile(file, req.ProtoFile, params) clientName := getClientOutputFileName(fileName) resp.File = append(resp.File, &pluginpb.CodeGeneratorResponse_File{ @@ -229,6 +505,36 @@ func generate(req *pluginpb.CodeGeneratorRequest) *pluginpb.CodeGeneratorRespons Content: proto.String(clientContent), }) } + + // Generate gRPC server file if any service has ts.server = GRPC1_SERVER + if fileNeedsGrpc1Server(file) { + serverContent := generateGrpcServerFile(file, req.ProtoFile, params) + serverName := getServerOutputFileName(fileName) + resp.File = append(resp.File, &pluginpb.CodeGeneratorResponse_File{ + Name: proto.String(serverName), + Content: proto.String(serverContent), + }) + } + + // Generate gRPC client file if any service has ts.client = GRPC1_CLIENT + if fileNeedsGrpc1Client(file) { + clientContent := generateGrpcClientFile(file, req.ProtoFile, params) + clientName := getGrpcClientOutputFileName(fileName) + resp.File = append(resp.File, &pluginpb.CodeGeneratorResponse_File{ + Name: proto.String(clientName), + Content: proto.String(clientContent), + }) + } + + // Generate generic server file if any service has ts.server = GENERIC_SERVER + if fileNeedsGenericServer(file) { + serverContent := generateGenericServerFile(file, req.ProtoFile, params) + serverName := getGenericServerOutputFileName(fileName) + resp.File = append(resp.File, &pluginpb.CodeGeneratorResponse_File{ + Name: proto.String(serverName), + Content: proto.String(serverContent), + }) + } } // Also generate for google.protobuf well-known types if they're dependencies, @@ -236,6 +542,12 @@ func generate(req *pluginpb.CodeGeneratorRequest) *pluginpb.CodeGeneratorRespons if len(generatedFiles) > 0 || hasExtensionFiles { // Collect all WKT files transitively reachable from FileToGenerate neededWKTs := collectTransitiveWKTDeps(req.FileToGenerate, req.ProtoFile) + // Collect candidate WKT files and their generated content + type wktCandidate struct { + file *descriptorpb.FileDescriptorProto + content string + } + var wktCandidates []wktCandidate for _, file := range req.ProtoFile { fileName := file.GetName() if !neededWKTs[fileName] { @@ -243,10 +555,27 @@ func generate(req *pluginpb.CodeGeneratorRequest) *pluginpb.CodeGeneratorRespons } content := generateFile(file, req.ProtoFile, params, false) if content != "" { - outputName := getOutputFileName(fileName) + wktCandidates = append(wktCandidates, wktCandidate{file: file, content: content}) + } + } + // Collect all files in the generated set (FileToGenerate + WKT candidates) + allGenFiles := make([]*descriptorpb.FileDescriptorProto, 0) + for _, fileName := range req.FileToGenerate { + if f := findFile(req.ProtoFile, fileName); f != nil { + allGenFiles = append(allGenFiles, f) + } + } + for _, wkt := range wktCandidates { + allGenFiles = append(allGenFiles, wkt.file) + } + // Filter: only emit WKT files whose types are used as field types or + // service method types in any file in the generated set (including itself) + for _, wkt := range wktCandidates { + if isWKTFileUsed(wkt.file, allGenFiles) { + outputName := getOutputFileName(wkt.file.GetName()) resp.File = append(resp.File, &pluginpb.CodeGeneratorResponse_File{ Name: proto.String(outputName), - Content: proto.String(content), + Content: proto.String(wkt.content), }) } } @@ -366,14 +695,16 @@ type mapEntryValue struct { } type extInfo struct { - ext *descriptorpb.FieldDescriptorProto - pkg string - msgPrefix string // parent message name(s) for nested extensions, e.g. "Extensions." + ext *descriptorpb.FieldDescriptorProto + pkg string + msgPrefix string // parent message name(s) for nested extensions, e.g. "Extensions." + registryOrder int // order this extension was discovered (for matching TS plugin output order) } // buildExtensionMap builds a map of extension field number -> extension info for a given extendee type func (g *generator) buildExtensionMap(extendeeName string) map[int32]extInfo { extensionMap := make(map[int32]extInfo) + order := 0 collectFromFile := func(f *descriptorpb.FileDescriptorProto) { pkg := "" @@ -383,7 +714,8 @@ func (g *generator) buildExtensionMap(extendeeName string) map[int32]extInfo { // Top-level extensions for _, ext := range f.Extension { if ext.GetExtendee() == extendeeName { - extensionMap[ext.GetNumber()] = extInfo{ext: ext, pkg: pkg} + extensionMap[ext.GetNumber()] = extInfo{ext: ext, pkg: pkg, registryOrder: order} + order++ } } // Extensions nested in messages (recursively) @@ -392,7 +724,8 @@ func (g *generator) buildExtensionMap(extendeeName string) map[int32]extInfo { msgPrefix := prefix + msg.GetName() + "." for _, ext := range msg.Extension { if ext.GetExtendee() == extendeeName { - extensionMap[ext.GetNumber()] = extInfo{ext: ext, pkg: pkg, msgPrefix: msgPrefix} + extensionMap[ext.GetNumber()] = extInfo{ext: ext, pkg: pkg, msgPrefix: msgPrefix, registryOrder: order} + order++ } } for _, nested := range msg.NestedType { @@ -423,11 +756,16 @@ func (g *generator) resolveEnumValueName(typeName string, number int32) string { fqn = "." + f.GetPackage() + "." + enum.GetName() } if fqn == typeName { + // Use the LAST value with matching number (JS object overwrite behavior for allow_alias) + result := "" for _, val := range enum.Value { if val.GetNumber() == number { - return val.GetName() + result = val.GetName() } } + if result != "" { + return result + } } } // Also check nested enums inside messages @@ -454,11 +792,16 @@ func (g *generator) findEnumInMessageWithPrefix(prefix string, msg *descriptorpb for _, enum := range msg.EnumType { fqn := prefix + "." + enum.GetName() if fqn == typeName { + // Use the LAST value with matching number (JS object overwrite behavior for allow_alias) + result := "" for _, val := range enum.Value { if val.GetNumber() == number { - return val.GetName() + result = val.GetName() } } + if result != "" { + return result + } } } for _, nested := range msg.NestedType { @@ -504,7 +847,24 @@ func (g *generator) parseCustomOptions(unknown []byte, extensionMap map[int32]ex pkg += "." } extName := pkg + extInf.msgPrefix + ext.GetName() - + + // Handle packed repeated encoding: when wire type is BytesType but field type + // is a varint-based scalar (enum, bool, int32, etc.), protoc sends packed encoding + if typ == protowire.BytesType && isVarintFieldType(ext.GetType()) { + packedData, n := protowire.ConsumeBytes(unknown) + unknown = unknown[n:] + for len(packedData) > 0 { + v, vn := protowire.ConsumeVarint(packedData) + if vn < 0 { + break + } + packedData = packedData[vn:] + opt := g.parseVarintValue(ext, extName, v) + result = append(result, opt) + } + continue + } + switch ext.GetType() { case descriptorpb.FieldDescriptorProto_TYPE_STRING: v, n := protowire.ConsumeBytes(unknown) @@ -516,8 +876,12 @@ func (g *generator) parseCustomOptions(unknown []byte, extensionMap map[int32]ex unknown = unknown[n:] case descriptorpb.FieldDescriptorProto_TYPE_ENUM: v, n := protowire.ConsumeVarint(unknown) - enumName := g.resolveEnumValueName(ext.GetTypeName(), int32(v)) - result = append(result, customOption{key: extName, value: enumName}) + if ext.GetTypeName() == ".google.protobuf.NullValue" { + result = append(result, customOption{key: extName, value: nil}) + } else { + enumName := g.resolveEnumValueName(ext.GetTypeName(), int32(v)) + result = append(result, customOption{key: extName, value: enumName}) + } unknown = unknown[n:] case descriptorpb.FieldDescriptorProto_TYPE_INT32, descriptorpb.FieldDescriptorProto_TYPE_UINT32: @@ -590,7 +954,7 @@ func (g *generator) parseCustomOptions(unknown []byte, extensionMap map[int32]ex v, n := protowire.ConsumeBytes(unknown) msgDesc := g.findMessageType(ext.GetTypeName()) if msgDesc != nil { - nested := g.parseMessageValue(v, msgDesc) + nested := g.parseMessageValue(v, msgDesc, ext.GetTypeName()) result = append(result, customOption{key: extName, value: nested}) } unknown = unknown[n:] @@ -615,9 +979,92 @@ func (g *generator) parseCustomOptions(unknown []byte, extensionMap map[int32]ex } // Merge repeated fields with the same key into arrays result = mergeRepeatedOptions(result) + + // Ensure repeated (non-map) extension fields are always arrays, even with a single element. + repeatedExts := make(map[string]bool) + for _, ei := range extensionMap { + if ei.ext.GetLabel() == descriptorpb.FieldDescriptorProto_LABEL_REPEATED { + if ei.ext.GetType() == descriptorpb.FieldDescriptorProto_TYPE_MESSAGE { + nestedMsg := g.findMessageType(ei.ext.GetTypeName()) + if nestedMsg != nil && nestedMsg.Options != nil && nestedMsg.GetOptions().GetMapEntry() { + continue + } + } + pkg := ei.pkg + if pkg != "" { + pkg += "." + } + repeatedExts[pkg+ei.msgPrefix+ei.ext.GetName()] = true + } + } + for i, opt := range result { + if repeatedExts[opt.key] { + if _, ok := opt.value.([]interface{}); !ok { + result[i].value = []interface{}{opt.value} + } + } + } + + // Sort by registry order (the order extensions were discovered across files) + // to match the TS plugin's output order, which uses registration order not field number order. + extOrder := make(map[string]int, len(extensionMap)) + for _, ei := range extensionMap { + pkg := ei.pkg + if pkg != "" { + pkg += "." + } + extOrder[pkg+ei.msgPrefix+ei.ext.GetName()] = ei.registryOrder + } + sort.SliceStable(result, func(i, j int) bool { + return extOrder[result[i].key] < extOrder[result[j].key] + }) + return result } +// isVarintFieldType returns true if the proto field type uses varint wire encoding +func isVarintFieldType(t descriptorpb.FieldDescriptorProto_Type) bool { + switch t { + case descriptorpb.FieldDescriptorProto_TYPE_BOOL, + descriptorpb.FieldDescriptorProto_TYPE_ENUM, + descriptorpb.FieldDescriptorProto_TYPE_INT32, + descriptorpb.FieldDescriptorProto_TYPE_UINT32, + descriptorpb.FieldDescriptorProto_TYPE_INT64, + descriptorpb.FieldDescriptorProto_TYPE_UINT64, + descriptorpb.FieldDescriptorProto_TYPE_SINT32, + descriptorpb.FieldDescriptorProto_TYPE_SINT64: + return true + } + return false +} + +// parseVarintValue interprets a varint value according to the extension's field type +func (g *generator) parseVarintValue(ext *descriptorpb.FieldDescriptorProto, extName string, v uint64) customOption { + switch ext.GetType() { + case descriptorpb.FieldDescriptorProto_TYPE_BOOL: + return customOption{key: extName, value: v != 0} + case descriptorpb.FieldDescriptorProto_TYPE_ENUM: + if ext.GetTypeName() == ".google.protobuf.NullValue" { + return customOption{key: extName, value: nil} + } + enumName := g.resolveEnumValueName(ext.GetTypeName(), int32(v)) + return customOption{key: extName, value: enumName} + case descriptorpb.FieldDescriptorProto_TYPE_INT32, + descriptorpb.FieldDescriptorProto_TYPE_UINT32: + return customOption{key: extName, value: int(v)} + case descriptorpb.FieldDescriptorProto_TYPE_INT64: + return customOption{key: extName, value: fmt.Sprintf("%d", int64(v))} + case descriptorpb.FieldDescriptorProto_TYPE_UINT64: + return customOption{key: extName, value: fmt.Sprintf("%d", v)} + case descriptorpb.FieldDescriptorProto_TYPE_SINT32: + return customOption{key: extName, value: int(protowire.DecodeZigZag(v))} + case descriptorpb.FieldDescriptorProto_TYPE_SINT64: + return customOption{key: extName, value: fmt.Sprintf("%d", protowire.DecodeZigZag(v))} + default: + return customOption{key: extName, value: int(v)} + } +} + // mergeRepeatedOptions merges customOption entries with the same key into array values. // e.g. [{key:"tags", value:"alpha"}, {key:"tags", value:"beta"}] → [{key:"tags", value:["alpha","beta"]}] func mergeRepeatedOptions(opts []customOption) []customOption { @@ -652,11 +1099,64 @@ func mergeRepeatedOptions(opts []customOption) []customOption { merged = append(merged, opt) } } + // Sort map entries to match JavaScript Object.keys() enumeration order: + // integer-index keys (0..2^32-2) come first in ascending numeric order, + // followed by non-integer keys in insertion order. + for i, opt := range merged { + if entries, ok := opt.value.([]customOption); ok { + merged[i].value = sortMapEntriesJSOrder(entries) + } + } return merged } +// sortMapEntriesJSOrder sorts []customOption entries the way JavaScript's +// Object.keys() would enumerate them: array-index keys first (ascending +// numeric), then everything else in insertion order. +func sortMapEntriesJSOrder(entries []customOption) []customOption { + // Separate integer-index keys from non-integer keys + type indexedEntry struct { + idx uint64 + entry customOption + } + var intEntries []indexedEntry + var otherEntries []customOption + + for _, e := range entries { + // Keys may be quoted like `"1"` — strip quotes for the check + raw := e.key + if len(raw) >= 2 && raw[0] == '"' && raw[len(raw)-1] == '"' { + raw = raw[1 : len(raw)-1] + } + if isArrayIndex(raw) { + v, _ := strconv.ParseUint(raw, 10, 64) + intEntries = append(intEntries, indexedEntry{idx: v, entry: e}) + } else { + otherEntries = append(otherEntries, e) + } + } + + if len(intEntries) <= 1 && len(otherEntries) == 0 { + return entries // nothing to reorder + } + if len(intEntries) == 0 { + return entries // nothing to reorder + } + + sort.Slice(intEntries, func(i, j int) bool { + return intEntries[i].idx < intEntries[j].idx + }) + + result := make([]customOption, 0, len(entries)) + for _, ie := range intEntries { + result = append(result, ie.entry) + } + result = append(result, otherEntries...) + return result +} + // parseMessageValue decodes a message's wire bytes into an ordered list of field name→value pairs -func (g *generator) parseMessageValue(data []byte, msgDesc *descriptorpb.DescriptorProto) []customOption { +func (g *generator) parseMessageValue(data []byte, msgDesc *descriptorpb.DescriptorProto, msgTypeName string) []customOption { // Build field number → field descriptor map fieldMap := make(map[int32]*descriptorpb.FieldDescriptorProto) for _, f := range msgDesc.Field { @@ -721,8 +1221,12 @@ func (g *generator) parseMessageValue(data []byte, msgDesc *descriptorpb.Descrip case descriptorpb.FieldDescriptorProto_TYPE_BOOL: result = append(result, customOption{key: fieldName, value: v != 0}) case descriptorpb.FieldDescriptorProto_TYPE_ENUM: - enumName := g.resolveEnumValueName(fd.GetTypeName(), int32(v)) - result = append(result, customOption{key: fieldName, value: enumName}) + if fd.GetTypeName() == ".google.protobuf.NullValue" { + result = append(result, customOption{key: fieldName, value: nil}) + } else { + enumName := g.resolveEnumValueName(fd.GetTypeName(), int32(v)) + result = append(result, customOption{key: fieldName, value: enumName}) + } } } continue @@ -798,8 +1302,12 @@ func (g *generator) parseMessageValue(data []byte, msgDesc *descriptorpb.Descrip data = data[n:] case descriptorpb.FieldDescriptorProto_TYPE_ENUM: v, n := protowire.ConsumeVarint(data) - enumName := g.resolveEnumValueName(fd.GetTypeName(), int32(v)) - result = append(result, customOption{key: fieldName, value: enumName}) + if fd.GetTypeName() == ".google.protobuf.NullValue" { + result = append(result, customOption{key: fieldName, value: nil}) + } else { + enumName := g.resolveEnumValueName(fd.GetTypeName(), int32(v)) + result = append(result, customOption{key: fieldName, value: enumName}) + } data = data[n:] case descriptorpb.FieldDescriptorProto_TYPE_INT32, descriptorpb.FieldDescriptorProto_TYPE_UINT32: @@ -874,7 +1382,7 @@ func (g *generator) parseMessageValue(data []byte, msgDesc *descriptorpb.Descrip if nestedMsg != nil { if nestedMsg.Options != nil && nestedMsg.GetOptions().GetMapEntry() { // Map entry: parse key/value and store as mapEntryValue - nested := g.parseMessageValue(v, nestedMsg) + nested := g.parseMessageValue(v, nestedMsg, fd.GetTypeName()) var mapKey string var mapVal interface{} // Determine if map key is numeric (needs quoting in JSON) @@ -908,7 +1416,7 @@ func (g *generator) parseMessageValue(data []byte, msgDesc *descriptorpb.Descrip } result = append(result, customOption{key: fieldName, value: mapEntryValue{key: mapKey, value: mapVal}}) } else { - nested := g.parseMessageValue(v, nestedMsg) + nested := g.parseMessageValue(v, nestedMsg, fd.GetTypeName()) result = append(result, customOption{key: fieldName, value: nested}) } } @@ -927,23 +1435,96 @@ func (g *generator) parseMessageValue(data []byte, msgDesc *descriptorpb.Descrip data = data[n:] } } - return mergeRepeatedOptions(result) -} - -func (g *generator) getCustomMethodOptions(opts *descriptorpb.MethodOptions) []customOption { - if opts == nil { - return nil + merged := mergeRepeatedOptions(result) + // Ensure repeated (non-map) fields are always arrays, even with a single element. + // protobuf-ts toJson() always emits arrays for repeated fields. + repeatedFields := make(map[string]bool) + for _, fd := range msgDesc.Field { + if fd.GetLabel() == descriptorpb.FieldDescriptorProto_LABEL_REPEATED { + // Skip map entries — they are handled as objects, not arrays + if fd.GetType() == descriptorpb.FieldDescriptorProto_TYPE_MESSAGE { + nestedMsg := g.findMessageType(fd.GetTypeName()) + if nestedMsg != nil && nestedMsg.Options != nil && nestedMsg.GetOptions().GetMapEntry() { + continue + } + } + repeatedFields[fd.GetJsonName()] = true + } } - extensionMap := g.buildExtensionMap(".google.protobuf.MethodOptions") - return g.parseCustomOptions(opts.ProtoReflect().GetUnknown(), extensionMap) -} - -func (g *generator) getCustomMessageOptions(opts *descriptorpb.MessageOptions) []customOption { - if opts == nil { - return nil + for i, opt := range merged { + if repeatedFields[opt.key] { + if _, ok := opt.value.([]interface{}); !ok { + merged[i].value = []interface{}{opt.value} + } + } + } + // Filter out fields with default values (protobuf-ts toJson() omits defaults). + // Skip for map entry messages — key/value fields are always meaningful. + // Skip for fields with explicit presence (proto2 optional, proto3 explicit optional) — + // protobuf-ts sets field.opt=true for these, so toJson() always emits them. + if !(msgDesc.Options != nil && msgDesc.GetOptions().GetMapEntry()) { + syntax := g.findFileSyntaxForMessageType(msgTypeName) + isProto2 := syntax == "proto2" || syntax == "" + jsonNameToField := make(map[string]*descriptorpb.FieldDescriptorProto) + for _, fd := range msgDesc.Field { + jsonNameToField[fd.GetJsonName()] = fd + } + var filtered []customOption + for _, opt := range merged { + if fd, ok := jsonNameToField[opt.key]; ok && g.isDefaultValue(fd, opt.value) { + // In proto2, optional fields have presence → keep defaults + // In proto3, explicit optional fields have presence → keep defaults + hasPresence := false + if isProto2 { + hasPresence = fd.GetLabel() == descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL + } else { + // proto3 explicit optional or oneof member (non-synthetic) + hasPresence = fd.GetProto3Optional() + if !hasPresence && fd.OneofIndex != nil { + hasPresence = true + } + } + if !hasPresence { + continue + } + } + filtered = append(filtered, opt) + } + merged = filtered + } + // Reorder fields to match the message descriptor's declaration order. + // protoc serializes by field number, but protobuf-ts toJson() emits in declaration order. + fieldOrder := make(map[string]int) + for i, fd := range msgDesc.Field { + fieldOrder[fd.GetJsonName()] = i + } + sort.SliceStable(merged, func(i, j int) bool { + oi, oki := fieldOrder[merged[i].key] + oj, okj := fieldOrder[merged[j].key] + if oki && okj { + return oi < oj + } + return false + }) + return merged +} + +func (g *generator) getCustomMethodOptions(opts *descriptorpb.MethodOptions) []customOption { + if opts == nil { + return nil + } + extensionMap := g.buildExtensionMap(".google.protobuf.MethodOptions") + allOpts := g.parseCustomOptions(opts.ProtoReflect().GetUnknown(), extensionMap) + return filterExcludedOptions(allOpts, g.getExcludeOptions()) +} + +func (g *generator) getCustomMessageOptions(opts *descriptorpb.MessageOptions) []customOption { + if opts == nil { + return nil } extensionMap := g.buildExtensionMap(".google.protobuf.MessageOptions") - return g.parseCustomOptions(opts.ProtoReflect().GetUnknown(), extensionMap) + allOpts := g.parseCustomOptions(opts.ProtoReflect().GetUnknown(), extensionMap) + return filterExcludedOptions(allOpts, g.getExcludeOptions()) } func (g *generator) getCustomFieldOptions(opts *descriptorpb.FieldOptions) []customOption { @@ -951,7 +1532,8 @@ func (g *generator) getCustomFieldOptions(opts *descriptorpb.FieldOptions) []cus return nil } extensionMap := g.buildExtensionMap(".google.protobuf.FieldOptions") - return g.parseCustomOptions(opts.ProtoReflect().GetUnknown(), extensionMap) + allOpts := g.parseCustomOptions(opts.ProtoReflect().GetUnknown(), extensionMap) + return filterExcludedOptions(allOpts, g.getExcludeOptions()) } func (g *generator) getCustomServiceOptions(opts *descriptorpb.ServiceOptions) []customOption { @@ -959,7 +1541,103 @@ func (g *generator) getCustomServiceOptions(opts *descriptorpb.ServiceOptions) [ return nil } extensionMap := g.buildExtensionMap(".google.protobuf.ServiceOptions") - return g.parseCustomOptions(opts.ProtoReflect().GetUnknown(), extensionMap) + allOpts := g.parseCustomOptions(opts.ProtoReflect().GetUnknown(), extensionMap) + // protobuf-ts only excludes ts.client from service options (not ts.server) + var filtered []customOption + for _, opt := range allOpts { + if opt.key == "ts.client" { + continue + } + filtered = append(filtered, opt) + } + return filterExcludedOptions(filtered, g.getExcludeOptions()) +} + +// getExcludeOptions reads ts.exclude_options from the current file's FileOptions. +// Field 777701 in google.protobuf.FileOptions from package ts, repeated string. +func (g *generator) getExcludeOptions() []string { + if g.file.Options == nil { + return nil + } + unknown := g.file.Options.ProtoReflect().GetUnknown() + var patterns []string + for len(unknown) > 0 { + num, typ, n := protowire.ConsumeTag(unknown) + if n < 0 { + break + } + unknown = unknown[n:] + switch typ { + case protowire.BytesType: + val, n := protowire.ConsumeBytes(unknown) + if n < 0 { + return patterns + } + unknown = unknown[n:] + if num == 777701 { + patterns = append(patterns, string(val)) + } + case protowire.VarintType: + _, n := protowire.ConsumeVarint(unknown) + if n < 0 { + return patterns + } + unknown = unknown[n:] + case protowire.Fixed32Type: + unknown = unknown[4:] + case protowire.Fixed64Type: + unknown = unknown[8:] + default: + return patterns + } + } + return patterns +} + +// filterExcludedOptions removes custom options whose keys match any exclude pattern. +// Matches protobuf-ts behavior: literal patterns (no *) use exact match (key === pattern), +// wildcard patterns are converted to regex (dots escaped, * → .*) and matched as substrings. +func filterExcludedOptions(opts []customOption, excludePatterns []string) []customOption { + if len(excludePatterns) == 0 { + return opts + } + var literals []string + var regexes []*regexp.Regexp + for _, pattern := range excludePatterns { + if strings.Contains(pattern, "*") { + escaped := strings.ReplaceAll(pattern, ".", "\\.") + escaped = strings.ReplaceAll(escaped, "*", ".*") + re, err := regexp.Compile(escaped) + if err != nil { + continue + } + regexes = append(regexes, re) + } else { + literals = append(literals, pattern) + } + } + var filtered []customOption + for _, opt := range opts { + excluded := false + for _, lit := range literals { + if opt.key == lit { + excluded = true + break + } + } + if !excluded { + for _, re := range regexes { + if re.MatchString(opt.key) { + excluded = true + break + } + } + } + if !excluded { + filtered = append(filtered, opt) + } + } + return filtered } // formatCustomOptions formats custom options as a TypeScript object literal @@ -973,13 +1651,10 @@ func formatCustomOptions(opts []customOption) string { for _, opt := range opts { var valueStr string switch val := opt.value.(type) { + case nil: + valueStr = "null" case string: - escaped := strings.ReplaceAll(val, `\`, `\\`) - escaped = strings.ReplaceAll(escaped, `"`, `\"`) - escaped = strings.ReplaceAll(escaped, "\n", `\n`) - escaped = strings.ReplaceAll(escaped, "\r", `\r`) - escaped = strings.ReplaceAll(escaped, "\t", `\t`) - valueStr = fmt.Sprintf("\"%s\"", escaped) + valueStr = fmt.Sprintf("\"%s\"", escapeStringForJS(val)) case bool: valueStr = fmt.Sprintf("%t", val) case int: @@ -994,8 +1669,8 @@ func formatCustomOptions(opts []customOption) string { valueStr = fmt.Sprintf("%v", val) } keyStr := opt.key - if strings.Contains(opt.key, ".") || (len(opt.key) > 0 && opt.key[0] >= '0' && opt.key[0] <= '9') { - keyStr = fmt.Sprintf("\"%s\"", opt.key) + if needsQuoteAsPropertyKey(opt.key) { + keyStr = fmt.Sprintf("\"%s\"", escapeStringForJS(opt.key)) } parts = append(parts, fmt.Sprintf("%s: %s", keyStr, valueStr)) } @@ -1003,6 +1678,29 @@ func formatCustomOptions(opts []customOption) string { return "{ " + strings.Join(parts, ", ") + " }" } +// needsQuoteAsPropertyKey returns true if the key needs to be quoted in a JS object literal. +func needsQuoteAsPropertyKey(key string) bool { + if len(key) == 0 { + return true + } + // Already quoted (e.g. map keys with numeric types like "1") + if len(key) >= 2 && key[0] == '"' && key[len(key)-1] == '"' { + return false + } + for i, c := range key { + if i == 0 { + if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_' || c == '$') { + return true + } + } else { + if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '$') { + return true + } + } + } + return false +} + // isArrayIndex returns true if s is a canonical JS array index (non-negative integer < 2^32-1). // These keys are enumerated first by Object.entries() in ascending numeric order. var wrapperTypeNames = map[string]bool{ @@ -1038,6 +1736,55 @@ func isArrayIndex(s string) bool { return v < (1<<32 - 1) } +// escapeStringForJS escapes a string for use inside a JavaScript double-quoted string literal, +// matching TypeScript compiler's escapeString behavior. +func escapeStringForJS(s string) string { + var b strings.Builder + b.Grow(len(s)) + runes := []rune(s) + for i, r := range runes { + switch r { + case '\\': + b.WriteString(`\\`) + case '"': + b.WriteString(`\"`) + case '\n': + b.WriteString(`\n`) + case '\r': + b.WriteString(`\r`) + case '\t': + b.WriteString(`\t`) + case '\v': + b.WriteString(`\v`) + case '\f': + b.WriteString(`\f`) + case '\b': + b.WriteString(`\b`) + case 0: + // Use \x00 when followed by a digit to avoid ambiguous octal escapes + if i+1 < len(runes) && runes[i+1] >= '0' && runes[i+1] <= '9' { + b.WriteString(`\x00`) + } else { + b.WriteString(`\0`) + } + default: + if r < 0x20 || r >= 0x80 { + if r > 0xFFFF { + // Encode as surrogate pair to match TypeScript's escapeString + hi := 0xD800 + ((r-0x10000)>>10)&0x3FF + lo := 0xDC00 + (r-0x10000)&0x3FF + fmt.Fprintf(&b, `\u%04X\u%04X`, hi, lo) + } else { + fmt.Fprintf(&b, `\u%04X`, r) + } + } else { + b.WriteRune(r) + } + } + } + return b.String() +} + // formatFloatJS formats a float64 the way JavaScript's Number.prototype.toString() does: // scientific notation for |v| < 1e-6 or |v| >= 1e21, fixed-point otherwise. func formatFloatJS(v float64) string { @@ -1075,18 +1822,62 @@ func formatFloatJS(v float64) string { return strconv.FormatFloat(v, 'f', -1, 64) } +// isDefaultValue returns true if the value equals the proto3 JSON default for the field type. +// protobuf-ts toJson() omits fields with default values. +func (g *generator) isDefaultValue(fd *descriptorpb.FieldDescriptorProto, value interface{}) bool { + // Repeated/map fields: present means non-default + if fd.GetLabel() == descriptorpb.FieldDescriptorProto_LABEL_REPEATED { + return false + } + switch fd.GetType() { + case descriptorpb.FieldDescriptorProto_TYPE_INT32, + descriptorpb.FieldDescriptorProto_TYPE_UINT32, + descriptorpb.FieldDescriptorProto_TYPE_SINT32, + descriptorpb.FieldDescriptorProto_TYPE_FIXED32, + descriptorpb.FieldDescriptorProto_TYPE_SFIXED32: + v, ok := value.(int) + return ok && v == 0 + case descriptorpb.FieldDescriptorProto_TYPE_INT64, + descriptorpb.FieldDescriptorProto_TYPE_UINT64, + descriptorpb.FieldDescriptorProto_TYPE_SINT64, + descriptorpb.FieldDescriptorProto_TYPE_FIXED64, + descriptorpb.FieldDescriptorProto_TYPE_SFIXED64: + v, ok := value.(string) + return ok && v == "0" + case descriptorpb.FieldDescriptorProto_TYPE_FLOAT, + descriptorpb.FieldDescriptorProto_TYPE_DOUBLE: + v, ok := value.(float64) + return ok && v == 0 + case descriptorpb.FieldDescriptorProto_TYPE_BOOL: + v, ok := value.(bool) + return ok && !v + case descriptorpb.FieldDescriptorProto_TYPE_STRING: + v, ok := value.(string) + return ok && v == "" + case descriptorpb.FieldDescriptorProto_TYPE_BYTES: + v, ok := value.(string) + return ok && v == "" + case descriptorpb.FieldDescriptorProto_TYPE_ENUM: + if fd.GetTypeName() == ".google.protobuf.NullValue" { + return value == nil + } + // Default enum value has number 0; resolve its name and compare + defaultName := g.resolveEnumValueName(fd.GetTypeName(), 0) + v, ok := value.(string) + return ok && v == defaultName + } + return false +} + // formatCustomOptionArray formats a []interface{} as a TypeScript array literal func formatCustomOptionArray(vals []interface{}) string { var elems []string for _, v := range vals { switch val := v.(type) { + case nil: + elems = append(elems, "null") case string: - escaped := strings.ReplaceAll(val, `\`, `\\`) - escaped = strings.ReplaceAll(escaped, `"`, `\"`) - escaped = strings.ReplaceAll(escaped, "\n", `\n`) - escaped = strings.ReplaceAll(escaped, "\r", `\r`) - escaped = strings.ReplaceAll(escaped, "\t", `\t`) - elems = append(elems, fmt.Sprintf("\"%s\"", escaped)) + elems = append(elems, fmt.Sprintf("\"%s\"", escapeStringForJS(val))) case bool: elems = append(elems, fmt.Sprintf("%t", val)) case int: @@ -1682,7 +2473,7 @@ func (g *generator) collectUsedTypes() (map[string]bool, []string) { // prepend semantics: later methods appear first, but within each method // output stays above input. Collect per-method type pairs, reverse the pairs, // then flatten. For files with messages, keep forward order (output, input). - if len(g.file.MessageType) == 0 && len(serviceTypes) > 0 { + if len(serviceTypes) > 0 { // Re-collect as per-method pairs so we can reverse method order var methodPairs [][]string usedInServices2 := make(map[string]bool) @@ -3096,7 +3887,7 @@ func (g *generator) generateField(field *descriptorpb.FieldDescriptorProto, msgN if msgType != nil && msgType.Options != nil && msgType.GetOptions().GetMapEntry() { // Map field - multiline format keyField := msgType.Field[0] - valueField := msgType.Field[1] + valueField := mapValueWithJstype(field, msgType.Field[1]) keyType := g.getTypescriptTypeForMapKey(keyField) valueType := g.getBaseTypescriptType(valueField) g.p("%s: {", fieldName) @@ -3649,7 +4440,7 @@ func (g *generator) getTypescriptType(field *descriptorpb.FieldDescriptorProto) if msgType != nil && msgType.Options != nil && msgType.GetOptions().GetMapEntry() { // It's a map entry keyField := msgType.Field[0] - valueField := msgType.Field[1] + valueField := mapValueWithJstype(field, msgType.Field[1]) keyType := g.getBaseTypescriptType(keyField) valueType := g.getBaseTypescriptType(valueField) return fmt.Sprintf("{\n [key: %s]: %s;\n }", keyType, valueType) @@ -3755,6 +4546,40 @@ func is64BitIntType(field *descriptorpb.FieldDescriptorProto) bool { return false } +// mapValueWithJstype returns a copy of valueField with the jstype from outerField +// propagated, if the value field is a 64-bit int type. Needed because protobuf puts +// jstype on the outer map field, but the synthetic map entry value field doesn't carry it. +func mapValueWithJstype(outerField, valueField *descriptorpb.FieldDescriptorProto) *descriptorpb.FieldDescriptorProto { + if outerField.Options == nil || outerField.GetOptions().Jstype == nil || !is64BitIntType(valueField) { + return valueField + } + vf := *valueField + if vf.Options == nil { + vf.Options = &descriptorpb.FieldOptions{} + } else { + optsCopy := *vf.Options + vf.Options = &optsCopy + } + jstype := outerField.GetOptions().GetJstype() + vf.Options.Jstype = &jstype + return &vf +} + +// findFileSyntaxForMessageType returns the syntax ("proto2" or "proto3") of the file +// that defines the given message type. Returns "proto3" if not found (the default). +func (g *generator) findFileSyntaxForMessageType(typeName string) string { + typeName = strings.TrimPrefix(typeName, ".") + for _, f := range g.allFiles { + pkg := f.GetPackage() + for _, msg := range f.MessageType { + if g.findMessageTypeInMessage(msg, typeName, pkg) != nil { + return f.GetSyntax() + } + } + } + return "proto3" +} + func (g *generator) findMessageType(typeName string) *descriptorpb.DescriptorProto { typeName = strings.TrimPrefix(typeName, ".") @@ -3769,18 +4594,18 @@ func (g *generator) findMessageType(typeName string) *descriptorpb.DescriptorPro } } - // Search in dependencies - for _, dep := range g.file.Dependency { - depFile := g.findFileByName(dep) - if depFile != nil { - depPkg := "" - if depFile.Package != nil && *depFile.Package != "" { - depPkg = *depFile.Package - } - for _, msg := range depFile.MessageType { - if found := g.findMessageTypeInMessage(msg, typeName, depPkg); found != nil { - return found - } + // Search in all files (needed for transitive deps, e.g. WKT types used as option values) + for _, f := range g.allFiles { + if f.GetName() == g.file.GetName() { + continue + } + depPkg := "" + if f.Package != nil && *f.Package != "" { + depPkg = *f.Package + } + for _, msg := range f.MessageType { + if found := g.findMessageTypeInMessage(msg, typeName, depPkg); found != nil { + return found } } } @@ -3899,7 +4724,7 @@ func (g *generator) findMessageTypeInMessage(msg *descriptorpb.DescriptorProto, // generateFieldDescriptor generates a single field descriptor in the MessageType constructor // oneofName is the proto snake_case name - it will be converted to camelCase for the descriptor -func (g *generator) generateFieldDescriptor(field *descriptorpb.FieldDescriptorProto, oneofName string, comma string) { +func (g *generator) generateFieldDescriptor(field *descriptorpb.FieldDescriptorProto, oneofName string, comma string, customOptionsSource *descriptorpb.FieldOptions) { kind := "scalar" t := g.getScalarTypeEnum(field) extraFields := "" @@ -3942,7 +4767,15 @@ func (g *generator) generateFieldDescriptor(field *descriptorpb.FieldDescriptorP } else { valueT := g.getScalarTypeEnum(valueField) valueTypeName := g.getScalarTypeName(valueField) - extraFields = fmt.Sprintf(", K: %s /*ScalarType.%s*/, V: { kind: \"scalar\", T: %s /*ScalarType.%s*/ }", keyT, keyTypeName, valueT, valueTypeName) + mapValueLongType := "" + if field.Options != nil && field.GetOptions().Jstype != nil && is64BitIntType(valueField) { + if field.GetOptions().GetJstype() == descriptorpb.FieldOptions_JS_NUMBER { + mapValueLongType = ", L: 2 /*LongType.NUMBER*/" + } else if field.GetOptions().GetJstype() == descriptorpb.FieldOptions_JS_NORMAL { + mapValueLongType = ", L: 0 /*LongType.BIGINT*/" + } + } + extraFields = fmt.Sprintf(", K: %s /*ScalarType.%s*/, V: { kind: \"scalar\", T: %s /*ScalarType.%s*/%s }", keyT, keyTypeName, valueT, valueTypeName, mapValueLongType) } } else { // Message field @@ -4011,12 +4844,7 @@ func (g *generator) generateFieldDescriptor(field *descriptorpb.FieldDescriptorP actualJsonName := *field.JsonName // Include jsonName if it differs from the unescaped camelCase name if camelName != actualJsonName { - escaped := strings.ReplaceAll(actualJsonName, `\`, `\\`) - escaped = strings.ReplaceAll(escaped, `"`, `\"`) - escaped = strings.ReplaceAll(escaped, "\n", `\n`) - escaped = strings.ReplaceAll(escaped, "\r", `\r`) - escaped = strings.ReplaceAll(escaped, "\t", `\t`) - jsonNameField = fmt.Sprintf(", jsonName: \"%s\"", escaped) + jsonNameField = fmt.Sprintf(", jsonName: \"%s\"", escapeStringForJS(actualJsonName)) } } @@ -4045,9 +4873,9 @@ func (g *generator) generateFieldDescriptor(field *descriptorpb.FieldDescriptorP } } - // Custom field options + // Custom field options (use customOptionsSource for bug-compatibility with protobuf-ts group field index shift) customFieldOptsStr := "" - customFieldOpts := g.getCustomFieldOptions(field.Options) + customFieldOpts := g.getCustomFieldOptions(customOptionsSource) if len(customFieldOpts) > 0 { customFieldOptsStr = ", options: " + formatCustomOptions(customFieldOpts) } @@ -4093,6 +4921,7 @@ func (g *generator) generateMessageTypeClass(msg *descriptorpb.DescriptorProto, field *descriptorpb.FieldDescriptorProto isProto3Optional bool oneofName string // Proto snake_case oneof name (for real oneofs only) + customOptionsSource *descriptorpb.FieldOptions // Bug-compatible: options source shifted by group fields } var allFields []fieldInfo @@ -4121,6 +4950,16 @@ func (g *generator) generateMessageTypeClass(msg *descriptorpb.DescriptorProto, allFields = append(allFields, info) } + + // Bug-compatible with protobuf-ts: when reading custom options, protobuf-ts + // uses array index alignment between the original descriptor fields (includes + // groups) and the filtered fields (no groups). This causes custom options to + // shift by the number of preceding group fields. We replicate this bug. + for i := range allFields { + if i < len(msg.Field) { + allFields[i].customOptionsSource = msg.Field[i].Options + } + } // Keep fields in proto file order (don't sort) // The order in msg.Field is the order they appear in the .proto file @@ -4148,7 +4987,7 @@ func (g *generator) generateMessageTypeClass(msg *descriptorpb.DescriptorProto, } // Generate field descriptor - g.generateFieldDescriptor(field, info.oneofName, comma) + g.generateFieldDescriptor(field, info.oneofName, comma, info.customOptionsSource) } g.indent = " " @@ -4258,22 +5097,21 @@ func (g *generator) generateMessageTypeClass(msg *descriptorpb.DescriptorProto, } // Deduplicate fields with the same property name (e.g. x123y and x_123_y both → x123Y) - // Last-write-wins: keep the LAST occurrence (matches JS Object.entries behavior) - fieldNameSeen := make(map[string]bool) + // JS Object.entries semantics: property position is where it was FIRST created, + // but value comes from the LAST assignment (overwrite). + fieldNameIdx := make(map[string]int) // fieldName → index in dedupItems dedupItems := make([]initItem, 0, len(initItems)) - for i := len(initItems) - 1; i >= 0; i-- { - item := initItems[i] - if item.isOneof || !fieldNameSeen[item.fieldName] { - if !item.isOneof { - fieldNameSeen[item.fieldName] = true - } + for _, item := range initItems { + if item.isOneof { + dedupItems = append(dedupItems, item) + } else if idx, exists := fieldNameIdx[item.fieldName]; exists { + // Update existing entry's value (last-write-wins) at first-seen position + dedupItems[idx] = item + } else { + fieldNameIdx[item.fieldName] = len(dedupItems) dedupItems = append(dedupItems, item) } } - // Reverse to restore original order - for i, j := 0, len(dedupItems)-1; i < j; i, j = i+1, j-1 { - dedupItems[i], dedupItems[j] = dedupItems[j], dedupItems[i] - } initItems = dedupItems // Reorder to match JavaScript Object.entries() enumeration: @@ -4469,7 +5307,7 @@ func (g *generator) generateMessageTypeClass(msg *descriptorpb.DescriptorProto, msgType := g.findMessageType(field.GetTypeName()) if msgType != nil && msgType.Options != nil && msgType.GetOptions().GetMapEntry() { keyField := msgType.Field[0] - valueField := msgType.Field[1] + valueField := mapValueWithJstype(field, msgType.Field[1]) fieldName := g.propertyName(field) g.p("private binaryReadMap%d(map: %s[\"%s\"], reader: %s, options: %s): void {", @@ -5800,6 +6638,14 @@ func generateClientFile(file *descriptorpb.FileDescriptorProto, allFiles []*desc } g.precomputeImportAliases(depFiles) + // Build filtered list of services that need a generic client + var clientServices []*descriptorpb.ServiceDescriptorProto + for _, svc := range file.Service { + if serviceNeedsGenericClient(svc) { + clientServices = append(clientServices, svc) + } + } + // Detect cross-file type name collisions in client imports. // In the client file, local types (from current file) are also imported, // so they can collide with external imports that have the same TS name. @@ -5808,6 +6654,9 @@ func generateClientFile(file *descriptorpb.FileDescriptorProto, allFiles []*desc claimed := make(map[string]string) // raw tsName → first proto type seenProto := make(map[string]bool) for _, service := range file.Service { + if !serviceNeedsGenericClient(service) { + continue + } for _, method := range service.Method { for _, typeName := range []string{method.GetInputType(), method.GetOutputType()} { if seenProto[typeName] { @@ -5844,6 +6693,9 @@ func generateClientFile(file *descriptorpb.FileDescriptorProto, allFiles []*desc usedCallTypes := make(map[string]bool) hasAnyMethod := false for _, service := range file.Service { + if !serviceNeedsGenericClient(service) { + continue + } for _, method := range service.Method { hasAnyMethod = true cs := method.GetClientStreaming() @@ -5882,6 +6734,9 @@ func generateClientFile(file *descriptorpb.FileDescriptorProto, allFiles []*desc runtimeClaimed := make(map[string]bool) // call type names registered so far protoClaimed := make(map[string]bool) // proto types that claimed a call type name first for _, service := range file.Service { + if !serviceNeedsGenericClient(service) { + continue + } for _, method := range service.Method { // Step 1: Register call type (registered before input/output in protobuf-ts) callType := methodCallTypeName(method) @@ -5928,6 +6783,9 @@ func generateClientFile(file *descriptorpb.FileDescriptorProto, allFiles []*desc g.serviceInfoRef = "ServiceInfo" g.serviceImportAliases = make(map[string]string) for _, service := range file.Service { + if !serviceNeedsGenericClient(service) { + continue + } svcName := escapeTypescriptKeyword(service.GetName()) if service.GetName() == "RpcTransport" { g.rpcTransportRef = "RpcTransport$" @@ -5942,6 +6800,9 @@ func generateClientFile(file *descriptorpb.FileDescriptorProto, allFiles []*desc // Also check if any proto message type used in service methods collides with // RpcTransport, ServiceInfo, or stackIntercept runtime-rpc imports. for _, service := range file.Service { + if !serviceNeedsGenericClient(service) { + continue + } for _, method := range service.Method { for _, typeName := range []string{method.GetInputType(), method.GetOutputType()} { tsName := g.stripPackage(typeName) @@ -5963,9 +6824,9 @@ func generateClientFile(file *descriptorpb.FileDescriptorProto, allFiles []*desc // Find the first service with methods (the "primary" service whose types get special positioning) primaryServiceIdx := 0 - if len(file.Service) > 0 { - for si := 0; si < len(file.Service); si++ { - if len(file.Service[si].Method) > 0 { + if len(clientServices) > 0 { + for si := 0; si < len(clientServices); si++ { + if len(clientServices[si].Method) > 0 { primaryServiceIdx = si break } @@ -5974,8 +6835,8 @@ func generateClientFile(file *descriptorpb.FileDescriptorProto, allFiles []*desc // Collect all types used in primary service to avoid importing them early service1Types := make(map[string]bool) - if len(file.Service) > 0 { - for _, method := range file.Service[primaryServiceIdx].Method { + if len(clientServices) > 0 { + for _, method := range clientServices[primaryServiceIdx].Method { service1Types[g.stripPackage(method.GetOutputType())] = true service1Types[g.stripPackage(method.GetInputType())] = true } @@ -5983,8 +6844,8 @@ func generateClientFile(file *descriptorpb.FileDescriptorProto, allFiles []*desc // Find first service (forward order) with a unary method — determines where UnaryCall import goes firstUnaryServiceIdx := -1 - for si := 0; si < len(file.Service); si++ { - for _, m := range file.Service[si].Method { + for si := 0; si < len(clientServices); si++ { + for _, m := range clientServices[si].Method { if !m.GetClientStreaming() && !m.GetServerStreaming() { firstUnaryServiceIdx = si break @@ -5997,8 +6858,8 @@ func generateClientFile(file *descriptorpb.FileDescriptorProto, allFiles []*desc // For services 2..N (in reverse order), output Service + all method types + streaming call types seenCallTypes := make(map[string]bool) - for svcIdx := len(file.Service) - 1; svcIdx >= 1; svcIdx-- { - service := file.Service[svcIdx] + for svcIdx := len(clientServices) - 1; svcIdx >= 1; svcIdx-- { + service := clientServices[svcIdx] escapedServiceName := escapeTypescriptKeyword(service.GetName()) svcImportClause := escapedServiceName if alias, ok := g.serviceImportAliases[escapedServiceName]; ok { @@ -6121,8 +6982,8 @@ func generateClientFile(file *descriptorpb.FileDescriptorProto, allFiles []*desc } // First service + methods types with special ordering - if len(file.Service) > 0 { - service := file.Service[0] + if len(clientServices) > 0 { + service := clientServices[0] escapedServiceName := escapeTypescriptKeyword(service.GetName()) svcImportClause := escapedServiceName if alias, ok := g.serviceImportAliases[escapedServiceName]; ok { @@ -6315,7 +7176,7 @@ func generateClientFile(file *descriptorpb.FileDescriptorProto, allFiles []*desc // 4. Check if we need stackIntercept (for any method - unary or streaming) hasUnary := false - for _, service := range file.Service { + for _, service := range clientServices { for _, method := range service.Method { if !method.GetClientStreaming() && !method.GetServerStreaming() { hasUnary = true @@ -6329,8 +7190,8 @@ func generateClientFile(file *descriptorpb.FileDescriptorProto, allFiles []*desc // Compute method0IsStreaming for later use method0IsStreaming := false - if len(file.Service) > 0 && len(file.Service[0].Method) > 0 { - m0 := file.Service[0].Method[0] + if len(clientServices) > 0 && len(clientServices[0].Method) > 0 { + m0 := clientServices[0].Method[0] method0IsStreaming = m0.GetClientStreaming() || m0.GetServerStreaming() } @@ -6343,8 +7204,8 @@ func generateClientFile(file *descriptorpb.FileDescriptorProto, allFiles []*desc } // 5. Emit method 0 types (output first, then input) - if len(file.Service) > 0 && len(file.Service[0].Method) > 0 { - method := file.Service[0].Method[0] + if len(clientServices) > 0 && len(clientServices[0].Method) > 0 { + method := clientServices[0].Method[0] resType := g.stripPackage(method.GetOutputType()) reqType := g.stripPackage(method.GetInputType()) resTypeImport := g.formatTypeImport(method.GetOutputType()) @@ -6381,7 +7242,7 @@ func generateClientFile(file *descriptorpb.FileDescriptorProto, allFiles []*desc } // Emit UnaryCall (if method 0 is unary) and RpcOptions - if len(file.Service) > 0 && primaryServiceIdx == 0 { + if len(clientServices) > 0 && primaryServiceIdx == 0 { if hasUnary && !method0IsStreaming && !seenCallTypes["UnaryCall"] { g.pNoIndent("import type { %s } from \"@protobuf-ts/runtime-rpc\";", g.callTypeImportClause("UnaryCall")) seenCallTypes["UnaryCall"] = true @@ -6392,7 +7253,7 @@ func generateClientFile(file *descriptorpb.FileDescriptorProto, allFiles []*desc } // Generate service clients - for _, service := range file.Service { + for _, service := range clientServices { g.generateServiceClient(service) } @@ -6921,6 +7782,14 @@ func (g *generator) getMapValueDefault(field *descriptorpb.FieldDescriptorProto) descriptorpb.FieldDescriptorProto_TYPE_SINT64, descriptorpb.FieldDescriptorProto_TYPE_FIXED64, descriptorpb.FieldDescriptorProto_TYPE_SFIXED64: + if field.Options != nil && field.GetOptions().Jstype != nil { + if field.GetOptions().GetJstype() == descriptorpb.FieldOptions_JS_NUMBER { + return "0" + } + if field.GetOptions().GetJstype() == descriptorpb.FieldOptions_JS_NORMAL { + return "0n" + } + } return "\"0\"" case descriptorpb.FieldDescriptorProto_TYPE_BOOL: return "false" @@ -7923,3 +8792,1068 @@ func (g *generator) generateGoogleTypeTimeOfDayMethods() { g.indent = " " g.p("}") } + +// generateGrpcServerFile generates the .grpc-server.ts file for services with ts.server = GRPC1_SERVER. +func generateGrpcServerFile(file *descriptorpb.FileDescriptorProto, allFiles []*descriptorpb.FileDescriptorProto, params params) string { + g := &generator{ + params: params, + file: file, + allFiles: allFiles, + importedTypeNames: make(map[string]bool), + localTypeNames: make(map[string]bool), + importAliases: make(map[string]string), + rawImportNames: make(map[string]string), + wireTypeRef: "WireType", + scalarTypeRef: "ScalarType", + } + + // Header + g.pNoIndent("// @generated by protobuf-ts 2.11.1 with parameter long_type_string") + pkgComment := "" + syntax := file.GetSyntax() + if syntax == "" { + syntax = "proto2" + } + if file.Package != nil && *file.Package != "" { + pkgComment = fmt.Sprintf(" (package \"%s\", syntax %s)", *file.Package, syntax) + } else { + pkgComment = fmt.Sprintf(" (syntax %s)", syntax) + } + g.pNoIndent("// @generated from protobuf file \"%s\"%s", file.GetName(), pkgComment) + g.pNoIndent("// tslint:disable") + if g.isFileDeprecated() { + g.pNoIndent("// @deprecated") + } + + // Detached comments (same as client file) + if file.SourceCodeInfo != nil { + for _, loc := range file.SourceCodeInfo.Location { + if len(loc.Path) == 1 && loc.Path[0] == 12 && len(loc.LeadingDetachedComments) > 0 { + g.pNoIndent("//") + for blockIdx, detached := range loc.LeadingDetachedComments { + if strings.TrimRight(detached, "\n") != "" { + lines := strings.Split(detached, "\n") + hasTrailingNewline := len(lines) > 0 && lines[len(lines)-1] == "" + endIdx := len(lines) + if hasTrailingNewline { + endIdx = len(lines) - 1 + } + for i := 0; i < endIdx; i++ { + line := lines[i] + if line == "" { + g.pNoIndent("//") + } else { + g.pNoIndent("//%s", line) + } + } + if hasTrailingNewline { + g.pNoIndent("//") + } + if blockIdx < len(loc.LeadingDetachedComments)-1 { + g.pNoIndent("//") + } + } + } + } + } + } + if file.SourceCodeInfo != nil { + for _, loc := range file.SourceCodeInfo.Location { + if len(loc.Path) == 1 && loc.Path[0] == 2 && len(loc.LeadingDetachedComments) > 0 { + g.pNoIndent("//") + for blockIdx, detached := range loc.LeadingDetachedComments { + if strings.TrimRight(detached, "\n") != "" { + lines := strings.Split(detached, "\n") + hasTrailingNewline := len(lines) > 0 && lines[len(lines)-1] == "" + endIdx := len(lines) + if hasTrailingNewline { + endIdx = len(lines) - 1 + } + for i := 0; i < endIdx; i++ { + line := lines[i] + if line == "" { + g.pNoIndent("//") + } else { + g.pNoIndent("//%s", line) + } + } + if hasTrailingNewline { + g.pNoIndent("//") + } + if blockIdx < len(loc.LeadingDetachedComments)-1 { + g.pNoIndent("//") + } + } + } + } + } + } + + baseFileName := strings.TrimSuffix(filepath.Base(file.GetName()), ".proto") + _ = baseFileName + + // Build depFiles and pre-compute import aliases + depFiles := make(map[string]*descriptorpb.FileDescriptorProto) + currentFileDir := filepath.Dir(file.GetName()) + for _, dep := range file.Dependency { + depFile := g.findFileByName(dep) + if depFile != nil { + depPath := strings.TrimSuffix(dep, ".proto") + relPath := g.getRelativeImportPath(currentFileDir, depPath) + depFiles[relPath] = depFile + } + } + for _, pubFile := range g.collectTransitivePublicDeps(file) { + depPath := strings.TrimSuffix(pubFile.GetName(), ".proto") + relPath := g.getRelativeImportPath(currentFileDir, depPath) + if _, exists := depFiles[relPath]; !exists { + depFiles[relPath] = pubFile + } + } + g.precomputeImportAliases(depFiles) + + // Collect message types needed by GRPC1_SERVER services using forward-iterate+prepend + // (matching TS plugin behavior: processes methods forward, prepending imports as encountered) + seen := make(map[string]bool) + type importEntry struct { + importClause string + importPath string + } + var imports []importEntry + for _, service := range file.Service { + if !serviceNeedsGrpc1Server(service) { + continue + } + for _, method := range service.Method { + reqType := g.stripPackage(method.GetInputType()) + resType := g.stripPackage(method.GetOutputType()) + reqTypeImport := g.formatTypeImport(method.GetInputType()) + resTypeImport := g.formatTypeImport(method.GetOutputType()) + reqTypePath := g.getImportPathForType(method.GetInputType()) + resTypePath := g.getImportPathForType(method.GetOutputType()) + + // Prepend input first, then output (output ends up above input) + if !seen[reqType] { + imports = append([]importEntry{{reqTypeImport, reqTypePath}}, imports...) + seen[reqType] = true + } + if !seen[resType] { + imports = append([]importEntry{{resTypeImport, resTypePath}}, imports...) + seen[resType] = true + } + } + } + + // Emit value imports (not type imports) + for _, imp := range imports { + g.pNoIndent("import { %s } from \"%s\";", imp.importClause, imp.importPath) + } + g.pNoIndent("import type * as grpc from \"@grpc/grpc-js\";") + + // Generate interface and definition for each service with GRPC1_SERVER + pkgPrefix := "" + if file.Package != nil && *file.Package != "" { + pkgPrefix = *file.Package + "." + } + + for svcIdx, service := range file.Service { + if !serviceNeedsGrpc1Server(service) { + continue + } + baseName := service.GetName() + serviceName := escapeTypescriptKeyword(baseName) + interfaceName := "I" + serviceName + definitionName := strings.ToLower(baseName[:1]) + baseName[1:] + "Definition" + fullServiceName := pkgPrefix + baseName + + // Interface JSDoc with service comments + g.generateGrpcServerServiceComment(service, svcIdx, fullServiceName) + g.pNoIndent("export interface %s extends grpc.UntypedServiceImplementation {", interfaceName) + g.indent = " " + + // Methods + for methodIdx, method := range service.Method { + methodName := escapeMethodName(g.toCamelCase(method.GetName())) + reqType := g.resolveTypeRef(method.GetInputType()) + resType := g.resolveTypeRef(method.GetOutputType()) + + // Method detached comments + methodPath := []int32{6, int32(svcIdx), 2, int32(methodIdx)} + detachedComments := g.getLeadingDetachedComments(methodPath) + if len(detachedComments) > 0 { + for idx, detached := range detachedComments { + detached = strings.TrimRight(detached, "\n") + for _, line := range strings.Split(detached, "\n") { + if line == "" { + g.p("// ") + } else { + g.p("// %s", line) + } + } + if idx < len(detachedComments)-1 { + g.pNoIndent("") + } + } + g.pNoIndent("") + } + + // Method JSDoc + g.p("/**") + leadingComments, hasLeading := g.getLeadingComments(methodPath) + if hasLeading { + hasTrailingBlank := strings.HasSuffix(leadingComments, "__HAS_TRAILING_BLANK__") + if hasTrailingBlank { + leadingComments = strings.TrimSuffix(leadingComments, "\n__HAS_TRAILING_BLANK__") + } + lines := strings.Split(leadingComments, "\n") + for _, line := range lines { + if line == "" { + g.p(" *") + } else { + g.p(" * %s", escapeJSDocComment(line)) + } + } + if hasTrailingBlank { + g.p(" *") + g.p(" *") + } else { + g.p(" *") + } + } + if (method.Options != nil && method.GetOptions().GetDeprecated()) || + g.isFileDeprecated() { + g.p(" * @deprecated") + } + g.p(" * @generated from protobuf rpc: %s", method.GetName()) + g.p(" */") + + // Method signature + cs := method.GetClientStreaming() + ss := method.GetServerStreaming() + var grpcHandler string + if cs && ss { + grpcHandler = "grpc.handleBidiStreamingCall" + } else if ss { + grpcHandler = "grpc.handleServerStreamingCall" + } else if cs { + grpcHandler = "grpc.handleClientStreamingCall" + } else { + grpcHandler = "grpc.handleUnaryCall" + } + g.p("%s: %s<%s, %s>;", methodName, grpcHandler, reqType, resType) + } + + g.indent = "" + g.pNoIndent("}") + + // Service definition const + g.pNoIndent("/**") + g.pNoIndent(" * @grpc/grpc-js definition for the protobuf service %s.", fullServiceName) + g.pNoIndent(" *") + g.pNoIndent(" * Usage: Implement the interface %s and add to a grpc server.", interfaceName) + g.pNoIndent(" *") + g.pNoIndent(" * ```typescript") + g.pNoIndent(" * const server = new grpc.Server();") + g.pNoIndent(" * const service: %s = ...", interfaceName) + g.pNoIndent(" * server.addService(%s, service);", definitionName) + g.pNoIndent(" * ```") + g.pNoIndent(" */") + g.pNoIndent("export const %s: grpc.ServiceDefinition<%s> = {", definitionName, interfaceName) + g.indent = " " + + for i, method := range service.Method { + methodName := escapeMethodName(g.toCamelCase(method.GetName())) + reqType := g.resolveTypeRef(method.GetInputType()) + resType := g.resolveTypeRef(method.GetOutputType()) + cs := method.GetClientStreaming() + ss := method.GetServerStreaming() + + g.p("%s: {", methodName) + g.indent = " " + g.p("path: \"/%s/%s\",", fullServiceName, method.GetName()) + g.p("originalName: \"%s\",", method.GetName()) + g.p("requestStream: %v,", cs) + g.p("responseStream: %v,", ss) + g.p("responseDeserialize: bytes => %s.fromBinary(bytes),", resType) + g.p("requestDeserialize: bytes => %s.fromBinary(bytes),", reqType) + g.p("responseSerialize: value => Buffer.from(%s.toBinary(value)),", resType) + g.p("requestSerialize: value => Buffer.from(%s.toBinary(value))", reqType) + g.indent = " " + if i < len(service.Method)-1 { + g.p("},") + } else { + g.p("}") + } + } + + g.indent = "" + g.pNoIndent("};") + } + + return g.b.String() +} + +// generateGrpcClientFile generates the .grpc-client.ts file for services with ts.client = GRPC1_CLIENT. +func generateGrpcClientFile(file *descriptorpb.FileDescriptorProto, allFiles []*descriptorpb.FileDescriptorProto, params params) string { + g := &generator{ + params: params, + file: file, + allFiles: allFiles, + importedTypeNames: make(map[string]bool), + localTypeNames: make(map[string]bool), + importAliases: make(map[string]string), + rawImportNames: make(map[string]string), + wireTypeRef: "WireType", + scalarTypeRef: "ScalarType", + } + + // Header + g.pNoIndent("// @generated by protobuf-ts 2.11.1 with parameter long_type_string") + pkgComment := "" + syntax := file.GetSyntax() + if syntax == "" { + syntax = "proto2" + } + if file.Package != nil && *file.Package != "" { + pkgComment = fmt.Sprintf(" (package \"%s\", syntax %s)", *file.Package, syntax) + } else { + pkgComment = fmt.Sprintf(" (syntax %s)", syntax) + } + g.pNoIndent("// @generated from protobuf file \"%s\"%s", file.GetName(), pkgComment) + g.pNoIndent("// tslint:disable") + if g.isFileDeprecated() { + g.pNoIndent("// @deprecated") + } + + // Detached comments + if file.SourceCodeInfo != nil { + for _, loc := range file.SourceCodeInfo.Location { + if len(loc.Path) == 1 && loc.Path[0] == 12 && len(loc.LeadingDetachedComments) > 0 { + g.pNoIndent("//") + for blockIdx, detached := range loc.LeadingDetachedComments { + if strings.TrimRight(detached, "\n") != "" { + lines := strings.Split(detached, "\n") + hasTrailingNewline := len(lines) > 0 && lines[len(lines)-1] == "" + endIdx := len(lines) + if hasTrailingNewline { + endIdx = len(lines) - 1 + } + for i := 0; i < endIdx; i++ { + line := lines[i] + if line == "" { + g.pNoIndent("//") + } else { + g.pNoIndent("//%s", line) + } + } + if hasTrailingNewline { + g.pNoIndent("//") + } + if blockIdx < len(loc.LeadingDetachedComments)-1 { + g.pNoIndent("//") + } + } + } + } + } + } + if file.SourceCodeInfo != nil { + for _, loc := range file.SourceCodeInfo.Location { + if len(loc.Path) == 1 && loc.Path[0] == 2 && len(loc.LeadingDetachedComments) > 0 { + g.pNoIndent("//") + for blockIdx, detached := range loc.LeadingDetachedComments { + if strings.TrimRight(detached, "\n") != "" { + lines := strings.Split(detached, "\n") + hasTrailingNewline := len(lines) > 0 && lines[len(lines)-1] == "" + endIdx := len(lines) + if hasTrailingNewline { + endIdx = len(lines) - 1 + } + for i := 0; i < endIdx; i++ { + line := lines[i] + if line == "" { + g.pNoIndent("//") + } else { + g.pNoIndent("//%s", line) + } + } + if hasTrailingNewline { + g.pNoIndent("//") + } + if blockIdx < len(loc.LeadingDetachedComments)-1 { + g.pNoIndent("//") + } + } + } + } + } + } + + baseFileName := strings.TrimSuffix(filepath.Base(file.GetName()), ".proto") + + // Build depFiles and pre-compute import aliases + depFiles := make(map[string]*descriptorpb.FileDescriptorProto) + currentFileDir := filepath.Dir(file.GetName()) + for _, dep := range file.Dependency { + depFile := g.findFileByName(dep) + if depFile != nil { + depPath := strings.TrimSuffix(dep, ".proto") + relPath := g.getRelativeImportPath(currentFileDir, depPath) + depFiles[relPath] = depFile + } + } + for _, pubFile := range g.collectTransitivePublicDeps(file) { + depPath := strings.TrimSuffix(pubFile.GetName(), ".proto") + relPath := g.getRelativeImportPath(currentFileDir, depPath) + if _, exists := depFiles[relPath]; !exists { + depFiles[relPath] = pubFile + } + } + g.precomputeImportAliases(depFiles) + + // Collect service value imports and message type imports using prepend-as-encountered + // (matching TS plugin behavior: forward method iteration, prepend imports as types are encountered) + seen := make(map[string]bool) + type importEntry struct { + importClause string + importPath string + } + var serviceImports []importEntry + var typeImports []importEntry + + prepend := func(entry importEntry) { + typeImports = append([]importEntry{entry}, typeImports...) + } + + for _, service := range file.Service { + if !serviceNeedsGrpc1Client(service) { + continue + } + serviceName := escapeTypescriptKeyword(service.GetName()) + if !seen["svc:"+serviceName] { + serviceImports = append(serviceImports, importEntry{serviceName, "./" + baseFileName}) + seen["svc:"+serviceName] = true + } + for _, method := range service.Method { + resType := g.stripPackage(method.GetOutputType()) + reqType := g.stripPackage(method.GetInputType()) + resTypeImport := g.formatTypeImport(method.GetOutputType()) + reqTypeImport := g.formatTypeImport(method.GetInputType()) + resTypePath := g.getImportPathForType(method.GetOutputType()) + reqTypePath := g.getImportPathForType(method.GetInputType()) + + cs := method.GetClientStreaming() + ss := method.GetServerStreaming() + if cs && !ss { + // Client streaming: TS encounters output (callback) before input (stream type), + // so with prepend, input ends up above output. + if !seen[resType] { + prepend(importEntry{resTypeImport, resTypePath}) + seen[resType] = true + } + if !seen[reqType] { + prepend(importEntry{reqTypeImport, reqTypePath}) + seen[reqType] = true + } + } else { + // Unary/server-stream/bidi: TS encounters input (parameter) before output (callback), + // so with prepend, output ends up above input. + if !seen[reqType] { + prepend(importEntry{reqTypeImport, reqTypePath}) + seen[reqType] = true + } + if !seen[resType] { + prepend(importEntry{resTypeImport, resTypePath}) + seen[resType] = true + } + } + } + } + + // Emit imports: service value imports, then BinaryWriteOptions/BinaryReadOptions, then type imports, then grpc + for _, imp := range serviceImports { + g.pNoIndent("import { %s } from \"%s\";", imp.importClause, imp.importPath) + } + g.pNoIndent("import type { BinaryWriteOptions } from \"@protobuf-ts/runtime\";") + g.pNoIndent("import type { BinaryReadOptions } from \"@protobuf-ts/runtime\";") + for _, imp := range typeImports { + g.pNoIndent("import type { %s } from \"%s\";", imp.importClause, imp.importPath) + } + g.pNoIndent("import * as grpc from \"@grpc/grpc-js\";") + + // Generate interface and class for each service with GRPC1_CLIENT + pkgPrefix := "" + if file.Package != nil && *file.Package != "" { + pkgPrefix = *file.Package + "." + } + + for svcIdx, service := range file.Service { + if !serviceNeedsGrpc1Client(service) { + continue + } + baseName := service.GetName() + serviceName := escapeTypescriptKeyword(baseName) + interfaceName := "I" + serviceName + "Client" + className := serviceName + "Client" + fullServiceName := pkgPrefix + baseName + + // Interface + g.generateGrpcServerServiceComment(service, svcIdx, fullServiceName) + g.pNoIndent("export interface %s {", interfaceName) + g.indent = " " + + for methodIdx, method := range service.Method { + methodName := escapeMethodName(g.toCamelCase(method.GetName())) + reqType := g.resolveTypeRef(method.GetInputType()) + resType := g.resolveTypeRef(method.GetOutputType()) + cs := method.GetClientStreaming() + ss := method.GetServerStreaming() + + // Method comments + methodPath := []int32{6, int32(svcIdx), 2, int32(methodIdx)} + detachedComments := g.getLeadingDetachedComments(methodPath) + if len(detachedComments) > 0 { + for idx, detached := range detachedComments { + detached = strings.TrimRight(detached, "\n") + for _, line := range strings.Split(detached, "\n") { + if line == "" { + g.p("// ") + } else { + g.p("// %s", line) + } + } + if idx < len(detachedComments)-1 { + g.pNoIndent("") + } + } + g.pNoIndent("") + } + + g.p("/**") + leadingComments, hasLeading := g.getLeadingComments(methodPath) + if hasLeading { + hasTrailingBlank := strings.HasSuffix(leadingComments, "__HAS_TRAILING_BLANK__") + if hasTrailingBlank { + leadingComments = strings.TrimSuffix(leadingComments, "\n__HAS_TRAILING_BLANK__") + } + lines := strings.Split(leadingComments, "\n") + for _, line := range lines { + if line == "" { + g.p(" *") + } else { + g.p(" * %s", escapeJSDocComment(line)) + } + } + if hasTrailingBlank { + g.p(" *") + g.p(" *") + } else { + g.p(" *") + } + } + if (method.Options != nil && method.GetOptions().GetDeprecated()) || + g.isFileDeprecated() { + g.p(" * @deprecated") + } + g.p(" * @generated from protobuf rpc: %s", method.GetName()) + g.p(" */") + + callbackType := fmt.Sprintf("(err: grpc.ServiceError | null, value?: %s) => void", resType) + + if cs && ss { + // Bidi streaming: no overloads, returns ClientDuplexStream + g.p("%s(metadata: grpc.Metadata, options?: grpc.CallOptions): grpc.ClientDuplexStream<%s, %s>;", methodName, reqType, resType) + g.p("%s(options?: grpc.CallOptions): grpc.ClientDuplexStream<%s, %s>;", methodName, reqType, resType) + } else if ss { + // Server streaming: input + optional metadata/options, returns ClientReadableStream + g.p("%s(input: %s, metadata?: grpc.Metadata, options?: grpc.CallOptions): grpc.ClientReadableStream<%s>;", methodName, reqType, resType) + g.p("%s(input: %s, options?: grpc.CallOptions): grpc.ClientReadableStream<%s>;", methodName, reqType, resType) + } else if cs { + // Client streaming: callback + optional metadata/options, returns ClientWritableStream + g.p("%s(metadata: grpc.Metadata, options: grpc.CallOptions, callback: %s): grpc.ClientWritableStream<%s>;", methodName, callbackType, reqType) + g.p("%s(metadata: grpc.Metadata, callback: %s): grpc.ClientWritableStream<%s>;", methodName, callbackType, reqType) + g.p("%s(options: grpc.CallOptions, callback: %s): grpc.ClientWritableStream<%s>;", methodName, callbackType, reqType) + g.p("%s(callback: %s): grpc.ClientWritableStream<%s>;", methodName, callbackType, reqType) + } else { + // Unary: 4 overloads + g.p("%s(input: %s, metadata: grpc.Metadata, options: grpc.CallOptions, callback: %s): grpc.ClientUnaryCall;", methodName, reqType, callbackType) + g.p("%s(input: %s, metadata: grpc.Metadata, callback: %s): grpc.ClientUnaryCall;", methodName, reqType, callbackType) + g.p("%s(input: %s, options: grpc.CallOptions, callback: %s): grpc.ClientUnaryCall;", methodName, reqType, callbackType) + g.p("%s(input: %s, callback: %s): grpc.ClientUnaryCall;", methodName, reqType, callbackType) + } + } + + g.indent = "" + g.pNoIndent("}") + + // Class + g.generateGrpcServerServiceComment(service, svcIdx, fullServiceName) + g.pNoIndent("export class %s extends grpc.Client implements %s {", className, interfaceName) + g.indent = " " + g.p("private readonly _binaryOptions: Partial;") + g.p("constructor(address: string, credentials: grpc.ChannelCredentials, options: grpc.ClientOptions = {}, binaryOptions: Partial = {}) {") + g.indent = " " + g.p("super(address, credentials, options);") + g.p("this._binaryOptions = binaryOptions;") + g.indent = " " + g.p("}") + + // Track method index within the service for methods[N] reference + for methodIdx, method := range service.Method { + methodName := escapeMethodName(g.toCamelCase(method.GetName())) + reqType := g.resolveTypeRef(method.GetInputType()) + resType := g.resolveTypeRef(method.GetOutputType()) + cs := method.GetClientStreaming() + ss := method.GetServerStreaming() + + // Method comments + methodPath := []int32{6, int32(svcIdx), 2, int32(methodIdx)} + detachedComments := g.getLeadingDetachedComments(methodPath) + if len(detachedComments) > 0 { + for idx, detached := range detachedComments { + detached = strings.TrimRight(detached, "\n") + for _, line := range strings.Split(detached, "\n") { + if line == "" { + g.p("// ") + } else { + g.p("// %s", line) + } + } + if idx < len(detachedComments)-1 { + g.pNoIndent("") + } + } + g.pNoIndent("") + } + + g.p("/**") + leadingComments, hasLeading := g.getLeadingComments(methodPath) + if hasLeading { + hasTrailingBlank := strings.HasSuffix(leadingComments, "__HAS_TRAILING_BLANK__") + if hasTrailingBlank { + leadingComments = strings.TrimSuffix(leadingComments, "\n__HAS_TRAILING_BLANK__") + } + lines := strings.Split(leadingComments, "\n") + for _, line := range lines { + if line == "" { + g.p(" *") + } else { + g.p(" * %s", escapeJSDocComment(line)) + } + } + if hasTrailingBlank { + g.p(" *") + g.p(" *") + } else { + g.p(" *") + } + } + if (method.Options != nil && method.GetOptions().GetDeprecated()) || + g.isFileDeprecated() { + g.p(" * @deprecated") + } + g.p(" * @generated from protobuf rpc: %s", method.GetName()) + g.p(" */") + + callbackType := fmt.Sprintf("(err: grpc.ServiceError | null, value?: %s) => void", resType) + callbackTypeParen := fmt.Sprintf("(%s)", callbackType) + serializeReq := fmt.Sprintf("(value: %s): Buffer => Buffer.from(method.I.toBinary(value, this._binaryOptions))", reqType) + deserializeRes := fmt.Sprintf("(value: Buffer): %s => method.O.fromBinary(value, this._binaryOptions)", resType) + + if cs && ss { + // Bidi streaming implementation + g.p("%s(metadata?: grpc.Metadata | grpc.CallOptions, options?: grpc.CallOptions): grpc.ClientDuplexStream<%s, %s> {", methodName, reqType, resType) + g.indent = " " + g.p("const method = %s.methods[%d];", serviceName, methodIdx) + g.p("return this.makeBidiStreamRequest<%s, %s>(`/${%s.typeName}/${method.name}`, %s, %s, (metadata as any), options);", reqType, resType, serviceName, serializeReq, deserializeRes) + g.indent = " " + g.p("}") + } else if ss { + // Server streaming implementation + g.p("%s(input: %s, metadata?: grpc.Metadata | grpc.CallOptions, options?: grpc.CallOptions): grpc.ClientReadableStream<%s> {", methodName, reqType, resType) + g.indent = " " + g.p("const method = %s.methods[%d];", serviceName, methodIdx) + g.p("return this.makeServerStreamRequest<%s, %s>(`/${%s.typeName}/${method.name}`, %s, %s, input, (metadata as any), options);", reqType, resType, serviceName, serializeReq, deserializeRes) + g.indent = " " + g.p("}") + } else if cs { + // Client streaming implementation + g.p("%s(metadata: grpc.Metadata | grpc.CallOptions | %s, options?: grpc.CallOptions | %s, callback?: %s): grpc.ClientWritableStream<%s> {", methodName, callbackTypeParen, callbackTypeParen, callbackTypeParen, reqType) + g.indent = " " + g.p("const method = %s.methods[%d];", serviceName, methodIdx) + g.p("return this.makeClientStreamRequest<%s, %s>(`/${%s.typeName}/${method.name}`, %s, %s, (metadata as any), (options as any), (callback as any));", reqType, resType, serviceName, serializeReq, deserializeRes) + g.indent = " " + g.p("}") + } else { + // Unary implementation + g.p("%s(input: %s, metadata: grpc.Metadata | grpc.CallOptions | %s, options?: grpc.CallOptions | %s, callback?: %s): grpc.ClientUnaryCall {", methodName, reqType, callbackTypeParen, callbackTypeParen, callbackTypeParen) + g.indent = " " + g.p("const method = %s.methods[%d];", serviceName, methodIdx) + g.p("return this.makeUnaryRequest<%s, %s>(`/${%s.typeName}/${method.name}`, %s, %s, input, (metadata as any), (options as any), (callback as any));", reqType, resType, serviceName, serializeReq, deserializeRes) + g.indent = " " + g.p("}") + } + } + + g.indent = "" + g.pNoIndent("}") + } + + return g.b.String() +} + +// generateGenericServerFile generates the .server.ts file for services with ts.server = GENERIC_SERVER. +func generateGenericServerFile(file *descriptorpb.FileDescriptorProto, allFiles []*descriptorpb.FileDescriptorProto, params params) string { + g := &generator{ + params: params, + file: file, + allFiles: allFiles, + importedTypeNames: make(map[string]bool), + localTypeNames: make(map[string]bool), + importAliases: make(map[string]string), + rawImportNames: make(map[string]string), + wireTypeRef: "WireType", + scalarTypeRef: "ScalarType", + } + + // Header + g.pNoIndent("// @generated by protobuf-ts 2.11.1 with parameter long_type_string") + pkgComment := "" + syntax := file.GetSyntax() + if syntax == "" { + syntax = "proto2" + } + if file.Package != nil && *file.Package != "" { + pkgComment = fmt.Sprintf(" (package \"%s\", syntax %s)", *file.Package, syntax) + } else { + pkgComment = fmt.Sprintf(" (syntax %s)", syntax) + } + g.pNoIndent("// @generated from protobuf file \"%s\"%s", file.GetName(), pkgComment) + g.pNoIndent("// tslint:disable") + if g.isFileDeprecated() { + g.pNoIndent("// @deprecated") + } + + // Detached comments (same as other server files) + if file.SourceCodeInfo != nil { + for _, loc := range file.SourceCodeInfo.Location { + if len(loc.Path) == 1 && loc.Path[0] == 12 && len(loc.LeadingDetachedComments) > 0 { + g.pNoIndent("//") + for blockIdx, detached := range loc.LeadingDetachedComments { + if strings.TrimRight(detached, "\n") != "" { + lines := strings.Split(detached, "\n") + hasTrailingNewline := len(lines) > 0 && lines[len(lines)-1] == "" + endIdx := len(lines) + if hasTrailingNewline { + endIdx = len(lines) - 1 + } + for i := 0; i < endIdx; i++ { + line := lines[i] + if line == "" { + g.pNoIndent("//") + } else { + g.pNoIndent("//%s", line) + } + } + if hasTrailingNewline { + g.pNoIndent("//") + } + if blockIdx < len(loc.LeadingDetachedComments)-1 { + g.pNoIndent("//") + } + } + } + } + } + } + if file.SourceCodeInfo != nil { + for _, loc := range file.SourceCodeInfo.Location { + if len(loc.Path) == 1 && loc.Path[0] == 2 && len(loc.LeadingDetachedComments) > 0 { + g.pNoIndent("//") + for blockIdx, detached := range loc.LeadingDetachedComments { + if strings.TrimRight(detached, "\n") != "" { + lines := strings.Split(detached, "\n") + hasTrailingNewline := len(lines) > 0 && lines[len(lines)-1] == "" + endIdx := len(lines) + if hasTrailingNewline { + endIdx = len(lines) - 1 + } + for i := 0; i < endIdx; i++ { + line := lines[i] + if line == "" { + g.pNoIndent("//") + } else { + g.pNoIndent("//%s", line) + } + } + if hasTrailingNewline { + g.pNoIndent("//") + } + if blockIdx < len(loc.LeadingDetachedComments)-1 { + g.pNoIndent("//") + } + } + } + } + } + } + + baseFileName := strings.TrimSuffix(filepath.Base(file.GetName()), ".proto") + _ = baseFileName + + // Build depFiles and pre-compute import aliases + depFiles := make(map[string]*descriptorpb.FileDescriptorProto) + currentFileDir := filepath.Dir(file.GetName()) + for _, dep := range file.Dependency { + depFile := g.findFileByName(dep) + if depFile != nil { + depPath := strings.TrimSuffix(dep, ".proto") + relPath := g.getRelativeImportPath(currentFileDir, depPath) + depFiles[relPath] = depFile + } + } + for _, pubFile := range g.collectTransitivePublicDeps(file) { + depPath := strings.TrimSuffix(pubFile.GetName(), ".proto") + relPath := g.getRelativeImportPath(currentFileDir, depPath) + if _, exists := depFiles[relPath]; !exists { + depFiles[relPath] = pubFile + } + } + g.precomputeImportAliases(depFiles) + + // Build imports by simulating the TS plugin's prepend-as-encountered behavior. + // For each method (forward order), prepend input type, output type, then streaming + // types. This interleaves RpcInputStream/RpcOutputStream with message type imports. + seen := make(map[string]bool) + type importEntry struct { + importClause string + importPath string + } + var imports []importEntry + + prepend := func(entry importEntry) { + imports = append([]importEntry{entry}, imports...) + } + + for _, service := range file.Service { + if !serviceNeedsGenericServer(service) { + continue + } + for _, method := range service.Method { + resType := g.stripPackage(method.GetOutputType()) + reqType := g.stripPackage(method.GetInputType()) + resTypeImport := g.formatTypeImport(method.GetOutputType()) + reqTypeImport := g.formatTypeImport(method.GetInputType()) + resTypePath := g.getImportPathForType(method.GetOutputType()) + reqTypePath := g.getImportPathForType(method.GetInputType()) + + cs := method.GetClientStreaming() + ss := method.GetServerStreaming() + + if !seen[reqType] { + prepend(importEntry{reqTypeImport, reqTypePath}) + seen[reqType] = true + } + if !seen[resType] { + prepend(importEntry{resTypeImport, resTypePath}) + seen[resType] = true + } + if cs && !seen["RpcOutputStream"] { + prepend(importEntry{"RpcOutputStream", "@protobuf-ts/runtime-rpc"}) + seen["RpcOutputStream"] = true + } + if ss && !seen["RpcInputStream"] { + prepend(importEntry{"RpcInputStream", "@protobuf-ts/runtime-rpc"}) + seen["RpcInputStream"] = true + } + } + } + + for _, imp := range imports { + g.pNoIndent("import { %s } from \"%s\";", imp.importClause, imp.importPath) + } + g.pNoIndent("import { ServerCallContext } from \"@protobuf-ts/runtime-rpc\";") + + // Generate interface for each service with GENERIC_SERVER + pkgPrefix := "" + if file.Package != nil && *file.Package != "" { + pkgPrefix = *file.Package + "." + } + + for svcIdx, service := range file.Service { + if !serviceNeedsGenericServer(service) { + continue + } + baseName := service.GetName() + serviceName := escapeTypescriptKeyword(baseName) + interfaceName := "I" + serviceName + fullServiceName := pkgPrefix + baseName + + // Interface JSDoc with service comments + g.generateGrpcServerServiceComment(service, svcIdx, fullServiceName) + g.pNoIndent("export interface %s {", interfaceName) + g.indent = " " + + // Methods + for methodIdx, method := range service.Method { + methodName := escapeMethodName(g.toCamelCase(method.GetName())) + reqType := g.resolveTypeRef(method.GetInputType()) + resType := g.resolveTypeRef(method.GetOutputType()) + + // Method detached comments + methodPath := []int32{6, int32(svcIdx), 2, int32(methodIdx)} + detachedComments := g.getLeadingDetachedComments(methodPath) + if len(detachedComments) > 0 { + for idx, detached := range detachedComments { + detached = strings.TrimRight(detached, "\n") + for _, line := range strings.Split(detached, "\n") { + if line == "" { + g.p("// ") + } else { + g.p("// %s", line) + } + } + if idx < len(detachedComments)-1 { + g.pNoIndent("") + } + } + g.pNoIndent("") + } + + // Method JSDoc + g.p("/**") + leadingComments, hasLeading := g.getLeadingComments(methodPath) + if hasLeading { + hasTrailingBlank := strings.HasSuffix(leadingComments, "__HAS_TRAILING_BLANK__") + if hasTrailingBlank { + leadingComments = strings.TrimSuffix(leadingComments, "\n__HAS_TRAILING_BLANK__") + } + lines := strings.Split(leadingComments, "\n") + for _, line := range lines { + if line == "" { + g.p(" *") + } else { + g.p(" * %s", escapeJSDocComment(line)) + } + } + if hasTrailingBlank { + g.p(" *") + g.p(" *") + } else { + g.p(" *") + } + } + if (method.Options != nil && method.GetOptions().GetDeprecated()) || + g.isFileDeprecated() { + g.p(" * @deprecated") + } + g.p(" * @generated from protobuf rpc: %s", method.GetName()) + g.p(" */") + + // Method signature + cs := method.GetClientStreaming() + ss := method.GetServerStreaming() + if cs && ss { + // Bidi streaming + g.p("%s(requests: RpcOutputStream<%s>, responses: RpcInputStream<%s>, context: T): Promise;", methodName, reqType, resType) + } else if ss { + // Server streaming + g.p("%s(request: %s, responses: RpcInputStream<%s>, context: T): Promise;", methodName, reqType, resType) + } else if cs { + // Client streaming + g.p("%s(requests: RpcOutputStream<%s>, context: T): Promise<%s>;", methodName, reqType, resType) + } else { + // Unary + g.p("%s(request: %s, context: T): Promise<%s>;", methodName, reqType, resType) + } + } + + g.indent = "" + g.pNoIndent("}") + } + + return g.b.String() +} + +func (g *generator) resolveTypeRef(protoTypeName string) string { + if alias, ok := g.importAliases[protoTypeName]; ok { + return alias + } + return g.stripPackage(protoTypeName) +} + +func (g *generator) generateGrpcServerServiceComment(service *descriptorpb.ServiceDescriptorProto, svcIndex int, fullServiceName string) { + // Detached comments + detachedComments := g.getLeadingDetachedComments([]int32{6, int32(svcIndex)}) + if len(detachedComments) > 0 { + for idx, detached := range detachedComments { + detached = strings.TrimRight(detached, "\n") + for _, line := range strings.Split(detached, "\n") { + if line == "" { + g.pNoIndent("// ") + } else { + g.pNoIndent("// %s", line) + } + } + if idx < len(detachedComments)-1 { + g.pNoIndent("") + } + } + g.pNoIndent("") + } + + g.pNoIndent("/**") + + // Leading comments + leadingComments, hasLeading := g.getLeadingComments([]int32{6, int32(svcIndex)}) + if hasLeading { + hasTrailingBlank := strings.HasSuffix(leadingComments, "__HAS_TRAILING_BLANK__") + if hasTrailingBlank { + leadingComments = strings.TrimSuffix(leadingComments, "\n__HAS_TRAILING_BLANK__") + } + lines := strings.Split(leadingComments, "\n") + for _, line := range lines { + if line == "" { + g.pNoIndent(" *") + } else { + g.pNoIndent(" * %s", escapeJSDocComment(line)) + } + } + if hasTrailingBlank { + g.pNoIndent(" *") + g.pNoIndent(" *") + } else { + g.pNoIndent(" *") + } + } + + // Trailing comments + trailingComments := g.getEnumTrailingComments([]int32{6, int32(svcIndex)}) + if trailingComments != "" { + hasTrailingBlank := strings.HasSuffix(trailingComments, "__HAS_TRAILING_BLANK__") + if hasTrailingBlank { + trailingComments = strings.TrimSuffix(trailingComments, "\n__HAS_TRAILING_BLANK__") + } + lines := strings.Split(trailingComments, "\n") + for _, line := range lines { + if line == "" { + g.pNoIndent(" *") + } else { + g.pNoIndent(" * %s", escapeJSDocComment(line)) + } + } + if hasTrailingBlank { + g.pNoIndent(" *") + g.pNoIndent(" *") + } else { + g.pNoIndent(" *") + } + } + + if (service.Options != nil && service.GetOptions().GetDeprecated()) || g.isFileDeprecated() { + g.pNoIndent(" * @deprecated") + } + + g.pNoIndent(" * @generated from protobuf service %s", fullServiceName) + g.pNoIndent(" */") +} diff --git a/protoc-gen-kaja/scripts/test b/protoc-gen-kaja/scripts/test index d64cc1c3..2c7305dc 100755 --- a/protoc-gen-kaja/scripts/test +++ b/protoc-gen-kaja/scripts/test @@ -139,7 +139,16 @@ run_test() { fi # If one succeeded and the other failed, it's a failure + # Exception: if protoc-gen-ts crashed (JS stack trace) but protoc-gen-kaja + # succeeded, treat as PASS — Go correctly handles what TS can't. if [ "$ts_ok" != "$kaja_ok" ]; then + if [ "$ts_ok" = false ] && [ "$kaja_ok" = true ]; then + ts_err=$(cat "$ts_error_file" 2>/dev/null || echo "") + if echo "$ts_err" | grep -q ' at '; then + echo "PASS $ts_elapsed $kaja_elapsed" > "$result_file" + return + fi + fi echo "FAIL $ts_elapsed $kaja_elapsed" > "$result_file" if [ "$ts_ok" = true ]; then { diff --git a/protoc-gen-kaja/tests/239_wkt_custom_option/options.proto b/protoc-gen-kaja/tests/239_wkt_custom_option/options.proto new file mode 100644 index 00000000..00e90bea --- /dev/null +++ b/protoc-gen-kaja/tests/239_wkt_custom_option/options.proto @@ -0,0 +1,9 @@ +syntax = "proto2"; +package test; + +import "google/protobuf/descriptor.proto"; +import "google/protobuf/duration.proto"; + +extend google.protobuf.FieldOptions { + optional google.protobuf.Duration timeout = 50001; +} diff --git a/protoc-gen-kaja/tests/239_wkt_custom_option/test.proto b/protoc-gen-kaja/tests/239_wkt_custom_option/test.proto new file mode 100644 index 00000000..f1b010c8 --- /dev/null +++ b/protoc-gen-kaja/tests/239_wkt_custom_option/test.proto @@ -0,0 +1,10 @@ +syntax = "proto3"; +package test; + +import "options.proto"; + +// Tests that custom field options with WKT message types +// (like google.protobuf.Duration) are correctly emitted. +message TestMsg { + string name = 1 [(timeout) = { seconds: 30 }]; +} diff --git a/protoc-gen-kaja/tests/240_custom_option_hyphen_json_name/test.proto b/protoc-gen-kaja/tests/240_custom_option_hyphen_json_name/test.proto new file mode 100644 index 00000000..33a9ede6 --- /dev/null +++ b/protoc-gen-kaja/tests/240_custom_option_hyphen_json_name/test.proto @@ -0,0 +1,21 @@ +// Tests custom message option where nested message has a +// field with json_name containing a hyphen. The property key +// must be quoted in the generated TypeScript. +syntax = "proto3"; +package test; + +import "google/protobuf/descriptor.proto"; + +message RuleConfig { + string value = 1 [json_name = "my-value"]; + int32 count = 2 [json_name = "item-count"]; +} + +extend google.protobuf.MessageOptions { + optional RuleConfig rule = 50001; +} + +message MyMessage { + option (rule) = { value: "hello", count: 5 }; + string name = 1; +} diff --git a/protoc-gen-kaja/tests/241_custom_option_string_vtab/test.proto b/protoc-gen-kaja/tests/241_custom_option_string_vtab/test.proto new file mode 100644 index 00000000..0fc69d0b --- /dev/null +++ b/protoc-gen-kaja/tests/241_custom_option_string_vtab/test.proto @@ -0,0 +1,16 @@ +// Tests that custom string option values containing vertical tab (\v, 0x0B) +// are properly escaped in the generated TypeScript output. +// The TS plugin escapes \v via TypeScript's string literal printer. +// The Go plugin only escapes \, ", \n, \r, \t — missing \v. +syntax = "proto3"; +package test; + +import "google/protobuf/descriptor.proto"; + +extend google.protobuf.FieldOptions { + string label = 50001; +} + +message TestMsg { + string name = 1 [(label) = "hello\013world"]; +} diff --git a/protoc-gen-kaja/tests/242_custom_map_int_key_order/test.proto b/protoc-gen-kaja/tests/242_custom_map_int_key_order/test.proto new file mode 100644 index 00000000..53cab53a --- /dev/null +++ b/protoc-gen-kaja/tests/242_custom_map_int_key_order/test.proto @@ -0,0 +1,25 @@ +// Tests that map entries in custom options are ordered by +// ascending numeric key, matching JavaScript's Object.keys() enumeration +// (integer indices come first in numeric order). The Go plugin preserves +// wire order instead of re-sorting, so keys 10,1 stay as {"10","1"} +// while the TS plugin outputs {"1","10"}. +syntax = "proto3"; +package test; + +import "google/protobuf/descriptor.proto"; + +message Limits { + map thresholds = 1; +} + +extend google.protobuf.MessageOptions { + Limits limits = 50001; +} + +message Alert { + option (limits) = { + thresholds: { key: 10, value: "high" } + thresholds: { key: 1, value: "low" } + }; + string name = 1; +} diff --git a/protoc-gen-kaja/tests/243_custom_option_repeated_single/test.proto b/protoc-gen-kaja/tests/243_custom_option_repeated_single/test.proto new file mode 100644 index 00000000..06bbd051 --- /dev/null +++ b/protoc-gen-kaja/tests/243_custom_option_repeated_single/test.proto @@ -0,0 +1,24 @@ +// Tests that a repeated field with a single element in a custom option +// is emitted as an array [42], not a bare scalar 42. +// The TS plugin uses type.toJson() which always produces arrays for +// repeated fields, even with one element. The Go plugin may not wrap +// single repeated values in an array. +syntax = "proto3"; +package test; + +import "google/protobuf/descriptor.proto"; + +message Metadata { + repeated string tags = 1; +} + +extend google.protobuf.MessageOptions { + Metadata meta = 50001; +} + +message Item { + option (meta) = { + tags: "solo" + }; + string name = 1; +} diff --git a/protoc-gen-kaja/tests/244_custom_option_string_linesep/test.proto b/protoc-gen-kaja/tests/244_custom_option_string_linesep/test.proto new file mode 100644 index 00000000..5352743d --- /dev/null +++ b/protoc-gen-kaja/tests/244_custom_option_string_linesep/test.proto @@ -0,0 +1,17 @@ +// Tests that U+2028 LINE SEPARATOR in a custom option string value is +// escaped as \u2028. TypeScript's printer escapes U+2028 and U+2029 +// because they are not valid unescaped inside JS string literals +// (pre-ES2019). The Go plugin only escapes characters < U+0020, +// so it emits the raw byte sequence instead of \u2028. +syntax = "proto3"; +package test; + +import "google/protobuf/descriptor.proto"; + +extend google.protobuf.FieldOptions { + string separator = 50001; +} + +message Document { + string content = 1 [(separator) = "line\xe2\x80\xa8sep"]; +} diff --git a/protoc-gen-kaja/tests/245_repeated_extension_single/test.proto b/protoc-gen-kaja/tests/245_repeated_extension_single/test.proto new file mode 100644 index 00000000..cc2bc19d --- /dev/null +++ b/protoc-gen-kaja/tests/245_repeated_extension_single/test.proto @@ -0,0 +1,19 @@ +// Tests that a repeated top-level extension field with a single value +// is emitted as an array ["solo"], not a bare scalar "solo". +// The TS plugin uses type.toJson() which always wraps repeated fields +// in arrays. The Go plugin's parseCustomOptions only wraps repeated +// values when mergeRepeatedOptions finds multiple entries; a single +// repeated entry is left unwrapped. +syntax = "proto3"; +package test; + +import "google/protobuf/descriptor.proto"; + +extend google.protobuf.MessageOptions { + repeated string labels = 50001; +} + +message Item { + option (labels) = "solo"; + string name = 1; +} diff --git a/protoc-gen-kaja/tests/246_custom_map_string_key_escape/test.proto b/protoc-gen-kaja/tests/246_custom_map_string_key_escape/test.proto new file mode 100644 index 00000000..b27a5474 --- /dev/null +++ b/protoc-gen-kaja/tests/246_custom_map_string_key_escape/test.proto @@ -0,0 +1,25 @@ +// Tests that map keys containing special characters +// (backslash, quotes, newlines) are properly escaped when quoted as +// JS object property keys. The Go plugin wraps keys in quotes via +// needsQuoteAsPropertyKey but does not escape the content, so a key +// like "a\b" becomes "a\b" (backspace escape) instead of "a\\b". +syntax = "proto3"; +package test; + +import "google/protobuf/descriptor.proto"; + +message Config { + map meta = 1; +} + +extend google.protobuf.MessageOptions { + Config cfg = 50001; +} + +message Widget { + option (cfg) = { + meta: { key: "back\\slash" value: "val1" } + meta: { key: "has\"quote" value: "val2" } + }; + string name = 1; +} diff --git a/protoc-gen-kaja/tests/247_custom_option_string_nextline/test.proto b/protoc-gen-kaja/tests/247_custom_option_string_nextline/test.proto new file mode 100644 index 00000000..2e410344 --- /dev/null +++ b/protoc-gen-kaja/tests/247_custom_option_string_nextline/test.proto @@ -0,0 +1,19 @@ +// Tests that U+0085 (NEXT LINE) in a custom option string is escaped as +// \u0085 in the generated TypeScript. The TypeScript compiler's escapeString +// explicitly includes U+0085 in its regex: +// /[\\\"\u0000-\u001f\t\v\f\b\r\n\u2028\u2029\u0085]/g +// The Go plugin's escapeStringForJS only checks r < 0x20, 0x2028, 0x2029, +// missing U+0085 which is 0x85 (above 0x20). +syntax = "proto3"; +package test; + +import "google/protobuf/descriptor.proto"; + +extend google.protobuf.MessageOptions { + string note = 50001; +} + +message Doc { + option (note) = "line1\302\205line2"; + string id = 1; +} diff --git a/protoc-gen-kaja/tests/248_custom_option_string_null_digit/test.proto b/protoc-gen-kaja/tests/248_custom_option_string_null_digit/test.proto new file mode 100644 index 00000000..c6e2e103 --- /dev/null +++ b/protoc-gen-kaja/tests/248_custom_option_string_null_digit/test.proto @@ -0,0 +1,16 @@ +// Tests that null byte (\0) followed by a digit in custom option strings +// is escaped as \x00 (not \0) to avoid ambiguity with octal escape sequences. +// TypeScript's printer uses \x00 when null is followed by 0-9. +// The Go plugin always uses \0, which creates an octal escape when followed by a digit. +syntax = "proto3"; +package test; + +import "google/protobuf/descriptor.proto"; + +extend google.protobuf.FieldOptions { + string label = 50001; +} + +message TestMsg { + string name = 1 [(label) = "before\0001after"]; +} diff --git a/protoc-gen-kaja/tests/249_custom_option_cross_file_order/a_options.proto b/protoc-gen-kaja/tests/249_custom_option_cross_file_order/a_options.proto new file mode 100644 index 00000000..9aa7509d --- /dev/null +++ b/protoc-gen-kaja/tests/249_custom_option_cross_file_order/a_options.proto @@ -0,0 +1,9 @@ +// Extension with higher field number, in a file sorted first alphabetically. +syntax = "proto2"; +package test; + +import "google/protobuf/descriptor.proto"; + +extend google.protobuf.FieldOptions { + optional string alpha_tag = 50002; +} diff --git a/protoc-gen-kaja/tests/249_custom_option_cross_file_order/b_options.proto b/protoc-gen-kaja/tests/249_custom_option_cross_file_order/b_options.proto new file mode 100644 index 00000000..d4475c55 --- /dev/null +++ b/protoc-gen-kaja/tests/249_custom_option_cross_file_order/b_options.proto @@ -0,0 +1,9 @@ +// Extension with lower field number, in a file sorted second alphabetically. +syntax = "proto2"; +package test; + +import "google/protobuf/descriptor.proto"; + +extend google.protobuf.FieldOptions { + optional string beta_tag = 50001; +} diff --git a/protoc-gen-kaja/tests/249_custom_option_cross_file_order/test.proto b/protoc-gen-kaja/tests/249_custom_option_cross_file_order/test.proto new file mode 100644 index 00000000..c62ef877 --- /dev/null +++ b/protoc-gen-kaja/tests/249_custom_option_cross_file_order/test.proto @@ -0,0 +1,20 @@ +// Tests ordering of custom options from different files. +// a_options.proto defines alpha_tag at field 50002. +// b_options.proto defines beta_tag at field 50001. +// +// The TS plugin builds a synthetic type with extensions in registry +// order (= file processing order). Since a_options.proto is imported +// first, alpha_tag (50002) appears before beta_tag (50001) in the +// field list and therefore in the toJson() output. +// +// The Go plugin reads the binary data in wire order (field-number +// order), so beta_tag (50001) comes before alpha_tag (50002). +syntax = "proto3"; +package test; + +import "a_options.proto"; +import "b_options.proto"; + +message TestMsg { + string name = 1 [(alpha_tag) = "aaa", (beta_tag) = "bbb"]; +} diff --git a/protoc-gen-kaja/tests/250_custom_option_field_order/field_order.proto b/protoc-gen-kaja/tests/250_custom_option_field_order/field_order.proto new file mode 100644 index 00000000..5ba359be --- /dev/null +++ b/protoc-gen-kaja/tests/250_custom_option_field_order/field_order.proto @@ -0,0 +1,21 @@ +syntax = "proto3"; + +package fieldorder; + +import "google/protobuf/descriptor.proto"; + +// Message with fields declared in REVERSE field-number order. +// protoc serializes by field number (alpha=1 first, beta=2 second), +// but the TS plugin emits JSON keys in declaration order (beta first, alpha second). +message StyleOption { + string beta = 2; + string alpha = 1; +} + +extend google.protobuf.FieldOptions { + StyleOption style = 50001; +} + +message StyledMessage { + string value = 1 [(style) = { beta: "round", alpha: "bold" }]; +} diff --git a/protoc-gen-kaja/tests/251_custom_option_string_del/options.proto b/protoc-gen-kaja/tests/251_custom_option_string_del/options.proto new file mode 100644 index 00000000..f9320123 --- /dev/null +++ b/protoc-gen-kaja/tests/251_custom_option_string_del/options.proto @@ -0,0 +1,8 @@ +syntax = "proto2"; +package test; + +import "google/protobuf/descriptor.proto"; + +extend google.protobuf.FieldOptions { + optional string annotation = 50001; +} diff --git a/protoc-gen-kaja/tests/251_custom_option_string_del/test.proto b/protoc-gen-kaja/tests/251_custom_option_string_del/test.proto new file mode 100644 index 00000000..c1c1fbdb --- /dev/null +++ b/protoc-gen-kaja/tests/251_custom_option_string_del/test.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; +package test; + +import "options.proto"; + +// Tests that the DEL character (U+007F) in a custom option string +// is NOT escaped — the TS compiler's escapeString only covers U+0000-U+001F, +// not DEL. The Go plugin incorrectly escapes it as \u007f. +message TestMsg { + string name = 1 [(annotation) = "hello\x7fworld"]; +} diff --git a/protoc-gen-kaja/tests/252_custom_option_string_bom/options.proto b/protoc-gen-kaja/tests/252_custom_option_string_bom/options.proto new file mode 100644 index 00000000..f9320123 --- /dev/null +++ b/protoc-gen-kaja/tests/252_custom_option_string_bom/options.proto @@ -0,0 +1,8 @@ +syntax = "proto2"; +package test; + +import "google/protobuf/descriptor.proto"; + +extend google.protobuf.FieldOptions { + optional string annotation = 50001; +} diff --git a/protoc-gen-kaja/tests/252_custom_option_string_bom/test.proto b/protoc-gen-kaja/tests/252_custom_option_string_bom/test.proto new file mode 100644 index 00000000..c99571e4 --- /dev/null +++ b/protoc-gen-kaja/tests/252_custom_option_string_bom/test.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; +package test; + +import "options.proto"; + +// Tests that U+FEFF (BOM) in a custom option string is escaped with +// UPPERCASE hex digits. The TS compiler emits \uFEFF but the Go plugin +// emits \ufeff because it uses fmt %04x (lowercase) instead of %04X. +message TestMsg { + string name = 1 [(annotation) = "hello\xEF\xBB\xBFworld"]; +} diff --git a/protoc-gen-kaja/tests/253_custom_option_string_nonascii/options.proto b/protoc-gen-kaja/tests/253_custom_option_string_nonascii/options.proto new file mode 100644 index 00000000..1f753893 --- /dev/null +++ b/protoc-gen-kaja/tests/253_custom_option_string_nonascii/options.proto @@ -0,0 +1,8 @@ +syntax = "proto2"; +package test; + +import "google/protobuf/descriptor.proto"; + +extend google.protobuf.FieldOptions { + optional string label = 50001; +} diff --git a/protoc-gen-kaja/tests/253_custom_option_string_nonascii/test.proto b/protoc-gen-kaja/tests/253_custom_option_string_nonascii/test.proto new file mode 100644 index 00000000..fc84f072 --- /dev/null +++ b/protoc-gen-kaja/tests/253_custom_option_string_nonascii/test.proto @@ -0,0 +1,10 @@ +syntax = "proto3"; +package test; + +import "options.proto"; + +// Tests that non-ASCII characters in custom option string values +// are escaped as \uXXXX (matching TypeScript printer's escapeNonAsciiString). +message TestMsg { + string name = 1 [(label) = "café"]; +} diff --git a/protoc-gen-kaja/tests/254_custom_option_string_emoji/options.proto b/protoc-gen-kaja/tests/254_custom_option_string_emoji/options.proto new file mode 100644 index 00000000..70d2796c --- /dev/null +++ b/protoc-gen-kaja/tests/254_custom_option_string_emoji/options.proto @@ -0,0 +1,8 @@ +syntax = "proto2"; +package test; + +import "google/protobuf/descriptor.proto"; + +extend google.protobuf.FieldOptions { + optional string emoji_label = 50001; +} diff --git a/protoc-gen-kaja/tests/254_custom_option_string_emoji/test.proto b/protoc-gen-kaja/tests/254_custom_option_string_emoji/test.proto new file mode 100644 index 00000000..ebfd88f8 --- /dev/null +++ b/protoc-gen-kaja/tests/254_custom_option_string_emoji/test.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; +package test; + +import "options.proto"; + +// Tests that supplementary plane characters (above U+FFFF) in custom option +// strings are escaped as surrogate pairs (\uD83C\uDF89) matching the TS +// compiler, not as ES2015 \u{1F389} which the Go plugin produces. +message TestMsg { + string name = 1 [(emoji_label) = "🎉"]; +} diff --git a/protoc-gen-kaja/tests/255_group_field_options/test.proto b/protoc-gen-kaja/tests/255_group_field_options/test.proto new file mode 100644 index 00000000..262917bd --- /dev/null +++ b/protoc-gen-kaja/tests/255_group_field_options/test.proto @@ -0,0 +1,16 @@ +syntax = "proto2"; +package test; + +import "google/protobuf/descriptor.proto"; + +extend google.protobuf.FieldOptions { + optional int32 my_tag = 50000; +} + +message Foo { + optional int32 before = 1 [(my_tag) = 111]; + optional group MyGroup = 2 { + optional int32 inner = 3; + } + optional int32 after = 4 [(my_tag) = 444]; +} diff --git a/protoc-gen-kaja/tests/256_custom_option_enum_alias/test.proto b/protoc-gen-kaja/tests/256_custom_option_enum_alias/test.proto new file mode 100644 index 00000000..331efea9 --- /dev/null +++ b/protoc-gen-kaja/tests/256_custom_option_enum_alias/test.proto @@ -0,0 +1,35 @@ +// Tests that when a custom option uses an enum with allow_alias, +// the correct alias name is used for the value. +// The TS plugin uses the LAST alias name (JS object overwrite behavior), +// while the Go plugin may use the FIRST alias name. +syntax = "proto3"; + +package test; + +import "google/protobuf/descriptor.proto"; + +// Enum with allow_alias: two names map to the same numeric value. +enum Priority { + option allow_alias = true; + + PRIORITY_LOW = 0; + PRIORITY_DEFAULT = 0; // alias for LOW + PRIORITY_MEDIUM = 1; + PRIORITY_NORMAL = 1; // alias for MEDIUM + PRIORITY_HIGH = 2; +} + +message MyOptions { + Priority priority = 1; +} + +extend google.protobuf.FieldOptions { + MyOptions my_opts = 50001; +} + +message TestMessage { + // Use value 0, which maps to both PRIORITY_LOW and PRIORITY_DEFAULT + string name = 1 [(my_opts) = { priority: PRIORITY_LOW }]; + // Use value 1, which maps to both PRIORITY_MEDIUM and PRIORITY_NORMAL + string description = 2 [(my_opts) = { priority: PRIORITY_MEDIUM }]; +} diff --git a/protoc-gen-kaja/tests/257_required_default_option/test.proto b/protoc-gen-kaja/tests/257_required_default_option/test.proto new file mode 100644 index 00000000..2f84ba11 --- /dev/null +++ b/protoc-gen-kaja/tests/257_required_default_option/test.proto @@ -0,0 +1,17 @@ +syntax = "proto2"; +package test; + +import "google/protobuf/descriptor.proto"; + +message MyOption { + required int32 count = 1; + required string label = 2; +} + +extend google.protobuf.FieldOptions { + optional MyOption my_opt = 50000; +} + +message Foo { + required string name = 1 [(my_opt) = { count: 0, label: "" }]; +} diff --git a/protoc-gen-kaja/tests/258_optional_default_option/test.proto b/protoc-gen-kaja/tests/258_optional_default_option/test.proto new file mode 100644 index 00000000..c379211b --- /dev/null +++ b/protoc-gen-kaja/tests/258_optional_default_option/test.proto @@ -0,0 +1,20 @@ +syntax = "proto2"; +package test; + +import "google/protobuf/descriptor.proto"; + +// Proto2 optional fields set to their default values. +// protobuf-ts toJson() INCLUDES these because field.opt=true → ed=true. +// The Go plugin's isDefaultValue filters them out incorrectly. +message OptionalOption { + optional int32 count = 1; + optional string label = 2; +} + +extend google.protobuf.FieldOptions { + optional OptionalOption opt_info = 50000; +} + +message Bar { + required string name = 1 [(opt_info) = { count: 0, label: "" }]; +} diff --git a/protoc-gen-kaja/tests/259_custom_option_null_value/test.proto b/protoc-gen-kaja/tests/259_custom_option_null_value/test.proto new file mode 100644 index 00000000..20e1fdab --- /dev/null +++ b/protoc-gen-kaja/tests/259_custom_option_null_value/test.proto @@ -0,0 +1,26 @@ +syntax = "proto2"; +package test; + +import "google/protobuf/descriptor.proto"; +import "google/protobuf/struct.proto"; + +// Custom option message with a google.protobuf.NullValue enum field. +// The TS runtime's ReflectionJsonWriter.enum() has special handling for NullValue: +// it returns JSON `null` instead of the enum value name string. +// The Go plugin has no special NullValue handling and outputs the enum name +// as a quoted string: "NULL_VALUE". +message NullOption { + optional string label = 1; + optional google.protobuf.NullValue null_field = 2; +} + +extend google.protobuf.FieldOptions { + optional NullOption null_opt = 50001; +} + +message Foo { + // Set null_field to NULL_VALUE (enum value 0). + // TS outputs: { label: "hello", nullField: null } + // Go outputs: { label: "hello", nullField: "NULL_VALUE" } + required string name = 1 [(null_opt) = { label: "hello", null_field: NULL_VALUE }]; +} diff --git a/protoc-gen-kaja/tests/260_map_int64_jstype/test.proto b/protoc-gen-kaja/tests/260_map_int64_jstype/test.proto new file mode 100644 index 00000000..f6fd8f54 --- /dev/null +++ b/protoc-gen-kaja/tests/260_map_int64_jstype/test.proto @@ -0,0 +1,10 @@ +syntax = "proto3"; + +package test; + +// Test map field with int64 value and explicit jstype option. +// The jstype should propagate to the V (value) part of the field info. +message TestMsg { + map normal_data = 1; + map bigint_data = 2 [jstype = JS_NORMAL]; +} diff --git a/protoc-gen-kaja/tests/261_custom_option_oneof_default/test.proto b/protoc-gen-kaja/tests/261_custom_option_oneof_default/test.proto new file mode 100644 index 00000000..f8cc70f0 --- /dev/null +++ b/protoc-gen-kaja/tests/261_custom_option_oneof_default/test.proto @@ -0,0 +1,40 @@ +// Tests that proto3 oneof scalar fields set to default values in custom options +// are emitted, not filtered. +// +// The TS plugin's ReflectionJsonWriter.write() forces emitDefaultValues=true +// for the selected scalar/enum oneof member: +// const opt = field.kind == 'scalar' || field.kind == 'enum' +// ? { ...options, emitDefaultValues: true } : options; +// +// The Go plugin's isDefaultValue filtering treats proto3 oneof fields as +// non-presence fields (hasPresence = fd.GetProto3Optional() = false), so it +// incorrectly filters default-valued oneof scalars. +// +// TS: { text: "" } Go: {} +syntax = "proto3"; +package test; + +import "google/protobuf/descriptor.proto"; + +message ChoiceOption { + oneof choice { + string text = 1; + int32 number = 2; + bool flag = 3; + } +} + +extend google.protobuf.FieldOptions { + optional ChoiceOption choice_opt = 50001; +} + +message Foo { + // Non-default oneof value (control case — both plugins should include it) + string alpha = 1 [(choice_opt) = { text: "hello" }]; + // Oneof string set to empty string (default for string) + string beta = 2 [(choice_opt) = { text: "" }]; + // Oneof int32 set to 0 (default for int32) + string gamma = 3 [(choice_opt) = { number: 0 }]; + // Oneof bool set to false (default for bool) + string delta = 4 [(choice_opt) = { flag: false }]; +} diff --git a/protoc-gen-kaja/tests/263_constructor_optional_field/test.proto b/protoc-gen-kaja/tests/263_constructor_optional_field/test.proto new file mode 100644 index 00000000..168e4452 --- /dev/null +++ b/protoc-gen-kaja/tests/263_constructor_optional_field/test.proto @@ -0,0 +1,18 @@ +// Test: proto3 optional field named "constructor" causes the TS plugin to crash. +// +// Root cause: InternalBinaryWrite.scalar() creates a synthetic MessageType and +// calls .create() to get default values for write conditions. The create method +// skips optional fields (fi.opt = true), so message["constructor"] reads from +// Object.prototype.constructor (the built-in Object function). Then +// typescriptLiteralFromValue(Object) throws "Unexpected object: function ...". +// +// The Go plugin handles this correctly — it hardcodes write conditions without +// runtime evaluation, producing valid output. +syntax = "proto3"; + +package test; + +message ConstructorOptional { + optional string constructor = 1; + string name = 2; +} diff --git a/protoc-gen-kaja/tests/264_create_property_collision/collision.proto b/protoc-gen-kaja/tests/264_create_property_collision/collision.proto new file mode 100644 index 00000000..05626fec --- /dev/null +++ b/protoc-gen-kaja/tests/264_create_property_collision/collision.proto @@ -0,0 +1,18 @@ +syntax = "proto3"; + +package test.collision; + +// When two fields have different proto names but collide after lowerCamelCase +// conversion, the create() method property assignment order should reflect +// JavaScript Object.entries() semantics: properties appear at the position +// where they were FIRST created, not where the LAST duplicate was assigned. +// +// Here x_1_y (field 1) and x1y (field 3) both map to localName "x1Y". +// In JS, the property "x1Y" is created at field 1's position but its value +// is overwritten by field 3. Object.entries() returns "x1Y" before "middle" +// because it was created first. +message Collide { + string x_1_y = 1; + int32 middle = 2; + int32 x1y = 3; +} diff --git a/protoc-gen-kaja/tests/265_ts_client_service_option/protobuf-ts.proto b/protoc-gen-kaja/tests/265_ts_client_service_option/protobuf-ts.proto new file mode 100644 index 00000000..ff92b601 --- /dev/null +++ b/protoc-gen-kaja/tests/265_ts_client_service_option/protobuf-ts.proto @@ -0,0 +1,83 @@ +// This proto file is part of protobuf-ts. It defines custom options +// that are interpreted by @protobuf-ts/plugin. +// +// To use the options, add an import to this file: +// +// import "protobuf-ts.proto"; +// +// If you use @protobuf-ts/plugin, it should not be necessary to add a proto +// path to the file. @protobuf-ts/protoc automatically adds +// `--proto_path ./node_modules/@protobuf-ts/plugin` to your commands. + +syntax = "proto3"; + +package ts; + +import "google/protobuf/descriptor.proto"; + + +// Custom file options interpreted by @protobuf-ts/plugin +extend google.protobuf.FileOptions { + + // Exclude field or method options from being emitted in reflection data. + // + // For example, to stop the data of the "google.api.http" method option + // from being exported in the reflection information, set the following + // file option: + // + // ```proto + // option (ts.exclude_options) = "google.api.http"; + // ``` + // + // The option can be set multiple times. + // `*` serves as a wildcard and will greedily match anything. + repeated string exclude_options = 777701; + +} + + +// Custom service options interpreted by @protobuf-ts/plugin +extend google.protobuf.ServiceOptions { + + // Generate a client for this service with this style. + // Can be set multiple times to generate several styles. + repeated ClientStyle client = 777701; + + // Generate a server for this service with this style. + // Can be set multiple times to generate several styles. + repeated ServerStyle server = 777702; + +} + + +// The available client styles that can be generated by @protobuf-ts/plugin +enum ClientStyle { + + // Do not emit a client for this service. + NO_CLIENT = 0; + + // Use the generic implementations of @protobuf-ts/runtime-rpc. + // This is the default behaviour. + GENERIC_CLIENT = 1; + + // Generate a client using @grpc/grpc-js (major version 1). + GRPC1_CLIENT = 4; +} + + +// The available server styles that can be generated by @protobuf-ts/plugin +enum ServerStyle { + + // Do not emit a server for this service. + // This is the default behaviour. + NO_SERVER = 0; + + // Generate a generic server interface. + // Adapters be used to serve the service, for example @protobuf-ts/grpc-backend + // for gRPC. + GENERIC_SERVER = 1; + + // Generate a server for @grpc/grpc-js (major version 1). + GRPC1_SERVER = 2; + +} diff --git a/protoc-gen-kaja/tests/265_ts_client_service_option/test.proto b/protoc-gen-kaja/tests/265_ts_client_service_option/test.proto new file mode 100644 index 00000000..0ac2bee9 --- /dev/null +++ b/protoc-gen-kaja/tests/265_ts_client_service_option/test.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; +package test; +import "protobuf-ts.proto"; + +message Req { + string q = 1; +} + +message Resp { + string r = 1; +} + +service MySvc { + option (ts.client) = GENERIC_CLIENT; + rpc DoIt(Req) returns (Resp); +} diff --git a/protoc-gen-kaja/tests/266_ts_exclude_options/protobuf-ts.proto b/protoc-gen-kaja/tests/266_ts_exclude_options/protobuf-ts.proto new file mode 100644 index 00000000..ff92b601 --- /dev/null +++ b/protoc-gen-kaja/tests/266_ts_exclude_options/protobuf-ts.proto @@ -0,0 +1,83 @@ +// This proto file is part of protobuf-ts. It defines custom options +// that are interpreted by @protobuf-ts/plugin. +// +// To use the options, add an import to this file: +// +// import "protobuf-ts.proto"; +// +// If you use @protobuf-ts/plugin, it should not be necessary to add a proto +// path to the file. @protobuf-ts/protoc automatically adds +// `--proto_path ./node_modules/@protobuf-ts/plugin` to your commands. + +syntax = "proto3"; + +package ts; + +import "google/protobuf/descriptor.proto"; + + +// Custom file options interpreted by @protobuf-ts/plugin +extend google.protobuf.FileOptions { + + // Exclude field or method options from being emitted in reflection data. + // + // For example, to stop the data of the "google.api.http" method option + // from being exported in the reflection information, set the following + // file option: + // + // ```proto + // option (ts.exclude_options) = "google.api.http"; + // ``` + // + // The option can be set multiple times. + // `*` serves as a wildcard and will greedily match anything. + repeated string exclude_options = 777701; + +} + + +// Custom service options interpreted by @protobuf-ts/plugin +extend google.protobuf.ServiceOptions { + + // Generate a client for this service with this style. + // Can be set multiple times to generate several styles. + repeated ClientStyle client = 777701; + + // Generate a server for this service with this style. + // Can be set multiple times to generate several styles. + repeated ServerStyle server = 777702; + +} + + +// The available client styles that can be generated by @protobuf-ts/plugin +enum ClientStyle { + + // Do not emit a client for this service. + NO_CLIENT = 0; + + // Use the generic implementations of @protobuf-ts/runtime-rpc. + // This is the default behaviour. + GENERIC_CLIENT = 1; + + // Generate a client using @grpc/grpc-js (major version 1). + GRPC1_CLIENT = 4; +} + + +// The available server styles that can be generated by @protobuf-ts/plugin +enum ServerStyle { + + // Do not emit a server for this service. + // This is the default behaviour. + NO_SERVER = 0; + + // Generate a generic server interface. + // Adapters be used to serve the service, for example @protobuf-ts/grpc-backend + // for gRPC. + GENERIC_SERVER = 1; + + // Generate a server for @grpc/grpc-js (major version 1). + GRPC1_SERVER = 2; + +} diff --git a/protoc-gen-kaja/tests/266_ts_exclude_options/test.proto b/protoc-gen-kaja/tests/266_ts_exclude_options/test.proto new file mode 100644 index 00000000..3ae840f1 --- /dev/null +++ b/protoc-gen-kaja/tests/266_ts_exclude_options/test.proto @@ -0,0 +1,23 @@ +// Tests that the ts.exclude_options file option causes the named +// custom options to be excluded from generated reflection data. +// The TS plugin reads this option and filters matching extensions; +// the Go plugin has no knowledge of ts.exclude_options and includes +// the options in its output. +syntax = "proto3"; +package test; + +import "protobuf-ts.proto"; +import "google/protobuf/descriptor.proto"; + +// Exclude the "test.my_tag" field option from generated reflection data. +// The TS plugin reads ts.exclude_options and filters matching keys; +// the Go plugin does not implement this filtering. +option (ts.exclude_options) = "test.my_tag"; + +extend google.protobuf.FieldOptions { + string my_tag = 50001; +} + +message Item { + string name = 1 [(my_tag) = "important"]; +} diff --git a/protoc-gen-kaja/tests/267_exclude_options_wildcard_substring/options.proto b/protoc-gen-kaja/tests/267_exclude_options_wildcard_substring/options.proto new file mode 100644 index 00000000..ef299940 --- /dev/null +++ b/protoc-gen-kaja/tests/267_exclude_options_wildcard_substring/options.proto @@ -0,0 +1,9 @@ +syntax = "proto2"; + +package other.test; + +import "google/protobuf/descriptor.proto"; + +extend google.protobuf.FieldOptions { + optional string foo = 50001; +} diff --git a/protoc-gen-kaja/tests/267_exclude_options_wildcard_substring/protobuf-ts.proto b/protoc-gen-kaja/tests/267_exclude_options_wildcard_substring/protobuf-ts.proto new file mode 100644 index 00000000..ff92b601 --- /dev/null +++ b/protoc-gen-kaja/tests/267_exclude_options_wildcard_substring/protobuf-ts.proto @@ -0,0 +1,83 @@ +// This proto file is part of protobuf-ts. It defines custom options +// that are interpreted by @protobuf-ts/plugin. +// +// To use the options, add an import to this file: +// +// import "protobuf-ts.proto"; +// +// If you use @protobuf-ts/plugin, it should not be necessary to add a proto +// path to the file. @protobuf-ts/protoc automatically adds +// `--proto_path ./node_modules/@protobuf-ts/plugin` to your commands. + +syntax = "proto3"; + +package ts; + +import "google/protobuf/descriptor.proto"; + + +// Custom file options interpreted by @protobuf-ts/plugin +extend google.protobuf.FileOptions { + + // Exclude field or method options from being emitted in reflection data. + // + // For example, to stop the data of the "google.api.http" method option + // from being exported in the reflection information, set the following + // file option: + // + // ```proto + // option (ts.exclude_options) = "google.api.http"; + // ``` + // + // The option can be set multiple times. + // `*` serves as a wildcard and will greedily match anything. + repeated string exclude_options = 777701; + +} + + +// Custom service options interpreted by @protobuf-ts/plugin +extend google.protobuf.ServiceOptions { + + // Generate a client for this service with this style. + // Can be set multiple times to generate several styles. + repeated ClientStyle client = 777701; + + // Generate a server for this service with this style. + // Can be set multiple times to generate several styles. + repeated ServerStyle server = 777702; + +} + + +// The available client styles that can be generated by @protobuf-ts/plugin +enum ClientStyle { + + // Do not emit a client for this service. + NO_CLIENT = 0; + + // Use the generic implementations of @protobuf-ts/runtime-rpc. + // This is the default behaviour. + GENERIC_CLIENT = 1; + + // Generate a client using @grpc/grpc-js (major version 1). + GRPC1_CLIENT = 4; +} + + +// The available server styles that can be generated by @protobuf-ts/plugin +enum ServerStyle { + + // Do not emit a server for this service. + // This is the default behaviour. + NO_SERVER = 0; + + // Generate a generic server interface. + // Adapters be used to serve the service, for example @protobuf-ts/grpc-backend + // for gRPC. + GENERIC_SERVER = 1; + + // Generate a server for @grpc/grpc-js (major version 1). + GRPC1_SERVER = 2; + +} diff --git a/protoc-gen-kaja/tests/267_exclude_options_wildcard_substring/test.proto b/protoc-gen-kaja/tests/267_exclude_options_wildcard_substring/test.proto new file mode 100644 index 00000000..85dd8ffa --- /dev/null +++ b/protoc-gen-kaja/tests/267_exclude_options_wildcard_substring/test.proto @@ -0,0 +1,19 @@ +// Tests that ts.exclude_options wildcard pattern "test.*" excludes +// option keys containing "test." anywhere in the string (substring match), +// not just as a prefix. The TS plugin uses regex String.match() which +// finds matches anywhere; the Go plugin uses strings.HasPrefix which +// only matches at the start. +syntax = "proto3"; + +package myapp; + +import "protobuf-ts.proto"; +import "options.proto"; + +// Pattern "test.*" should exclude "other.test.foo" because the TS plugin's +// regex /test\..*/ matches the substring "test.foo" within "other.test.foo". +option (ts.exclude_options) = "test.*"; + +message Item { + string name = 1 [(other.test.foo) = "tagged"]; +} diff --git a/protoc-gen-kaja/tests/268_exclude_options_literal_exact/main.proto b/protoc-gen-kaja/tests/268_exclude_options_literal_exact/main.proto new file mode 100644 index 00000000..9c17acd5 --- /dev/null +++ b/protoc-gen-kaja/tests/268_exclude_options_literal_exact/main.proto @@ -0,0 +1,20 @@ +// Tests that ts.exclude_options with a literal pattern (no wildcard) +// uses exact match. The TS plugin uses `key === pattern` for literals, +// so "test.tag" only excludes key "test.tag", NOT "prefix.test.tag". +// The Go plugin converts all patterns to regex and uses substring match, +// so "test.tag" as regex /test\.tag/ also matches "prefix.test.tag". +syntax = "proto3"; + +package myapp; + +import "protobuf-ts.proto"; +import "test_tag.proto"; +import "prefix_test_tag.proto"; + +// Literal pattern "test.tag" should exclude only the exact key "test.tag", +// not "prefix.test.tag" which merely contains "test.tag" as a substring. +option (ts.exclude_options) = "test.tag"; + +message Item { + string name = 1 [(test.tag) = "hello", (prefix.test.tag) = "world"]; +} diff --git a/protoc-gen-kaja/tests/268_exclude_options_literal_exact/prefix_test_tag.proto b/protoc-gen-kaja/tests/268_exclude_options_literal_exact/prefix_test_tag.proto new file mode 100644 index 00000000..eeb71514 --- /dev/null +++ b/protoc-gen-kaja/tests/268_exclude_options_literal_exact/prefix_test_tag.proto @@ -0,0 +1,9 @@ +syntax = "proto2"; + +package prefix.test; + +import "google/protobuf/descriptor.proto"; + +extend google.protobuf.FieldOptions { + optional string tag = 50002; +} diff --git a/protoc-gen-kaja/tests/268_exclude_options_literal_exact/protobuf-ts.proto b/protoc-gen-kaja/tests/268_exclude_options_literal_exact/protobuf-ts.proto new file mode 100644 index 00000000..ff92b601 --- /dev/null +++ b/protoc-gen-kaja/tests/268_exclude_options_literal_exact/protobuf-ts.proto @@ -0,0 +1,83 @@ +// This proto file is part of protobuf-ts. It defines custom options +// that are interpreted by @protobuf-ts/plugin. +// +// To use the options, add an import to this file: +// +// import "protobuf-ts.proto"; +// +// If you use @protobuf-ts/plugin, it should not be necessary to add a proto +// path to the file. @protobuf-ts/protoc automatically adds +// `--proto_path ./node_modules/@protobuf-ts/plugin` to your commands. + +syntax = "proto3"; + +package ts; + +import "google/protobuf/descriptor.proto"; + + +// Custom file options interpreted by @protobuf-ts/plugin +extend google.protobuf.FileOptions { + + // Exclude field or method options from being emitted in reflection data. + // + // For example, to stop the data of the "google.api.http" method option + // from being exported in the reflection information, set the following + // file option: + // + // ```proto + // option (ts.exclude_options) = "google.api.http"; + // ``` + // + // The option can be set multiple times. + // `*` serves as a wildcard and will greedily match anything. + repeated string exclude_options = 777701; + +} + + +// Custom service options interpreted by @protobuf-ts/plugin +extend google.protobuf.ServiceOptions { + + // Generate a client for this service with this style. + // Can be set multiple times to generate several styles. + repeated ClientStyle client = 777701; + + // Generate a server for this service with this style. + // Can be set multiple times to generate several styles. + repeated ServerStyle server = 777702; + +} + + +// The available client styles that can be generated by @protobuf-ts/plugin +enum ClientStyle { + + // Do not emit a client for this service. + NO_CLIENT = 0; + + // Use the generic implementations of @protobuf-ts/runtime-rpc. + // This is the default behaviour. + GENERIC_CLIENT = 1; + + // Generate a client using @grpc/grpc-js (major version 1). + GRPC1_CLIENT = 4; +} + + +// The available server styles that can be generated by @protobuf-ts/plugin +enum ServerStyle { + + // Do not emit a server for this service. + // This is the default behaviour. + NO_SERVER = 0; + + // Generate a generic server interface. + // Adapters be used to serve the service, for example @protobuf-ts/grpc-backend + // for gRPC. + GENERIC_SERVER = 1; + + // Generate a server for @grpc/grpc-js (major version 1). + GRPC1_SERVER = 2; + +} diff --git a/protoc-gen-kaja/tests/268_exclude_options_literal_exact/test_tag.proto b/protoc-gen-kaja/tests/268_exclude_options_literal_exact/test_tag.proto new file mode 100644 index 00000000..d50b28d2 --- /dev/null +++ b/protoc-gen-kaja/tests/268_exclude_options_literal_exact/test_tag.proto @@ -0,0 +1,9 @@ +syntax = "proto2"; + +package test; + +import "google/protobuf/descriptor.proto"; + +extend google.protobuf.FieldOptions { + optional string tag = 50001; +} diff --git a/protoc-gen-kaja/tests/269_ts_server_service_option/protobuf-ts.proto b/protoc-gen-kaja/tests/269_ts_server_service_option/protobuf-ts.proto new file mode 100644 index 00000000..ff92b601 --- /dev/null +++ b/protoc-gen-kaja/tests/269_ts_server_service_option/protobuf-ts.proto @@ -0,0 +1,83 @@ +// This proto file is part of protobuf-ts. It defines custom options +// that are interpreted by @protobuf-ts/plugin. +// +// To use the options, add an import to this file: +// +// import "protobuf-ts.proto"; +// +// If you use @protobuf-ts/plugin, it should not be necessary to add a proto +// path to the file. @protobuf-ts/protoc automatically adds +// `--proto_path ./node_modules/@protobuf-ts/plugin` to your commands. + +syntax = "proto3"; + +package ts; + +import "google/protobuf/descriptor.proto"; + + +// Custom file options interpreted by @protobuf-ts/plugin +extend google.protobuf.FileOptions { + + // Exclude field or method options from being emitted in reflection data. + // + // For example, to stop the data of the "google.api.http" method option + // from being exported in the reflection information, set the following + // file option: + // + // ```proto + // option (ts.exclude_options) = "google.api.http"; + // ``` + // + // The option can be set multiple times. + // `*` serves as a wildcard and will greedily match anything. + repeated string exclude_options = 777701; + +} + + +// Custom service options interpreted by @protobuf-ts/plugin +extend google.protobuf.ServiceOptions { + + // Generate a client for this service with this style. + // Can be set multiple times to generate several styles. + repeated ClientStyle client = 777701; + + // Generate a server for this service with this style. + // Can be set multiple times to generate several styles. + repeated ServerStyle server = 777702; + +} + + +// The available client styles that can be generated by @protobuf-ts/plugin +enum ClientStyle { + + // Do not emit a client for this service. + NO_CLIENT = 0; + + // Use the generic implementations of @protobuf-ts/runtime-rpc. + // This is the default behaviour. + GENERIC_CLIENT = 1; + + // Generate a client using @grpc/grpc-js (major version 1). + GRPC1_CLIENT = 4; +} + + +// The available server styles that can be generated by @protobuf-ts/plugin +enum ServerStyle { + + // Do not emit a server for this service. + // This is the default behaviour. + NO_SERVER = 0; + + // Generate a generic server interface. + // Adapters be used to serve the service, for example @protobuf-ts/grpc-backend + // for gRPC. + GENERIC_SERVER = 1; + + // Generate a server for @grpc/grpc-js (major version 1). + GRPC1_SERVER = 2; + +} diff --git a/protoc-gen-kaja/tests/269_ts_server_service_option/test.proto b/protoc-gen-kaja/tests/269_ts_server_service_option/test.proto new file mode 100644 index 00000000..65eb20d3 --- /dev/null +++ b/protoc-gen-kaja/tests/269_ts_server_service_option/test.proto @@ -0,0 +1,18 @@ +syntax = "proto3"; +package test; +import "protobuf-ts.proto"; + +message Req { + string q = 1; +} + +message Resp { + string r = 1; +} + +// The TS plugin only hardcode-excludes "ts.client" from service options, +// NOT "ts.server". So ts.server should appear in the generated output. +service MySvc { + option (ts.server) = GRPC1_SERVER; + rpc DoIt(Req) returns (Resp); +} diff --git a/protoc-gen-kaja/tests/270_generic_server_option/protobuf-ts.proto b/protoc-gen-kaja/tests/270_generic_server_option/protobuf-ts.proto new file mode 100644 index 00000000..ff92b601 --- /dev/null +++ b/protoc-gen-kaja/tests/270_generic_server_option/protobuf-ts.proto @@ -0,0 +1,83 @@ +// This proto file is part of protobuf-ts. It defines custom options +// that are interpreted by @protobuf-ts/plugin. +// +// To use the options, add an import to this file: +// +// import "protobuf-ts.proto"; +// +// If you use @protobuf-ts/plugin, it should not be necessary to add a proto +// path to the file. @protobuf-ts/protoc automatically adds +// `--proto_path ./node_modules/@protobuf-ts/plugin` to your commands. + +syntax = "proto3"; + +package ts; + +import "google/protobuf/descriptor.proto"; + + +// Custom file options interpreted by @protobuf-ts/plugin +extend google.protobuf.FileOptions { + + // Exclude field or method options from being emitted in reflection data. + // + // For example, to stop the data of the "google.api.http" method option + // from being exported in the reflection information, set the following + // file option: + // + // ```proto + // option (ts.exclude_options) = "google.api.http"; + // ``` + // + // The option can be set multiple times. + // `*` serves as a wildcard and will greedily match anything. + repeated string exclude_options = 777701; + +} + + +// Custom service options interpreted by @protobuf-ts/plugin +extend google.protobuf.ServiceOptions { + + // Generate a client for this service with this style. + // Can be set multiple times to generate several styles. + repeated ClientStyle client = 777701; + + // Generate a server for this service with this style. + // Can be set multiple times to generate several styles. + repeated ServerStyle server = 777702; + +} + + +// The available client styles that can be generated by @protobuf-ts/plugin +enum ClientStyle { + + // Do not emit a client for this service. + NO_CLIENT = 0; + + // Use the generic implementations of @protobuf-ts/runtime-rpc. + // This is the default behaviour. + GENERIC_CLIENT = 1; + + // Generate a client using @grpc/grpc-js (major version 1). + GRPC1_CLIENT = 4; +} + + +// The available server styles that can be generated by @protobuf-ts/plugin +enum ServerStyle { + + // Do not emit a server for this service. + // This is the default behaviour. + NO_SERVER = 0; + + // Generate a generic server interface. + // Adapters be used to serve the service, for example @protobuf-ts/grpc-backend + // for gRPC. + GENERIC_SERVER = 1; + + // Generate a server for @grpc/grpc-js (major version 1). + GRPC1_SERVER = 2; + +} diff --git a/protoc-gen-kaja/tests/270_generic_server_option/test.proto b/protoc-gen-kaja/tests/270_generic_server_option/test.proto new file mode 100644 index 00000000..222e792e --- /dev/null +++ b/protoc-gen-kaja/tests/270_generic_server_option/test.proto @@ -0,0 +1,18 @@ +syntax = "proto3"; +package test; +import "protobuf-ts.proto"; + +message Req { + string q = 1; +} + +message Resp { + string r = 1; +} + +// The TS plugin generates a .server.ts file for GENERIC_SERVER. +// The Go plugin has no GENERIC_SERVER support and won't generate it. +service MySvc { + option (ts.server) = GENERIC_SERVER; + rpc DoIt(Req) returns (Resp); +} diff --git a/protoc-gen-kaja/tests/271_generic_server_streaming/protobuf-ts.proto b/protoc-gen-kaja/tests/271_generic_server_streaming/protobuf-ts.proto new file mode 100644 index 00000000..ff92b601 --- /dev/null +++ b/protoc-gen-kaja/tests/271_generic_server_streaming/protobuf-ts.proto @@ -0,0 +1,83 @@ +// This proto file is part of protobuf-ts. It defines custom options +// that are interpreted by @protobuf-ts/plugin. +// +// To use the options, add an import to this file: +// +// import "protobuf-ts.proto"; +// +// If you use @protobuf-ts/plugin, it should not be necessary to add a proto +// path to the file. @protobuf-ts/protoc automatically adds +// `--proto_path ./node_modules/@protobuf-ts/plugin` to your commands. + +syntax = "proto3"; + +package ts; + +import "google/protobuf/descriptor.proto"; + + +// Custom file options interpreted by @protobuf-ts/plugin +extend google.protobuf.FileOptions { + + // Exclude field or method options from being emitted in reflection data. + // + // For example, to stop the data of the "google.api.http" method option + // from being exported in the reflection information, set the following + // file option: + // + // ```proto + // option (ts.exclude_options) = "google.api.http"; + // ``` + // + // The option can be set multiple times. + // `*` serves as a wildcard and will greedily match anything. + repeated string exclude_options = 777701; + +} + + +// Custom service options interpreted by @protobuf-ts/plugin +extend google.protobuf.ServiceOptions { + + // Generate a client for this service with this style. + // Can be set multiple times to generate several styles. + repeated ClientStyle client = 777701; + + // Generate a server for this service with this style. + // Can be set multiple times to generate several styles. + repeated ServerStyle server = 777702; + +} + + +// The available client styles that can be generated by @protobuf-ts/plugin +enum ClientStyle { + + // Do not emit a client for this service. + NO_CLIENT = 0; + + // Use the generic implementations of @protobuf-ts/runtime-rpc. + // This is the default behaviour. + GENERIC_CLIENT = 1; + + // Generate a client using @grpc/grpc-js (major version 1). + GRPC1_CLIENT = 4; +} + + +// The available server styles that can be generated by @protobuf-ts/plugin +enum ServerStyle { + + // Do not emit a server for this service. + // This is the default behaviour. + NO_SERVER = 0; + + // Generate a generic server interface. + // Adapters be used to serve the service, for example @protobuf-ts/grpc-backend + // for gRPC. + GENERIC_SERVER = 1; + + // Generate a server for @grpc/grpc-js (major version 1). + GRPC1_SERVER = 2; + +} diff --git a/protoc-gen-kaja/tests/271_generic_server_streaming/test.proto b/protoc-gen-kaja/tests/271_generic_server_streaming/test.proto new file mode 100644 index 00000000..4b94c7e5 --- /dev/null +++ b/protoc-gen-kaja/tests/271_generic_server_streaming/test.proto @@ -0,0 +1,24 @@ +syntax = "proto3"; +package test; +import "protobuf-ts.proto"; + +message Req { + string q = 1; +} + +message Resp { + string r = 1; +} + +// GENERIC_SERVER with all four streaming types. +// The TS plugin adds imports as they are encountered during code generation +// (each prepended at the top of the file). The Go plugin emits message type +// imports first, then runtime-rpc streaming imports, then ServerCallContext. +// This causes the import order to differ. +service MySvc { + option (ts.server) = GENERIC_SERVER; + rpc Unary(Req) returns (Resp); + rpc ServerStream(Req) returns (stream Resp); + rpc ClientStream(stream Req) returns (Resp); + rpc BidiStream(stream Req) returns (stream Resp); +} diff --git a/protoc-gen-kaja/tests/272_generic_server_import_interleave/protobuf-ts.proto b/protoc-gen-kaja/tests/272_generic_server_import_interleave/protobuf-ts.proto new file mode 100644 index 00000000..ff92b601 --- /dev/null +++ b/protoc-gen-kaja/tests/272_generic_server_import_interleave/protobuf-ts.proto @@ -0,0 +1,83 @@ +// This proto file is part of protobuf-ts. It defines custom options +// that are interpreted by @protobuf-ts/plugin. +// +// To use the options, add an import to this file: +// +// import "protobuf-ts.proto"; +// +// If you use @protobuf-ts/plugin, it should not be necessary to add a proto +// path to the file. @protobuf-ts/protoc automatically adds +// `--proto_path ./node_modules/@protobuf-ts/plugin` to your commands. + +syntax = "proto3"; + +package ts; + +import "google/protobuf/descriptor.proto"; + + +// Custom file options interpreted by @protobuf-ts/plugin +extend google.protobuf.FileOptions { + + // Exclude field or method options from being emitted in reflection data. + // + // For example, to stop the data of the "google.api.http" method option + // from being exported in the reflection information, set the following + // file option: + // + // ```proto + // option (ts.exclude_options) = "google.api.http"; + // ``` + // + // The option can be set multiple times. + // `*` serves as a wildcard and will greedily match anything. + repeated string exclude_options = 777701; + +} + + +// Custom service options interpreted by @protobuf-ts/plugin +extend google.protobuf.ServiceOptions { + + // Generate a client for this service with this style. + // Can be set multiple times to generate several styles. + repeated ClientStyle client = 777701; + + // Generate a server for this service with this style. + // Can be set multiple times to generate several styles. + repeated ServerStyle server = 777702; + +} + + +// The available client styles that can be generated by @protobuf-ts/plugin +enum ClientStyle { + + // Do not emit a client for this service. + NO_CLIENT = 0; + + // Use the generic implementations of @protobuf-ts/runtime-rpc. + // This is the default behaviour. + GENERIC_CLIENT = 1; + + // Generate a client using @grpc/grpc-js (major version 1). + GRPC1_CLIENT = 4; +} + + +// The available server styles that can be generated by @protobuf-ts/plugin +enum ServerStyle { + + // Do not emit a server for this service. + // This is the default behaviour. + NO_SERVER = 0; + + // Generate a generic server interface. + // Adapters be used to serve the service, for example @protobuf-ts/grpc-backend + // for gRPC. + GENERIC_SERVER = 1; + + // Generate a server for @grpc/grpc-js (major version 1). + GRPC1_SERVER = 2; + +} diff --git a/protoc-gen-kaja/tests/272_generic_server_import_interleave/test.proto b/protoc-gen-kaja/tests/272_generic_server_import_interleave/test.proto new file mode 100644 index 00000000..fb839ab4 --- /dev/null +++ b/protoc-gen-kaja/tests/272_generic_server_import_interleave/test.proto @@ -0,0 +1,30 @@ +syntax = "proto3"; +package test; +import "protobuf-ts.proto"; + +message Alpha { + string a = 1; +} + +message Beta { + string b = 1; +} + +message Gamma { + string g = 1; +} + +message Delta { + string d = 1; +} + +// GENERIC_SERVER with two server-streaming methods using different types. +// The TS plugin prepends each import as it's encountered during code +// generation. RpcInputStream is first encountered during StreamA, so it +// sits between Gamma/Delta and Beta/Alpha imports. The Go plugin always +// emits RpcInputStream at the top before all message types. +service MySvc { + option (ts.server) = GENERIC_SERVER; + rpc StreamA(Alpha) returns (stream Beta); + rpc StreamB(Gamma) returns (stream Delta); +} diff --git a/protoc-gen-kaja/tests/273_generic_server_bidi/protobuf-ts.proto b/protoc-gen-kaja/tests/273_generic_server_bidi/protobuf-ts.proto new file mode 100644 index 00000000..44e209e0 --- /dev/null +++ b/protoc-gen-kaja/tests/273_generic_server_bidi/protobuf-ts.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; +package ts; +import "google/protobuf/descriptor.proto"; +enum ServerStyle { + NO_SERVER = 0; + GENERIC_SERVER = 1; + GRPC1_SERVER = 2; +} +extend google.protobuf.ServiceOptions { + repeated ServerStyle server = 777702; +} +extend google.protobuf.ServiceOptions { + repeated string client = 777701; +} diff --git a/protoc-gen-kaja/tests/273_generic_server_bidi/test.proto b/protoc-gen-kaja/tests/273_generic_server_bidi/test.proto new file mode 100644 index 00000000..b82b4b68 --- /dev/null +++ b/protoc-gen-kaja/tests/273_generic_server_bidi/test.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; +package test; +import "protobuf-ts.proto"; + +message Alpha { string a = 1; } +message Beta { string b = 1; } + +// Bidi streaming: the TS plugin's createBidi() encounters RpcOutputStream +// (for requests param) BEFORE RpcInputStream (for responses param). +// Since imports are prepended, RpcInputStream ends up above RpcOutputStream. +// The Go plugin checks ss before cs in the import loop, so RpcInputStream +// is prepended first, then RpcOutputStream goes on top — reversed order. +service MySvc { + option (ts.server) = GENERIC_SERVER; + rpc Chat(stream Alpha) returns (stream Beta); +} diff --git a/protoc-gen-kaja/tests/274_no_client_service_option/protobuf-ts.proto b/protoc-gen-kaja/tests/274_no_client_service_option/protobuf-ts.proto new file mode 100644 index 00000000..ff92b601 --- /dev/null +++ b/protoc-gen-kaja/tests/274_no_client_service_option/protobuf-ts.proto @@ -0,0 +1,83 @@ +// This proto file is part of protobuf-ts. It defines custom options +// that are interpreted by @protobuf-ts/plugin. +// +// To use the options, add an import to this file: +// +// import "protobuf-ts.proto"; +// +// If you use @protobuf-ts/plugin, it should not be necessary to add a proto +// path to the file. @protobuf-ts/protoc automatically adds +// `--proto_path ./node_modules/@protobuf-ts/plugin` to your commands. + +syntax = "proto3"; + +package ts; + +import "google/protobuf/descriptor.proto"; + + +// Custom file options interpreted by @protobuf-ts/plugin +extend google.protobuf.FileOptions { + + // Exclude field or method options from being emitted in reflection data. + // + // For example, to stop the data of the "google.api.http" method option + // from being exported in the reflection information, set the following + // file option: + // + // ```proto + // option (ts.exclude_options) = "google.api.http"; + // ``` + // + // The option can be set multiple times. + // `*` serves as a wildcard and will greedily match anything. + repeated string exclude_options = 777701; + +} + + +// Custom service options interpreted by @protobuf-ts/plugin +extend google.protobuf.ServiceOptions { + + // Generate a client for this service with this style. + // Can be set multiple times to generate several styles. + repeated ClientStyle client = 777701; + + // Generate a server for this service with this style. + // Can be set multiple times to generate several styles. + repeated ServerStyle server = 777702; + +} + + +// The available client styles that can be generated by @protobuf-ts/plugin +enum ClientStyle { + + // Do not emit a client for this service. + NO_CLIENT = 0; + + // Use the generic implementations of @protobuf-ts/runtime-rpc. + // This is the default behaviour. + GENERIC_CLIENT = 1; + + // Generate a client using @grpc/grpc-js (major version 1). + GRPC1_CLIENT = 4; +} + + +// The available server styles that can be generated by @protobuf-ts/plugin +enum ServerStyle { + + // Do not emit a server for this service. + // This is the default behaviour. + NO_SERVER = 0; + + // Generate a generic server interface. + // Adapters be used to serve the service, for example @protobuf-ts/grpc-backend + // for gRPC. + GENERIC_SERVER = 1; + + // Generate a server for @grpc/grpc-js (major version 1). + GRPC1_SERVER = 2; + +} diff --git a/protoc-gen-kaja/tests/274_no_client_service_option/test.proto b/protoc-gen-kaja/tests/274_no_client_service_option/test.proto new file mode 100644 index 00000000..690515d1 --- /dev/null +++ b/protoc-gen-kaja/tests/274_no_client_service_option/test.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; +package test; +import "protobuf-ts.proto"; + +message Req { + string q = 1; +} + +message Resp { + string r = 1; +} + +service MySvc { + option (ts.client) = NO_CLIENT; + rpc DoIt(Req) returns (Resp); +} diff --git a/protoc-gen-kaja/tests/275_grpc1_client_option/protobuf-ts.proto b/protoc-gen-kaja/tests/275_grpc1_client_option/protobuf-ts.proto new file mode 100644 index 00000000..ff92b601 --- /dev/null +++ b/protoc-gen-kaja/tests/275_grpc1_client_option/protobuf-ts.proto @@ -0,0 +1,83 @@ +// This proto file is part of protobuf-ts. It defines custom options +// that are interpreted by @protobuf-ts/plugin. +// +// To use the options, add an import to this file: +// +// import "protobuf-ts.proto"; +// +// If you use @protobuf-ts/plugin, it should not be necessary to add a proto +// path to the file. @protobuf-ts/protoc automatically adds +// `--proto_path ./node_modules/@protobuf-ts/plugin` to your commands. + +syntax = "proto3"; + +package ts; + +import "google/protobuf/descriptor.proto"; + + +// Custom file options interpreted by @protobuf-ts/plugin +extend google.protobuf.FileOptions { + + // Exclude field or method options from being emitted in reflection data. + // + // For example, to stop the data of the "google.api.http" method option + // from being exported in the reflection information, set the following + // file option: + // + // ```proto + // option (ts.exclude_options) = "google.api.http"; + // ``` + // + // The option can be set multiple times. + // `*` serves as a wildcard and will greedily match anything. + repeated string exclude_options = 777701; + +} + + +// Custom service options interpreted by @protobuf-ts/plugin +extend google.protobuf.ServiceOptions { + + // Generate a client for this service with this style. + // Can be set multiple times to generate several styles. + repeated ClientStyle client = 777701; + + // Generate a server for this service with this style. + // Can be set multiple times to generate several styles. + repeated ServerStyle server = 777702; + +} + + +// The available client styles that can be generated by @protobuf-ts/plugin +enum ClientStyle { + + // Do not emit a client for this service. + NO_CLIENT = 0; + + // Use the generic implementations of @protobuf-ts/runtime-rpc. + // This is the default behaviour. + GENERIC_CLIENT = 1; + + // Generate a client using @grpc/grpc-js (major version 1). + GRPC1_CLIENT = 4; +} + + +// The available server styles that can be generated by @protobuf-ts/plugin +enum ServerStyle { + + // Do not emit a server for this service. + // This is the default behaviour. + NO_SERVER = 0; + + // Generate a generic server interface. + // Adapters be used to serve the service, for example @protobuf-ts/grpc-backend + // for gRPC. + GENERIC_SERVER = 1; + + // Generate a server for @grpc/grpc-js (major version 1). + GRPC1_SERVER = 2; + +} diff --git a/protoc-gen-kaja/tests/275_grpc1_client_option/test.proto b/protoc-gen-kaja/tests/275_grpc1_client_option/test.proto new file mode 100644 index 00000000..d670a7db --- /dev/null +++ b/protoc-gen-kaja/tests/275_grpc1_client_option/test.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; +package test; +import "protobuf-ts.proto"; + +message Req { + string q = 1; +} + +message Resp { + string r = 1; +} + +service MySvc { + option (ts.client) = GRPC1_CLIENT; + rpc DoIt(Req) returns (Resp); +} diff --git a/protoc-gen-kaja/tests/276_grpc1_client_streaming/protobuf-ts.proto b/protoc-gen-kaja/tests/276_grpc1_client_streaming/protobuf-ts.proto new file mode 100644 index 00000000..ff92b601 --- /dev/null +++ b/protoc-gen-kaja/tests/276_grpc1_client_streaming/protobuf-ts.proto @@ -0,0 +1,83 @@ +// This proto file is part of protobuf-ts. It defines custom options +// that are interpreted by @protobuf-ts/plugin. +// +// To use the options, add an import to this file: +// +// import "protobuf-ts.proto"; +// +// If you use @protobuf-ts/plugin, it should not be necessary to add a proto +// path to the file. @protobuf-ts/protoc automatically adds +// `--proto_path ./node_modules/@protobuf-ts/plugin` to your commands. + +syntax = "proto3"; + +package ts; + +import "google/protobuf/descriptor.proto"; + + +// Custom file options interpreted by @protobuf-ts/plugin +extend google.protobuf.FileOptions { + + // Exclude field or method options from being emitted in reflection data. + // + // For example, to stop the data of the "google.api.http" method option + // from being exported in the reflection information, set the following + // file option: + // + // ```proto + // option (ts.exclude_options) = "google.api.http"; + // ``` + // + // The option can be set multiple times. + // `*` serves as a wildcard and will greedily match anything. + repeated string exclude_options = 777701; + +} + + +// Custom service options interpreted by @protobuf-ts/plugin +extend google.protobuf.ServiceOptions { + + // Generate a client for this service with this style. + // Can be set multiple times to generate several styles. + repeated ClientStyle client = 777701; + + // Generate a server for this service with this style. + // Can be set multiple times to generate several styles. + repeated ServerStyle server = 777702; + +} + + +// The available client styles that can be generated by @protobuf-ts/plugin +enum ClientStyle { + + // Do not emit a client for this service. + NO_CLIENT = 0; + + // Use the generic implementations of @protobuf-ts/runtime-rpc. + // This is the default behaviour. + GENERIC_CLIENT = 1; + + // Generate a client using @grpc/grpc-js (major version 1). + GRPC1_CLIENT = 4; +} + + +// The available server styles that can be generated by @protobuf-ts/plugin +enum ServerStyle { + + // Do not emit a server for this service. + // This is the default behaviour. + NO_SERVER = 0; + + // Generate a generic server interface. + // Adapters be used to serve the service, for example @protobuf-ts/grpc-backend + // for gRPC. + GENERIC_SERVER = 1; + + // Generate a server for @grpc/grpc-js (major version 1). + GRPC1_SERVER = 2; + +} diff --git a/protoc-gen-kaja/tests/276_grpc1_client_streaming/test.proto b/protoc-gen-kaja/tests/276_grpc1_client_streaming/test.proto new file mode 100644 index 00000000..59d4f9cb --- /dev/null +++ b/protoc-gen-kaja/tests/276_grpc1_client_streaming/test.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; +package test; +import "protobuf-ts.proto"; + +message Req { + string q = 1; +} + +message Resp { + string r = 1; +} + +service MySvc { + option (ts.client) = GRPC1_CLIENT; + rpc ServerStream(Req) returns (stream Resp); +} diff --git a/protoc-gen-kaja/tests/277_grpc1_client_bidi/protobuf-ts.proto b/protoc-gen-kaja/tests/277_grpc1_client_bidi/protobuf-ts.proto new file mode 100644 index 00000000..ff92b601 --- /dev/null +++ b/protoc-gen-kaja/tests/277_grpc1_client_bidi/protobuf-ts.proto @@ -0,0 +1,83 @@ +// This proto file is part of protobuf-ts. It defines custom options +// that are interpreted by @protobuf-ts/plugin. +// +// To use the options, add an import to this file: +// +// import "protobuf-ts.proto"; +// +// If you use @protobuf-ts/plugin, it should not be necessary to add a proto +// path to the file. @protobuf-ts/protoc automatically adds +// `--proto_path ./node_modules/@protobuf-ts/plugin` to your commands. + +syntax = "proto3"; + +package ts; + +import "google/protobuf/descriptor.proto"; + + +// Custom file options interpreted by @protobuf-ts/plugin +extend google.protobuf.FileOptions { + + // Exclude field or method options from being emitted in reflection data. + // + // For example, to stop the data of the "google.api.http" method option + // from being exported in the reflection information, set the following + // file option: + // + // ```proto + // option (ts.exclude_options) = "google.api.http"; + // ``` + // + // The option can be set multiple times. + // `*` serves as a wildcard and will greedily match anything. + repeated string exclude_options = 777701; + +} + + +// Custom service options interpreted by @protobuf-ts/plugin +extend google.protobuf.ServiceOptions { + + // Generate a client for this service with this style. + // Can be set multiple times to generate several styles. + repeated ClientStyle client = 777701; + + // Generate a server for this service with this style. + // Can be set multiple times to generate several styles. + repeated ServerStyle server = 777702; + +} + + +// The available client styles that can be generated by @protobuf-ts/plugin +enum ClientStyle { + + // Do not emit a client for this service. + NO_CLIENT = 0; + + // Use the generic implementations of @protobuf-ts/runtime-rpc. + // This is the default behaviour. + GENERIC_CLIENT = 1; + + // Generate a client using @grpc/grpc-js (major version 1). + GRPC1_CLIENT = 4; +} + + +// The available server styles that can be generated by @protobuf-ts/plugin +enum ServerStyle { + + // Do not emit a server for this service. + // This is the default behaviour. + NO_SERVER = 0; + + // Generate a generic server interface. + // Adapters be used to serve the service, for example @protobuf-ts/grpc-backend + // for gRPC. + GENERIC_SERVER = 1; + + // Generate a server for @grpc/grpc-js (major version 1). + GRPC1_SERVER = 2; + +} diff --git a/protoc-gen-kaja/tests/277_grpc1_client_bidi/test.proto b/protoc-gen-kaja/tests/277_grpc1_client_bidi/test.proto new file mode 100644 index 00000000..9b69b5f3 --- /dev/null +++ b/protoc-gen-kaja/tests/277_grpc1_client_bidi/test.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; +package test; +import "protobuf-ts.proto"; + +message Req { + string q = 1; +} + +message Resp { + string r = 1; +} + +service MySvc { + option (ts.client) = GRPC1_CLIENT; + rpc BidiStream(stream Req) returns (stream Resp); +} diff --git a/protoc-gen-kaja/tests/278_grpc_server_definition_name/protobuf-ts.proto b/protoc-gen-kaja/tests/278_grpc_server_definition_name/protobuf-ts.proto new file mode 100644 index 00000000..ff92b601 --- /dev/null +++ b/protoc-gen-kaja/tests/278_grpc_server_definition_name/protobuf-ts.proto @@ -0,0 +1,83 @@ +// This proto file is part of protobuf-ts. It defines custom options +// that are interpreted by @protobuf-ts/plugin. +// +// To use the options, add an import to this file: +// +// import "protobuf-ts.proto"; +// +// If you use @protobuf-ts/plugin, it should not be necessary to add a proto +// path to the file. @protobuf-ts/protoc automatically adds +// `--proto_path ./node_modules/@protobuf-ts/plugin` to your commands. + +syntax = "proto3"; + +package ts; + +import "google/protobuf/descriptor.proto"; + + +// Custom file options interpreted by @protobuf-ts/plugin +extend google.protobuf.FileOptions { + + // Exclude field or method options from being emitted in reflection data. + // + // For example, to stop the data of the "google.api.http" method option + // from being exported in the reflection information, set the following + // file option: + // + // ```proto + // option (ts.exclude_options) = "google.api.http"; + // ``` + // + // The option can be set multiple times. + // `*` serves as a wildcard and will greedily match anything. + repeated string exclude_options = 777701; + +} + + +// Custom service options interpreted by @protobuf-ts/plugin +extend google.protobuf.ServiceOptions { + + // Generate a client for this service with this style. + // Can be set multiple times to generate several styles. + repeated ClientStyle client = 777701; + + // Generate a server for this service with this style. + // Can be set multiple times to generate several styles. + repeated ServerStyle server = 777702; + +} + + +// The available client styles that can be generated by @protobuf-ts/plugin +enum ClientStyle { + + // Do not emit a client for this service. + NO_CLIENT = 0; + + // Use the generic implementations of @protobuf-ts/runtime-rpc. + // This is the default behaviour. + GENERIC_CLIENT = 1; + + // Generate a client using @grpc/grpc-js (major version 1). + GRPC1_CLIENT = 4; +} + + +// The available server styles that can be generated by @protobuf-ts/plugin +enum ServerStyle { + + // Do not emit a server for this service. + // This is the default behaviour. + NO_SERVER = 0; + + // Generate a generic server interface. + // Adapters be used to serve the service, for example @protobuf-ts/grpc-backend + // for gRPC. + GENERIC_SERVER = 1; + + // Generate a server for @grpc/grpc-js (major version 1). + GRPC1_SERVER = 2; + +} diff --git a/protoc-gen-kaja/tests/278_grpc_server_definition_name/test.proto b/protoc-gen-kaja/tests/278_grpc_server_definition_name/test.proto new file mode 100644 index 00000000..331b2acc --- /dev/null +++ b/protoc-gen-kaja/tests/278_grpc_server_definition_name/test.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; +package test; +import "protobuf-ts.proto"; + +message Req { + string q = 1; +} + +message Resp { + string r = 1; +} + +service my_svc { + option (ts.server) = GRPC1_SERVER; + rpc DoIt(Req) returns (Resp); +} diff --git a/protoc-gen-kaja/tests/279_grpc1_client_cs/protobuf-ts.proto b/protoc-gen-kaja/tests/279_grpc1_client_cs/protobuf-ts.proto new file mode 100644 index 00000000..ff92b601 --- /dev/null +++ b/protoc-gen-kaja/tests/279_grpc1_client_cs/protobuf-ts.proto @@ -0,0 +1,83 @@ +// This proto file is part of protobuf-ts. It defines custom options +// that are interpreted by @protobuf-ts/plugin. +// +// To use the options, add an import to this file: +// +// import "protobuf-ts.proto"; +// +// If you use @protobuf-ts/plugin, it should not be necessary to add a proto +// path to the file. @protobuf-ts/protoc automatically adds +// `--proto_path ./node_modules/@protobuf-ts/plugin` to your commands. + +syntax = "proto3"; + +package ts; + +import "google/protobuf/descriptor.proto"; + + +// Custom file options interpreted by @protobuf-ts/plugin +extend google.protobuf.FileOptions { + + // Exclude field or method options from being emitted in reflection data. + // + // For example, to stop the data of the "google.api.http" method option + // from being exported in the reflection information, set the following + // file option: + // + // ```proto + // option (ts.exclude_options) = "google.api.http"; + // ``` + // + // The option can be set multiple times. + // `*` serves as a wildcard and will greedily match anything. + repeated string exclude_options = 777701; + +} + + +// Custom service options interpreted by @protobuf-ts/plugin +extend google.protobuf.ServiceOptions { + + // Generate a client for this service with this style. + // Can be set multiple times to generate several styles. + repeated ClientStyle client = 777701; + + // Generate a server for this service with this style. + // Can be set multiple times to generate several styles. + repeated ServerStyle server = 777702; + +} + + +// The available client styles that can be generated by @protobuf-ts/plugin +enum ClientStyle { + + // Do not emit a client for this service. + NO_CLIENT = 0; + + // Use the generic implementations of @protobuf-ts/runtime-rpc. + // This is the default behaviour. + GENERIC_CLIENT = 1; + + // Generate a client using @grpc/grpc-js (major version 1). + GRPC1_CLIENT = 4; +} + + +// The available server styles that can be generated by @protobuf-ts/plugin +enum ServerStyle { + + // Do not emit a server for this service. + // This is the default behaviour. + NO_SERVER = 0; + + // Generate a generic server interface. + // Adapters be used to serve the service, for example @protobuf-ts/grpc-backend + // for gRPC. + GENERIC_SERVER = 1; + + // Generate a server for @grpc/grpc-js (major version 1). + GRPC1_SERVER = 2; + +} diff --git a/protoc-gen-kaja/tests/279_grpc1_client_cs/test.proto b/protoc-gen-kaja/tests/279_grpc1_client_cs/test.proto new file mode 100644 index 00000000..499c52b1 --- /dev/null +++ b/protoc-gen-kaja/tests/279_grpc1_client_cs/test.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; +package test; +import "protobuf-ts.proto"; + +message Req { + string q = 1; +} + +message Resp { + string r = 1; +} + +service MySvc { + option (ts.client) = GRPC1_CLIENT; + rpc ClientStream(stream Req) returns (Resp); +} diff --git a/protoc-gen-kaja/tests/280_grpc_client_deprecated_service/protobuf-ts.proto b/protoc-gen-kaja/tests/280_grpc_client_deprecated_service/protobuf-ts.proto new file mode 100644 index 00000000..ff92b601 --- /dev/null +++ b/protoc-gen-kaja/tests/280_grpc_client_deprecated_service/protobuf-ts.proto @@ -0,0 +1,83 @@ +// This proto file is part of protobuf-ts. It defines custom options +// that are interpreted by @protobuf-ts/plugin. +// +// To use the options, add an import to this file: +// +// import "protobuf-ts.proto"; +// +// If you use @protobuf-ts/plugin, it should not be necessary to add a proto +// path to the file. @protobuf-ts/protoc automatically adds +// `--proto_path ./node_modules/@protobuf-ts/plugin` to your commands. + +syntax = "proto3"; + +package ts; + +import "google/protobuf/descriptor.proto"; + + +// Custom file options interpreted by @protobuf-ts/plugin +extend google.protobuf.FileOptions { + + // Exclude field or method options from being emitted in reflection data. + // + // For example, to stop the data of the "google.api.http" method option + // from being exported in the reflection information, set the following + // file option: + // + // ```proto + // option (ts.exclude_options) = "google.api.http"; + // ``` + // + // The option can be set multiple times. + // `*` serves as a wildcard and will greedily match anything. + repeated string exclude_options = 777701; + +} + + +// Custom service options interpreted by @protobuf-ts/plugin +extend google.protobuf.ServiceOptions { + + // Generate a client for this service with this style. + // Can be set multiple times to generate several styles. + repeated ClientStyle client = 777701; + + // Generate a server for this service with this style. + // Can be set multiple times to generate several styles. + repeated ServerStyle server = 777702; + +} + + +// The available client styles that can be generated by @protobuf-ts/plugin +enum ClientStyle { + + // Do not emit a client for this service. + NO_CLIENT = 0; + + // Use the generic implementations of @protobuf-ts/runtime-rpc. + // This is the default behaviour. + GENERIC_CLIENT = 1; + + // Generate a client using @grpc/grpc-js (major version 1). + GRPC1_CLIENT = 4; +} + + +// The available server styles that can be generated by @protobuf-ts/plugin +enum ServerStyle { + + // Do not emit a server for this service. + // This is the default behaviour. + NO_SERVER = 0; + + // Generate a generic server interface. + // Adapters be used to serve the service, for example @protobuf-ts/grpc-backend + // for gRPC. + GENERIC_SERVER = 1; + + // Generate a server for @grpc/grpc-js (major version 1). + GRPC1_SERVER = 2; + +} diff --git a/protoc-gen-kaja/tests/280_grpc_client_deprecated_service/test.proto b/protoc-gen-kaja/tests/280_grpc_client_deprecated_service/test.proto new file mode 100644 index 00000000..13ae09b0 --- /dev/null +++ b/protoc-gen-kaja/tests/280_grpc_client_deprecated_service/test.proto @@ -0,0 +1,25 @@ +syntax = "proto3"; +package test; +import "protobuf-ts.proto"; + +message Req { + string q = 1; +} + +message Resp { + string r = 1; +} + +// A deprecated service. +service MySvc { + option deprecated = true; + option (ts.client) = GRPC1_CLIENT; + + // This method is explicitly deprecated. + rpc DoIt(Req) returns (Resp) { + option deprecated = true; + } + + // This method is NOT deprecated itself. + rpc OtherMethod(Req) returns (Resp); +} diff --git a/protoc-gen-kaja/tests/281_generic_server_deprecated_service/protobuf-ts.proto b/protoc-gen-kaja/tests/281_generic_server_deprecated_service/protobuf-ts.proto new file mode 100644 index 00000000..ff92b601 --- /dev/null +++ b/protoc-gen-kaja/tests/281_generic_server_deprecated_service/protobuf-ts.proto @@ -0,0 +1,83 @@ +// This proto file is part of protobuf-ts. It defines custom options +// that are interpreted by @protobuf-ts/plugin. +// +// To use the options, add an import to this file: +// +// import "protobuf-ts.proto"; +// +// If you use @protobuf-ts/plugin, it should not be necessary to add a proto +// path to the file. @protobuf-ts/protoc automatically adds +// `--proto_path ./node_modules/@protobuf-ts/plugin` to your commands. + +syntax = "proto3"; + +package ts; + +import "google/protobuf/descriptor.proto"; + + +// Custom file options interpreted by @protobuf-ts/plugin +extend google.protobuf.FileOptions { + + // Exclude field or method options from being emitted in reflection data. + // + // For example, to stop the data of the "google.api.http" method option + // from being exported in the reflection information, set the following + // file option: + // + // ```proto + // option (ts.exclude_options) = "google.api.http"; + // ``` + // + // The option can be set multiple times. + // `*` serves as a wildcard and will greedily match anything. + repeated string exclude_options = 777701; + +} + + +// Custom service options interpreted by @protobuf-ts/plugin +extend google.protobuf.ServiceOptions { + + // Generate a client for this service with this style. + // Can be set multiple times to generate several styles. + repeated ClientStyle client = 777701; + + // Generate a server for this service with this style. + // Can be set multiple times to generate several styles. + repeated ServerStyle server = 777702; + +} + + +// The available client styles that can be generated by @protobuf-ts/plugin +enum ClientStyle { + + // Do not emit a client for this service. + NO_CLIENT = 0; + + // Use the generic implementations of @protobuf-ts/runtime-rpc. + // This is the default behaviour. + GENERIC_CLIENT = 1; + + // Generate a client using @grpc/grpc-js (major version 1). + GRPC1_CLIENT = 4; +} + + +// The available server styles that can be generated by @protobuf-ts/plugin +enum ServerStyle { + + // Do not emit a server for this service. + // This is the default behaviour. + NO_SERVER = 0; + + // Generate a generic server interface. + // Adapters be used to serve the service, for example @protobuf-ts/grpc-backend + // for gRPC. + GENERIC_SERVER = 1; + + // Generate a server for @grpc/grpc-js (major version 1). + GRPC1_SERVER = 2; + +} diff --git a/protoc-gen-kaja/tests/281_generic_server_deprecated_service/test.proto b/protoc-gen-kaja/tests/281_generic_server_deprecated_service/test.proto new file mode 100644 index 00000000..899c969b --- /dev/null +++ b/protoc-gen-kaja/tests/281_generic_server_deprecated_service/test.proto @@ -0,0 +1,25 @@ +syntax = "proto3"; +package test; +import "protobuf-ts.proto"; + +message Req { + string q = 1; +} + +message Resp { + string r = 1; +} + +// A deprecated service with a generic server. +service MySvc { + option deprecated = true; + option (ts.server) = GENERIC_SERVER; + + // This method is explicitly deprecated. + rpc DoIt(Req) returns (Resp) { + option deprecated = true; + } + + // This method is NOT deprecated itself. + rpc OtherMethod(Req) returns (Resp); +} diff --git a/protoc-gen-kaja/tests/282_grpc_client_import_order/protobuf-ts.proto b/protoc-gen-kaja/tests/282_grpc_client_import_order/protobuf-ts.proto new file mode 100644 index 00000000..ff92b601 --- /dev/null +++ b/protoc-gen-kaja/tests/282_grpc_client_import_order/protobuf-ts.proto @@ -0,0 +1,83 @@ +// This proto file is part of protobuf-ts. It defines custom options +// that are interpreted by @protobuf-ts/plugin. +// +// To use the options, add an import to this file: +// +// import "protobuf-ts.proto"; +// +// If you use @protobuf-ts/plugin, it should not be necessary to add a proto +// path to the file. @protobuf-ts/protoc automatically adds +// `--proto_path ./node_modules/@protobuf-ts/plugin` to your commands. + +syntax = "proto3"; + +package ts; + +import "google/protobuf/descriptor.proto"; + + +// Custom file options interpreted by @protobuf-ts/plugin +extend google.protobuf.FileOptions { + + // Exclude field or method options from being emitted in reflection data. + // + // For example, to stop the data of the "google.api.http" method option + // from being exported in the reflection information, set the following + // file option: + // + // ```proto + // option (ts.exclude_options) = "google.api.http"; + // ``` + // + // The option can be set multiple times. + // `*` serves as a wildcard and will greedily match anything. + repeated string exclude_options = 777701; + +} + + +// Custom service options interpreted by @protobuf-ts/plugin +extend google.protobuf.ServiceOptions { + + // Generate a client for this service with this style. + // Can be set multiple times to generate several styles. + repeated ClientStyle client = 777701; + + // Generate a server for this service with this style. + // Can be set multiple times to generate several styles. + repeated ServerStyle server = 777702; + +} + + +// The available client styles that can be generated by @protobuf-ts/plugin +enum ClientStyle { + + // Do not emit a client for this service. + NO_CLIENT = 0; + + // Use the generic implementations of @protobuf-ts/runtime-rpc. + // This is the default behaviour. + GENERIC_CLIENT = 1; + + // Generate a client using @grpc/grpc-js (major version 1). + GRPC1_CLIENT = 4; +} + + +// The available server styles that can be generated by @protobuf-ts/plugin +enum ServerStyle { + + // Do not emit a server for this service. + // This is the default behaviour. + NO_SERVER = 0; + + // Generate a generic server interface. + // Adapters be used to serve the service, for example @protobuf-ts/grpc-backend + // for gRPC. + GENERIC_SERVER = 1; + + // Generate a server for @grpc/grpc-js (major version 1). + GRPC1_SERVER = 2; + +} diff --git a/protoc-gen-kaja/tests/282_grpc_client_import_order/test.proto b/protoc-gen-kaja/tests/282_grpc_client_import_order/test.proto new file mode 100644 index 00000000..275060bb --- /dev/null +++ b/protoc-gen-kaja/tests/282_grpc_client_import_order/test.proto @@ -0,0 +1,29 @@ +// Tests that grpc-client type imports match TS plugin order when two methods +// use the same cross-package types in swapped input/output positions. +// +// Method GetTime has input=Empty, output=Timestamp. +// Method SetTime has input=Timestamp, output=Empty. +// +// The TS plugin processes methods forward. GetTime encounters Empty (input) +// first, then Timestamp (callback output). With the prepend strategy, +// Timestamp ends up above Empty. The Go plugin iterates methods in reverse +// and appends resType before reqType. SetTime is processed first (in reverse), +// appending Empty (output) then Timestamp (input), putting Empty above +// Timestamp — the opposite of TS. +syntax = "proto3"; +package test; +import "protobuf-ts.proto"; +import "google/protobuf/empty.proto"; +import "google/protobuf/timestamp.proto"; + +message Req { + string q = 1; +} + +service MySvc { + option (ts.client) = GRPC1_CLIENT; + + rpc GetTime(google.protobuf.Empty) returns (google.protobuf.Timestamp); + rpc SetTime(google.protobuf.Timestamp) returns (google.protobuf.Empty); + rpc DoIt(Req) returns (Req); +} diff --git a/protoc-gen-kaja/tests/283_grpc_server_import_order/protobuf-ts.proto b/protoc-gen-kaja/tests/283_grpc_server_import_order/protobuf-ts.proto new file mode 100644 index 00000000..a96f0115 --- /dev/null +++ b/protoc-gen-kaja/tests/283_grpc_server_import_order/protobuf-ts.proto @@ -0,0 +1,29 @@ +// This proto file is part of protobuf-ts. It defines custom options +// that are interpreted by @protobuf-ts/plugin. + +syntax = "proto3"; + +package ts; + +import "google/protobuf/descriptor.proto"; + +extend google.protobuf.FileOptions { + repeated string exclude_options = 777701; +} + +extend google.protobuf.ServiceOptions { + repeated ClientStyle client = 777701; + repeated ServerStyle server = 777702; +} + +enum ClientStyle { + NO_CLIENT = 0; + GENERIC_CLIENT = 1; + GRPC1_CLIENT = 4; +} + +enum ServerStyle { + NO_SERVER = 0; + GENERIC_SERVER = 1; + GRPC1_SERVER = 2; +} diff --git a/protoc-gen-kaja/tests/283_grpc_server_import_order/test.proto b/protoc-gen-kaja/tests/283_grpc_server_import_order/test.proto new file mode 100644 index 00000000..f00ca243 --- /dev/null +++ b/protoc-gen-kaja/tests/283_grpc_server_import_order/test.proto @@ -0,0 +1,26 @@ +// Tests that grpc-server type imports match TS plugin order when two methods +// use the same cross-package types in swapped input/output positions. +// +// Method GetTime has input=Empty, output=Timestamp. +// Method SetTime has input=Timestamp, output=Empty. +// +// The TS plugin processes methods forward with prepend-as-encountered. +// The Go plugin iterates methods in reverse and appends resType before reqType. +// When two methods share types in swapped positions, the ordering diverges. +syntax = "proto3"; +package test; +import "protobuf-ts.proto"; +import "google/protobuf/empty.proto"; +import "google/protobuf/timestamp.proto"; + +message Req { + string q = 1; +} + +service MySvc { + option (ts.server) = GRPC1_SERVER; + + rpc GetTime(google.protobuf.Empty) returns (google.protobuf.Timestamp); + rpc SetTime(google.protobuf.Timestamp) returns (google.protobuf.Empty); + rpc DoIt(Req) returns (Req); +} diff --git a/protoc-gen-kaja/tests/284_grpc_server_alias_import/protobuf-ts.proto b/protoc-gen-kaja/tests/284_grpc_server_alias_import/protobuf-ts.proto new file mode 100644 index 00000000..ff92b601 --- /dev/null +++ b/protoc-gen-kaja/tests/284_grpc_server_alias_import/protobuf-ts.proto @@ -0,0 +1,83 @@ +// This proto file is part of protobuf-ts. It defines custom options +// that are interpreted by @protobuf-ts/plugin. +// +// To use the options, add an import to this file: +// +// import "protobuf-ts.proto"; +// +// If you use @protobuf-ts/plugin, it should not be necessary to add a proto +// path to the file. @protobuf-ts/protoc automatically adds +// `--proto_path ./node_modules/@protobuf-ts/plugin` to your commands. + +syntax = "proto3"; + +package ts; + +import "google/protobuf/descriptor.proto"; + + +// Custom file options interpreted by @protobuf-ts/plugin +extend google.protobuf.FileOptions { + + // Exclude field or method options from being emitted in reflection data. + // + // For example, to stop the data of the "google.api.http" method option + // from being exported in the reflection information, set the following + // file option: + // + // ```proto + // option (ts.exclude_options) = "google.api.http"; + // ``` + // + // The option can be set multiple times. + // `*` serves as a wildcard and will greedily match anything. + repeated string exclude_options = 777701; + +} + + +// Custom service options interpreted by @protobuf-ts/plugin +extend google.protobuf.ServiceOptions { + + // Generate a client for this service with this style. + // Can be set multiple times to generate several styles. + repeated ClientStyle client = 777701; + + // Generate a server for this service with this style. + // Can be set multiple times to generate several styles. + repeated ServerStyle server = 777702; + +} + + +// The available client styles that can be generated by @protobuf-ts/plugin +enum ClientStyle { + + // Do not emit a client for this service. + NO_CLIENT = 0; + + // Use the generic implementations of @protobuf-ts/runtime-rpc. + // This is the default behaviour. + GENERIC_CLIENT = 1; + + // Generate a client using @grpc/grpc-js (major version 1). + GRPC1_CLIENT = 4; +} + + +// The available server styles that can be generated by @protobuf-ts/plugin +enum ServerStyle { + + // Do not emit a server for this service. + // This is the default behaviour. + NO_SERVER = 0; + + // Generate a generic server interface. + // Adapters be used to serve the service, for example @protobuf-ts/grpc-backend + // for gRPC. + GENERIC_SERVER = 1; + + // Generate a server for @grpc/grpc-js (major version 1). + GRPC1_SERVER = 2; + +} diff --git a/protoc-gen-kaja/tests/284_grpc_server_alias_import/test.proto b/protoc-gen-kaja/tests/284_grpc_server_alias_import/test.proto new file mode 100644 index 00000000..83feb066 --- /dev/null +++ b/protoc-gen-kaja/tests/284_grpc_server_alias_import/test.proto @@ -0,0 +1,15 @@ +syntax = "proto3"; +package test; +import "protobuf-ts.proto"; +import "types_a.proto"; +import "types_b.proto"; + +message Req { + string query = 1; +} + +service MySvc { + option (ts.server) = GRPC1_SERVER; + rpc GetAlpha(Req) returns (alpha.Response); + rpc GetBeta(Req) returns (beta.Response); +} diff --git a/protoc-gen-kaja/tests/284_grpc_server_alias_import/types_a.proto b/protoc-gen-kaja/tests/284_grpc_server_alias_import/types_a.proto new file mode 100644 index 00000000..56c88fb0 --- /dev/null +++ b/protoc-gen-kaja/tests/284_grpc_server_alias_import/types_a.proto @@ -0,0 +1,6 @@ +syntax = "proto3"; +package alpha; + +message Response { + string data = 1; +} diff --git a/protoc-gen-kaja/tests/284_grpc_server_alias_import/types_b.proto b/protoc-gen-kaja/tests/284_grpc_server_alias_import/types_b.proto new file mode 100644 index 00000000..29c4cf02 --- /dev/null +++ b/protoc-gen-kaja/tests/284_grpc_server_alias_import/types_b.proto @@ -0,0 +1,6 @@ +syntax = "proto3"; +package beta; + +message Response { + string info = 1; +} diff --git a/protoc-gen-kaja/tests/285_client_service_filtering/protobuf-ts.proto b/protoc-gen-kaja/tests/285_client_service_filtering/protobuf-ts.proto new file mode 100644 index 00000000..ff92b601 --- /dev/null +++ b/protoc-gen-kaja/tests/285_client_service_filtering/protobuf-ts.proto @@ -0,0 +1,83 @@ +// This proto file is part of protobuf-ts. It defines custom options +// that are interpreted by @protobuf-ts/plugin. +// +// To use the options, add an import to this file: +// +// import "protobuf-ts.proto"; +// +// If you use @protobuf-ts/plugin, it should not be necessary to add a proto +// path to the file. @protobuf-ts/protoc automatically adds +// `--proto_path ./node_modules/@protobuf-ts/plugin` to your commands. + +syntax = "proto3"; + +package ts; + +import "google/protobuf/descriptor.proto"; + + +// Custom file options interpreted by @protobuf-ts/plugin +extend google.protobuf.FileOptions { + + // Exclude field or method options from being emitted in reflection data. + // + // For example, to stop the data of the "google.api.http" method option + // from being exported in the reflection information, set the following + // file option: + // + // ```proto + // option (ts.exclude_options) = "google.api.http"; + // ``` + // + // The option can be set multiple times. + // `*` serves as a wildcard and will greedily match anything. + repeated string exclude_options = 777701; + +} + + +// Custom service options interpreted by @protobuf-ts/plugin +extend google.protobuf.ServiceOptions { + + // Generate a client for this service with this style. + // Can be set multiple times to generate several styles. + repeated ClientStyle client = 777701; + + // Generate a server for this service with this style. + // Can be set multiple times to generate several styles. + repeated ServerStyle server = 777702; + +} + + +// The available client styles that can be generated by @protobuf-ts/plugin +enum ClientStyle { + + // Do not emit a client for this service. + NO_CLIENT = 0; + + // Use the generic implementations of @protobuf-ts/runtime-rpc. + // This is the default behaviour. + GENERIC_CLIENT = 1; + + // Generate a client using @grpc/grpc-js (major version 1). + GRPC1_CLIENT = 4; +} + + +// The available server styles that can be generated by @protobuf-ts/plugin +enum ServerStyle { + + // Do not emit a server for this service. + // This is the default behaviour. + NO_SERVER = 0; + + // Generate a generic server interface. + // Adapters be used to serve the service, for example @protobuf-ts/grpc-backend + // for gRPC. + GENERIC_SERVER = 1; + + // Generate a server for @grpc/grpc-js (major version 1). + GRPC1_SERVER = 2; + +} diff --git a/protoc-gen-kaja/tests/285_client_service_filtering/test.proto b/protoc-gen-kaja/tests/285_client_service_filtering/test.proto new file mode 100644 index 00000000..e5d90c1e --- /dev/null +++ b/protoc-gen-kaja/tests/285_client_service_filtering/test.proto @@ -0,0 +1,37 @@ +// Tests that .client.ts only includes services with GENERIC_CLIENT style. +// +// When a file has two services — one with ts.client = GRPC1_CLIENT and one +// with default (GENERIC_CLIENT) — the .client.ts file should only contain +// the generic client for the second service. The first service should only +// appear in .grpc-client.ts. +syntax = "proto3"; +package test; +import "protobuf-ts.proto"; + +message AlphaReq { + string query = 1; +} + +message AlphaResp { + string result = 1; +} + +message BetaReq { + int32 id = 1; +} + +message BetaResp { + string name = 1; +} + +// This service should only appear in .grpc-client.ts, NOT in .client.ts. +service AlphaSvc { + option (ts.client) = GRPC1_CLIENT; + rpc GetAlpha(AlphaReq) returns (AlphaResp); +} + +// This service has default client style (GENERIC_CLIENT), +// so it should appear in .client.ts. +service BetaSvc { + rpc GetBeta(BetaReq) returns (BetaResp); +}