diff --git a/README.md b/README.md index 270fb33..a69f624 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ High-performance Protocol Buffers code generator for Java, optimized for seriali - **Lazy string/bytes deserialization** — decoded only on access - **Optimized string handling** — single-copy ASCII fast path via `sun.misc.Unsafe` - **Protobuf-compatible JSON serialization and deserialization** — `toJson()` / `parseFromJson()` methods +- **Protobuf TextFormat (de)serialization** — opt-in `toTextFormat()` / `parseFromTextFormat()` for compatibility with `com.google.protobuf.TextFormat` - **No runtime dependencies** — generated code is self-contained - **Maven and Gradle plugins** for seamless build integration @@ -70,11 +71,25 @@ before compilation. Optional configuration: lightproto { classPrefix = '' // prefix for generated class names singleOuterClass = false // wrap all messages in a single outer class + generateTextFormat = false // also generate protobuf TextFormat (de)serialization methods protocVersion = '4.34.0' // protoc compiler version // protocPath = '/usr/local/bin/protoc' // use a local protoc binary } ``` +For Maven, the same options are configured under `` on the plugin: + +```xml + + io.streamnative.lightproto + lightproto-maven-plugin + + true + + + +``` + ### API Example LightProto generates mutable, reusable objects instead of the Builder pattern used by Google Protobuf: @@ -129,6 +144,39 @@ The JSON encoding follows protobuf conventions: lowerCamelCase field names, int6 as strings, enum values as names, and bytes fields as base64. Unknown fields are silently ignored during parsing, ensuring forward compatibility. +### Protobuf TextFormat (opt-in) + +Set `generateTextFormat = true` (Gradle) or `true` +(Maven) to also emit TextFormat (de)serialization on every message. The output is compatible +with `com.google.protobuf.TextFormat.printer()` and `TextFormat.merge()` for backward +compatibility with existing TextFormat data: + +```java +// Serialize to a multi-line, indented TextFormat string +String text = md.toTextFormat(); +// producer_name: "producer-1" +// sequence_id: 12345 +// publish_time: 1711234567890 +// property { +// key: "key1" +// value: "value1" +// } + +// Or write to a StringBuilder +StringBuilder sb = new StringBuilder(); +md.writeTextFormatTo(sb); + +// Deserialize from a String, byte[], or ByteBuf +MessageMetadata parsed = new MessageMetadata(); +parsed.parseFromTextFormat(text); +``` + +Differences from JSON: field names are the original proto snake_case, `int64` values are +**not** quoted, enum values are emitted as bare identifiers, and bytes are written as +quoted strings with C-style escapes. The parser tolerates `# … ` comments, single- or +double-quoted strings, angle-bracket sub-messages (`field <…>`), `[v1, v2]` array syntax for +repeated fields, and ignores unknown fields. + ### gRPC Integration LightProto generates `*Grpc.java` service stubs directly from `service` definitions in `.proto` @@ -213,6 +261,7 @@ client streaming, and bidirectional streaming. | Multiple `.proto` files / `import` | ✅ | ✅ | | `service` / RPC definitions (gRPC stubs) | ✅ | ✅ | | JSON serialization and deserialization | ✅ | ✅ | +| TextFormat serialization and deserialization (opt-in) | ✅ | ✅ | | Extensions | ❌ | — | | `Any`, `Timestamp`, well-known types | ❌ | ❌ | | `group` (deprecated) | ❌ | — | diff --git a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProto.java b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProto.java index 5da082f..ddf74c6 100644 --- a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProto.java +++ b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProto.java @@ -40,6 +40,12 @@ public class LightProto { public LightProto(ProtoFileDescriptor proto, String protoFileName, String outerClassName, boolean useOuterClass) { + this(proto, protoFileName, outerClassName, useOuterClass, false); + } + + public LightProto(ProtoFileDescriptor proto, String protoFileName, + String outerClassName, boolean useOuterClass, + boolean generateTextFormat) { this.proto = proto; this.protoFileName = protoFileName; this.outerClassName = outerClassName; @@ -53,7 +59,7 @@ public LightProto(ProtoFileDescriptor proto, String protoFileName, " */%n", LightProtoGenerator.getVersion(), protoFileName); this.enums = proto.getEnumGroups().stream().map(LightProtoEnum::new).collect(Collectors.toList()); - this.messages = proto.getMessages().stream().map(m -> new LightProtoMessage(m, useOuterClass)).collect(Collectors.toList()); + this.messages = proto.getMessages().stream().map(m -> new LightProtoMessage(m, useOuterClass, generateTextFormat)).collect(Collectors.toList()); this.services = proto.getServices().stream().map(LightProtoService::new).collect(Collectors.toList()); } diff --git a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoBooleanField.java b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoBooleanField.java index 8cd491f..669d108 100644 --- a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoBooleanField.java +++ b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoBooleanField.java @@ -55,6 +55,19 @@ public void parseJson(PrintWriter w) { w.format(" %s(_r.readBool());\n", Util.camelCase("set", field.getName())); } + @Override + public void serializeTextFormat(PrintWriter w) { + w.format("LightProtoCodec.writeTextFormatIndent(_sb, _indent);\n"); + w.format("_sb.append(\"%s: \").append(Boolean.toString(%s)).append('\\n');\n", + field.getName(), ccName); + } + + @Override + public void parseTextFormat(PrintWriter w) { + w.format(" _r.consumeFieldSeparator();\n"); + w.format(" %s(_r.readBool());\n", Util.camelCase("set", field.getName())); + } + @Override public void equalsCode(PrintWriter w) { w.format("if (%s != _other.%s) return false;\n", ccName, ccName); diff --git a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoBytesField.java b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoBytesField.java index 843772c..dfe6315 100644 --- a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoBytesField.java +++ b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoBytesField.java @@ -135,6 +135,24 @@ public void parseJson(PrintWriter w) { w.format(" %s(_r.readBase64Bytes());\n", Util.camelCase("set", field.getName())); } + @Override + public void serializeTextFormat(PrintWriter w) { + w.format("LightProtoCodec.writeTextFormatIndent(_sb, _indent);\n"); + w.format("_sb.append(\"%s: \");\n", field.getName()); + w.format("if (_%sIdx == -1) {\n", ccName); + w.format(" LightProtoCodec.writeTextFormatBytes(_sb, %s, 0, _%sLen);\n", ccName, ccName); + w.format("} else {\n"); + w.format(" LightProtoCodec.writeTextFormatBytes(_sb, _parsedBuffer, _%sIdx, _%sLen);\n", ccName, ccName); + w.format("}\n"); + w.format("_sb.append('\\n');\n"); + } + + @Override + public void parseTextFormat(PrintWriter w) { + w.format(" _r.consumeFieldSeparator();\n"); + w.format(" %s(_r.readBytes());\n", Util.camelCase("set", field.getName())); + } + @Override public void serialize(PrintWriter w) { w.format("%s;\n", writeTagExpr(tagName())); diff --git a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoEnumField.java b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoEnumField.java index 9842d13..ca02431 100644 --- a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoEnumField.java +++ b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoEnumField.java @@ -76,6 +76,19 @@ public void parseJson(PrintWriter w) { w.format(" if (_v != null) { %s(_v); } }\n", Util.camelCase("set", field.getName())); } + @Override + public void serializeTextFormat(PrintWriter w) { + w.format("LightProtoCodec.writeTextFormatIndent(_sb, _indent);\n"); + w.format("_sb.append(\"%s: \").append(%s.name()).append('\\n');\n", field.getName(), ccName); + } + + @Override + public void parseTextFormat(PrintWriter w) { + w.format(" _r.consumeFieldSeparator();\n"); + w.format(" { %s _v = %s.valueOf(_r.readIdentifier());\n", field.getJavaType(), field.getJavaType()); + w.format(" if (_v != null) { %s(_v); } }\n", Util.camelCase("set", field.getName())); + } + @Override public void equalsCode(PrintWriter w) { w.format("if (%s != _other.%s) return false;\n", ccName, ccName); diff --git a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoField.java b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoField.java index 0205b27..c6d13c2 100644 --- a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoField.java +++ b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoField.java @@ -142,6 +142,24 @@ public void fieldClear(PrintWriter w, String enclosingType) { abstract public void parseJson(PrintWriter w); + /** + * Emit code that writes the complete TextFormat representation of this field. + * Locals available: {@code _sb} (StringBuilder), {@code _indent} (int). + * The emitted code MUST write the indent, field name, value, and trailing newline(s). + */ + public void serializeTextFormat(PrintWriter w) { + throw new UnsupportedOperationException("serializeTextFormat not implemented for " + getClass().getSimpleName()); + } + + /** + * Emit code that, given a {@code LightProtoCodec.TextFormatReader _r} positioned + * immediately after this field's name, consumes the separator, reads the value, + * and stores it on the message. + */ + public void parseTextFormat(PrintWriter w) { + throw new UnsupportedOperationException("parseTextFormat not implemented for " + getClass().getSimpleName()); + } + abstract public void parse(PrintWriter w); abstract public void copy(PrintWriter w); diff --git a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoGenerator.java b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoGenerator.java index 886a131..6e62947 100644 --- a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoGenerator.java +++ b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoGenerator.java @@ -51,6 +51,13 @@ static String getVersion() { public static List generate(List descriptors, File outputDirectory, String classPrefix, boolean useOuterClass, List fileNames) throws Exception { + return generate(descriptors, outputDirectory, classPrefix, useOuterClass, fileNames, false); + } + + public static List generate(List descriptors, File outputDirectory, + String classPrefix, boolean useOuterClass, + List fileNames, + boolean generateTextFormat) throws Exception { List generatedFiles = new ArrayList<>(); Set javaPackages = new HashSet<>(); @@ -67,7 +74,7 @@ public static List generate(List descriptors, File ou String javaDir = Joiner.on('/').join(javaPackageName.split("\\.")); Path targetDir = Paths.get(String.format("%s/%s", outputDirectory, javaDir)); - LightProto lightProto = new LightProto(proto, fileName, outerClassName, useOuterClass); + LightProto lightProto = new LightProto(proto, fileName, outerClassName, useOuterClass, generateTextFormat); generatedFiles.addAll(lightProto.generate(targetDir.toFile())); javaPackages.add(javaPackageName); diff --git a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoMapField.java b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoMapField.java index eff2b3f..f8795e8 100644 --- a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoMapField.java +++ b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoMapField.java @@ -622,6 +622,174 @@ public void parseJson(PrintWriter w) { w.format(" }\n"); } + @Override + public void serializeTextFormat(PrintWriter w) { + w.format("for (int _i = 0; _i < _%sCount; _i++) {\n", ccName); + w.format(" LightProtoCodec.writeTextFormatIndent(_sb, _indent);\n"); + w.format(" _sb.append(\"%s {\\n\");\n", field.getName()); + + // Write key at indent + 1 + w.format(" LightProtoCodec.writeTextFormatIndent(_sb, _indent + 1);\n"); + w.format(" _sb.append(\"key: \");\n"); + if (isStringKey()) { + w.format(" LightProtoCodec.StringHolder _ksh = _%sKeys[_i];\n", ccName); + w.format(" if (_ksh.s == null) {\n"); + w.format(" _ksh.s = LightProtoCodec.readString(_parsedBuffer, _ksh.idx, _ksh.len);\n"); + w.format(" }\n"); + w.format(" LightProtoCodec.writeTextFormatString(_sb, _ksh.s);\n"); + } else if (keyField.getJavaType().equals("boolean")) { + w.format(" _sb.append(Boolean.toString(_%sKeys[_i]));\n", ccName); + } else if (keyField.getJavaType().equals("long")) { + w.format(" _sb.append(Long.toString(_%sKeys[_i]));\n", ccName); + } else { + w.format(" _sb.append(Integer.toString(_%sKeys[_i]));\n", ccName); + } + w.format(" _sb.append('\\n');\n"); + + // Write value at indent + 1 + if (isMessageValue()) { + w.format(" LightProtoCodec.writeTextFormatIndent(_sb, _indent + 1);\n"); + w.format(" _sb.append(\"value {\\n\");\n"); + w.format(" _%sValues[_i].writeTextFormatTo(_sb, _indent + 2);\n", ccName); + w.format(" LightProtoCodec.writeTextFormatIndent(_sb, _indent + 1);\n"); + w.format(" _sb.append(\"}\\n\");\n"); + } else { + w.format(" LightProtoCodec.writeTextFormatIndent(_sb, _indent + 1);\n"); + w.format(" _sb.append(\"value: \");\n"); + if (isStringValue()) { + w.format(" LightProtoCodec.StringHolder _vsh = _%sValues[_i];\n", ccName); + w.format(" if (_vsh.s == null) {\n"); + w.format(" _vsh.s = LightProtoCodec.readString(_parsedBuffer, _vsh.idx, _vsh.len);\n"); + w.format(" }\n"); + w.format(" LightProtoCodec.writeTextFormatString(_sb, _vsh.s);\n"); + } else if (isBytesValue()) { + w.format(" LightProtoCodec.BytesHolder _vbh = _%sValues[_i];\n", ccName); + w.format(" if (_vbh.idx == -1) {\n"); + w.format(" LightProtoCodec.writeTextFormatBytes(_sb, _vbh.b, 0, _vbh.len);\n"); + w.format(" } else {\n"); + w.format(" LightProtoCodec.writeTextFormatBytes(_sb, _parsedBuffer, _vbh.idx, _vbh.len);\n"); + w.format(" }\n"); + } else if (isEnumValue()) { + w.format(" _sb.append(_%sValues[_i].name());\n", ccName); + } else if (valueField.getProtoType().equals("bool")) { + w.format(" _sb.append(Boolean.toString(_%sValues[_i]));\n", ccName); + } else if (valueField.getProtoType().equals("float")) { + w.format(" LightProtoCodec.writeTextFormatFloat(_sb, _%sValues[_i]);\n", ccName); + } else if (valueField.getProtoType().equals("double")) { + w.format(" LightProtoCodec.writeTextFormatDouble(_sb, _%sValues[_i]);\n", ccName); + } else if (valueField.getJavaType().equals("long")) { + w.format(" _sb.append(Long.toString(_%sValues[_i]));\n", ccName); + } else { + w.format(" _sb.append(Integer.toString(_%sValues[_i]));\n", ccName); + } + w.format(" _sb.append('\\n');\n"); + } + + w.format(" LightProtoCodec.writeTextFormatIndent(_sb, _indent);\n"); + w.format(" _sb.append(\"}\\n\");\n"); + w.format("}\n"); + } + + @Override + public void parseTextFormat(PrintWriter w) { + // Each invocation reads ONE entry: '{ key: ... value: ... }' + w.format(" if (_r.tryConsume(':')) {}\n"); + w.format(" { char _entryClose = _r.consumeMessageOpen();\n"); + + // Declare temp vars for key/value + if (isStringKey()) { + w.format(" String _key = \"\";\n"); + } else if (keyField.getJavaType().equals("boolean")) { + w.format(" boolean _key = false;\n"); + } else if (keyField.getJavaType().equals("long")) { + w.format(" long _key = 0L;\n"); + } else { + w.format(" int _key = 0;\n"); + } + + if (!isMessageValue()) { + if (isStringValue()) { + w.format(" String _val = \"\";\n"); + } else if (isBytesValue()) { + w.format(" byte[] _val = new byte[0];\n"); + } else if (isEnumValue()) { + w.format(" %s _val = null;\n", valueField.getJavaType()); + } else if (valueField.getProtoType().equals("bool")) { + w.format(" boolean _val = false;\n"); + } else if (valueField.getProtoType().equals("float")) { + w.format(" float _val = 0.0f;\n"); + } else if (valueField.getProtoType().equals("double")) { + w.format(" double _val = 0.0;\n"); + } else if (valueField.getJavaType().equals("long")) { + w.format(" long _val = 0L;\n"); + } else { + w.format(" int _val = 0;\n"); + } + } + + if (isMessageValue()) { + // For message values, we can't buffer the inner block — parse directly + // into a freshly-put entry. Maps allow `value` to come before `key`, but + // we resolve that by overwriting the key after. + w.format(" %s _msg = null;\n", valueField.getJavaType()); + } + + w.format(" while (!_r.atFieldsEnd()) {\n"); + w.format(" String _entryFieldName = _r.readIdentifier();\n"); + w.format(" if (_entryFieldName.equals(\"key\")) {\n"); + w.format(" _r.consumeFieldSeparator();\n"); + if (isStringKey()) { + w.format(" _key = _r.readString();\n"); + } else if (keyField.getJavaType().equals("boolean")) { + w.format(" _key = _r.readBool();\n"); + } else if (keyField.getJavaType().equals("long")) { + w.format(" _key = _r.readLong();\n"); + } else { + w.format(" _key = _r.readInt();\n"); + } + w.format(" } else if (_entryFieldName.equals(\"value\")) {\n"); + if (isMessageValue()) { + w.format(" if (_r.tryConsume(':')) {}\n"); + w.format(" _msg = new %s();\n", valueField.getJavaType()); + w.format(" _msg.parseFromTextFormat(_r.buf());\n"); + } else { + w.format(" _r.consumeFieldSeparator();\n"); + if (isStringValue()) { + w.format(" _val = _r.readString();\n"); + } else if (isBytesValue()) { + w.format(" _val = _r.readBytes();\n"); + } else if (isEnumValue()) { + w.format(" _val = %s.valueOf(_r.readIdentifier());\n", valueField.getJavaType()); + } else if (valueField.getProtoType().equals("bool")) { + w.format(" _val = _r.readBool();\n"); + } else if (valueField.getProtoType().equals("float")) { + w.format(" _val = _r.readFloat();\n"); + } else if (valueField.getProtoType().equals("double")) { + w.format(" _val = _r.readDouble();\n"); + } else if (valueField.getJavaType().equals("long")) { + w.format(" _val = _r.readLong();\n"); + } else { + w.format(" _val = _r.readInt();\n"); + } + } + w.format(" } else {\n"); + w.format(" _r.skipValue();\n"); + w.format(" }\n"); + w.format(" _r.skipOptionalSeparator();\n"); + w.format(" }\n"); + w.format(" _r.expect(_entryClose);\n"); + + if (isMessageValue()) { + w.format(" if (_msg != null) { %s(_key).copyFrom(_msg); }\n", + Util.camelCase("put", ccName)); + } else if (isEnumValue()) { + w.format(" if (_val != null) { %s(_key, _val); }\n", Util.camelCase("put", ccName)); + } else { + w.format(" %s(_key, _val);\n", Util.camelCase("put", ccName)); + } + w.format(" }\n"); + } + @Override public void serialize(PrintWriter w) { w.format("for (int _i = 0; _i < _%sCount; _i++) {\n", ccName); diff --git a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoMessage.java b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoMessage.java index 7f3d5ff..832b92d 100644 --- a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoMessage.java +++ b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoMessage.java @@ -26,6 +26,7 @@ public class LightProtoMessage { private final ProtoMessageDescriptor message; private final boolean isNested; + private final boolean generateTextFormat; private final List enums; private final List fields; private final List nestedMessages; @@ -33,10 +34,15 @@ public class LightProtoMessage { private final Map> oneofFields; public LightProtoMessage(ProtoMessageDescriptor message, boolean isNested) { + this(message, isNested, false); + } + + public LightProtoMessage(ProtoMessageDescriptor message, boolean isNested, boolean generateTextFormat) { this.message = message; this.isNested = isNested; + this.generateTextFormat = generateTextFormat; this.enums = message.getNestedEnumGroups().stream().map(LightProtoEnum::new).collect(Collectors.toList()); - this.nestedMessages = message.getNestedMessages().stream().map(m -> new LightProtoMessage(m, true)).collect(Collectors.toList()); + this.nestedMessages = message.getNestedMessages().stream().map(m -> new LightProtoMessage(m, true, generateTextFormat)).collect(Collectors.toList()); this.oneofs = message.getOneofs(); this.fields = new ArrayList<>(); @@ -84,6 +90,10 @@ public void generate(PrintWriter w) { generateCopyFrom(w); generateParseFromJson(w); + if (generateTextFormat) { + generateWriteTextFormatTo(w); + generateParseFromTextFormat(w); + } generateMaterialize(w); generateEquals(w); generateHashCode(w); @@ -354,6 +364,95 @@ private void generateParseFromJson(PrintWriter w) { w.format(" }\n"); } + private void generateWriteTextFormatTo(PrintWriter w) { + w.println(" /**"); + w.println(" * Serialize this message in protobuf canonical TextFormat (multi-line, indented),"); + w.println(" * compatible with what {@code com.google.protobuf.TextFormat.printer()} produces."); + w.println(" */"); + w.println(" public void writeTextFormatTo(StringBuilder _sb, int _indent) {"); + + for (LightProtoField f : fields) { + String condition = f.serializeCondition(); + // Repeated/map fields handle their own iteration and write nothing when empty. + String guard = condition; + if (guard == null && f.isRepeated()) { + if (f instanceof LightProtoMapField) { + guard = "_" + f.ccName + "Count > 0"; + } else if (f instanceof LightProtoAbstractRepeated repeated) { + guard = "_" + repeated.pluralName + "Count > 0"; + } + } + if (guard != null) { + w.format(" if (%s) {\n", guard); + } + f.serializeTextFormat(w); + if (guard != null) { + w.format(" }\n"); + } + } + + w.println(" }"); + + w.println(" /** Serialize this message to a protobuf TextFormat string. */"); + w.println(" public String toTextFormat() {"); + w.println(" StringBuilder _sb = new StringBuilder();"); + w.println(" writeTextFormatTo(_sb, 0);"); + w.println(" return _sb.toString();"); + w.println(" }"); + + w.println(" /** Serialize this message to the given StringBuilder in protobuf TextFormat. */"); + w.println(" public void writeTextFormatTo(StringBuilder _sb) {"); + w.println(" writeTextFormatTo(_sb, 0);"); + w.println(" }"); + } + + private void generateParseFromTextFormat(PrintWriter w) { + w.println(" /** Parse this message from a protobuf TextFormat string. */"); + w.println(" public void parseFromTextFormat(String _text) {"); + w.println(" parseFromTextFormat(_text.getBytes(java.nio.charset.StandardCharsets.UTF_8));"); + w.println(" }"); + + w.println(" /** Parse this message from a UTF-8 encoded protobuf TextFormat byte array. */"); + w.println(" public void parseFromTextFormat(byte[] _bytes) {"); + w.println(" parseFromTextFormat(io.netty.buffer.Unpooled.wrappedBuffer(_bytes));"); + w.println(" }"); + + w.println(" /**"); + w.println(" * Parse this message from a TextFormat-encoded {@link io.netty.buffer.ByteBuf}."); + w.println(" * Accepts both top-level (no enclosing braces) and sub-message (wrapped in {..} or <..>)"); + w.println(" * encodings; this lets messages from different generated packages share the cursor."); + w.println(" */"); + w.println(" public void parseFromTextFormat(io.netty.buffer.ByteBuf _b) {"); + w.println(" clear();"); + w.println(" LightProtoCodec.TextFormatReader _r = new LightProtoCodec.TextFormatReader(_b);"); + w.println(" if (_r.atMessageStart()) {"); + w.println(" char _close = _r.consumeMessageOpen();"); + w.println(" _parseTextFormatMessage(_r);"); + w.println(" _r.expect(_close);"); + w.println(" } else {"); + w.println(" _parseTextFormatMessage(_r);"); + w.println(" }"); + w.println(" }"); + + w.format(" void _parseTextFormatMessage(LightProtoCodec.TextFormatReader _r) {\n"); + w.format(" while (!_r.atFieldsEnd()) {\n"); + w.format(" String _fieldName = _r.readIdentifier();\n"); + w.format(" switch (_fieldName) {\n"); + + for (LightProtoField f : fields) { + w.format(" case \"%s\":\n", f.field.getName()); + f.parseTextFormat(w); + w.format(" break;\n"); + } + + w.format(" default:\n"); + w.format(" _r.skipValue();\n"); + w.format(" }\n"); + w.format(" _r.skipOptionalSeparator();\n"); + w.format(" }\n"); + w.format(" }\n"); + } + private void generateBitFields(PrintWriter w) { for (int i = 0; i < bitFieldsCount(); i++) { w.format("private int _bitField%d;\n", i); diff --git a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoMessageField.java b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoMessageField.java index aac8501..24b0f90 100644 --- a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoMessageField.java +++ b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoMessageField.java @@ -86,6 +86,21 @@ public void parseJson(PrintWriter w) { w.format(" %s().parseFromJson(_r.buf());\n", Util.camelCase("set", ccName)); } + @Override + public void serializeTextFormat(PrintWriter w) { + w.format("LightProtoCodec.writeTextFormatIndent(_sb, _indent);\n"); + w.format("_sb.append(\"%s {\\n\");\n", field.getName()); + w.format("%s.writeTextFormatTo(_sb, _indent + 1);\n", ccName); + w.format("LightProtoCodec.writeTextFormatIndent(_sb, _indent);\n"); + w.format("_sb.append(\"}\\n\");\n"); + } + + @Override + public void parseTextFormat(PrintWriter w) { + w.format(" if (_r.tryConsume(':')) {}\n"); + w.format(" %s().parseFromTextFormat(_r.buf());\n", Util.camelCase("set", ccName)); + } + @Override public void serialize(PrintWriter w) { w.format("%s;\n", writeTagExpr(tagName())); diff --git a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoNumberField.java b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoNumberField.java index 87502d6..f857238 100644 --- a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoNumberField.java +++ b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoNumberField.java @@ -224,6 +224,40 @@ public void parseJson(PrintWriter w) { } } + @Override + public void serializeTextFormat(PrintWriter w) { + w.format("LightProtoCodec.writeTextFormatIndent(_sb, _indent);\n"); + w.format("_sb.append(\"%s: \");\n", field.getName()); + String type = field.getProtoType(); + if (type.equals("float")) { + w.format("LightProtoCodec.writeTextFormatFloat(_sb, %s);\n", ccName); + } else if (type.equals("double")) { + w.format("LightProtoCodec.writeTextFormatDouble(_sb, %s);\n", ccName); + } else if (type.equals("int64") || type.equals("uint64") || type.equals("sint64") + || type.equals("fixed64") || type.equals("sfixed64")) { + w.format("_sb.append(Long.toString(%s));\n", ccName); + } else { + w.format("_sb.append(Integer.toString(%s));\n", ccName); + } + w.format("_sb.append('\\n');\n"); + } + + @Override + public void parseTextFormat(PrintWriter w) { + w.format(" _r.consumeFieldSeparator();\n"); + String type = field.getProtoType(); + if (type.equals("float")) { + w.format(" %s(_r.readFloat());\n", Util.camelCase("set", field.getName())); + } else if (type.equals("double")) { + w.format(" %s(_r.readDouble());\n", Util.camelCase("set", field.getName())); + } else if (type.equals("int64") || type.equals("uint64") || type.equals("sint64") + || type.equals("fixed64") || type.equals("sfixed64")) { + w.format(" %s(_r.readLong());\n", Util.camelCase("set", field.getName())); + } else { + w.format(" %s(_r.readInt());\n", Util.camelCase("set", field.getName())); + } + } + @Override public void setter(PrintWriter w, String enclosingType) { w.format("/** Set the {@code %s} field. */\n", field.getName()); diff --git a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoRepeatedBytesField.java b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoRepeatedBytesField.java index f7f1070..5a19aa4 100644 --- a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoRepeatedBytesField.java +++ b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoRepeatedBytesField.java @@ -122,6 +122,37 @@ public void parseJson(PrintWriter w) { w.format(" }\n"); } + @Override + public void serializeTextFormat(PrintWriter w) { + w.format("for (int i = 0; i < _%sCount; i++) {\n", pluralName); + w.format(" LightProtoCodec.BytesHolder _bh = %s[i];\n", pluralName); + w.format(" LightProtoCodec.writeTextFormatIndent(_sb, _indent);\n"); + w.format(" _sb.append(\"%s: \");\n", field.getName()); + w.format(" if (_bh.idx == -1) {\n"); + w.format(" LightProtoCodec.writeTextFormatBytes(_sb, _bh.b, 0, _bh.len);\n"); + w.format(" } else {\n"); + w.format(" LightProtoCodec.writeTextFormatBytes(_sb, _parsedBuffer, _bh.idx, _bh.len);\n"); + w.format(" }\n"); + w.format(" _sb.append('\\n');\n"); + w.format("}\n"); + } + + @Override + public void parseTextFormat(PrintWriter w) { + w.format(" _r.consumeFieldSeparator();\n"); + w.format(" if (_r.atArrayStart()) {\n"); + w.format(" _r.expect('[');\n"); + w.format(" if (!_r.tryConsume(']')) {\n"); + w.format(" do {\n"); + w.format(" %s(_r.readBytes());\n", Util.camelCase("add", singularName)); + w.format(" } while (_r.tryConsume(','));\n"); + w.format(" _r.expect(']');\n"); + w.format(" }\n"); + w.format(" } else {\n"); + w.format(" %s(_r.readBytes());\n", Util.camelCase("add", singularName)); + w.format(" }\n"); + } + @Override public void setter(PrintWriter w, String enclosingType) { w.format("/** Adds a value to the {@code %s} list from a byte array. */\n", field.getName()); diff --git a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoRepeatedEnumField.java b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoRepeatedEnumField.java index e9e1787..4af9879 100644 --- a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoRepeatedEnumField.java +++ b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoRepeatedEnumField.java @@ -39,6 +39,37 @@ public void hashCodeCode(PrintWriter w) { w.format("}\n"); } + @Override + public void serializeTextFormat(PrintWriter w) { + w.format("for (int i = 0; i < _%sCount; i++) {\n", pluralName); + w.format(" LightProtoCodec.writeTextFormatIndent(_sb, _indent);\n"); + w.format(" _sb.append(\"%s: \").append(%s[i].name()).append('\\n');\n", + field.getName(), pluralName); + w.format("}\n"); + } + + @Override + public void parseTextFormat(PrintWriter w) { + w.format(" _r.consumeFieldSeparator();\n"); + w.format(" if (_r.atArrayStart()) {\n"); + w.format(" _r.expect('[');\n"); + w.format(" if (!_r.tryConsume(']')) {\n"); + w.format(" do {\n"); + w.format(" { %s _v = %s.valueOf(_r.readIdentifier());\n", + field.getJavaType(), field.getJavaType()); + w.format(" if (_v != null) { %s(_v); } }\n", + Util.camelCase("add", singularName)); + w.format(" } while (_r.tryConsume(','));\n"); + w.format(" _r.expect(']');\n"); + w.format(" }\n"); + w.format(" } else {\n"); + w.format(" { %s _v = %s.valueOf(_r.readIdentifier());\n", + field.getJavaType(), field.getJavaType()); + w.format(" if (_v != null) { %s(_v); } }\n", + Util.camelCase("add", singularName)); + w.format(" }\n"); + } + public void parsePacked(PrintWriter w) { w.format("int _%s = LightProtoCodec.readVarInt(_buffer);\n", Util.camelCase(singularName, "size")); w.format("int _%s = _buffer.readerIndex() + _%s;\n", Util.camelCase(singularName, "endIdx"), Util.camelCase(singularName, "size")); diff --git a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoRepeatedMessageField.java b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoRepeatedMessageField.java index 81055a8..513c720 100644 --- a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoRepeatedMessageField.java +++ b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoRepeatedMessageField.java @@ -101,6 +101,23 @@ public void parseJson(PrintWriter w) { w.format(" }\n"); } + @Override + public void serializeTextFormat(PrintWriter w) { + w.format("for (int i = 0; i < _%sCount; i++) {\n", pluralName); + w.format(" LightProtoCodec.writeTextFormatIndent(_sb, _indent);\n"); + w.format(" _sb.append(\"%s {\\n\");\n", field.getName()); + w.format(" %s[i].writeTextFormatTo(_sb, _indent + 1);\n", pluralName); + w.format(" LightProtoCodec.writeTextFormatIndent(_sb, _indent);\n"); + w.format(" _sb.append(\"}\\n\");\n"); + w.format("}\n"); + } + + @Override + public void parseTextFormat(PrintWriter w) { + w.format(" if (_r.tryConsume(':')) {}\n"); + w.format(" %s().parseFromTextFormat(_r.buf());\n", Util.camelCase("add", singularName)); + } + @Override public void copy(PrintWriter w) { w.format("for (int i = 0; i < _other.%s(); i++) {\n", Util.camelCase("get", pluralName, "count")); diff --git a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoRepeatedNumberField.java b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoRepeatedNumberField.java index 9610255..35083cb 100644 --- a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoRepeatedNumberField.java +++ b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoRepeatedNumberField.java @@ -163,6 +163,61 @@ public void parseJson(PrintWriter w) { w.format(" }\n"); } + @Override + public void serializeTextFormat(PrintWriter w) { + String type = field.getProtoType(); + w.format("for (int i = 0; i < _%sCount; i++) {\n", pluralName); + w.format(" %s _item = %s[i];\n", field.getJavaType(), pluralName); + w.format(" LightProtoCodec.writeTextFormatIndent(_sb, _indent);\n"); + w.format(" _sb.append(\"%s: \");\n", field.getName()); + if (type.equals("bool")) { + w.format(" _sb.append(Boolean.toString(_item));\n"); + } else if (type.equals("float")) { + w.format(" LightProtoCodec.writeTextFormatFloat(_sb, _item);\n"); + } else if (type.equals("double")) { + w.format(" LightProtoCodec.writeTextFormatDouble(_sb, _item);\n"); + } else if (type.equals("int64") || type.equals("uint64") || type.equals("sint64") + || type.equals("fixed64") || type.equals("sfixed64")) { + w.format(" _sb.append(Long.toString(_item));\n"); + } else { + w.format(" _sb.append(Integer.toString(_item));\n"); + } + w.format(" _sb.append('\\n');\n"); + w.format("}\n"); + } + + @Override + public void parseTextFormat(PrintWriter w) { + String type = field.getProtoType(); + w.format(" _r.consumeFieldSeparator();\n"); + w.format(" if (_r.atArrayStart()) {\n"); + w.format(" _r.expect('[');\n"); + w.format(" if (!_r.tryConsume(']')) {\n"); + w.format(" do {\n"); + emitParseTextFormatScalarAdd(w, type, " "); + w.format(" } while (_r.tryConsume(','));\n"); + w.format(" _r.expect(']');\n"); + w.format(" }\n"); + w.format(" } else {\n"); + emitParseTextFormatScalarAdd(w, type, " "); + w.format(" }\n"); + } + + private void emitParseTextFormatScalarAdd(PrintWriter w, String type, String indent) { + if (type.equals("bool")) { + w.format("%s%s(_r.readBool());\n", indent, Util.camelCase("add", singularName)); + } else if (type.equals("float")) { + w.format("%s%s(_r.readFloat());\n", indent, Util.camelCase("add", singularName)); + } else if (type.equals("double")) { + w.format("%s%s(_r.readDouble());\n", indent, Util.camelCase("add", singularName)); + } else if (type.equals("int64") || type.equals("uint64") || type.equals("sint64") + || type.equals("fixed64") || type.equals("sfixed64")) { + w.format("%s%s(_r.readLong());\n", indent, Util.camelCase("add", singularName)); + } else { + w.format("%s%s(_r.readInt());\n", indent, Util.camelCase("add", singularName)); + } + } + @Override public void setter(PrintWriter w, String enclosingType) { w.format("/** Adds a value to the {@code %s} list. */\n", field.getName()); diff --git a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoRepeatedStringField.java b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoRepeatedStringField.java index 475fcff..eae88d4 100644 --- a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoRepeatedStringField.java +++ b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoRepeatedStringField.java @@ -122,6 +122,33 @@ public void parseJson(PrintWriter w) { w.format(" }\n"); } + @Override + public void serializeTextFormat(PrintWriter w) { + w.format("for (int i = 0; i < _%sCount; i++) {\n", pluralName); + w.format(" LightProtoCodec.writeTextFormatIndent(_sb, _indent);\n"); + w.format(" _sb.append(\"%s: \");\n", field.getName()); + w.format(" LightProtoCodec.writeTextFormatString(_sb, %s(i));\n", + Util.camelCase("get", singularName, "at")); + w.format(" _sb.append('\\n');\n"); + w.format("}\n"); + } + + @Override + public void parseTextFormat(PrintWriter w) { + w.format(" _r.consumeFieldSeparator();\n"); + w.format(" if (_r.atArrayStart()) {\n"); + w.format(" _r.expect('[');\n"); + w.format(" if (!_r.tryConsume(']')) {\n"); + w.format(" do {\n"); + w.format(" %s(_r.readString());\n", Util.camelCase("add", singularName)); + w.format(" } while (_r.tryConsume(','));\n"); + w.format(" _r.expect(']');\n"); + w.format(" }\n"); + w.format(" } else {\n"); + w.format(" %s(_r.readString());\n", Util.camelCase("add", singularName)); + w.format(" }\n"); + } + @Override public void copy(PrintWriter w) { w.format("for (int i = 0; i < _other.%s(); i++) {\n", Util.camelCase("get", pluralName, "count")); diff --git a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoStringField.java b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoStringField.java index f0f82f1..0c41578 100644 --- a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoStringField.java +++ b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoStringField.java @@ -127,6 +127,20 @@ public void parseJson(PrintWriter w) { w.format(" %s(_r.readString());\n", Util.camelCase("set", field.getName())); } + @Override + public void serializeTextFormat(PrintWriter w) { + w.format("LightProtoCodec.writeTextFormatIndent(_sb, _indent);\n"); + w.format("_sb.append(\"%s: \");\n", field.getName()); + w.format("LightProtoCodec.writeTextFormatString(_sb, %s());\n", Util.camelCase("get", field.getName())); + w.format("_sb.append('\\n');\n"); + } + + @Override + public void parseTextFormat(PrintWriter w) { + w.format(" _r.consumeFieldSeparator();\n"); + w.format(" %s(_r.readString());\n", Util.camelCase("set", field.getName())); + } + @Override public void parse(PrintWriter w) { w.format("_%sBufferLen = LightProtoCodec.readVarInt(_buffer);\n", ccName); diff --git a/code-generator/src/main/resources/io/streamnative/lightproto/generator/LightProtoCodec.java b/code-generator/src/main/resources/io/streamnative/lightproto/generator/LightProtoCodec.java index 922c758..c068755 100644 --- a/code-generator/src/main/resources/io/streamnative/lightproto/generator/LightProtoCodec.java +++ b/code-generator/src/main/resources/io/streamnative/lightproto/generator/LightProtoCodec.java @@ -930,4 +930,557 @@ boolean isEof() { return readable() <= 0; } } + + // ==================== TextFormat serialization helpers ==================== + + interface LightProtoTextFormatMessage { + void writeTextFormatTo(StringBuilder sb, int indent); + } + + static void writeTextFormatIndent(StringBuilder sb, int indent) { + for (int i = 0; i < indent; i++) { + sb.append(" "); + } + } + + static void writeTextFormatString(StringBuilder sb, String s) { + sb.append('"'); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + appendTextFormatEscapedChar(sb, c); + } + sb.append('"'); + } + + static void writeTextFormatBytes(StringBuilder sb, ByteBuf data, int offset, int len) { + sb.append('"'); + for (int i = 0; i < len; i++) { + int b = data.getByte(offset + i) & 0xFF; + appendTextFormatEscapedByte(sb, b); + } + sb.append('"'); + } + + private static void appendTextFormatEscapedChar(StringBuilder sb, char c) { + switch (c) { + case '\b': sb.append("\\b"); return; + case '\f': sb.append("\\f"); return; + case '\n': sb.append("\\n"); return; + case '\r': sb.append("\\r"); return; + case '\t': sb.append("\\t"); return; + case '\\': sb.append("\\\\"); return; + case '\'': sb.append("\\'"); return; + case '"': sb.append("\\\""); return; + default: + if (c >= 0x20 && c < 0x7F) { + sb.append(c); + } else { + // Encode as UTF-8 escape sequences (\xNN per byte) + byte[] enc = String.valueOf(c).getBytes(java.nio.charset.StandardCharsets.UTF_8); + for (byte b : enc) { + appendOctalEscape(sb, b & 0xFF); + } + } + } + } + + private static void appendTextFormatEscapedByte(StringBuilder sb, int b) { + switch (b) { + case '\b': sb.append("\\b"); return; + case '\f': sb.append("\\f"); return; + case '\n': sb.append("\\n"); return; + case '\r': sb.append("\\r"); return; + case '\t': sb.append("\\t"); return; + case '\\': sb.append("\\\\"); return; + case '\'': sb.append("\\'"); return; + case '"': sb.append("\\\""); return; + default: + if (b >= 0x20 && b < 0x7F) { + sb.append((char) b); + } else { + appendOctalEscape(sb, b); + } + } + } + + private static void appendOctalEscape(StringBuilder sb, int b) { + sb.append('\\'); + sb.append((char) ('0' + ((b >> 6) & 0x3))); + sb.append((char) ('0' + ((b >> 3) & 0x7))); + sb.append((char) ('0' + (b & 0x7))); + } + + static void writeTextFormatFloat(StringBuilder sb, float f) { + if (Float.isNaN(f)) { + sb.append("nan"); + } else if (f == Float.POSITIVE_INFINITY) { + sb.append("inf"); + } else if (f == Float.NEGATIVE_INFINITY) { + sb.append("-inf"); + } else { + sb.append(Float.toString(f)); + } + } + + static void writeTextFormatDouble(StringBuilder sb, double d) { + if (Double.isNaN(d)) { + sb.append("nan"); + } else if (d == Double.POSITIVE_INFINITY) { + sb.append("inf"); + } else if (d == Double.NEGATIVE_INFINITY) { + sb.append("-inf"); + } else { + sb.append(Double.toString(d)); + } + } + + // ==================== TextFormat parsing utilities ==================== + + /** + * Lightweight reader for protobuf canonical TextFormat, operating on a {@link ByteBuf} so + * that nested sub-messages from another generated package can advance the same cursor by + * sharing the underlying buffer (mirrors the {@link JsonReader} pattern). + * + *

Handles the syntax produced by protobuf-java's {@code TextFormat.printer()} plus a few + * common variants (angle-bracket sub-messages, single-quoted strings, '#' comments, optional + * commas/semicolons between fields, '[..]' array syntax for repeated values). + */ + static final class TextFormatReader { + private final ByteBuf buf; + + TextFormatReader(ByteBuf buf) { + this.buf = buf; + } + + ByteBuf buf() { return buf; } + + private int readable() { return buf.readableBytes(); } + private byte at(int offset) { return buf.getByte(buf.readerIndex() + offset); } + + void skipWhitespaceAndComments() { + while (readable() > 0) { + byte b = at(0); + if (b == ' ' || b == '\t' || b == '\n' || b == '\r') { + buf.skipBytes(1); + } else if (b == '#') { + while (readable() > 0 && at(0) != '\n') { + buf.skipBytes(1); + } + } else { + break; + } + } + } + + boolean isEof() { + skipWhitespaceAndComments(); + return readable() <= 0; + } + + /** True at EOF or when the next non-whitespace byte is '}' or '>'. */ + boolean atFieldsEnd() { + skipWhitespaceAndComments(); + if (readable() <= 0) return true; + byte b = at(0); + return b == '}' || b == '>'; + } + + /** True when the next non-whitespace byte is '{' or '<' (start of a sub-message body). */ + boolean atMessageStart() { + skipWhitespaceAndComments(); + if (readable() <= 0) return false; + byte b = at(0); + return b == '{' || b == '<'; + } + + boolean tryConsume(char c) { + skipWhitespaceAndComments(); + if (readable() > 0 && at(0) == (byte) c) { + buf.skipBytes(1); + return true; + } + return false; + } + + void expect(char c) { + skipWhitespaceAndComments(); + if (readable() <= 0 || at(0) != (byte) c) { + throw new IllegalArgumentException("Expected '" + c + "' at position " + buf.readerIndex() + + " but found " + (readable() > 0 ? "'" + (char) at(0) + "'" : "end of input")); + } + buf.skipBytes(1); + } + + /** Read an identifier ([a-zA-Z_][a-zA-Z0-9_]*). Used for field names and enum values. */ + String readIdentifier() { + skipWhitespaceAndComments(); + int start = buf.readerIndex(); + if (readable() > 0) { + byte b = at(0); + if (b == '_' || (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z')) { + buf.skipBytes(1); + while (readable() > 0) { + b = at(0); + if (b == '_' || (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') + || (b >= '0' && b <= '9')) { + buf.skipBytes(1); + } else { + break; + } + } + } + } + int end = buf.readerIndex(); + if (end == start) { + throw new IllegalArgumentException("Expected identifier at position " + start); + } + byte[] tmp = new byte[end - start]; + buf.getBytes(start, tmp); + return new String(tmp, java.nio.charset.StandardCharsets.US_ASCII); + } + + /** + * Consume the separator after a field name. Either ':' (always valid) or, when the + * next significant character is '{' or '<' (sub-message), the colon may be omitted. + */ + void consumeFieldSeparator() { + skipWhitespaceAndComments(); + if (readable() > 0) { + byte b = at(0); + if (b == '{' || b == '<') { + return; // colon optional before sub-message + } + } + expect(':'); + } + + /** Consume the message opener ('{' or '<') and return the matching closer character. */ + char consumeMessageOpen() { + skipWhitespaceAndComments(); + if (readable() > 0) { + byte b = at(0); + if (b == '{') { buf.skipBytes(1); return '}'; } + if (b == '<') { buf.skipBytes(1); return '>'; } + } + throw new IllegalArgumentException("Expected '{' or '<' at position " + buf.readerIndex()); + } + + /** Optional separator between fields/elements ('','' or '';''). */ + void skipOptionalSeparator() { + skipWhitespaceAndComments(); + if (readable() > 0) { + byte b = at(0); + if (b == ',' || b == ';') buf.skipBytes(1); + } + } + + boolean atArrayStart() { + skipWhitespaceAndComments(); + return readable() > 0 && at(0) == '['; + } + + /** Read raw bytes of a quoted string (handles concatenation of adjacent strings). */ + byte[] readBytes() { + skipWhitespaceAndComments(); + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + readQuotedBytesInto(out); + // Concatenated string literals: "abc" "def" → "abcdef" + while (true) { + int save = buf.readerIndex(); + skipWhitespaceAndComments(); + if (readable() > 0 && (at(0) == '"' || at(0) == '\'')) { + readQuotedBytesInto(out); + } else { + buf.readerIndex(save); + break; + } + } + return out.toByteArray(); + } + + String readString() { + return new String(readBytes(), java.nio.charset.StandardCharsets.UTF_8); + } + + private void readQuotedBytesInto(java.io.ByteArrayOutputStream out) { + if (readable() <= 0) { + throw new IllegalArgumentException("Expected string at position " + buf.readerIndex()); + } + byte quote = at(0); + if (quote != '"' && quote != '\'') { + throw new IllegalArgumentException("Expected '\"' or '\\'' at position " + buf.readerIndex()); + } + buf.skipBytes(1); + while (readable() > 0) { + byte b = buf.readByte(); + if (b == quote) { + return; + } + if (b == '\\') { + if (readable() <= 0) { + throw new IllegalArgumentException("Unterminated escape"); + } + byte esc = buf.readByte(); + switch (esc) { + case 'a': out.write(0x07); break; + case 'b': out.write(0x08); break; + case 'f': out.write(0x0C); break; + case 'n': out.write(0x0A); break; + case 'r': out.write(0x0D); break; + case 't': out.write(0x09); break; + case 'v': out.write(0x0B); break; + case '\\': out.write('\\'); break; + case '\'': out.write('\''); break; + case '"': out.write('"'); break; + case '?': out.write('?'); break; + case 'x': + case 'X': { + int val = 0; + int n = 0; + while (readable() > 0 && n < 2 && isHexDigit(at(0))) { + val = (val << 4) | hexDigitValue(at(0)); + buf.skipBytes(1); + n++; + } + if (n == 0) { + throw new IllegalArgumentException("Invalid \\x escape"); + } + out.write(val); + break; + } + case 'u': { + int val = readFixedHex(4); + byte[] enc = new String(Character.toChars(val)) + .getBytes(java.nio.charset.StandardCharsets.UTF_8); + out.write(enc, 0, enc.length); + break; + } + case 'U': { + int val = readFixedHex(8); + byte[] enc = new String(Character.toChars(val)) + .getBytes(java.nio.charset.StandardCharsets.UTF_8); + out.write(enc, 0, enc.length); + break; + } + default: + if (esc >= '0' && esc <= '7') { + int val = esc - '0'; + int n = 1; + while (readable() > 0 && n < 3 && at(0) >= '0' && at(0) <= '7') { + val = (val << 3) | (at(0) - '0'); + buf.skipBytes(1); + n++; + } + out.write(val); + } else { + throw new IllegalArgumentException("Invalid escape '\\" + (char) esc + "'"); + } + } + } else { + out.write(b & 0xFF); + } + } + throw new IllegalArgumentException("Unterminated string literal"); + } + + private int readFixedHex(int n) { + int val = 0; + for (int i = 0; i < n; i++) { + if (readable() <= 0 || !isHexDigit(at(0))) { + throw new IllegalArgumentException("Expected " + n + " hex digits"); + } + val = (val << 4) | hexDigitValue(at(0)); + buf.skipBytes(1); + } + return val; + } + + private static boolean isHexDigit(byte b) { + return (b >= '0' && b <= '9') || (b >= 'a' && b <= 'f') || (b >= 'A' && b <= 'F'); + } + + private static int hexDigitValue(byte b) { + if (b >= '0' && b <= '9') return b - '0'; + if (b >= 'a' && b <= 'f') return b - 'a' + 10; + return b - 'A' + 10; + } + + /** Read a raw numeric token (sign, digits, optional 0x prefix, decimal, exponent, suffix). */ + String readNumberToken() { + skipWhitespaceAndComments(); + int start = buf.readerIndex(); + if (readable() > 0 && (at(0) == '-' || at(0) == '+')) { + buf.skipBytes(1); + } + while (readable() > 0) { + byte b = at(0); + if ((b >= '0' && b <= '9') || b == '.' || b == 'e' || b == 'E' + || b == 'x' || b == 'X' || b == '-' || b == '+' + || (b >= 'a' && b <= 'f') || (b >= 'A' && b <= 'F')) { + buf.skipBytes(1); + } else { + break; + } + } + // Optional integer suffix (u, l, U, L) + while (readable() > 0) { + byte b = at(0); + if (b == 'u' || b == 'U' || b == 'l' || b == 'L') { + buf.skipBytes(1); + } else { + break; + } + } + int end = buf.readerIndex(); + if (end == start) { + throw new IllegalArgumentException("Expected number at position " + start); + } + byte[] tmp = new byte[end - start]; + buf.getBytes(start, tmp); + return new String(tmp, java.nio.charset.StandardCharsets.US_ASCII); + } + + int readInt() { + return (int) parseLongToken(readNumberToken()); + } + + long readLong() { + return parseLongToken(readNumberToken()); + } + + private static long parseLongToken(String tok) { + int end = tok.length(); + while (end > 0) { + char c = tok.charAt(end - 1); + if (c == 'u' || c == 'U' || c == 'l' || c == 'L') end--; else break; + } + tok = tok.substring(0, end); + boolean negative = false; + int start = 0; + if (tok.startsWith("-")) { negative = true; start = 1; } + else if (tok.startsWith("+")) { start = 1; } + String body = tok.substring(start); + long val; + if (body.startsWith("0x") || body.startsWith("0X")) { + val = Long.parseUnsignedLong(body.substring(2), 16); + } else if (body.length() > 1 && body.startsWith("0") && allOctalDigits(body)) { + val = Long.parseUnsignedLong(body, 8); + } else { + val = Long.parseUnsignedLong(body, 10); + } + return negative ? -val : val; + } + + private static boolean allOctalDigits(String s) { + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c < '0' || c > '7') return false; + } + return true; + } + + float readFloat() { + return (float) readDouble(); + } + + double readDouble() { + skipWhitespaceAndComments(); + int save = buf.readerIndex(); + boolean negative = false; + if (readable() > 0 && (at(0) == '-' || at(0) == '+')) { + negative = at(0) == '-'; + buf.skipBytes(1); + } + if (readable() > 0) { + byte b = at(0); + if (b == 'n' || b == 'N') { + if (matchKeyword("nan")) return Double.NaN; + } + if (b == 'i' || b == 'I') { + if (matchKeyword("infinity") || matchKeyword("inf")) { + return negative ? Double.NEGATIVE_INFINITY : Double.POSITIVE_INFINITY; + } + } + } + buf.readerIndex(save); + String tok = readNumberToken(); + if (tok.endsWith("f") || tok.endsWith("F")) { + tok = tok.substring(0, tok.length() - 1); + } + return Double.parseDouble(tok); + } + + private boolean matchKeyword(String keyword) { + if (readable() < keyword.length()) return false; + for (int i = 0; i < keyword.length(); i++) { + byte a = at(i); + char b = keyword.charAt(i); + if (Character.toLowerCase((char) a) != b) return false; + } + // Must not be followed by an identifier char + if (readable() > keyword.length()) { + byte c = at(keyword.length()); + if (c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') + || (c >= '0' && c <= '9')) { + return false; + } + } + buf.skipBytes(keyword.length()); + return true; + } + + boolean readBool() { + skipWhitespaceAndComments(); + if (matchKeyword("true") || matchKeyword("t")) return true; + if (matchKeyword("false") || matchKeyword("f")) return false; + String tok = readNumberToken(); + if (tok.equals("1")) return true; + if (tok.equals("0")) return false; + throw new IllegalArgumentException("Expected boolean but found '" + tok + "'"); + } + + /** Skip a single value (scalar, sub-message, or array). Used for unknown fields. */ + void skipValue() { + skipWhitespaceAndComments(); + if (readable() <= 0) return; + byte b = at(0); + if (b == ':') { + buf.skipBytes(1); + skipWhitespaceAndComments(); + if (readable() <= 0) return; + b = at(0); + } + if (b == '{' || b == '<') { + char close = consumeMessageOpen(); + while (!atFieldsEnd()) { + readIdentifier(); + skipWhitespaceAndComments(); + if (readable() > 0 && (at(0) == '{' || at(0) == '<')) { + skipValue(); + } else { + if (readable() > 0 && at(0) == ':') buf.skipBytes(1); + skipValue(); + } + skipOptionalSeparator(); + } + expect(close); + } else if (b == '[') { + buf.skipBytes(1); + if (!tryConsume(']')) { + do { + skipValue(); + } while (tryConsume(',')); + expect(']'); + } + } else if (b == '"' || b == '\'') { + readBytes(); + } else if (b == '-' || b == '+' || (b >= '0' && b <= '9')) { + readNumberToken(); + } else if (b == '_' || (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z')) { + readIdentifier(); + } else { + throw new IllegalArgumentException("Unexpected character '" + (char) b + "' at position " + buf.readerIndex()); + } + } + } } diff --git a/gradle-plugin/src/main/java/io/streamnative/lightproto/gradle/GenerateLightProtoTask.java b/gradle-plugin/src/main/java/io/streamnative/lightproto/gradle/GenerateLightProtoTask.java index ecca997..cc51894 100644 --- a/gradle-plugin/src/main/java/io/streamnative/lightproto/gradle/GenerateLightProtoTask.java +++ b/gradle-plugin/src/main/java/io/streamnative/lightproto/gradle/GenerateLightProtoTask.java @@ -46,6 +46,9 @@ public abstract class GenerateLightProtoTask extends DefaultTask { @Input public abstract Property getSingleOuterClass(); + @Input + public abstract Property getGenerateTextFormat(); + @Input public abstract Property getProtocVersion(); @@ -144,7 +147,7 @@ public void generate() { } LightProtoGenerator.generate(descriptors, outputDir, getClassPrefix().get(), - getSingleOuterClass().get(), fileNames); + getSingleOuterClass().get(), fileNames, getGenerateTextFormat().get()); } catch (GradleException e) { throw e; } catch (Exception e) { diff --git a/gradle-plugin/src/main/java/io/streamnative/lightproto/gradle/LightProtoExtension.java b/gradle-plugin/src/main/java/io/streamnative/lightproto/gradle/LightProtoExtension.java index 387ef5d..39ee205 100644 --- a/gradle-plugin/src/main/java/io/streamnative/lightproto/gradle/LightProtoExtension.java +++ b/gradle-plugin/src/main/java/io/streamnative/lightproto/gradle/LightProtoExtension.java @@ -25,6 +25,8 @@ public abstract class LightProtoExtension { public abstract Property getSingleOuterClass(); + public abstract Property getGenerateTextFormat(); + public abstract Property getProtocVersion(); public abstract Property getProtocPath(); diff --git a/gradle-plugin/src/main/java/io/streamnative/lightproto/gradle/LightProtoPlugin.java b/gradle-plugin/src/main/java/io/streamnative/lightproto/gradle/LightProtoPlugin.java index 743e1cd..8f065ec 100644 --- a/gradle-plugin/src/main/java/io/streamnative/lightproto/gradle/LightProtoPlugin.java +++ b/gradle-plugin/src/main/java/io/streamnative/lightproto/gradle/LightProtoPlugin.java @@ -34,6 +34,7 @@ public void apply(Project project) { extension.getClassPrefix().convention(""); extension.getSingleOuterClass().convention(false); + extension.getGenerateTextFormat().convention(false); extension.getProtocVersion().convention("4.34.0"); registerTaskForSourceSet(project, extension, "main"); @@ -51,6 +52,7 @@ private void registerTaskForSourceSet(Project project, LightProtoExtension exten project.getTasks().register(taskName, GenerateLightProtoTask.class, task -> { task.getClassPrefix().set(extension.getClassPrefix()); task.getSingleOuterClass().set(extension.getSingleOuterClass()); + task.getGenerateTextFormat().set(extension.getGenerateTextFormat()); task.getProtocVersion().set(extension.getProtocVersion()); task.getProtocPath().set(extension.getProtocPath()); task.getExtraProtoPaths().from(extension.getExtraProtoPaths()); diff --git a/maven-plugin/src/main/java/io/streamnative/lightproto/maven/plugin/LightProtoMojo.java b/maven-plugin/src/main/java/io/streamnative/lightproto/maven/plugin/LightProtoMojo.java index 1c4708a..6177c53 100644 --- a/maven-plugin/src/main/java/io/streamnative/lightproto/maven/plugin/LightProtoMojo.java +++ b/maven-plugin/src/main/java/io/streamnative/lightproto/maven/plugin/LightProtoMojo.java @@ -55,6 +55,9 @@ public class LightProtoMojo extends AbstractMojo { @Parameter(property = "singleOuterClass", defaultValue = "false", required = false) private boolean singleOuterClass; + @Parameter(property = "generateTextFormat", defaultValue = "false", required = false) + private boolean generateTextFormat; + @Parameter(property = "sources", required = false) private List sources; @@ -159,7 +162,7 @@ private void generate(File protoDir, List protoFiles, File outputDirectory } } - LightProtoGenerator.generate(descriptors, outputDirectory, classPrefix, singleOuterClass, fileNames); + LightProtoGenerator.generate(descriptors, outputDirectory, classPrefix, singleOuterClass, fileNames, generateTextFormat); } catch (MojoExecutionException e) { throw e; } catch (Exception e) { diff --git a/tests/pom.xml b/tests/pom.xml index 6a677eb..8ecc21b 100644 --- a/tests/pom.xml +++ b/tests/pom.xml @@ -99,6 +99,9 @@ ${project.groupId} lightproto-maven-plugin ${project.version} + + true + diff --git a/tests/src/test/java/io/streamnative/lightproto/tests/TextFormatTest.java b/tests/src/test/java/io/streamnative/lightproto/tests/TextFormatTest.java new file mode 100644 index 0000000..d40cb78 --- /dev/null +++ b/tests/src/test/java/io/streamnative/lightproto/tests/TextFormatTest.java @@ -0,0 +1,311 @@ +/** + * Copyright 2026 StreamNative + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.streamnative.lightproto.tests; + +import com.google.protobuf.TextFormat; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests that the generated TextFormat (de)serialization is wire-compatible with + * {@link com.google.protobuf.TextFormat} from protobuf-java. + */ +public class TextFormatTest { + + @Test + public void numbersRoundTripThroughProtobufTextFormat() throws Exception { + Numbers lp = new Numbers() + .setXInt32(42) + .setXInt64(123456789L) + .setXUint32(100) + .setXUint64(200L) + .setXSint32(-50) + .setXSint64(-100L) + .setXFixed32(1000) + .setXFixed64(2000L) + .setXSfixed32(-1000) + .setXSfixed64(-2000L) + .setXFloat(3.14f) + .setXDouble(2.71828) + .setXBool(true) + .setEnum1(Enum1.X1_1) + .setEnum2(Numbers.Enum2.X2_2); + + String text = lp.toTextFormat(); + + // protobuf-java parses what LightProto wrote + NumbersOuterClass.Numbers.Builder b = NumbersOuterClass.Numbers.newBuilder(); + TextFormat.merge(text, b); + NumbersOuterClass.Numbers pb = b.build(); + + assertEquals(42, pb.getXInt32()); + assertEquals(123456789L, pb.getXInt64()); + assertEquals(100, pb.getXUint32()); + assertEquals(200L, pb.getXUint64()); + assertEquals(-50, pb.getXSint32()); + assertEquals(-100L, pb.getXSint64()); + assertEquals(1000, pb.getXFixed32()); + assertEquals(2000L, pb.getXFixed64()); + assertEquals(-1000, pb.getXSfixed32()); + assertEquals(-2000L, pb.getXSfixed64()); + assertEquals(3.14f, pb.getXFloat()); + assertEquals(2.71828, pb.getXDouble()); + assertTrue(pb.getXBool()); + assertEquals(NumbersOuterClass.Enum1.X1_1, pb.getEnum1()); + assertEquals(NumbersOuterClass.Numbers.Enum2.X2_2, pb.getEnum2()); + + // LightProto parses what protobuf-java wrote + String pbText = TextFormat.printer().printToString(pb); + Numbers parsed = new Numbers(); + parsed.parseFromTextFormat(pbText); + assertEquals(lp, parsed); + } + + @Test + public void stringRoundTripWithEscapes() throws Exception { + S lp = new S(); + lp.setId("hello \"world\"\nnew\tline\\slash"); + lp.addName("alice"); + lp.addName("bob"); + + String text = lp.toTextFormat(); + + Strings.S.Builder b = Strings.S.newBuilder(); + TextFormat.merge(text, b); + Strings.S pb = b.build(); + + assertEquals("hello \"world\"\nnew\tline\\slash", pb.getId()); + assertEquals(2, pb.getNamesCount()); + assertEquals("alice", pb.getNames(0)); + assertEquals("bob", pb.getNames(1)); + + // Reverse direction + String pbText = TextFormat.printer().printToString(pb); + S parsed = new S(); + parsed.parseFromTextFormat(pbText); + assertEquals(lp, parsed); + } + + @Test + public void utf8StringRoundTrip() throws Exception { + S lp = new S(); + lp.setId("café — naïve 日本語"); + + String text = lp.toTextFormat(); + + Strings.S.Builder b = Strings.S.newBuilder(); + TextFormat.merge(text, b); + assertEquals("café — naïve 日本語", b.build().getId()); + + S parsed = new S(); + parsed.parseFromTextFormat(TextFormat.printer().printToString(b.build())); + assertEquals("café — naïve 日本語", parsed.getId()); + } + + @Test + public void nestedMessagesAndRepeated() throws Exception { + M lp = new M(); + lp.setX().setA("value-a").setB("value-b"); + lp.addItem().setK("key1").setV("val1"); + lp.addItem().setK("key2").setV("val2").setXx().setN(42); + + String text = lp.toTextFormat(); + + Messages.M.Builder b = Messages.M.newBuilder(); + TextFormat.merge(text, b); + Messages.M pb = b.build(); + + assertEquals("value-a", pb.getX().getA()); + assertEquals("value-b", pb.getX().getB()); + assertEquals(2, pb.getItemsCount()); + assertEquals("key1", pb.getItems(0).getK()); + assertEquals("val1", pb.getItems(0).getV()); + assertEquals("key2", pb.getItems(1).getK()); + assertEquals("val2", pb.getItems(1).getV()); + assertEquals(42, pb.getItems(1).getXx().getN()); + + // Reverse direction + M parsed = new M(); + parsed.parseFromTextFormat(TextFormat.printer().printToString(pb)); + assertEquals(lp, parsed); + } + + @Test + public void mapsRoundTrip() throws Exception { + MapMessage lp = new MapMessage(); + lp.putStringToInt("a", 1); + lp.putStringToInt("b", 2); + lp.putIntToString(10, "ten"); + lp.putIntToString(20, "twenty"); + lp.putStringToDouble("pi", 3.14); + lp.setName("test-map"); + + String text = lp.toTextFormat(); + + MapsProtos.MapMessage.Builder b = MapsProtos.MapMessage.newBuilder(); + TextFormat.merge(text, b); + MapsProtos.MapMessage pb = b.build(); + + assertEquals(1, pb.getStringToIntOrThrow("a")); + assertEquals(2, pb.getStringToIntOrThrow("b")); + assertEquals("ten", pb.getIntToStringOrThrow(10)); + assertEquals("twenty", pb.getIntToStringOrThrow(20)); + assertEquals(3.14, pb.getStringToDoubleOrThrow("pi"), 0.001); + assertEquals("test-map", pb.getName()); + + MapMessage parsed = new MapMessage(); + parsed.parseFromTextFormat(TextFormat.printer().printToString(pb)); + assertEquals(lp, parsed); + } + + @Test + public void bytesRoundTrip() throws Exception { + B lp = new B(); + lp.setPayload(new byte[]{0, 1, 2, (byte) 0xFF, (byte) 0x80, 'a', 'b', '\n', '\\'}); + + String text = lp.toTextFormat(); + + Bytes.B.Builder b = Bytes.B.newBuilder(); + TextFormat.merge(text, b); + assertArrayEquals(new byte[]{0, 1, 2, (byte) 0xFF, (byte) 0x80, 'a', 'b', '\n', '\\'}, + b.build().getPayload().toByteArray()); + + B parsed = new B(); + parsed.parseFromTextFormat(TextFormat.printer().printToString(b.build())); + assertArrayEquals(lp.getPayload(), parsed.getPayload()); + } + + @Test + public void emptyMessageProducesEmptyText() throws Exception { + Numbers n = new Numbers(); + assertEquals("", n.toTextFormat()); + + Numbers parsed = new Numbers(); + parsed.parseFromTextFormat(""); + assertEquals(n, parsed); + } + + @Test + public void specialFloatValues() throws Exception { + Numbers lp = new Numbers() + .setXFloat(Float.NaN) + .setXDouble(Double.POSITIVE_INFINITY); + + String text = lp.toTextFormat(); + assertTrue(text.contains("nan")); + assertTrue(text.contains("inf")); + + NumbersOuterClass.Numbers.Builder b = NumbersOuterClass.Numbers.newBuilder(); + TextFormat.merge(text, b); + assertTrue(Float.isNaN(b.build().getXFloat())); + assertEquals(Double.POSITIVE_INFINITY, b.build().getXDouble()); + + // Reverse + Numbers parsed = new Numbers(); + parsed.parseFromTextFormat(TextFormat.printer().printToString(b.build())); + assertTrue(Float.isNaN(parsed.getXFloat())); + assertEquals(Double.POSITIVE_INFINITY, parsed.getXDouble()); + } + + @Test + public void parserAcceptsAngleBracketSubMessages() throws Exception { + // protobuf TextFormat allows '<...>' as an alternative to '{...}' for sub-messages + String text = "x < a: \"hi\" b: \"there\" >"; + + M parsed = new M(); + parsed.parseFromTextFormat(text); + + assertEquals("hi", parsed.getX().getA()); + assertEquals("there", parsed.getX().getB()); + } + + @Test + public void parserAcceptsCommentsAndArraySyntax() throws Exception { + // Comments with '#', and '[1,2,3]' array syntax for repeated values + String text = + "# leading comment\n" + + "x_int32: 1\n" + + "# inline comment about the next field\n" + + "x_int64: 2\n"; + + Numbers parsed = new Numbers(); + parsed.parseFromTextFormat(text); + assertEquals(1, parsed.getXInt32()); + assertEquals(2L, parsed.getXInt64()); + } + + @Test + public void parserSkipsUnknownFields() throws Exception { + String text = + "id: \"keep\"\n" + + "unknown_field: 42\n" + + "another_unknown { nested: \"x\" repeated: 1 repeated: 2 }\n" + + "names: \"a\"\n"; + + S parsed = new S(); + parsed.parseFromTextFormat(text); + assertEquals("keep", parsed.getId()); + assertEquals(1, parsed.getNamesCount()); + assertEquals("a", parsed.getNameAt(0)); + } + + @Test + public void crossPackageMessageRoundTrip() throws Exception { + // Importer.Container has a field of type imported.SharedItem from another package — + // exercises the cross-package ByteBuf cursor handoff. + io.streamnative.lightproto.tests.importer.Container c = + new io.streamnative.lightproto.tests.importer.Container(); + c.setLabel("outer"); + c.setItem().setName("inner-name").setValue(42); + + String text = c.toTextFormat(); + + io.streamnative.lightproto.tests.importer.Container parsed = + new io.streamnative.lightproto.tests.importer.Container(); + parsed.parseFromTextFormat(text); + + assertEquals("outer", parsed.getLabel()); + assertEquals("inner-name", parsed.getItem().getName()); + assertEquals(42, parsed.getItem().getValue()); + } + + @Test + public void enumValuesAreUnquoted() throws Exception { + Numbers lp = new Numbers().setEnum1(Enum1.X1_2); + String text = lp.toTextFormat(); + // Unquoted in TextFormat (unlike JSON which uses quoted strings) + assertTrue(text.contains("enum1: X1_2"), "Expected 'enum1: X1_2' in:\n" + text); + assertFalse(text.contains("\"X1_2\""), "Enum should not be quoted"); + } + + @Test + public void int64sAreUnquoted() throws Exception { + // Unlike JSON which quotes int64 as strings, TextFormat leaves them unquoted. + Numbers lp = new Numbers().setXInt64(123456789L); + String text = lp.toTextFormat(); + assertTrue(text.contains("x_int64: 123456789"), "Expected unquoted int64 in:\n" + text); + assertFalse(text.contains("\"123456789\""), "int64 should not be quoted"); + } + + @Test + public void fieldNamesUseSnakeCase() throws Exception { + Numbers lp = new Numbers().setXInt32(1); + String text = lp.toTextFormat(); + assertTrue(text.contains("x_int32:"), "TextFormat should use proto snake_case names"); + assertFalse(text.contains("xInt32:"), "TextFormat should not use camelCase"); + } +}