From 5d1a2bfe7d261943a69b335a53b10674b557dc62 Mon Sep 17 00:00:00 2001 From: Tim Ortel <100865202+TimOrtel@users.noreply.github.com> Date: Sat, 6 Dec 2025 10:29:38 +0100 Subject: [PATCH 01/23] Support export/local and default_symbol_visibility. --- .../kmpgrpc/anltr/ProtobufEditions.g4 | 21 +- .../sourcegeneration/CompilationException.kt | 7 + .../sourcegeneration/ProtoSourceGenerator.kt | 30 +- .../model/ProtoLanguageVersion.kt | 3 +- .../model/declaration/ProtoDeclaration.kt | 52 +++ .../ProtoDefaultSymbolVisibility.kt | 8 + .../model/declaration/ProtoEnum.kt | 1 + .../model/declaration/ProtoMessage.kt | 1 + .../declaration/ProtoSymbolVisibility.kt | 6 + .../message/field/ProtoMessageField.kt | 4 +- .../model/option/FeatureProtoOption.kt | 6 +- .../sourcegeneration/model/option/Options.kt | 37 +- .../model/option/SimpleProtoOption.kt | 21 +- .../sourcegeneration/model/type/ProtoType.kt | 2 + .../parsing/ProtobufModelBuilderVisitor.kt | 22 +- .../validation/BaseValidationTest.kt | 23 +- .../validation/ExportValidationTests.kt | 399 ++++++++++++++++++ .../ReservedFieldNumberValidationTests.kt | 4 +- 18 files changed, 605 insertions(+), 42 deletions(-) create mode 100644 kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoDefaultSymbolVisibility.kt create mode 100644 kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoSymbolVisibility.kt create mode 100644 kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/ExportValidationTests.kt diff --git a/kmp-grpc-plugin/src/main/antlr/io/github/timortel/kmpgrpc/anltr/ProtobufEditions.g4 b/kmp-grpc-plugin/src/main/antlr/io/github/timortel/kmpgrpc/anltr/ProtobufEditions.g4 index 85b9ba21..8f0f5803 100644 --- a/kmp-grpc-plugin/src/main/antlr/io/github/timortel/kmpgrpc/anltr/ProtobufEditions.g4 +++ b/kmp-grpc-plugin/src/main/antlr/io/github/timortel/kmpgrpc/anltr/ProtobufEditions.g4 @@ -34,7 +34,7 @@ edition // Import Statement importStatement - : IMPORT (WEAK | PUBLIC)? strLit SEMI + : IMPORT (WEAK | PUBLIC | OPTION)? strLit SEMI ; // Package @@ -183,10 +183,15 @@ topLevelDef | serviceDef ; +symbolVisibility + : EXPORT + | LOCAL + ; + // enum enumDef - : ENUM enumName enumBody + : (symbolVisibility)? ENUM enumName enumBody ; enumBody @@ -216,7 +221,7 @@ enumValueOption // message messageDef - : MESSAGE messageName messageBody + : (symbolVisibility)? MESSAGE messageName messageBody ; messageBody @@ -337,6 +342,14 @@ WEAK : 'weak' ; +EXPORT + : 'export' + ; + +LOCAL + : 'local' + ; + PUBLIC : 'public' ; @@ -637,6 +650,8 @@ keywords : EDITION | IMPORT | WEAK + | EXPORT + | LOCAL | PUBLIC | PACKAGE | OPTION diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/CompilationException.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/CompilationException.kt index 374fc01d..284c7f10 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/CompilationException.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/CompilationException.kt @@ -40,6 +40,13 @@ sealed class CompilationException(val msg: String, val filePath: String, val ctx class ExtensionInvalidReference(message: String, file: ProtoFile, ctx: ParserRuleContext) : CompilationException(message, file, ctx) class ExtensionInvalidRange(message: String, file: ProtoFile, ctx: ParserRuleContext) : CompilationException(message, file, ctx) + // Export + class ImportLocalDeclaration(message: String, file: ProtoFile, ctx: ParserRuleContext) : CompilationException(message, file, ctx) + class StrictExportViolation(message: String, file: ProtoFile, ctx: ParserRuleContext) : CompilationException(message, file, ctx) + + // Language + class UnsupportedLanguageFeatureUsed(message: String, file: ProtoFile, ctx: ParserRuleContext) : CompilationException(message, file, ctx) + override val message: String get() = if (ctx == null) { "$filePath: $msg" diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/ProtoSourceGenerator.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/ProtoSourceGenerator.kt index efc20a6f..3fb80022 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/ProtoSourceGenerator.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/ProtoSourceGenerator.kt @@ -10,6 +10,7 @@ import io.github.timortel.kmpgrpc.plugin.sourcegeneration.generators.project.Com import io.github.timortel.kmpgrpc.plugin.sourcegeneration.generators.project.NativeProtoProjectWriter import io.github.timortel.kmpgrpc.plugin.sourcegeneration.generators.project.JsProtoProjectWriter import io.github.timortel.kmpgrpc.plugin.sourcegeneration.generators.project.JvmProtoProjectWriter +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.ProtoLanguageVersion import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.ProtoProject import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.Visibility import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.file.ProtoFile @@ -157,17 +158,18 @@ object ProtoSourceGenerator { } private fun readProtoFile(file: InputFile, logger: Logger): ProtoFile? { - val visitor = ProtobufModelBuilderVisitor( - filePath = file.path, - fileNameWithoutExtension = file.nameWithoutExtension, - fileName = file.name - ) - val fileText = file.inputStream().use { inputStream -> inputStream.reader().use { reader -> reader.readText() } } return when { fileText.matches(proto3Regex) -> { + val visitor = ProtobufModelBuilderVisitor( + filePath = file.path, + fileNameWithoutExtension = file.nameWithoutExtension, + fileName = file.name, + protoLanguageVersion = ProtoLanguageVersion.PROTO3 + ) + val proto3Lexer = Protobuf3Lexer(CharStreams.fromStream(file.inputStream())) val proto3Parser = Protobuf3Parser(CommonTokenStream(proto3Lexer)) val proto3File = proto3Parser.proto() @@ -176,6 +178,22 @@ object ProtoSourceGenerator { } fileText.matches(protoEditionsRegex) -> { + val versionGroup = protoEditionsRegex.matchEntire(fileText)!!.groups[1]!!.value + + val visitor = ProtobufModelBuilderVisitor( + filePath = file.path, + fileNameWithoutExtension = file.nameWithoutExtension, + fileName = file.name, + protoLanguageVersion = when (versionGroup) { + "2023" -> ProtoLanguageVersion.EDITION2023 + "2024" -> ProtoLanguageVersion.EDITION2024 + else -> { + logger.warn("File $file uses unsupported proto editions $versionGroup. Only ${ProtoLanguageVersion.entries} are supported.") + return null + } + } + ) + val protoEditionsLexer = ProtobufEditionsLexer(CharStreams.fromStream(file.inputStream())) val protoEditionsParser = ProtobufEditionsParser(CommonTokenStream(protoEditionsLexer)) val protoEditionsFile = protoEditionsParser.proto() diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/ProtoLanguageVersion.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/ProtoLanguageVersion.kt index ba6abb2e..a6f688f9 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/ProtoLanguageVersion.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/ProtoLanguageVersion.kt @@ -2,5 +2,6 @@ package io.github.timortel.kmpgrpc.plugin.sourcegeneration.model enum class ProtoLanguageVersion { PROTO3, - EDITION2023 + EDITION2023, + EDITION2024 } diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoDeclaration.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoDeclaration.kt index 50432e84..4c6c0ff4 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoDeclaration.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoDeclaration.kt @@ -1,7 +1,10 @@ package io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration import com.squareup.kotlinpoet.ClassName +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.CompilationException import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.ProtoDeclParent +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.ProtoLanguageVersion +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.option.Options /** * Base interface of both messages and enums @@ -13,6 +16,23 @@ sealed interface ProtoDeclaration : ProtoBaseDeclaration { */ val parent: ProtoDeclParent + /** + * The symbol visibility set for this declaration. Null if not supported (< edition 2024) or not set. + */ + val symbolVisibility: ProtoSymbolVisibility? + + val isExported: Boolean + get() = when (symbolVisibility) { + ProtoSymbolVisibility.EXPORT -> true + ProtoSymbolVisibility.LOCAL -> false + null -> when (Options.Feature.defaultSymbolVisibility.get(this)) { + ProtoDefaultSymbolVisibility.EXPORT_ALL -> true + ProtoDefaultSymbolVisibility.EXPORT_TOP_LEVEL -> isProtoTopLevel + ProtoDefaultSymbolVisibility.LOCAL_ALL -> false + ProtoDefaultSymbolVisibility.STRICT -> false + } + } + /** * The type of this declaration as it will be generated */ @@ -29,4 +49,36 @@ sealed interface ProtoDeclaration : ProtoBaseDeclaration { is ProtoDeclParent.Message -> true is ProtoDeclParent.File -> super.isNested } + + val isProtoTopLevel: Boolean + get() = when (parent) { + is ProtoDeclParent.File -> true + is ProtoDeclParent.Message -> false + } + + override fun validate() { + super.validate() + + when (Options.Feature.defaultSymbolVisibility.get(this)) { + ProtoDefaultSymbolVisibility.STRICT -> { + if (symbolVisibility == ProtoSymbolVisibility.EXPORT && !isProtoTopLevel) { + throw CompilationException.StrictExportViolation( + message = "$name is nested and cannot be exported with STRICT default_symbol_visbility.", + file = file, + ctx = ctx + ) + } + } + + else -> {} + } + + if (symbolVisibility != null && file.languageVersion != ProtoLanguageVersion.EDITION2024) { + throw CompilationException.UnsupportedLanguageFeatureUsed( + message = "Symbol visibility ${symbolVisibility?.name} is not supported on ${file.languageVersion.name}", + file = file, + ctx = ctx + ) + } + } } diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoDefaultSymbolVisibility.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoDefaultSymbolVisibility.kt new file mode 100644 index 00000000..8f0b8a1e --- /dev/null +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoDefaultSymbolVisibility.kt @@ -0,0 +1,8 @@ +package io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration + +enum class ProtoDefaultSymbolVisibility { + EXPORT_ALL, + EXPORT_TOP_LEVEL, + LOCAL_ALL, + STRICT +} diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoEnum.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoEnum.kt index 704a808c..0afaa26c 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoEnum.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoEnum.kt @@ -20,6 +20,7 @@ data class ProtoEnum( val fields: List, override val options: List, override val reservation: ProtoReservation, + override val symbolVisibility: ProtoSymbolVisibility?, override val ctx: ParserRuleContext ) : ProtoDeclaration, BaseDeclarationResolver, ProtoFieldHolder { diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoMessage.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoMessage.kt index d2c757c3..91f8c801 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoMessage.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoMessage.kt @@ -30,6 +30,7 @@ data class ProtoMessage( override val options: List, override val extensionDefinitions: List, val extensionRange: ProtoExtensionRanges, + override val symbolVisibility: ProtoSymbolVisibility?, override val ctx: ParserRuleContext ) : ProtoDeclaration, FileBasedDeclarationResolver, ProtoFieldHolder, ProtoChildPropertyNameResolver, ProtoExtensionDefinitionHolder, ProtoExtensionDefinitionFinder { diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoSymbolVisibility.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoSymbolVisibility.kt new file mode 100644 index 00000000..9657b637 --- /dev/null +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoSymbolVisibility.kt @@ -0,0 +1,6 @@ +package io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration + +enum class ProtoSymbolVisibility { + LOCAL, + EXPORT +} diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/message/field/ProtoMessageField.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/message/field/ProtoMessageField.kt index be1f44fe..9caa8be7 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/message/field/ProtoMessageField.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/message/field/ProtoMessageField.kt @@ -66,7 +66,7 @@ class ProtoMessageField( FieldCardinality.SINGULAR_OPTIONAL -> ProtoFieldCardinality.Singular(ProtoFieldPresence.EXPLICIT) FieldCardinality.REPEATED -> ProtoFieldCardinality.Repeated } - ProtoLanguageVersion.EDITION2023 -> when (fieldCardinality) { + ProtoLanguageVersion.EDITION2023, ProtoLanguageVersion.EDITION2024 -> when (fieldCardinality) { FieldCardinality.SINGULAR -> ProtoFieldCardinality.Singular( presence = Options.Feature.fieldPresence.get(this) ) @@ -114,7 +114,7 @@ class ProtoMessageField( override val isPacked: Boolean get() = cardinality == ProtoFieldCardinality.Repeated && type.isPackable && when (file.languageVersion) { ProtoLanguageVersion.PROTO3 -> Options.Basic.packed.get(this) - ProtoLanguageVersion.EDITION2023 -> when (Options.Feature.repeatedFieldEncoding.get(this)) { + ProtoLanguageVersion.EDITION2023, ProtoLanguageVersion.EDITION2024 -> when (Options.Feature.repeatedFieldEncoding.get(this)) { ProtoRepeatedFieldEncoding.PACKED -> true ProtoRepeatedFieldEncoding.EXPANDED -> false } diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/FeatureProtoOption.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/FeatureProtoOption.kt index 43a3e842..f20e16f9 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/FeatureProtoOption.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/FeatureProtoOption.kt @@ -12,7 +12,7 @@ class FeatureProtoOption( name: String, parse: (String) -> T?, languageConfigurationMap: Map>, - targets: List + targets: List = OptionTarget.entries ) : Option( name = "features.$name", parse = parse, @@ -24,13 +24,15 @@ class FeatureProtoOption( name: String, parse: (String) -> T?, edition2023Config: LangConfig, + edition2024Config: LangConfig, targets: List = OptionTarget.entries ) : this( name = name, parse = parse, languageConfigurationMap = mapOf( ProtoLanguageVersion.PROTO3 to LangConfig.Unavailable(), - ProtoLanguageVersion.EDITION2023 to edition2023Config + ProtoLanguageVersion.EDITION2023 to edition2023Config, + ProtoLanguageVersion.EDITION2024 to edition2024Config, ), targets = targets ) diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/Options.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/Options.kt index bfd7f3b3..28cfed1d 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/Options.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/Options.kt @@ -1,5 +1,7 @@ package io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.option +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.ProtoLanguageVersion +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.ProtoDefaultSymbolVisibility import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.message.field.ProtoFieldPresence import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.message.field.ProtoRepeatedFieldEncoding @@ -11,7 +13,8 @@ object Options { parse = String::toBooleanStrictOrNull, targets = listOf(OptionTarget.FILE), proto3Config = LangConfig.Available(defaultValue = false), - edition2023Config = LangConfig.Available(defaultValue = false) + edition2023Config = LangConfig.Available(defaultValue = false), + edition2024Config = LangConfig.Available(defaultValue = false) // TODO: should be unsupported ) val javaPackage = SimpleProtoOption( @@ -19,7 +22,7 @@ object Options { parse = { it }, targets = listOf(OptionTarget.FILE), proto3Config = LangConfig.Available(defaultValue = null), - edition2023Config = LangConfig.Available(defaultValue = null) + editionConfig = LangConfig.Available(defaultValue = null) ) val javaOuterClassName = SimpleProtoOption( @@ -27,7 +30,7 @@ object Options { parse = { it }, targets = listOf(OptionTarget.FILE), proto3Config = LangConfig.Available(defaultValue = null), - edition2023Config = LangConfig.Available(defaultValue = null) + editionConfig = LangConfig.Available(defaultValue = null) ) val allowAlias = SimpleProtoOption( @@ -35,7 +38,7 @@ object Options { parse = String::toBooleanStrictOrNull, targets = listOf(OptionTarget.ENUM), proto3Config = LangConfig.Available(defaultValue = false), - edition2023Config = LangConfig.Available(defaultValue = false) + editionConfig = LangConfig.Available(defaultValue = false) ) val deprecated = SimpleProtoOption( @@ -43,7 +46,7 @@ object Options { parse = String::toBooleanStrictOrNull, targets = listOf(OptionTarget.FIELD), proto3Config = LangConfig.Available(defaultValue = false), - edition2023Config = LangConfig.Available(defaultValue = false) + editionConfig = LangConfig.Available(defaultValue = false) ) val packed = SimpleProtoOption( @@ -51,7 +54,7 @@ object Options { parse = String::toBooleanStrictOrNull, targets = listOf(OptionTarget.FIELD), proto3Config = LangConfig.Available(defaultValue = true), - edition2023Config = LangConfig.Available(defaultValue = true, isLocked = true) + editionConfig = LangConfig.Available(defaultValue = true, isLocked = true) ) } @@ -59,13 +62,28 @@ object Options { val fieldPresence = FeatureProtoOption( name = "field_presence", parse = { value -> ProtoFieldPresence.entries.firstOrNull { it.name == value } }, - edition2023Config = LangConfig.Available(defaultValue = ProtoFieldPresence.EXPLICIT) + edition2023Config = LangConfig.Available(defaultValue = ProtoFieldPresence.EXPLICIT), + edition2024Config = LangConfig.Available(defaultValue = ProtoFieldPresence.EXPLICIT), + targets = listOf(OptionTarget.FILE, OptionTarget.FIELD) ) val repeatedFieldEncoding = FeatureProtoOption( name = "repeated_field_encoding", parse = { value -> ProtoRepeatedFieldEncoding.entries.firstOrNull { it.name == value } }, - edition2023Config = LangConfig.Available(defaultValue = ProtoRepeatedFieldEncoding.PACKED) + edition2023Config = LangConfig.Available(defaultValue = ProtoRepeatedFieldEncoding.PACKED), + edition2024Config = LangConfig.Available(defaultValue = ProtoRepeatedFieldEncoding.PACKED), + targets = listOf(OptionTarget.FILE, OptionTarget.FIELD) + ) + + val defaultSymbolVisibility = FeatureProtoOption( + name = "default_symbol_visibility", + parse = { value -> ProtoDefaultSymbolVisibility.entries.firstOrNull { it.name == value } }, + languageConfigurationMap = mapOf( + ProtoLanguageVersion.PROTO3 to LangConfig.Available(defaultValue = ProtoDefaultSymbolVisibility.EXPORT_ALL, isLocked = true), + ProtoLanguageVersion.EDITION2023 to LangConfig.Available(defaultValue = ProtoDefaultSymbolVisibility.EXPORT_ALL, isLocked = true), + ProtoLanguageVersion.EDITION2024 to LangConfig.Available(defaultValue = ProtoDefaultSymbolVisibility.EXPORT_TOP_LEVEL) + ), + targets = listOf(OptionTarget.FILE) ) } @@ -77,7 +95,8 @@ object Options { Basic.deprecated, Basic.packed, Feature.fieldPresence, - Feature.repeatedFieldEncoding + Feature.repeatedFieldEncoding, + Feature.defaultSymbolVisibility ) /** diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/SimpleProtoOption.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/SimpleProtoOption.kt index 44cedd6c..3818b05b 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/SimpleProtoOption.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/SimpleProtoOption.kt @@ -19,13 +19,30 @@ class SimpleProtoOption( parse: (String) -> T?, targets: List, proto3Config: LangConfig, - edition2023Config: LangConfig + editionConfig: LangConfig + ) : this( + name = name, + parse = parse, + proto3Config = proto3Config, + edition2023Config = editionConfig, + edition2024Config = editionConfig, + targets = targets + ) + + constructor( + name: String, + parse: (String) -> T?, + targets: List, + proto3Config: LangConfig, + edition2023Config: LangConfig, + edition2024Config: LangConfig, ) : this( name = name, parse = parse, languageConfigurationMap = mapOf( ProtoLanguageVersion.PROTO3 to proto3Config, - ProtoLanguageVersion.EDITION2023 to edition2023Config + ProtoLanguageVersion.EDITION2023 to edition2023Config, + ProtoLanguageVersion.EDITION2024 to edition2024Config ), targets = targets ) diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/type/ProtoType.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/type/ProtoType.kt index b171345d..0569bc1f 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/type/ProtoType.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/type/ProtoType.kt @@ -241,6 +241,8 @@ sealed interface ProtoType { if (decl == null) { throw CompilationException.UnresolvedReference("Unresolved reference $declaration", file, ctx) + } else if (decl.file != file && !decl.isExported) { + throw CompilationException.ImportLocalDeclaration("Illegal import of local declaration $decl", file, ctx) } return decl diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/parsing/ProtobufModelBuilderVisitor.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/parsing/ProtobufModelBuilderVisitor.kt index 771dedfb..2b30df37 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/parsing/ProtobufModelBuilderVisitor.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/parsing/ProtobufModelBuilderVisitor.kt @@ -14,6 +14,7 @@ import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.ProtoOption import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.type.ProtoType import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.ProtoEnum import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.ProtoMessage +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.ProtoSymbolVisibility import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.enumeration.ProtoEnumField import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.message.ProtoExtensionRanges import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.message.ProtoExtensionRanges.Companion.fold @@ -38,11 +39,11 @@ class ProtobufModelBuilderVisitor( private val filePath: String, private val fileName: String, private val fileNameWithoutExtension: String, + private val protoLanguageVersion: ProtoLanguageVersion ) : Protobuf3Visitor, ProtobufEditionsVisitor { private fun visitProto( ctx: ParserRuleContext, - languageVersion: ProtoLanguageVersion, imports: List, options: List, messages: List, @@ -61,7 +62,7 @@ class ProtobufModelBuilderVisitor( `package` = packages.firstOrNull(), fileName = fileName, fileNameWithoutExtension = fileNameWithoutExtension, - languageVersion = languageVersion, + languageVersion = protoLanguageVersion, messages = messages, enums = topLevelEnums, services = services, @@ -84,7 +85,6 @@ class ProtobufModelBuilderVisitor( return visitProto( ctx = ctx, - languageVersion = ProtoLanguageVersion.EDITION2023, imports = imports, options = options, messages = messages, @@ -108,7 +108,6 @@ class ProtobufModelBuilderVisitor( return visitProto( ctx = ctx, - languageVersion = ProtoLanguageVersion.PROTO3, imports = imports, options = options, messages = messages, @@ -147,10 +146,19 @@ class ProtobufModelBuilderVisitor( return visitOption(ctx, ctx.optionName().text, ctx.constant().text) } + override fun visitSymbolVisibility(ctx: ProtobufEditionsParser.SymbolVisibilityContext?): ProtoSymbolVisibility? { + return when { + ctx?.LOCAL() != null -> ProtoSymbolVisibility.LOCAL + ctx?.EXPORT() != null -> ProtoSymbolVisibility.EXPORT + else -> null + } + } + // Message parsing override fun visitMessageDef(ctx: ProtobufEditionsParser.MessageDefContext): ProtoMessage { val name = ctx.messageName().text + val symbolVisibility = visitSymbolVisibility(ctx.symbolVisibility()) val elements = ctx.messageBody().messageElement() @@ -179,6 +187,7 @@ class ProtobufModelBuilderVisitor( options = options, extensionDefinitions = extensionDefinitions, extensionRange = extensionRange, + symbolVisibility = symbolVisibility, ctx = ctx ) } @@ -212,6 +221,7 @@ class ProtobufModelBuilderVisitor( options = options, extensionDefinitions = extensionDefinitions, extensionRange = ProtoExtensionRanges(), + symbolVisibility = null, ctx = ctx ) } @@ -285,6 +295,8 @@ class ProtobufModelBuilderVisitor( val name = ctx.enumName().text val elements = ctx.enumBody().enumElement() + val symbolVisibility = visitSymbolVisibility(ctx.symbolVisibility()) + val options = elements.mapNotNull { it.option() }.map { visitOption(it) } val fields = elements.mapNotNull { it.enumField() }.map { visitEnumField(it) } val reservation = elements.mapNotNull { it.reserved() }.map { visitReserved(it) }.fold() @@ -294,6 +306,7 @@ class ProtobufModelBuilderVisitor( fields = fields, options = options, reservation = reservation, + symbolVisibility = symbolVisibility, ctx = ctx ) } @@ -311,6 +324,7 @@ class ProtobufModelBuilderVisitor( fields = fields, options = options, reservation = reservation, + symbolVisibility = null, ctx = ctx ) } diff --git a/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/BaseValidationTest.kt b/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/BaseValidationTest.kt index abd4e943..40086100 100644 --- a/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/BaseValidationTest.kt +++ b/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/BaseValidationTest.kt @@ -1,12 +1,12 @@ package io.github.timortel.kotlin_multiplatform_grpc_plugin.validation import io.github.timortel.kmpgrpc.plugin.sourcegeneration.InputFile -import io.github.timortel.kotlin_multiplatform_grpc_plugin.createSingleFileProtoFolder import io.github.timortel.kmpgrpc.plugin.sourcegeneration.ProtoSourceGenerator +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.SourceTarget +import io.github.timortel.kotlin_multiplatform_grpc_plugin.createSingleFileProtoFolder import io.mockk.spyk import org.slf4j.Logger import org.slf4j.LoggerFactory -import java.io.File abstract class BaseValidationTest { val logger: Logger = spyk(LoggerFactory.getLogger("testlogger")) @@ -18,21 +18,22 @@ abstract class BaseValidationTest { } fun runGenerator(folder: List) { - ProtoSourceGenerator.writeProtoFiles( + ProtoSourceGenerator.generateProtoFiles( logger = logger, protoFolders = folder, - shouldGenerateTargetMap = emptyMap(), - internalVisibility = false, - commonOutputFolder = File(""), - jvmOutputFolder = File(""), - jsOutputFolder = File(""), - wasmJsFolder = File(""), - nativeOutputDir = File("") + shouldGenerateTargetMap = mapOf( + SourceTarget.Common to true, + SourceTarget.Jvm to true, + SourceTarget.Js to true, + SourceTarget.Native to true + ), + internalVisibility = false ) } enum class ProtoVersion(val header: String) { PROTO3("syntax = \"proto3\";"), - EDITION2023("edition = \"2023\";") + EDITION2023("edition = \"2023\";"), + EDITION2024("edition = \"2024\";") } } diff --git a/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/ExportValidationTests.kt b/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/ExportValidationTests.kt new file mode 100644 index 00000000..879da5e8 --- /dev/null +++ b/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/ExportValidationTests.kt @@ -0,0 +1,399 @@ +package io.github.timortel.kotlin_multiplatform_grpc_plugin.validation + +import com.google.testing.junit.testparameterinjector.junit5.TestParameter +import com.google.testing.junit.testparameterinjector.junit5.TestParameterInjectorTest +import com.google.testing.junit.testparameterinjector.junit5.TestParameterValuesProvider +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.CompilationException +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.ProtoDefaultSymbolVisibility +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.ProtoSymbolVisibility +import io.github.timortel.kotlin_multiplatform_grpc_plugin.FakeInputDirectory +import io.github.timortel.kotlin_multiplatform_grpc_plugin.createProtoFile +import io.mockk.verify +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import kotlin.reflect.KClass + +class ExportValidationTests : BaseValidationTest() { + + @Suppress("unused") + enum class VisibilityLangaugeVersionScenario( + val version: ProtoVersion, + val visibility: ProtoSymbolVisibility?, + val expectException: Boolean + ) { + EDITION2023_1(ProtoVersion.EDITION2023, null, false), + EDITION2023_2(ProtoVersion.EDITION2023, ProtoSymbolVisibility.EXPORT, true), + EDITION2023_3(ProtoVersion.EDITION2023, ProtoSymbolVisibility.LOCAL, true), + EDITION2024_1(ProtoVersion.EDITION2024, null, false), + EDITION2024_2(ProtoVersion.EDITION2024, ProtoSymbolVisibility.EXPORT, false), + EDITION2024_3(ProtoVersion.EDITION2024, ProtoSymbolVisibility.LOCAL, false) + } + + @TestParameterInjectorTest + fun `test GIVEN language version WHEN applying export and local modifiers THEN language rules are enforced`( + @TestParameter scenario: VisibilityLangaugeVersionScenario + ) { + val visibility = when (scenario.visibility) { + ProtoSymbolVisibility.LOCAL -> "local" + ProtoSymbolVisibility.EXPORT -> "export" + null -> "" + } + + val exec = { + runGenerator( + """ + $visibility message A { + } + """.trimIndent(), + protoVersion = scenario.version + ) + } + + if (scenario.expectException) { + assertThrows { + exec() + } + } else { + exec() + } + } + + @TestParameterInjectorTest + fun `test GIVEN default symbol visibility WHEN importing top level declaration THEN no warning or error is printed`( + @TestParameter version: ProtoVersion + ) { + runDefaultGenerator( + file1 = """ + message A {} + """, + version = version + ) + + verify(exactly = 0) { logger.warn(any()) } + } + + @Test + fun `test GIVEN edition 2024 WHEN importing exported top level declaration THEN no warning or error is printed`() { + runDefaultGenerator( + file1 = """ + export message A {} + """, + version = ProtoVersion.EDITION2024 + ) + + verify(exactly = 0) { logger.warn(any()) } + } + + data class VisibilityTestScenario( + val defaultVisibility: ProtoDefaultSymbolVisibility?, + val declarationModifier: ProtoSymbolVisibility?, + val exceptionClass: KClass? + ) + + private class VisibilityScenarioProvider : TestParameterValuesProvider() { + + override fun provideValues(context: Context): List = + listOf( + // NONE + VisibilityTestScenario( + null, + null, + exceptionClass = null, // bare top-level is exported + ), + VisibilityTestScenario( + null, + ProtoSymbolVisibility.EXPORT, + exceptionClass = null, // explicit export + ), + VisibilityTestScenario( + null, + ProtoSymbolVisibility.LOCAL, + exceptionClass = CompilationException.ImportLocalDeclaration::class, // explicit local wins + ), + + // EXPORT_ALL + VisibilityTestScenario( + ProtoDefaultSymbolVisibility.EXPORT_ALL, + null, + exceptionClass = null, // bare top-level is exported + ), + VisibilityTestScenario( + ProtoDefaultSymbolVisibility.EXPORT_ALL, + ProtoSymbolVisibility.EXPORT, + exceptionClass = null, // explicit export + ), + VisibilityTestScenario( + ProtoDefaultSymbolVisibility.EXPORT_ALL, + ProtoSymbolVisibility.LOCAL, + exceptionClass = CompilationException.ImportLocalDeclaration::class, // explicit local wins + ), + + // EXPORT_TOP_LEVEL + VisibilityTestScenario( + ProtoDefaultSymbolVisibility.EXPORT_TOP_LEVEL, + null, + exceptionClass = null, // bare top-level is exported + ), + VisibilityTestScenario( + ProtoDefaultSymbolVisibility.EXPORT_TOP_LEVEL, + ProtoSymbolVisibility.EXPORT, + exceptionClass = null, // explicit export + ), + VisibilityTestScenario( + ProtoDefaultSymbolVisibility.EXPORT_TOP_LEVEL, + ProtoSymbolVisibility.LOCAL, + exceptionClass = CompilationException.ImportLocalDeclaration::class, // explicit local + ), + + // LOCAL_ALL + VisibilityTestScenario( + ProtoDefaultSymbolVisibility.LOCAL_ALL, + null, + exceptionClass = CompilationException.ImportLocalDeclaration::class, // bare top-level is local + ), + VisibilityTestScenario( + ProtoDefaultSymbolVisibility.LOCAL_ALL, + ProtoSymbolVisibility.EXPORT, + exceptionClass = null, // explicit export overrides default + ), + VisibilityTestScenario( + ProtoDefaultSymbolVisibility.LOCAL_ALL, + ProtoSymbolVisibility.LOCAL, + exceptionClass = CompilationException.ImportLocalDeclaration::class, // explicit local + ), + + // STRICT + VisibilityTestScenario( + ProtoDefaultSymbolVisibility.STRICT, + null, + exceptionClass = CompilationException.ImportLocalDeclaration::class, // bare top-level is local + ), + VisibilityTestScenario( + ProtoDefaultSymbolVisibility.STRICT, + ProtoSymbolVisibility.EXPORT, + exceptionClass = null, // explicit export + ), + VisibilityTestScenario( + ProtoDefaultSymbolVisibility.STRICT, + ProtoSymbolVisibility.LOCAL, + exceptionClass = CompilationException.ImportLocalDeclaration::class, // explicit local + ) + ) + } + + @TestParameterInjectorTest + fun `test GIVEN edition 2024 WHEN importing top level declaration THEN visibility rules are enforced`( + @TestParameter(valuesProvider = VisibilityScenarioProvider::class) + scenario: VisibilityTestScenario + ) { + val optionLine = if (scenario.defaultVisibility != null) { + "option features.default_symbol_visibility = \"${scenario.defaultVisibility.name}\";" + } else "" + + val declarationLine = when (scenario.declarationModifier) { + null -> "message A {}" + ProtoSymbolVisibility.EXPORT -> "export message A {}" + ProtoSymbolVisibility.LOCAL -> "local message A {}" + } + + val file1 = """ + $optionLine + + $declarationLine + """ + + if (scenario.exceptionClass != null) { + Assertions.assertThrows(scenario.exceptionClass.java) { + runDefaultGenerator( + file1 = file1, + version = ProtoVersion.EDITION2024 + ) + } + } else { + runDefaultGenerator( + file1 = file1, + version = ProtoVersion.EDITION2024 + ) + } + } + + class NestedVisibilityScenarioProvider : TestParameterValuesProvider() { + + override fun provideValues(context: Context): List = + listOf( + // ===== NONE ===== + VisibilityTestScenario( + null, + null, + exceptionClass = CompilationException.ImportLocalDeclaration::class, + ), + VisibilityTestScenario( + null, + ProtoSymbolVisibility.EXPORT, + exceptionClass = null, + ), + VisibilityTestScenario( + null, + ProtoSymbolVisibility.LOCAL, + exceptionClass = CompilationException.ImportLocalDeclaration::class, + ), + + // ===== EXPORT_ALL ===== + // All symbols exported by default (including nested), unless explicitly local. + VisibilityTestScenario( + ProtoDefaultSymbolVisibility.EXPORT_ALL, + null, + exceptionClass = null, // nested A is exported by default + ), + VisibilityTestScenario( + ProtoDefaultSymbolVisibility.EXPORT_ALL, + ProtoSymbolVisibility.EXPORT, + exceptionClass = null, // explicit export + ), + VisibilityTestScenario( + ProtoDefaultSymbolVisibility.EXPORT_ALL, + ProtoSymbolVisibility.LOCAL, + exceptionClass = CompilationException.ImportLocalDeclaration::class, // explicit local + ), + + // ===== EXPORT_TOP_LEVEL ===== + // Only top-level are exported by default; nested are local unless explicitly exported. + VisibilityTestScenario( + ProtoDefaultSymbolVisibility.EXPORT_TOP_LEVEL, + null, + exceptionClass = CompilationException.ImportLocalDeclaration::class, // nested A is local by default + ), + VisibilityTestScenario( + ProtoDefaultSymbolVisibility.EXPORT_TOP_LEVEL, + ProtoSymbolVisibility.EXPORT, + exceptionClass = null, // explicit export on nested + ), + VisibilityTestScenario( + ProtoDefaultSymbolVisibility.EXPORT_TOP_LEVEL, + ProtoSymbolVisibility.LOCAL, + exceptionClass = CompilationException.ImportLocalDeclaration::class, // explicit local + ), + + // ===== LOCAL_ALL ===== + // Everything local by default; explicit export required, including nested. + VisibilityTestScenario( + ProtoDefaultSymbolVisibility.LOCAL_ALL, + null, + exceptionClass = CompilationException.ImportLocalDeclaration::class, // nested A is local by default + ), + VisibilityTestScenario( + ProtoDefaultSymbolVisibility.LOCAL_ALL, + ProtoSymbolVisibility.EXPORT, + exceptionClass = null, // explicit export + ), + VisibilityTestScenario( + ProtoDefaultSymbolVisibility.LOCAL_ALL, + ProtoSymbolVisibility.LOCAL, + exceptionClass = CompilationException.ImportLocalDeclaration::class, // explicit local + ), + + // ===== STRICT ===== + // All symbols local by default. Nested types cannot be exported (A is always local). + VisibilityTestScenario( + ProtoDefaultSymbolVisibility.STRICT, + null, + exceptionClass = CompilationException.ImportLocalDeclaration::class, // nested A local + ), + VisibilityTestScenario( + ProtoDefaultSymbolVisibility.STRICT, + ProtoSymbolVisibility.EXPORT, + exceptionClass = CompilationException.StrictExportViolation::class, // export on nested does not make it visible + ), + VisibilityTestScenario( + ProtoDefaultSymbolVisibility.STRICT, + ProtoSymbolVisibility.LOCAL, + exceptionClass = CompilationException.ImportLocalDeclaration::class, // explicit local + ), + ) + } + + @TestParameterInjectorTest + fun `test GIVEN edition 2024 WHEN importing nested declaration THEN visibility rules are enforced`( + @TestParameter(valuesProvider = NestedVisibilityScenarioProvider::class) + scenario: VisibilityTestScenario + ) { + val optionLine = if (scenario.defaultVisibility != null) { + "option features.default_symbol_visibility = \"${scenario.defaultVisibility.name}\";" + } else "" + + val nestedDeclaration = when (scenario.declarationModifier) { + null -> """ + message C { + message A {} + } + """ + + ProtoSymbolVisibility.EXPORT -> """ + message C { + export message A {} + } + """ + + ProtoSymbolVisibility.LOCAL -> """ + message C { + local message A {} + } + """ + }.trimIndent() + + val file1 = """ + $optionLine + + $nestedDeclaration + """ + + val file2 = """ + import "file1.proto"; + + message B { + C.A a = 1; + } + """ + + if (scenario.exceptionClass != null) { + Assertions.assertThrows(scenario.exceptionClass.java) { + runDefaultGenerator( + file1 = file1, + file2 = file2, + version = ProtoVersion.EDITION2024 + ) + } + } else { + runDefaultGenerator( + file1 = file1, + file2 = file2, + version = ProtoVersion.EDITION2024 + ) + } + } + + private fun runDefaultGenerator( + file1: String, + file2: String = """ + import "file1.proto"; + + message B { + A a = 1; + } + """, + version: ProtoVersion + ) { + runGenerator( + folder = listOf( + FakeInputDirectory( + name = "dir", + files = listOf( + createProtoFile(version.header, file1.trimIndent(), name = "file1.proto"), + createProtoFile(version.header, file2.trimIndent(), name = "file2.proto"), + ) + ) + ) + ) + } +} diff --git a/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/ReservedFieldNumberValidationTests.kt b/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/ReservedFieldNumberValidationTests.kt index 8ba91d62..e511b57a 100644 --- a/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/ReservedFieldNumberValidationTests.kt +++ b/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/ReservedFieldNumberValidationTests.kt @@ -14,7 +14,7 @@ class ReservedFieldNumberValidationTests : BaseValidationTest() { message TestMessage { reserved 2; string a = 1; - boolean b = 2; + bool b = 2; } """.trimIndent() ) @@ -28,7 +28,7 @@ class ReservedFieldNumberValidationTests : BaseValidationTest() { message TestMessage { reserved 2; string a = 1; - boolean b = 3; + bool b = 3; } """.trimIndent() ) From b50d92d84228cb5545a253165315b2b9572abbae Mon Sep 17 00:00:00 2001 From: Tim Ortel <100865202+TimOrtel@users.noreply.github.com> Date: Sat, 6 Dec 2025 23:24:13 +0100 Subject: [PATCH 02/23] Support (pb.java).nest_in_file_class --- .../kmpgrpc/anltr/ProtobufEditions.g4 | 1 + .../generators/protofile/ProtoFileWriter.kt | 66 +++----- .../model/ProtoOptionsHolder.kt | 13 +- .../model/declaration/ProtoBaseDeclaration.kt | 17 +- .../model/declaration/ProtoEnum.kt | 2 +- .../model/declaration/ProtoMessage.kt | 2 +- .../model/option/FeatureProtoOption.kt | 6 +- .../sourcegeneration/model/option/Option.kt | 2 +- .../model/option/OptionTarget.kt | 31 ++-- .../model/option/OptionTargetMatcher.kt | 23 +++ .../sourcegeneration/model/option/Options.kt | 47 ++++-- .../model/option/ProtoNestInFileClass.kt | 7 + .../model/option/SimpleProtoOption.kt | 6 +- .../ProtoFileBuilder.kt | 11 +- .../NestInFileClassGenerationTest.kt | 155 ++++++++++++++++++ readme.md | 20 ++- 16 files changed, 322 insertions(+), 87 deletions(-) create mode 100644 kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/OptionTargetMatcher.kt create mode 100644 kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/ProtoNestInFileClass.kt create mode 100644 kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/generation/NestInFileClassGenerationTest.kt diff --git a/kmp-grpc-plugin/src/main/antlr/io/github/timortel/kmpgrpc/anltr/ProtobufEditions.g4 b/kmp-grpc-plugin/src/main/antlr/io/github/timortel/kmpgrpc/anltr/ProtobufEditions.g4 index 8f0f5803..d9b71a61 100644 --- a/kmp-grpc-plugin/src/main/antlr/io/github/timortel/kmpgrpc/anltr/ProtobufEditions.g4 +++ b/kmp-grpc-plugin/src/main/antlr/io/github/timortel/kmpgrpc/anltr/ProtobufEditions.g4 @@ -53,6 +53,7 @@ optionName : ident | fullIdent | LP DOT? fullIdent RP + | ident (DOT optionName)+ ; // Fields diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/ProtoFileWriter.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/ProtoFileWriter.kt index 99e5ba7a..da610fa6 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/ProtoFileWriter.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/ProtoFileWriter.kt @@ -8,7 +8,7 @@ import io.github.timortel.kmpgrpc.plugin.sourcegeneration.generators.extensions. import io.github.timortel.kmpgrpc.plugin.sourcegeneration.generators.protofile.enumeration.ProtoEnumerationWriter import io.github.timortel.kmpgrpc.plugin.sourcegeneration.generators.protofile.message.ProtoMessageWriter import io.github.timortel.kmpgrpc.plugin.sourcegeneration.generators.service.ProtoServiceWriter -import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.option.Options +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.ProtoBaseDeclaration import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.file.ProtoFile abstract class ProtoFileWriter(val isActual: Boolean) { @@ -17,53 +17,41 @@ abstract class ProtoFileWriter(val isActual: Boolean) { abstract val protoEnumWriter: ProtoEnumerationWriter fun generateFiles(file: ProtoFile): List { - val files = if (Options.Basic.javaMultipleFiles.get(file)) { - val baseFile: List = if (file.extensionDefinitions.isNotEmpty()) { - val file = buildKotlinFileAndClassForProtoFile(file) {} - - listOf(file) - } else emptyList() - - val messageFiles = file.messages.map { message -> - FileSpec - .builder(message.className) - .addAnnotation(DefaultAnnotations.SuppressDeprecation) - .addAnnotation(DefaultAnnotations.OptIntoKmpGrpcInternalApi) - .addType(protoMessageWriter.generateProtoMessageClass(message)) - .build() - } - - val serviceFiles = file.services.map { service -> - FileSpec.builder(service.className) - .addAnnotation(DefaultAnnotations.SuppressDeprecation) - .addAnnotation(DefaultAnnotations.OptIntoKmpGrpcInternalApi) - .addType(protoServiceWriter.generateServiceStub(service)) - .build() + val declarationsWithData = (file.messages.map { protoMessageWriter.generateProtoMessageClass(message = it) to it } + + file.services.map { protoServiceWriter.generateServiceStub(service = it) to it } + + file.enums.map { protoEnumWriter.generateProtoEnum(protoEnum = it) to it }) + .map { (type, declaration) -> + TopLevelDeclarationTypeData(type = type, declaration = declaration, file = file) } - val enumFiles = if (!isActual) { - file.enums.map { enum -> - FileSpec.builder(enum.className) - .addAnnotation(DefaultAnnotations.SuppressDeprecation) - .addAnnotation(DefaultAnnotations.OptIntoKmpGrpcInternalApi) - .addType(protoEnumWriter.generateProtoEnum(enum)) - .build() - } - } else emptyList() + val hasNestedDeclarations = declarationsWithData.any { it.nestInFileClass } - messageFiles + serviceFiles + enumFiles + baseFile - } else { + val baseFile: List = if (file.extensionDefinitions.isNotEmpty() || hasNestedDeclarations) { val file = buildKotlinFileAndClassForProtoFile(file) { - file.messages.forEach { addType(protoMessageWriter.generateProtoMessageClass(message = it)) } - file.services.forEach { addType(protoServiceWriter.generateServiceStub(service = it)) } - - file.enums.forEach { addType(protoEnumWriter.generateProtoEnum(protoEnum = it)) } + declarationsWithData.filter { it.nestInFileClass }.forEach { addType(it.type) } } listOf(file) + } else emptyList() + + val topLevelDeclarationFiles = declarationsWithData.filterNot { it.nestInFileClass }.map { + FileSpec + .builder(it.declaration.className) + .addAnnotation(DefaultAnnotations.SuppressDeprecation) + .addAnnotation(DefaultAnnotations.OptIntoKmpGrpcInternalApi) + .addType(it.type) + .build() } - return files + return baseFile + topLevelDeclarationFiles + } + + private data class TopLevelDeclarationTypeData(val type: TypeSpec, val declaration: ProtoBaseDeclaration, val nestInFileClass: Boolean) { + constructor(type: TypeSpec, declaration: ProtoBaseDeclaration, file: ProtoFile) : this( + type = type, + declaration = declaration, + nestInFileClass = declaration.isNested + ) } private fun buildKotlinFileAndClassForProtoFile(file: ProtoFile, builder: TypeSpec.Builder.() -> Unit): FileSpec { diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/ProtoOptionsHolder.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/ProtoOptionsHolder.kt index b083a8ba..9d4b03d9 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/ProtoOptionsHolder.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/ProtoOptionsHolder.kt @@ -4,6 +4,7 @@ import io.github.timortel.kmpgrpc.plugin.sourcegeneration.CompilationException import io.github.timortel.kmpgrpc.plugin.sourcegeneration.Warnings import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.file.ProtoFile import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.option.OptionTarget +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.option.OptionTargetMatcher import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.option.Options import io.github.timortel.kmpgrpc.plugin.sourcegeneration.util.toFilePositionString @@ -19,7 +20,16 @@ interface ProtoOptionsHolder : ProtoNode { override fun validate() { options.forEach { option -> val relatedOption = Options.options.firstOrNull { it.name == option.name } - val isSupportedOnHolder = optionTarget in relatedOption?.targets.orEmpty() + + val isSupportedOnHolder = relatedOption?.targetMatchers?.any { + it.target == optionTarget::class && when (it) { + is OptionTargetMatcher.TypeDeclaration -> { + !it.restrictToTopLevel || (optionTarget as? OptionTarget.TypeDeclaration)?.isTopLevel == true + } + is OptionTargetMatcher.OtherDeclaration -> true + } + } == true + val isIgnored = option.name in Options.ignoredOptions val languageConfiguration = relatedOption?.languageConfigurationMap?.get(file.languageVersion) @@ -45,6 +55,7 @@ interface ProtoOptionsHolder : ProtoNode { ) } } + is Options.LangConfig.Unavailable, null -> {} } } diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoBaseDeclaration.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoBaseDeclaration.kt index 58385e4b..d6c0d762 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoBaseDeclaration.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoBaseDeclaration.kt @@ -1,11 +1,13 @@ package io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration import com.squareup.kotlinpoet.ClassName +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.ProtoLanguageVersion import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.option.Options import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.ProtoOptionsHolder import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.ProtoProject import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.ProtoVisibilityHolder import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.file.ProtoFile +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.option.ProtoNestInFileClass import org.antlr.v4.runtime.ParserRuleContext interface ProtoBaseDeclaration : ProtoOptionsHolder, ProtoVisibilityHolder { @@ -33,10 +35,10 @@ interface ProtoBaseDeclaration : ProtoOptionsHolder, ProtoVisibilityHolder { */ val className: ClassName get() { - return if (Options.Basic.javaMultipleFiles.get(file)) { - ClassName(file.javaPackage, kotlinClassName) - } else { + return if (isNested) { file.className.nestedClass(kotlinClassName) + } else { + ClassName(file.javaPackage, kotlinClassName) } } @@ -44,7 +46,14 @@ interface ProtoBaseDeclaration : ProtoOptionsHolder, ProtoVisibilityHolder { * If the message is nested within another class in the generated code. */ val isNested: Boolean - get() = !Options.Basic.javaMultipleFiles.get(file) + get() = when (file.languageVersion) { + ProtoLanguageVersion.PROTO3, ProtoLanguageVersion.EDITION2023 -> !Options.Basic.javaMultipleFiles.get(file) + ProtoLanguageVersion.EDITION2024 -> when (Options.Feature.nestInFileClass.get(this)) { + ProtoNestInFileClass.YES -> true + ProtoNestInFileClass.NO -> false + ProtoNestInFileClass.LEGACY -> !Options.Basic.javaMultipleFiles.get(file) + } + } val ctx: ParserRuleContext } diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoEnum.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoEnum.kt index 0afaa26c..bfbead13 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoEnum.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoEnum.kt @@ -49,7 +49,7 @@ data class ProtoEnum( override val heldFields: List = fields - override val optionTarget: OptionTarget = OptionTarget.ENUM + override val optionTarget: OptionTarget get() = OptionTarget.ENUM(isProtoTopLevel) override val parentOptionsHolder: ProtoOptionsHolder get() = when (val p = parent) { diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoMessage.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoMessage.kt index 91f8c801..0b87c75e 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoMessage.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoMessage.kt @@ -57,7 +57,7 @@ data class ProtoMessage( val isEmpty: Boolean = fields.isEmpty() && mapFields.isEmpty() && oneOfs.isEmpty() - override val optionTarget: OptionTarget = OptionTarget.MESSAGE + override val optionTarget: OptionTarget get() = OptionTarget.MESSAGE(isProtoTopLevel) override val parentOptionsHolder: ProtoOptionsHolder get() = when (val p = parent) { diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/FeatureProtoOption.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/FeatureProtoOption.kt index f20e16f9..729d6c02 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/FeatureProtoOption.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/FeatureProtoOption.kt @@ -12,12 +12,12 @@ class FeatureProtoOption( name: String, parse: (String) -> T?, languageConfigurationMap: Map>, - targets: List = OptionTarget.entries + targets: List ) : Option( name = "features.$name", parse = parse, languageConfigurationMap = languageConfigurationMap, - targets = targets + targetMatchers = targets ) { constructor( @@ -25,7 +25,7 @@ class FeatureProtoOption( parse: (String) -> T?, edition2023Config: LangConfig, edition2024Config: LangConfig, - targets: List = OptionTarget.entries + targets: List ) : this( name = name, parse = parse, diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/Option.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/Option.kt index 417fc59b..cda3711a 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/Option.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/Option.kt @@ -11,7 +11,7 @@ abstract class Option( val name: String, val parse: (String) -> T?, val languageConfigurationMap: Map>, - val targets: List + val targetMatchers: List ) { init { diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/OptionTarget.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/OptionTarget.kt index b2e9d81d..0b4eb6d2 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/OptionTarget.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/OptionTarget.kt @@ -1,13 +1,24 @@ package io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.option -enum class OptionTarget { - FILE, - EXTENSION_RANGE, - MESSAGE, - FIELD, - ONEOF, - ENUM, - ENUM_ENTRY, - SERVICE, - METHOD +sealed interface OptionTarget { + + interface TypeDeclaration : OptionTarget { + val isTopLevel: Boolean + } + + interface OtherDeclaration : OptionTarget + + data object FILE : OtherDeclaration + data object EXTENSION_RANGE : OtherDeclaration + data class MESSAGE(override val isTopLevel: Boolean) : TypeDeclaration + data object FIELD : OtherDeclaration + data object ONEOF : OtherDeclaration + data class ENUM(override val isTopLevel: Boolean) : TypeDeclaration + data object ENUM_ENTRY : OtherDeclaration + + data object SERVICE : TypeDeclaration { + override val isTopLevel: Boolean = true + } + + data object METHOD : OtherDeclaration } diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/OptionTargetMatcher.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/OptionTargetMatcher.kt new file mode 100644 index 00000000..9afc303a --- /dev/null +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/OptionTargetMatcher.kt @@ -0,0 +1,23 @@ +package io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.option + +import kotlin.reflect.KClass + +sealed interface OptionTargetMatcher { + + val target: KClass + + sealed class TypeDeclaration(override val target: KClass, val restrictToTopLevel: Boolean) : + OptionTargetMatcher + + sealed class OtherDeclaration(override val target: KClass) : OptionTargetMatcher + + data object FILE : OtherDeclaration(OptionTarget.FILE::class) + data object EXTENSION_RANGE : OtherDeclaration(OptionTarget.EXTENSION_RANGE::class) + class MESSAGE(restrictToTopLevel: Boolean = false) : TypeDeclaration(OptionTarget.MESSAGE::class, restrictToTopLevel) + data object FIELD : OtherDeclaration(OptionTarget.FIELD::class) + data object ONEOF : OtherDeclaration(OptionTarget.ONEOF::class) + class ENUM(restrictToTopLevel: Boolean = false) : TypeDeclaration(OptionTarget.ENUM::class, restrictToTopLevel) + data object ENUM_ENTRY : OtherDeclaration(OptionTarget.ENUM_ENTRY::class) + class SERVICE(restrictToTopLevel: Boolean = false) : TypeDeclaration(OptionTarget.SERVICE::class, restrictToTopLevel) + data object METHOD : OtherDeclaration(OptionTarget.METHOD::class) +} diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/Options.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/Options.kt index 28cfed1d..062723dd 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/Options.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/Options.kt @@ -11,16 +11,16 @@ object Options { val javaMultipleFiles = SimpleProtoOption( name = "java_multiple_files", parse = String::toBooleanStrictOrNull, - targets = listOf(OptionTarget.FILE), + targets = listOf(OptionTargetMatcher.FILE), proto3Config = LangConfig.Available(defaultValue = false), edition2023Config = LangConfig.Available(defaultValue = false), - edition2024Config = LangConfig.Available(defaultValue = false) // TODO: should be unsupported + edition2024Config = LangConfig.Available(defaultValue = false) ) val javaPackage = SimpleProtoOption( name = "java_package", parse = { it }, - targets = listOf(OptionTarget.FILE), + targets = listOf(OptionTargetMatcher.FILE), proto3Config = LangConfig.Available(defaultValue = null), editionConfig = LangConfig.Available(defaultValue = null) ) @@ -28,7 +28,7 @@ object Options { val javaOuterClassName = SimpleProtoOption( name = "java_outer_classname", parse = { it }, - targets = listOf(OptionTarget.FILE), + targets = listOf(OptionTargetMatcher.FILE), proto3Config = LangConfig.Available(defaultValue = null), editionConfig = LangConfig.Available(defaultValue = null) ) @@ -36,7 +36,7 @@ object Options { val allowAlias = SimpleProtoOption( name = "allow_alias", parse = String::toBooleanStrictOrNull, - targets = listOf(OptionTarget.ENUM), + targets = listOf(OptionTargetMatcher.ENUM(restrictToTopLevel = false)), proto3Config = LangConfig.Available(defaultValue = false), editionConfig = LangConfig.Available(defaultValue = false) ) @@ -44,7 +44,7 @@ object Options { val deprecated = SimpleProtoOption( name = "deprecated", parse = String::toBooleanStrictOrNull, - targets = listOf(OptionTarget.FIELD), + targets = listOf(OptionTargetMatcher.FIELD), proto3Config = LangConfig.Available(defaultValue = false), editionConfig = LangConfig.Available(defaultValue = false) ) @@ -52,7 +52,7 @@ object Options { val packed = SimpleProtoOption( name = "packed", parse = String::toBooleanStrictOrNull, - targets = listOf(OptionTarget.FIELD), + targets = listOf(OptionTargetMatcher.FIELD), proto3Config = LangConfig.Available(defaultValue = true), editionConfig = LangConfig.Available(defaultValue = true, isLocked = true) ) @@ -64,7 +64,7 @@ object Options { parse = { value -> ProtoFieldPresence.entries.firstOrNull { it.name == value } }, edition2023Config = LangConfig.Available(defaultValue = ProtoFieldPresence.EXPLICIT), edition2024Config = LangConfig.Available(defaultValue = ProtoFieldPresence.EXPLICIT), - targets = listOf(OptionTarget.FILE, OptionTarget.FIELD) + targets = listOf(OptionTargetMatcher.FILE, OptionTargetMatcher.FIELD) ) val repeatedFieldEncoding = FeatureProtoOption( @@ -72,18 +72,38 @@ object Options { parse = { value -> ProtoRepeatedFieldEncoding.entries.firstOrNull { it.name == value } }, edition2023Config = LangConfig.Available(defaultValue = ProtoRepeatedFieldEncoding.PACKED), edition2024Config = LangConfig.Available(defaultValue = ProtoRepeatedFieldEncoding.PACKED), - targets = listOf(OptionTarget.FILE, OptionTarget.FIELD) + targets = listOf(OptionTargetMatcher.FILE, OptionTargetMatcher.FIELD) ) val defaultSymbolVisibility = FeatureProtoOption( name = "default_symbol_visibility", parse = { value -> ProtoDefaultSymbolVisibility.entries.firstOrNull { it.name == value } }, languageConfigurationMap = mapOf( - ProtoLanguageVersion.PROTO3 to LangConfig.Available(defaultValue = ProtoDefaultSymbolVisibility.EXPORT_ALL, isLocked = true), - ProtoLanguageVersion.EDITION2023 to LangConfig.Available(defaultValue = ProtoDefaultSymbolVisibility.EXPORT_ALL, isLocked = true), + ProtoLanguageVersion.PROTO3 to LangConfig.Available( + defaultValue = ProtoDefaultSymbolVisibility.EXPORT_ALL, + isLocked = true + ), + ProtoLanguageVersion.EDITION2023 to LangConfig.Available( + defaultValue = ProtoDefaultSymbolVisibility.EXPORT_ALL, + isLocked = true + ), ProtoLanguageVersion.EDITION2024 to LangConfig.Available(defaultValue = ProtoDefaultSymbolVisibility.EXPORT_TOP_LEVEL) ), - targets = listOf(OptionTarget.FILE) + targets = listOf(OptionTargetMatcher.FILE) + ) + + val nestInFileClass = FeatureProtoOption( + name = "(pb.java).nest_in_file_class", + parse = { value -> ProtoNestInFileClass.entries.firstOrNull { it.name == value } }, + targets = listOf( + OptionTargetMatcher.MESSAGE(restrictToTopLevel = true), + OptionTargetMatcher.ENUM(restrictToTopLevel = true), + OptionTargetMatcher.SERVICE( + restrictToTopLevel = true + ) + ), + edition2023Config = LangConfig.Unavailable(), + edition2024Config = LangConfig.Available(defaultValue = ProtoNestInFileClass.NO) ) } @@ -96,7 +116,8 @@ object Options { Basic.packed, Feature.fieldPresence, Feature.repeatedFieldEncoding, - Feature.defaultSymbolVisibility + Feature.defaultSymbolVisibility, + Feature.nestInFileClass ) /** diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/ProtoNestInFileClass.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/ProtoNestInFileClass.kt new file mode 100644 index 00000000..e5ee926c --- /dev/null +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/ProtoNestInFileClass.kt @@ -0,0 +1,7 @@ +package io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.option + +enum class ProtoNestInFileClass { + YES, + NO, + LEGACY +} diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/SimpleProtoOption.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/SimpleProtoOption.kt index 3818b05b..da3efb3e 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/SimpleProtoOption.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/SimpleProtoOption.kt @@ -11,13 +11,13 @@ class SimpleProtoOption( name: String, parse: (String) -> T?, languageConfigurationMap: Map>, - targets: List + targets: List ) : Option(name, parse, languageConfigurationMap, targets) { constructor( name: String, parse: (String) -> T?, - targets: List, + targets: List, proto3Config: LangConfig, editionConfig: LangConfig ) : this( @@ -32,7 +32,7 @@ class SimpleProtoOption( constructor( name: String, parse: (String) -> T?, - targets: List, + targets: List, proto3Config: LangConfig, edition2023Config: LangConfig, edition2024Config: LangConfig, diff --git a/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/ProtoFileBuilder.kt b/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/ProtoFileBuilder.kt index c407d2c5..6618637e 100644 --- a/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/ProtoFileBuilder.kt +++ b/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/ProtoFileBuilder.kt @@ -1,7 +1,14 @@ package io.github.timortel.kotlin_multiplatform_grpc_plugin -fun createSingleFileProtoFolder(fileHeader: String, content: String): FakeInputDirectory { - return FakeInputDirectory("dir", listOf(createProtoFile(fileHeader, content))) +fun createSingleFileProtoFolder( + fileHeader: String, + content: String, + fileName: String = "testFile" +): FakeInputDirectory { + return FakeInputDirectory( + "dir", + listOf(createProtoFile(fileHeader = fileHeader, content = content, name = fileName)) + ) } fun createProtoFile(fileHeader: String, content: String, name: String = "testFile"): FakeInputFile { diff --git a/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/generation/NestInFileClassGenerationTest.kt b/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/generation/NestInFileClassGenerationTest.kt new file mode 100644 index 00000000..89bcc356 --- /dev/null +++ b/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/generation/NestInFileClassGenerationTest.kt @@ -0,0 +1,155 @@ +package io.github.timortel.kotlin_multiplatform_grpc_plugin.generation + +import com.google.testing.junit.testparameterinjector.junit5.TestParameter +import com.google.testing.junit.testparameterinjector.junit5.TestParameterInjectorTest +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.ProtoSourceGenerator +import io.github.timortel.kotlin_multiplatform_grpc_plugin.createSingleFileProtoFolder +import io.github.timortel.kotlin_multiplatform_grpc_plugin.validation.BaseValidationTest +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class NestInFileClassGenerationTest : BaseGenerationTest() { + + @TestParameterInjectorTest + fun `test GIVEN proto3 or edition2023 WHEN no option is set THEN all top level declarations are nested`( + @TestParameter(value = ["PROTO3", "EDITION2023"]) version: BaseValidationTest.ProtoVersion + ) { + val fileMap = ProtoSourceGenerator.generateProtoFiles( + logger = logger, + protoFolders = listOf( + createSingleFileProtoFolder( + fileHeader = version.header, + content = """ + message A { message B {} } + enum C { DEFAULT = 0; } + service D { } + """.trimIndent(), + fileName = "ProtoFile" + ) + ), + shouldGenerateTargetMap = targetMapAll, + internalVisibility = false + ) + + fileMap.values.forEach { collection -> + assertEquals(2, collection.size) + Assertions.assertTrue { collection.any { it.name == "ProtoFile" } } + } + } + + @Test + fun `test GIVEN edition2024 WHEN no option is set THEN all top level declarations have their own file`() { + val fileMap = ProtoSourceGenerator.generateProtoFiles( + logger = logger, + protoFolders = listOf( + createSingleFileProtoFolder( + fileHeader = BaseValidationTest.ProtoVersion.EDITION2024.header, + content = """ + message A { message B {} } + enum C { DEFAULT = 0; } + service D { } + """.trimIndent(), + fileName = "ProtoFile" + ) + ), + shouldGenerateTargetMap = targetMapAll, + internalVisibility = false + ) + + fileMap.values.forEach { collection -> + assertEquals(4, collection.size) + Assertions.assertTrue { collection.any { it.name == "A" } } + Assertions.assertTrue { collection.any { it.name == "C" } } + Assertions.assertTrue { collection.any { it.name == "DStub" } } + } + } + + @TestParameterInjectorTest + fun `test GIVEN proto3 or edition2023 WHEN multiple files option is set THEN all top level declarations have their own file`( + @TestParameter(value = ["PROTO3", "EDITION2023"]) version: BaseValidationTest.ProtoVersion + ) { + val fileMap = ProtoSourceGenerator.generateProtoFiles( + logger = logger, + protoFolders = listOf( + createSingleFileProtoFolder( + fileHeader = version.header, + content = """ + option java_multiple_files = true; + message A { message B {} } + enum C { DEFAULT = 0; } + service D { } + """.trimIndent() + ) + ), + shouldGenerateTargetMap = targetMapAll, + internalVisibility = false + ) + + fileMap.values.forEach { collection -> + assertEquals(4, collection.size) + Assertions.assertTrue { collection.any { it.name == "A" } } + Assertions.assertTrue { collection.any { it.name == "C" } } + Assertions.assertTrue { collection.any { it.name == "DStub" } } + } + } + + @Test + fun `test GIVEN edition2024 WHEN legacy feature is set THEN fallback on option happens`() { + val fileMap = ProtoSourceGenerator.generateProtoFiles( + logger = logger, + protoFolders = listOf( + createSingleFileProtoFolder( + fileHeader = BaseValidationTest.ProtoVersion.EDITION2024.header, + content = """ + import "google/protobuf/java_features.proto"; + + option java_multiple_files = false; + option features.(pb.java).nest_in_file_class = LEGACY; + message A { message B {} } + enum C { DEFAULT = 0; } + service D { } + """.trimIndent(), + fileName = "ProtoFile" + ) + ), + shouldGenerateTargetMap = targetMapAll, + internalVisibility = false + ) + + fileMap.values.forEach { collection -> + assertEquals(2, collection.size) + } + } + + @Test + fun `test GIVEN edition2024 WHEN a mix of features is set THEN the correct declarations are in their own file`() { + val fileMap = ProtoSourceGenerator.generateProtoFiles( + logger = logger, + protoFolders = listOf( + createSingleFileProtoFolder( + fileHeader = BaseValidationTest.ProtoVersion.EDITION2024.header, + content = """ + import "google/protobuf/java_features.proto"; + + message A { option features.(pb.java).nest_in_file_class = YES; } + enum C { + option features.(pb.java).nest_in_file_class = NO; + DEFAULT = 0; + } + service D { option features.(pb.java).nest_in_file_class = YES; } + """.trimIndent(), + fileName = "ProtoFile" + ) + ), + shouldGenerateTargetMap = targetMapAll, + internalVisibility = false + ) + + fileMap.values.forEach { collection -> + assertEquals(3, collection.size) + Assertions.assertTrue { collection.any { it.name == "ProtoFile" } } + Assertions.assertTrue { collection.any { it.name == "C" } } + } + } +} diff --git a/readme.md b/readme.md index f755a32a..f6a4cc6e 100644 --- a/readme.md +++ b/readme.md @@ -35,7 +35,7 @@ This projects implements client-side gRPC for Android, JVM, Native (including iO | Proto2 | ⏳ Planned | | Proto3 | ✅ Supported | | Editions 2023 | ✅ Supported | -| Editions 2024 | ⏳ Planned | +| Editions 2024 | ✅ Supported | Please note that not all features may be available even if the protobuf version is marked as _supported_. @@ -71,14 +71,16 @@ Please note that not all features may be available even if the protobuf version | `optimize_for` | ❌ | ❌ | ### Features -| Feature | Edition 2023 | -|---------------------------|--------------| -| `field_presence` | ✅ | -| `repeated_field_encoding` | ✅ | -| `enum_type` | ❌ | -| `json_format` | ❌ | -| `message_encoding` | ❌ | -| `utf8_validation` | ❌ | +| Feature | Edition 2023 | Edition 2024 | +|--------------------------------|--------------|--------------| +| `field_presence` | ✅ | ✅ | +| `repeated_field_encoding` | ✅ | ✅ | +| `enum_type` | ❌ | ❌ | +| `json_format` | ❌ | ❌ | +| `message_encoding` | ❌ | ❌ | +| `utf8_validation` | ❌ | ❌ | +| `default_symbol_visibility` | | ✅ | +| `(pb.java).nest_in_file_class` | | ✅ | From ea726e3f5dc176d28069b9ac93e2336c81bc3dfd Mon Sep 17 00:00:00 2001 From: Tim Ortel <100865202+TimOrtel@users.noreply.github.com> Date: Sun, 7 Dec 2025 11:38:30 +0100 Subject: [PATCH 03/23] Add option target validation tests. Closes #111 --- .../sourcegeneration/CompilationException.kt | 2 + .../model/ProtoOptionsHolder.kt | 86 +++++++------- .../model/declaration/ProtoField.kt | 3 - .../declaration/enumeration/ProtoEnumField.kt | 3 + .../message/field/ProtoMapField.kt | 3 + .../message/field/ProtoMessageField.kt | 40 ++++--- .../message/field/ProtoOneOfField.kt | 3 + .../model/option/OptionTarget.kt | 14 ++- .../model/option/OptionTargetMatcher.kt | 63 +++++++++- .../sourcegeneration/model/option/Options.kt | 8 +- .../OptionHolderTargetValidationTests.kt | 111 ++++++++++++++++++ .../OptionHolderValidationTests.kt | 80 ++++--------- 12 files changed, 291 insertions(+), 125 deletions(-) create mode 100644 kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/options/OptionHolderTargetValidationTests.kt rename kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/{ => options}/OptionHolderValidationTests.kt (73%) diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/CompilationException.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/CompilationException.kt index 284c7f10..6f3797c3 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/CompilationException.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/CompilationException.kt @@ -32,6 +32,8 @@ sealed class CompilationException(val msg: String, val filePath: String, val ctx // Options class OptionFailedParse(message: String, file: ProtoFile, ctx: ParserRuleContext) : CompilationException(message, file, ctx) + class OptionInvalidTarget(message: String, file: ProtoFile, ctx: ParserRuleContext) : CompilationException(message, file, ctx) + class OptionUsedWithInvalidLanguageVersion(message: String, file: ProtoFile, ctx: ParserRuleContext) : CompilationException(message, file, ctx) // Extensions class ExtensionDefinedOnNonExtendableMessage(message: String, file: ProtoFile, ctx: ParserRuleContext) : CompilationException(message, file, ctx) diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/ProtoOptionsHolder.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/ProtoOptionsHolder.kt index 9d4b03d9..2536ae83 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/ProtoOptionsHolder.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/ProtoOptionsHolder.kt @@ -3,8 +3,8 @@ package io.github.timortel.kmpgrpc.plugin.sourcegeneration.model import io.github.timortel.kmpgrpc.plugin.sourcegeneration.CompilationException import io.github.timortel.kmpgrpc.plugin.sourcegeneration.Warnings import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.file.ProtoFile +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.option.MatchResult import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.option.OptionTarget -import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.option.OptionTargetMatcher import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.option.Options import io.github.timortel.kmpgrpc.plugin.sourcegeneration.util.toFilePositionString @@ -19,31 +19,29 @@ interface ProtoOptionsHolder : ProtoNode { override fun validate() { options.forEach { option -> + val isIgnored = option.name in Options.ignoredOptions val relatedOption = Options.options.firstOrNull { it.name == option.name } - val isSupportedOnHolder = relatedOption?.targetMatchers?.any { - it.target == optionTarget::class && when (it) { - is OptionTargetMatcher.TypeDeclaration -> { - !it.restrictToTopLevel || (optionTarget as? OptionTarget.TypeDeclaration)?.isTopLevel == true - } - is OptionTargetMatcher.OtherDeclaration -> true + if (relatedOption == null) { + if (!isIgnored) { + file.project.logger.warn( + Warnings.unsupportedOptionUsed.withMessage( + "${option.name} at ${ + option.ctx.toFilePositionString(file.path) + }" + ) + ) + } + } else { + val throwInvalidOptionTargetException = { message: String -> + throw CompilationException.OptionInvalidTarget( + message = message, + file = file, + ctx = option.ctx + ) } - } == true - - val isIgnored = option.name in Options.ignoredOptions - - val languageConfiguration = relatedOption?.languageConfigurationMap?.get(file.languageVersion) - - val isSupportedOnLanguageVersion = when (languageConfiguration) { - is Options.LangConfig.Available -> true - is Options.LangConfig.Unavailable, null -> false - } - - // An option can be supported, but still not valid, for example because it is only valid on certain field types - val isValid = isSupportedOnHolder && isSupportedOnLanguageVersion && isSupportedOptionValid(option) - if (!isIgnored && isValid) { - when (languageConfiguration) { + when (val languageConfiguration = relatedOption.languageConfigurationMap[file.languageVersion]) { is Options.LangConfig.Available -> { val value = relatedOption.get(this) @@ -55,19 +53,24 @@ interface ProtoOptionsHolder : ProtoNode { ) } } - - is Options.LangConfig.Unavailable, null -> {} + is Options.LangConfig.Unavailable, null -> { + throw CompilationException.OptionUsedWithInvalidLanguageVersion( + message = "${option.name} is not supported on language version ${file.languageVersion}", + file = file, + ctx = option.ctx + ) + } } - } - if (!isIgnored && !isValid) { - file.project.logger.warn( - Warnings.unsupportedOptionUsed.withMessage( - "${option.name} at ${ - option.ctx.toFilePositionString(file.path) - }" - ) - ) + val targetMatcher = relatedOption.targetMatchers.firstOrNull { it.target == optionTarget::class } + if (targetMatcher == null) throwInvalidOptionTargetException("${option.name} is not supported on $optionTarget") + + when (val matchResult = targetMatcher.matches(optionTarget)) { + MatchResult.Success -> {} + is MatchResult.Failure -> { + throwInvalidOptionTargetException(matchResult.reason) + } + } } } @@ -75,16 +78,17 @@ interface ProtoOptionsHolder : ProtoNode { .groupBy { it.name } .filter { it.value.size > 1 } .forEach { (name, options) -> - val message = buildString { - append("Found clashing proto options for $name:\n") - options.joinToString(separator = "\n") { - "-> $name at ${it.ctx.toFilePositionString(file.path)}" + val relatedOption = Options.options.firstOrNull { it.name == name } + if (relatedOption != null) { + val message = buildString { + append("Found clashing proto options for $name:\n") + options.joinToString(separator = "\n") { + "-> $name at ${it.ctx.toFilePositionString(file.path)}" + } } - } - throw CompilationException.DuplicateDeclaration(message, file) + throw CompilationException.DuplicateDeclaration(message, file) + } } } - - fun isSupportedOptionValid(option: ProtoOption): Boolean = true } diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoField.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoField.kt index 4a426f85..9cc61f45 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoField.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoField.kt @@ -1,7 +1,6 @@ package io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.ProtoOptionsHolder -import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.option.OptionTarget import org.antlr.v4.runtime.ParserRuleContext /** @@ -11,6 +10,4 @@ interface ProtoField : ProtoOptionsHolder { val name: String val number: Int val ctx: ParserRuleContext - - override val optionTarget: OptionTarget get() = OptionTarget.FIELD } diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/enumeration/ProtoEnumField.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/enumeration/ProtoEnumField.kt index 8a877c09..1aeb79cd 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/enumeration/ProtoEnumField.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/enumeration/ProtoEnumField.kt @@ -5,6 +5,7 @@ import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.ProtoOptionsHold import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.ProtoEnum import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.ProtoField import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.file.ProtoFile +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.option.OptionTarget import org.antlr.v4.runtime.ParserRuleContext data class ProtoEnumField( @@ -20,4 +21,6 @@ data class ProtoEnumField( override val file: ProtoFile get() = enum.file + + override val optionTarget: OptionTarget get() = OptionTarget.ENUM_ENTRY } diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/message/field/ProtoMapField.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/message/field/ProtoMapField.kt index c9bdda8c..61482637 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/message/field/ProtoMapField.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/message/field/ProtoMapField.kt @@ -10,6 +10,7 @@ import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.ProtoOptionsHold import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.type.ProtoType import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.ProtoMessage import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.message.ProtoMessageProperty +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.option.OptionTarget import org.antlr.v4.runtime.ParserRuleContext data class ProtoMapField( @@ -36,6 +37,8 @@ data class ProtoMapField( override val declarationResolver: DeclarationResolver get() = message + override val optionTarget: OptionTarget get() = OptionTarget.FIELD(type = OptionTarget.FIELD.Type.Map) + init { keyType.parent = ProtoType.Parent.MapField(this) valuesType.parent = ProtoType.Parent.MapField(this) diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/message/field/ProtoMessageField.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/message/field/ProtoMessageField.kt index 9caa8be7..c4c59dce 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/message/field/ProtoMessageField.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/message/field/ProtoMessageField.kt @@ -15,6 +15,7 @@ import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.Prot import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.ProtoMessage import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.message.ProtoMessageProperty import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.file.ProtoFile +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.option.OptionTarget import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.option.Options import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.type.ProtoType import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.type.ProtoType.MessageDefaultValue @@ -38,6 +39,7 @@ class ProtoMessageField( is ProtoExtensionDefinition.Parent.File -> p2.file is ProtoExtensionDefinition.Parent.Message -> p2.message } + is Parent.Message -> p.message } @@ -66,19 +68,22 @@ class ProtoMessageField( FieldCardinality.SINGULAR_OPTIONAL -> ProtoFieldCardinality.Singular(ProtoFieldPresence.EXPLICIT) FieldCardinality.REPEATED -> ProtoFieldCardinality.Repeated } + ProtoLanguageVersion.EDITION2023, ProtoLanguageVersion.EDITION2024 -> when (fieldCardinality) { FieldCardinality.SINGULAR -> ProtoFieldCardinality.Singular( presence = Options.Feature.fieldPresence.get(this) ) + FieldCardinality.REPEATED -> ProtoFieldCardinality.Repeated FieldCardinality.SINGULAR_OPTIONAL -> throw IllegalArgumentException("FieldCardinality.SINGULAR_OPTIONAL is illegal for edition versions.") } } - override val desiredAttributeName: String get() = when (cardinality) { - is ProtoFieldCardinality.Singular -> name - ProtoFieldCardinality.Repeated -> "${name}List" - } + override val desiredAttributeName: String + get() = when (cardinality) { + is ProtoFieldCardinality.Singular -> name + ProtoFieldCardinality.Repeated -> "${name}List" + } override val propertyType: TypeName get() = when (cardinality) { @@ -114,7 +119,9 @@ class ProtoMessageField( override val isPacked: Boolean get() = cardinality == ProtoFieldCardinality.Repeated && type.isPackable && when (file.languageVersion) { ProtoLanguageVersion.PROTO3 -> Options.Basic.packed.get(this) - ProtoLanguageVersion.EDITION2023, ProtoLanguageVersion.EDITION2024 -> when (Options.Feature.repeatedFieldEncoding.get(this)) { + ProtoLanguageVersion.EDITION2023, ProtoLanguageVersion.EDITION2024 -> when (Options.Feature.repeatedFieldEncoding.get( + this + )) { ProtoRepeatedFieldEncoding.PACKED -> true ProtoRepeatedFieldEncoding.EXPANDED -> false } @@ -122,7 +129,7 @@ class ProtoMessageField( val memberName: MemberName get() { - val parentClassName = when (val p = parent) { + val parentClassName = when (val p = parent) { is Parent.ExtensionDefinition -> p.ext.parent.className is Parent.Message -> p.message.className } @@ -130,6 +137,17 @@ class ProtoMessageField( return parentClassName.member(name) } + override val optionTarget: OptionTarget + get() = OptionTarget.FIELD( + type = OptionTarget.FIELD.Type.Regular( + isRepeated = when (cardinality) { + ProtoFieldCardinality.Repeated -> true + is ProtoFieldCardinality.Singular -> false + }, + isPackable = type.isPackable + ) + ) + init { type.parent = ProtoType.Parent.MessageField(this) } @@ -141,16 +159,6 @@ class ProtoMessageField( } } - override fun isSupportedOptionValid(option: ProtoOption): Boolean { - return when (option.name) { - Options.Basic.packed.name -> { - // packed option is only valid on repeated fields that have a packable type - cardinality == ProtoFieldCardinality.Repeated && type.isPackable - } - else -> super.isSupportedOptionValid(option) - } - } - override fun validate() { super.validate() diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/message/field/ProtoOneOfField.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/message/field/ProtoOneOfField.kt index 9886d340..013ce3ff 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/message/field/ProtoOneOfField.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/message/field/ProtoOneOfField.kt @@ -9,6 +9,7 @@ import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.ProtoOptionsHold import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.ProtoChildPropertyNameResolver import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.type.ProtoType import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.message.ProtoOneOf +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.option.OptionTarget import org.antlr.v4.runtime.ParserRuleContext data class ProtoOneOfField( @@ -40,6 +41,8 @@ data class ProtoOneOfField( override val isPacked: Boolean = false + override val optionTarget: OptionTarget get() = OptionTarget.FIELD(type = OptionTarget.FIELD.Type.OneOf) + init { type.parent = ProtoType.Parent.OneOfField(this) } diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/OptionTarget.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/OptionTarget.kt index 0b4eb6d2..85ce580e 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/OptionTarget.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/OptionTarget.kt @@ -9,11 +9,23 @@ sealed interface OptionTarget { interface OtherDeclaration : OptionTarget data object FILE : OtherDeclaration + data object EXTENSION_RANGE : OtherDeclaration + data class MESSAGE(override val isTopLevel: Boolean) : TypeDeclaration - data object FIELD : OtherDeclaration + + data class FIELD(val type: Type) : OtherDeclaration { + sealed interface Type { + data object Map : Type + data object OneOf : Type + data class Regular(val isRepeated: Boolean, val isPackable: Boolean) : Type + } + } + data object ONEOF : OtherDeclaration + data class ENUM(override val isTopLevel: Boolean) : TypeDeclaration + data object ENUM_ENTRY : OtherDeclaration data object SERVICE : TypeDeclaration { diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/OptionTargetMatcher.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/OptionTargetMatcher.kt index 9afc303a..58715191 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/OptionTargetMatcher.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/OptionTargetMatcher.kt @@ -6,18 +6,73 @@ sealed interface OptionTargetMatcher { val target: KClass + fun matches(target: OptionTarget): MatchResult { + return if (target::class == this.target) MatchResult.Success + else MatchResult.Failure("Invalid option target") + } + sealed class TypeDeclaration(override val target: KClass, val restrictToTopLevel: Boolean) : - OptionTargetMatcher + OptionTargetMatcher { + override fun matches(target: OptionTarget): MatchResult { + if (target !is OptionTarget.TypeDeclaration) return MatchResult.Failure("Invalid option target") + + return (!restrictToTopLevel || target.isTopLevel) asResult "Only applicable to top level but not applied to top level declaration" + } + } sealed class OtherDeclaration(override val target: KClass) : OptionTargetMatcher data object FILE : OtherDeclaration(OptionTarget.FILE::class) data object EXTENSION_RANGE : OtherDeclaration(OptionTarget.EXTENSION_RANGE::class) - class MESSAGE(restrictToTopLevel: Boolean = false) : TypeDeclaration(OptionTarget.MESSAGE::class, restrictToTopLevel) - data object FIELD : OtherDeclaration(OptionTarget.FIELD::class) + class MESSAGE(restrictToTopLevel: Boolean = false) : + TypeDeclaration(OptionTarget.MESSAGE::class, restrictToTopLevel) + + data class FIELD(val restriction: Restriction = Restriction.NoRestriction) : + OtherDeclaration(OptionTarget.FIELD::class) { + override fun matches(target: OptionTarget): MatchResult { + if (target !is OptionTarget.FIELD) return MatchResult.Failure("Invalid option target") + + return when (restriction) { + Restriction.NoRestriction -> MatchResult.Success + is Restriction.OnlyOnRepeated -> when (target.type) { + OptionTarget.FIELD.Type.Map, OptionTarget.FIELD.Type.OneOf -> MatchResult.Failure("Only applicable to repeated fields, but applied to ${target.type}") + is OptionTarget.FIELD.Type.Regular -> target.type.isRepeated asResult "Only applicable to repeated fields but field is not repeated" and + ((!restriction.forcePackable || target.type.isPackable) asResult "Only applicable to packable field types but type is not packable.") + } + } + } + + sealed interface Restriction { + data object NoRestriction : Restriction + data class OnlyOnRepeated(val forcePackable: Boolean) : Restriction + } + } + data object ONEOF : OtherDeclaration(OptionTarget.ONEOF::class) + class ENUM(restrictToTopLevel: Boolean = false) : TypeDeclaration(OptionTarget.ENUM::class, restrictToTopLevel) + data object ENUM_ENTRY : OtherDeclaration(OptionTarget.ENUM_ENTRY::class) - class SERVICE(restrictToTopLevel: Boolean = false) : TypeDeclaration(OptionTarget.SERVICE::class, restrictToTopLevel) + + class SERVICE(restrictToTopLevel: Boolean = false) : + TypeDeclaration(OptionTarget.SERVICE::class, restrictToTopLevel) + data object METHOD : OtherDeclaration(OptionTarget.METHOD::class) + +} + +sealed interface MatchResult { + data object Success : MatchResult + data class Failure(val reason: String) : MatchResult +} + +infix fun Boolean.asResult(failureString: String): MatchResult { + return if (this) MatchResult.Success else MatchResult.Failure(failureString) +} + +infix fun MatchResult.and(other: MatchResult): MatchResult { + return when (this) { + is MatchResult.Failure -> this + MatchResult.Success -> other + } } diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/Options.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/Options.kt index 062723dd..9b1c9ec5 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/Options.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/Options.kt @@ -44,7 +44,7 @@ object Options { val deprecated = SimpleProtoOption( name = "deprecated", parse = String::toBooleanStrictOrNull, - targets = listOf(OptionTargetMatcher.FIELD), + targets = listOf(OptionTargetMatcher.FIELD()), proto3Config = LangConfig.Available(defaultValue = false), editionConfig = LangConfig.Available(defaultValue = false) ) @@ -52,7 +52,7 @@ object Options { val packed = SimpleProtoOption( name = "packed", parse = String::toBooleanStrictOrNull, - targets = listOf(OptionTargetMatcher.FIELD), + targets = listOf(OptionTargetMatcher.FIELD(restriction = OptionTargetMatcher.FIELD.Restriction.OnlyOnRepeated(forcePackable = true))), proto3Config = LangConfig.Available(defaultValue = true), editionConfig = LangConfig.Available(defaultValue = true, isLocked = true) ) @@ -64,7 +64,7 @@ object Options { parse = { value -> ProtoFieldPresence.entries.firstOrNull { it.name == value } }, edition2023Config = LangConfig.Available(defaultValue = ProtoFieldPresence.EXPLICIT), edition2024Config = LangConfig.Available(defaultValue = ProtoFieldPresence.EXPLICIT), - targets = listOf(OptionTargetMatcher.FILE, OptionTargetMatcher.FIELD) + targets = listOf(OptionTargetMatcher.FILE, OptionTargetMatcher.FIELD()) ) val repeatedFieldEncoding = FeatureProtoOption( @@ -72,7 +72,7 @@ object Options { parse = { value -> ProtoRepeatedFieldEncoding.entries.firstOrNull { it.name == value } }, edition2023Config = LangConfig.Available(defaultValue = ProtoRepeatedFieldEncoding.PACKED), edition2024Config = LangConfig.Available(defaultValue = ProtoRepeatedFieldEncoding.PACKED), - targets = listOf(OptionTargetMatcher.FILE, OptionTargetMatcher.FIELD) + targets = listOf(OptionTargetMatcher.FILE, OptionTargetMatcher.FIELD(OptionTargetMatcher.FIELD.Restriction.OnlyOnRepeated(forcePackable = true))) ) val defaultSymbolVisibility = FeatureProtoOption( diff --git a/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/options/OptionHolderTargetValidationTests.kt b/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/options/OptionHolderTargetValidationTests.kt new file mode 100644 index 00000000..6bf38e73 --- /dev/null +++ b/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/options/OptionHolderTargetValidationTests.kt @@ -0,0 +1,111 @@ +package io.github.timortel.kotlin_multiplatform_grpc_plugin.validation.options + +import com.google.testing.junit.testparameterinjector.junit5.TestParameter +import com.google.testing.junit.testparameterinjector.junit5.TestParameterInjectorTest +import com.google.testing.junit.testparameterinjector.junit5.TestParameterValuesProvider +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.CompilationException +import io.github.timortel.kotlin_multiplatform_grpc_plugin.validation.BaseValidationTest +import io.mockk.verify +import org.junit.jupiter.api.assertThrows + +class OptionHolderTargetValidationTests : BaseValidationTest() { + + data class OptionApplicationScenario(val content: String, val version: ProtoVersion) + + class ValidOptionApplicationScenarioProvider : TestParameterValuesProvider() { + override fun provideValues(context: Context?): List<*> { + val allVersionScenarios = listOf( + """ + option java_package = foo.bar; + """.trimIndent(), + """ + message A { + repeated int32 a = 1 [packed = true]; + } + """.trimIndent() + ) + + val featureScenarios = listOf( + """ + option features.field_presence = EXPLICIT; + """.trimIndent(), + """ + message A { + repeated int32 a = 1 [features.field_presence = EXPLICIT]; + } + """.trimIndent(), + """ + message A { + repeated int32 a = 1 [features.repeated_field_encoding = PACKED]; + } + """.trimIndent(), + ) + + return allVersionScenarios.flatMap { ProtoVersion.entries.map { version -> OptionApplicationScenario(it, version) } } + + featureScenarios.flatMap { listOf(ProtoVersion.EDITION2023, ProtoVersion.EDITION2024).map { version -> OptionApplicationScenario(it, version) } } + } + } + + @TestParameterInjectorTest + fun `test WHEN target option is applied to correct target THEN no warning is printed`( + @TestParameter(valuesProvider = ValidOptionApplicationScenarioProvider::class) scenario: OptionApplicationScenario + ) { + runGenerator( + content = scenario.content, + protoVersion = scenario.version + ) + + verify(exactly = 0) { logger.warn(any()) } + } + + class InvalidOptionApplicationScenarioProvider : TestParameterValuesProvider() { + override fun provideValues(context: Context?): List<*> { + val allVersionScenarios = listOf( + """ + message A { + option java_package = foo.bar; + } + """.trimIndent(), + """ + message A { + int32 a = 1 [packed = true]; + } + """.trimIndent() + ) + + val featureScenarios = listOf( + """ + enum A { + option features.field_presence = EXPLICIT; + DEFAULT = 0; + } + """.trimIndent(), + """ + message A { + int32 a = 1 [features.repeated_field_encoding = PACKED]; + } + """.trimIndent(), + """ + message A { + repeated string a = 1 [features.repeated_field_encoding = PACKED]; + } + """.trimIndent() + ) + + return allVersionScenarios.flatMap { ProtoVersion.entries.map { version -> OptionApplicationScenario(it, version) } } + + featureScenarios.flatMap { listOf(ProtoVersion.EDITION2023, ProtoVersion.EDITION2024).map { version -> OptionApplicationScenario(it, version) } } + } + } + + @TestParameterInjectorTest + fun `test WHEN file target option is applied to message level THEN exception is thrown`( + @TestParameter(valuesProvider = InvalidOptionApplicationScenarioProvider::class) scenario: OptionApplicationScenario + ) { + assertThrows { + runGenerator( + content = scenario.content, + protoVersion = scenario.version + ) + } + } +} diff --git a/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/OptionHolderValidationTests.kt b/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/options/OptionHolderValidationTests.kt similarity index 73% rename from kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/OptionHolderValidationTests.kt rename to kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/options/OptionHolderValidationTests.kt index 3fe40827..cc76daaa 100644 --- a/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/OptionHolderValidationTests.kt +++ b/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/options/OptionHolderValidationTests.kt @@ -1,11 +1,14 @@ -package io.github.timortel.kotlin_multiplatform_grpc_plugin.validation +package io.github.timortel.kotlin_multiplatform_grpc_plugin.validation.options import com.google.testing.junit.testparameterinjector.junit5.TestParameter import com.google.testing.junit.testparameterinjector.junit5.TestParameterInjectorTest +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.CompilationException import io.github.timortel.kmpgrpc.plugin.sourcegeneration.Warnings import io.github.timortel.kotlin_multiplatform_grpc_plugin.matchWarning +import io.github.timortel.kotlin_multiplatform_grpc_plugin.validation.BaseValidationTest import io.mockk.verify import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows class OptionHolderValidationTests : BaseValidationTest() { @@ -127,64 +130,16 @@ class OptionHolderValidationTests : BaseValidationTest() { verify(atLeast = 1) { logger.warn(matchWarning(Warnings.unsupportedOptionUsed)) } } - @TestParameterInjectorTest - fun `test WHEN packed option is used on a non-repeated field THEN a warning is printed`( - @TestParameter protoVersion: ProtoVersion - ) { - runGenerator( - """ - message TestMessage { - bool a = 1 [packed = false]; - } - """.trimIndent(), - protoVersion - ) - - verify(atLeast = 1) { logger.warn(matchWarning(Warnings.unsupportedOptionUsed)) } - } - - @TestParameterInjectorTest - fun `test WHEN packed option is used on a repeated field that has a non-packable type THEN a warning is printed`( - @TestParameter protoVersion: ProtoVersion - ) { - runGenerator( - """ - message TestMessage { - repeated string a = 1 [packed = false]; - } - """.trimIndent(), - protoVersion - ) - - verify(atLeast = 1) { logger.warn(matchWarning(Warnings.unsupportedOptionUsed)) } - } - - @TestParameterInjectorTest - fun `test WHEN packed option is used on a repeated field that has a packable type THEN no warning is printed`( - @TestParameter protoVersion: ProtoVersion - ) { - runGenerator( - """ - message TestMessage { - repeated int32 a = 1 [packed = false]; - } - """.trimIndent(), - protoVersion - ) - - verify(exactly = 0) { logger.warn(matchWarning(Warnings.unsupportedOptionUsed)) } - } - @Test - fun `test WHEN feature option is used on proto3 THEN a warning is printed`() { - runGenerator( - """ + fun `test WHEN feature option is used on proto3 THEN exception is thrown`() { + assertThrows { + runGenerator( + """ option features.field_presence = IMPLICIT; """.trimIndent(), - protoVersion = ProtoVersion.PROTO3 - ) - - verify(exactly = 1) { logger.warn(matchWarning(Warnings.unsupportedOptionUsed)) } + protoVersion = ProtoVersion.PROTO3 + ) + } } @Test @@ -198,4 +153,17 @@ class OptionHolderValidationTests : BaseValidationTest() { verify(exactly = 0) { logger.warn(matchWarning(Warnings.unsupportedOptionUsed)) } } + + @TestParameterInjectorTest + fun `test WHEN unknown option is declared multiple times THEN exception is thrown`( + @TestParameter version: ProtoVersion + ) { + runGenerator( + """ + option a = A; + option a = B; + """.trimIndent(), + protoVersion = version + ) + } } From 0db15c1700d6f944e72884f254c0b0ca5a672c79 Mon Sep 17 00:00:00 2001 From: Tim Ortel <100865202+TimOrtel@users.noreply.github.com> Date: Sun, 7 Dec 2025 12:57:50 +0100 Subject: [PATCH 04/23] Add (unsupported) handling for option and public import. --- .../plugin/sourcegeneration/Warnings.kt | 10 +++++ .../model/ProtoOptionsHolder.kt | 39 ++++++++++++------- .../sourcegeneration/model/file/ProtoFile.kt | 2 + .../model/file/ProtoImport.kt | 36 ++++++++++++++++- .../model/option/FeatureProtoOption.kt | 3 +- .../sourcegeneration/model/option/Option.kt | 3 +- .../sourcegeneration/model/option/Options.kt | 5 ++- .../model/option/SimpleProtoOption.kt | 15 ++++--- .../parsing/ProtobufModelBuilderVisitor.kt | 19 +++++++-- .../NestInFileClassGenerationTest.kt | 16 ++++++-- 10 files changed, 117 insertions(+), 31 deletions(-) diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/Warnings.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/Warnings.kt index 6d27612a..d851ee6e 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/Warnings.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/Warnings.kt @@ -1,5 +1,9 @@ package io.github.timortel.kmpgrpc.plugin.sourcegeneration +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.file.ProtoFile +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.util.toFilePositionString +import org.antlr.v4.runtime.ParserRuleContext + object Warnings { val enumAliasWithoutOption = Warning("Enum alias detected but option allow_alias not set") @@ -8,11 +12,17 @@ object Warnings { val unsupportedOptionValueUsed = Warning("Locked option value overwritten") + val unsupportedPublicImportUsed = Warning("Public import usage") + + val unsupportedOptionImportUsed = Warning("Option import usage") + class Warning(prefix: String) { private val prefix: String = "Warning: $prefix" fun withMessage(message: String): String = "$prefix - $message" + fun withMessage(ctx: ParserRuleContext, file: ProtoFile, message: String): String = withMessage("${ctx.toFilePositionString(file.path)}: $message") + fun isWarning(warning: String): Boolean = warning.startsWith(prefix) } } diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/ProtoOptionsHolder.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/ProtoOptionsHolder.kt index 2536ae83..a734dad6 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/ProtoOptionsHolder.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/ProtoOptionsHolder.kt @@ -33,26 +33,19 @@ interface ProtoOptionsHolder : ProtoNode { ) } } else { - val throwInvalidOptionTargetException = { message: String -> - throw CompilationException.OptionInvalidTarget( - message = message, - file = file, - ctx = option.ctx - ) - } - when (val languageConfiguration = relatedOption.languageConfigurationMap[file.languageVersion]) { is Options.LangConfig.Available -> { val value = relatedOption.get(this) if (languageConfiguration.isLocked && value != languageConfiguration.defaultValue) { Warnings.unsupportedOptionValueUsed.withMessage( - "${option.name} at ${ - option.ctx.toFilePositionString(file.path) - } has fixed value of ${languageConfiguration.defaultValue}. Set value will be ignored." + ctx = option.ctx, + file = file, + message = "${option.name} has fixed value of ${languageConfiguration.defaultValue}. Set value will be ignored." ) } } + is Options.LangConfig.Unavailable, null -> { throw CompilationException.OptionUsedWithInvalidLanguageVersion( message = "${option.name} is not supported on language version ${file.languageVersion}", @@ -62,13 +55,33 @@ interface ProtoOptionsHolder : ProtoNode { } } + val onInvalidOptionTargetUse = { message: String -> + if (relatedOption.failOnInvalidTargetUsage) { + throw CompilationException.OptionInvalidTarget( + message = message, + file = file, + ctx = option.ctx + ) + } else { + Warnings.unsupportedOptionUsed.withMessage( + ctx = option.ctx, + file = file, + message = message + ) + } + } + val targetMatcher = relatedOption.targetMatchers.firstOrNull { it.target == optionTarget::class } - if (targetMatcher == null) throwInvalidOptionTargetException("${option.name} is not supported on $optionTarget") + if (targetMatcher == null) { + onInvalidOptionTargetUse("${option.name} is not supported on $optionTarget") + return@forEach + } when (val matchResult = targetMatcher.matches(optionTarget)) { MatchResult.Success -> {} is MatchResult.Failure -> { - throwInvalidOptionTargetException(matchResult.reason) + onInvalidOptionTargetUse(matchResult.reason) + return@forEach } } } diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/file/ProtoFile.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/file/ProtoFile.kt index a1a04a97..a58dab7b 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/file/ProtoFile.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/file/ProtoFile.kt @@ -80,6 +80,7 @@ data class ProtoFile( init { val parent = ProtoDeclParent.File(this) + imports.forEach { it.file = this } services.forEach { it.file = this } messages.forEach { it.parent = parent } enums.forEach { it.parent = parent } @@ -98,5 +99,6 @@ data class ProtoFile( messages.forEach { it.validate() } enums.forEach { it.validate() } services.forEach { it.validate() } + imports.forEach { it.validate() } } } diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/file/ProtoImport.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/file/ProtoImport.kt index c02a50ba..6567b153 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/file/ProtoImport.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/file/ProtoImport.kt @@ -1,8 +1,42 @@ package io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.file +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.CompilationException +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.Warnings +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.ProtoLanguageVersion +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.ProtoNode import org.antlr.v4.runtime.ParserRuleContext -data class ProtoImport(val identifier: String, val ctx: ParserRuleContext) { +data class ProtoImport(val identifier: String, val type: Type, val ctx: ParserRuleContext) : ProtoNode { + + lateinit var file: ProtoFile + // Remove " from identifier val path: String = identifier.substring(1, identifier.length - 1) + + override fun validate() { + when (type) { + Type.PUBLIC -> { + file.project.logger.warn(Warnings.unsupportedPublicImportUsed.withMessage(ctx, file, "Currently not supported")) + } + Type.OPTION -> { + when (file.languageVersion) { + ProtoLanguageVersion.PROTO3, ProtoLanguageVersion.EDITION2023 -> throw CompilationException.UnsupportedLanguageFeatureUsed( + message = "Option imports are not available in language version ${file.languageVersion}", + file = file, + ctx = ctx + ) + ProtoLanguageVersion.EDITION2024 -> { + file.project.logger.warn(Warnings.unsupportedOptionImportUsed.withMessage(ctx, file, "Currently not supported")) + } + } + } + Type.DEFAULT -> {} + } + } + + enum class Type { + DEFAULT, + OPTION, + PUBLIC + } } diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/FeatureProtoOption.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/FeatureProtoOption.kt index 729d6c02..1adba00c 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/FeatureProtoOption.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/FeatureProtoOption.kt @@ -17,7 +17,8 @@ class FeatureProtoOption( name = "features.$name", parse = parse, languageConfigurationMap = languageConfigurationMap, - targetMatchers = targets + targetMatchers = targets, + failOnInvalidTargetUsage = true ) { constructor( diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/Option.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/Option.kt index cda3711a..b920afcc 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/Option.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/Option.kt @@ -11,7 +11,8 @@ abstract class Option( val name: String, val parse: (String) -> T?, val languageConfigurationMap: Map>, - val targetMatchers: List + val targetMatchers: List, + val failOnInvalidTargetUsage: Boolean ) { init { diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/Options.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/Options.kt index 9b1c9ec5..791011b9 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/Options.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/Options.kt @@ -44,9 +44,10 @@ object Options { val deprecated = SimpleProtoOption( name = "deprecated", parse = String::toBooleanStrictOrNull, - targets = listOf(OptionTargetMatcher.FIELD()), + targets = listOf(OptionTargetMatcher.FIELD(), OptionTargetMatcher.ENUM_ENTRY), proto3Config = LangConfig.Available(defaultValue = false), - editionConfig = LangConfig.Available(defaultValue = false) + editionConfig = LangConfig.Available(defaultValue = false), + failOnInvalidTargetUsage = false ) val packed = SimpleProtoOption( diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/SimpleProtoOption.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/SimpleProtoOption.kt index da3efb3e..f96a31b9 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/SimpleProtoOption.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/SimpleProtoOption.kt @@ -11,22 +11,25 @@ class SimpleProtoOption( name: String, parse: (String) -> T?, languageConfigurationMap: Map>, - targets: List -) : Option(name, parse, languageConfigurationMap, targets) { + targets: List, + failOnInvalidTargetUsage: Boolean +) : Option(name, parse, languageConfigurationMap, targets, failOnInvalidTargetUsage) { constructor( name: String, parse: (String) -> T?, targets: List, proto3Config: LangConfig, - editionConfig: LangConfig + editionConfig: LangConfig, + failOnInvalidTargetUsage: Boolean = true ) : this( name = name, parse = parse, proto3Config = proto3Config, edition2023Config = editionConfig, edition2024Config = editionConfig, - targets = targets + targets = targets, + failOnInvalidTargetUsage = failOnInvalidTargetUsage ) constructor( @@ -36,6 +39,7 @@ class SimpleProtoOption( proto3Config: LangConfig, edition2023Config: LangConfig, edition2024Config: LangConfig, + failOnInvalidTargetUsage: Boolean = true ) : this( name = name, parse = parse, @@ -44,7 +48,8 @@ class SimpleProtoOption( ProtoLanguageVersion.EDITION2023 to edition2023Config, ProtoLanguageVersion.EDITION2024 to edition2024Config ), - targets = targets + targets = targets, + failOnInvalidTargetUsage = failOnInvalidTargetUsage ) override fun get(optionsHolder: ProtoOptionsHolder): T { diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/parsing/ProtobufModelBuilderVisitor.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/parsing/ProtobufModelBuilderVisitor.kt index 2b30df37..e4a2b355 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/parsing/ProtobufModelBuilderVisitor.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/parsing/ProtobufModelBuilderVisitor.kt @@ -118,16 +118,27 @@ class ProtobufModelBuilderVisitor( ) } - private fun visitImportStatement(ctx: ParserRuleContext, identifier: String): ProtoImport { - return ProtoImport(identifier, ctx) + private fun visitImportStatement(ctx: ParserRuleContext, identifier: String, type: ProtoImport.Type): ProtoImport { + return ProtoImport(identifier = identifier, type = type, ctx = ctx) } override fun visitImportStatement(ctx: ProtobufEditionsParser.ImportStatementContext): ProtoImport { - return visitImportStatement(ctx, ctx.strLit().text) + val type = when { + ctx.OPTION() != null -> ProtoImport.Type.OPTION + ctx.PUBLIC() != null -> ProtoImport.Type.PUBLIC + else -> ProtoImport.Type.DEFAULT + } + + return visitImportStatement(ctx, ctx.strLit().text, type) } override fun visitImportStatement(ctx: Protobuf3Parser.ImportStatementContext): ProtoImport { - return visitImportStatement(ctx, ctx.strLit().text) + val type = when { + ctx.PUBLIC() != null -> ProtoImport.Type.PUBLIC + else -> ProtoImport.Type.DEFAULT + } + + return visitImportStatement(ctx, ctx.strLit().text, type) } private fun visitOption(ctx: ParserRuleContext, name: String, constant: String): ProtoOption { diff --git a/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/generation/NestInFileClassGenerationTest.kt b/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/generation/NestInFileClassGenerationTest.kt index 89bcc356..e830fb69 100644 --- a/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/generation/NestInFileClassGenerationTest.kt +++ b/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/generation/NestInFileClassGenerationTest.kt @@ -105,10 +105,18 @@ class NestInFileClassGenerationTest : BaseGenerationTest() { import "google/protobuf/java_features.proto"; option java_multiple_files = false; - option features.(pb.java).nest_in_file_class = LEGACY; - message A { message B {} } - enum C { DEFAULT = 0; } - service D { } + + message A { + option features.(pb.java).nest_in_file_class = LEGACY; + message B {} + } + enum C { + option features.(pb.java).nest_in_file_class = LEGACY; + DEFAULT = 0; + } + service D { + option features.(pb.java).nest_in_file_class = LEGACY; + } """.trimIndent(), fileName = "ProtoFile" ) From 8b2530b54045eaa8bed661502b918182c63af707 Mon Sep 17 00:00:00 2001 From: Tim Ortel <100865202+TimOrtel@users.noreply.github.com> Date: Sun, 7 Dec 2025 13:03:13 +0100 Subject: [PATCH 05/23] Only write top level enums for common source set. --- .../generators/protofile/ProtoFileWriter.kt | 39 ++++++++++++------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/ProtoFileWriter.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/ProtoFileWriter.kt index da610fa6..ea8950f2 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/ProtoFileWriter.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/ProtoFileWriter.kt @@ -9,6 +9,7 @@ import io.github.timortel.kmpgrpc.plugin.sourcegeneration.generators.protofile.e import io.github.timortel.kmpgrpc.plugin.sourcegeneration.generators.protofile.message.ProtoMessageWriter import io.github.timortel.kmpgrpc.plugin.sourcegeneration.generators.service.ProtoServiceWriter import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.ProtoBaseDeclaration +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.ProtoEnum import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.file.ProtoFile abstract class ProtoFileWriter(val isActual: Boolean) { @@ -17,12 +18,13 @@ abstract class ProtoFileWriter(val isActual: Boolean) { abstract val protoEnumWriter: ProtoEnumerationWriter fun generateFiles(file: ProtoFile): List { - val declarationsWithData = (file.messages.map { protoMessageWriter.generateProtoMessageClass(message = it) to it } + - file.services.map { protoServiceWriter.generateServiceStub(service = it) to it } + - file.enums.map { protoEnumWriter.generateProtoEnum(protoEnum = it) to it }) - .map { (type, declaration) -> - TopLevelDeclarationTypeData(type = type, declaration = declaration, file = file) - } + val declarationsWithData = + (file.messages.map { protoMessageWriter.generateProtoMessageClass(message = it) to it } + + file.services.map { protoServiceWriter.generateServiceStub(service = it) to it } + + file.enums.map { protoEnumWriter.generateProtoEnum(protoEnum = it) to it }) + .map { (type, declaration) -> + TopLevelDeclarationTypeData(type = type, declaration = declaration, file = file) + } val hasNestedDeclarations = declarationsWithData.any { it.nestInFileClass } @@ -34,19 +36,26 @@ abstract class ProtoFileWriter(val isActual: Boolean) { listOf(file) } else emptyList() - val topLevelDeclarationFiles = declarationsWithData.filterNot { it.nestInFileClass }.map { - FileSpec - .builder(it.declaration.className) - .addAnnotation(DefaultAnnotations.SuppressDeprecation) - .addAnnotation(DefaultAnnotations.OptIntoKmpGrpcInternalApi) - .addType(it.type) - .build() - } + val topLevelDeclarationFiles = declarationsWithData + .filterNot { it.nestInFileClass } + .filter { it.declaration !is ProtoEnum || !isActual } + .map { + FileSpec + .builder(it.declaration.className) + .addAnnotation(DefaultAnnotations.SuppressDeprecation) + .addAnnotation(DefaultAnnotations.OptIntoKmpGrpcInternalApi) + .addType(it.type) + .build() + } return baseFile + topLevelDeclarationFiles } - private data class TopLevelDeclarationTypeData(val type: TypeSpec, val declaration: ProtoBaseDeclaration, val nestInFileClass: Boolean) { + private data class TopLevelDeclarationTypeData( + val type: TypeSpec, + val declaration: ProtoBaseDeclaration, + val nestInFileClass: Boolean + ) { constructor(type: TypeSpec, declaration: ProtoBaseDeclaration, file: ProtoFile) : this( type = type, declaration = declaration, From 5add000f4c24fec3436b5d8749cae820d408a654 Mon Sep 17 00:00:00 2001 From: Tim Ortel <100865202+TimOrtel@users.noreply.github.com> Date: Sun, 21 Dec 2025 13:08:20 +0100 Subject: [PATCH 06/23] add support for closed enums. --- .../kmpgrpc/core/io/CodedInputStream.kt | 60 +---- .../core/io/internal/CodedInputStreamImpl.kt | 4 + .../timortel/kmpgrpc/core/io/mapreader.kt | 65 ++++++ .../kmpgrpc/core/message/EnumCompanion.kt | 9 + .../proto/editions/closed-enum-test.proto | 35 +++ .../proto/editions/open-enum-test.proto | 31 +++ ...OpenClosedEnumMapFieldSerializationTest.kt | 95 ++++++++ .../OpenClosedEnumOneOfSerializationTest.kt | 82 +++++++ ...losedEnumRepeatedFieldSerializationTest.kt | 110 ++++++++++ ...nClosedEnumScalarFieldSerializationTest.kt | 58 +++++ .../sourcegeneration/CompilationException.kt | 1 + .../sourcegeneration/constants/Const.kt | 14 +- .../constants/library_fields.kt | 2 + .../enumeration/ProtoEnumerationWriter.kt | 73 ++++--- .../DeserializationFunctionExtension.kt | 205 +++++++++++++----- .../model/ProtoExtensionDefinition.kt | 2 + .../model/declaration/ProtoEnum.kt | 26 +++ .../message/field/ProtoMapField.kt | 7 + .../message/field/ProtoRegularField.kt | 6 + .../sourcegeneration/model/option/Options.kt | 11 +- .../model/option/ProtoEnumType.kt | 6 + .../model/service/ProtoRpc.kt | 7 + .../sourcegeneration/model/type/ProtoType.kt | 22 +- .../validation/EnumImportValidationTest.kt | 80 +++++++ .../kmpgrpc/shared/internal/io/DataType.kt | 2 +- readme.md | 7 +- 26 files changed, 870 insertions(+), 150 deletions(-) create mode 100644 kmp-grpc-core/src/commonMain/kotlin/io/github/timortel/kmpgrpc/core/io/mapreader.kt create mode 100644 kmp-grpc-internal-test/src/commonMain/proto/editions/closed-enum-test.proto create mode 100644 kmp-grpc-internal-test/src/commonMain/proto/editions/open-enum-test.proto create mode 100644 kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/OpenClosedEnumMapFieldSerializationTest.kt create mode 100644 kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/OpenClosedEnumOneOfSerializationTest.kt create mode 100644 kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/OpenClosedEnumRepeatedFieldSerializationTest.kt create mode 100644 kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/OpenClosedEnumScalarFieldSerializationTest.kt create mode 100644 kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/ProtoEnumType.kt create mode 100644 kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/EnumImportValidationTest.kt diff --git a/kmp-grpc-core/src/commonMain/kotlin/io/github/timortel/kmpgrpc/core/io/CodedInputStream.kt b/kmp-grpc-core/src/commonMain/kotlin/io/github/timortel/kmpgrpc/core/io/CodedInputStream.kt index 4d471e8f..d6f0005d 100644 --- a/kmp-grpc-core/src/commonMain/kotlin/io/github/timortel/kmpgrpc/core/io/CodedInputStream.kt +++ b/kmp-grpc-core/src/commonMain/kotlin/io/github/timortel/kmpgrpc/core/io/CodedInputStream.kt @@ -1,6 +1,5 @@ package io.github.timortel.kmpgrpc.core.io -import io.github.timortel.kmpgrpc.shared.internal.io.DataType import io.github.timortel.kmpgrpc.core.message.Message import io.github.timortel.kmpgrpc.core.message.MessageDeserializer import io.github.timortel.kmpgrpc.core.message.UnknownField @@ -8,7 +7,6 @@ import io.github.timortel.kmpgrpc.core.message.extensions.Extension import io.github.timortel.kmpgrpc.core.message.extensions.ExtensionRegistry import io.github.timortel.kmpgrpc.shared.internal.InternalKmpGrpcApi import io.github.timortel.kmpgrpc.shared.internal.io.WireFormat -import io.github.timortel.kmpgrpc.shared.internal.io.wireFormatForType import io.github.timortel.kmpgrpc.shared.internal.io.wireFormatGetTagFieldNumber import io.github.timortel.kmpgrpc.shared.internal.io.wireFormatGetTagWireType import io.github.timortel.kmpgrpc.shared.internal.io.wireFormatMakeTag @@ -66,58 +64,6 @@ abstract class CodedInputStream { return recursiveRead { deserializer.deserialize(this, extensionRegistry) } } - // Adapted version of https://github.com/protocolbuffers/protobuf/blob/520c601c99012101c816b6ccc89e8d6fc28fdbb8/objectivec/GPBDictionary.m#L455 - /** - * Reads a map entry from a coded input stream and adds it to the given mutable map. - * - * @param map The mutable map to which the read key-value pair should be added. - * @param keyDataType The data type of the key in the map. - * @param valueDataType The data type of the value in the map. - * @param defaultKey The default key to use if no key is read. - * @param defaultValue The default value to use if no value is read. - * @param readKey A lambda function defining how to read the key from the input stream. - * @param readValue A lambda function defining how to read the value from the input stream. - */ - fun readMapEntry( - map: MutableMap, - keyDataType: DataType, - valueDataType: DataType, - defaultKey: K?, - defaultValue: V?, - readKey: CodedInputStream.() -> K, - readValue: CodedInputStream.() -> V - ) { - recursiveRead { - val keyTag = wireFormatMakeTag(kMapKeyFieldNumber, wireFormatForType(keyDataType, false)) - val valueTag = - wireFormatMakeTag(kMapValueFieldNumber, wireFormatForType(valueDataType, false)) - - var key: K? = defaultKey - var value: V? = defaultValue - - var hitError = false - - while (true) { - when (val tag = readTag()) { - 0 -> break - keyTag -> key = readKey() - valueTag -> value = readValue() - else -> { - //Unknown - if (!skipField(tag)) { - hitError = true - break - } - } - } - } - - if (!hitError && key != null && value != null) { - map[key] = value - } - } - } - abstract fun readBytes(): ByteArray abstract fun readUInt32(): UInt @@ -136,6 +82,8 @@ abstract class CodedInputStream { abstract fun popLimit(oldLimit: Int) + abstract fun peek(length: Int): ByteArray + fun readUnknownFieldOrExtension( tag: Int, extensionRegistry: ExtensionRegistry @@ -223,12 +171,12 @@ abstract class CodedInputStream { return UnknownField.Group(number, fields) } - private fun recursiveRead(readEntry: () -> T): T { + internal fun recursiveRead(readEntry: (size: Int) -> T): T { checkRecursionLimit() val length: Int = readInt32() val oldLimit = pushLimit(length) recursionDepth++ - val readResult = readEntry() + val readResult = readEntry(length) checkLastTagWas(0) recursionDepth-- popLimit(oldLimit) diff --git a/kmp-grpc-core/src/commonMain/kotlin/io/github/timortel/kmpgrpc/core/io/internal/CodedInputStreamImpl.kt b/kmp-grpc-core/src/commonMain/kotlin/io/github/timortel/kmpgrpc/core/io/internal/CodedInputStreamImpl.kt index 89a99b7c..e6b08532 100644 --- a/kmp-grpc-core/src/commonMain/kotlin/io/github/timortel/kmpgrpc/core/io/internal/CodedInputStreamImpl.kt +++ b/kmp-grpc-core/src/commonMain/kotlin/io/github/timortel/kmpgrpc/core/io/internal/CodedInputStreamImpl.kt @@ -201,4 +201,8 @@ internal class CodedInputStreamImpl(val source: Source) : CodedInputStream() { source.skip(byteCount.toLong()) position += byteCount } + + override fun peek(length: Int): ByteArray { + return source.peek().readByteArray(length) + } } diff --git a/kmp-grpc-core/src/commonMain/kotlin/io/github/timortel/kmpgrpc/core/io/mapreader.kt b/kmp-grpc-core/src/commonMain/kotlin/io/github/timortel/kmpgrpc/core/io/mapreader.kt new file mode 100644 index 00000000..ea54eae4 --- /dev/null +++ b/kmp-grpc-core/src/commonMain/kotlin/io/github/timortel/kmpgrpc/core/io/mapreader.kt @@ -0,0 +1,65 @@ +package io.github.timortel.kmpgrpc.core.io + +import io.github.timortel.kmpgrpc.core.message.UnknownField +import io.github.timortel.kmpgrpc.shared.internal.InternalKmpGrpcApi +import io.github.timortel.kmpgrpc.shared.internal.io.DataType +import io.github.timortel.kmpgrpc.shared.internal.io.wireFormatForType +import io.github.timortel.kmpgrpc.shared.internal.io.wireFormatMakeTag + +// Adapted version of https://github.com/protocolbuffers/protobuf/blob/520c601c99012101c816b6ccc89e8d6fc28fdbb8/objectivec/GPBDictionary.m#L455 +/** + * Reads a map entry from a coded input stream and adds it to the given mutable map. + * + * @param map The mutable map to which the read key-value pair should be added. + * @param keyDataType The data type of the key in the map. + * @param valueDataType The data type of the value in the map. + * @param defaultKey The default key to use if no key is read. + * @param defaultValue The default value to use if no value is read. + */ +@InternalKmpGrpcApi +fun readMapEntry( + stream: CodedInputStream, + fieldNumber: Int, + map: MutableMap, + unknownFields: MutableList, + keyDataType: DataType, + valueDataType: DataType, + defaultKey: K?, + defaultValue: V?, + readKey: CodedInputStream.() -> K, + readValue: CodedInputStream.() -> V? +) { + stream.recursiveRead { length -> + val mapEntry = stream.peek(length) + + val keyTag = wireFormatMakeTag(kMapKeyFieldNumber, wireFormatForType(keyDataType, false)) + val valueTag = + wireFormatMakeTag(kMapValueFieldNumber, wireFormatForType(valueDataType, false)) + + var key: K? = defaultKey + var value: V? = defaultValue + + var hitError = false + + while (true) { + when (val tag = stream.readTag()) { + 0 -> break + keyTag -> key = stream.readKey() + valueTag -> value = stream.readValue() + else -> { + //Unknown + if (!stream.skipField(tag)) { + hitError = true + break + } + } + } + } + + if (!hitError && key != null && value != null) { + map[key] = value + } else { + unknownFields += UnknownField.LengthDelimited(fieldNumber, mapEntry) + } + } +} diff --git a/kmp-grpc-core/src/commonMain/kotlin/io/github/timortel/kmpgrpc/core/message/EnumCompanion.kt b/kmp-grpc-core/src/commonMain/kotlin/io/github/timortel/kmpgrpc/core/message/EnumCompanion.kt index 1257ddba..6ede9fed 100644 --- a/kmp-grpc-core/src/commonMain/kotlin/io/github/timortel/kmpgrpc/core/message/EnumCompanion.kt +++ b/kmp-grpc-core/src/commonMain/kotlin/io/github/timortel/kmpgrpc/core/message/EnumCompanion.kt @@ -14,4 +14,13 @@ interface EnumCompanion { * or `UNRECOGNIZED` if no corresponding value is found */ fun getEnumForNumber(num: Int): T + + /** + * Retrieves the enumeration value corresponding to the given numeric value. + * + * @param num The numeric value for which the corresponding enumeration value is to be retrieved. + * @return The enumeration value of type T corresponding to the provided numeric value, + * or null if no corresponding value is found + */ + fun getEnumForNumberOrNull(num: Int): T? } diff --git a/kmp-grpc-internal-test/src/commonMain/proto/editions/closed-enum-test.proto b/kmp-grpc-internal-test/src/commonMain/proto/editions/closed-enum-test.proto new file mode 100644 index 00000000..917f1b2c --- /dev/null +++ b/kmp-grpc-internal-test/src/commonMain/proto/editions/closed-enum-test.proto @@ -0,0 +1,35 @@ +edition = "2023"; + +package io.github.timortel.kmpgrpc.test.editions; + +option java_outer_classname = "ClosedEnumTest"; + +enum ClosedEnum { + option features.enum_type = CLOSED; + + DEFAULT = 0; + ONE = 1; +} + +message MessageWithClosedEnum { + ClosedEnum a = 1; +} + +message MessageWithRepeatedClosedEnum { + repeated ClosedEnum a = 1 [packed = false]; + repeated ClosedEnum b = 2 [packed = true]; +} + +message MessageWithClosedEnumMap { + map a = 1; +} + +message MessageWithDummyMap { + map a = 1; +} + +message MessageWithClosedOneOf { + oneof a { + ClosedEnum b = 1; + } +} diff --git a/kmp-grpc-internal-test/src/commonMain/proto/editions/open-enum-test.proto b/kmp-grpc-internal-test/src/commonMain/proto/editions/open-enum-test.proto new file mode 100644 index 00000000..7773c1df --- /dev/null +++ b/kmp-grpc-internal-test/src/commonMain/proto/editions/open-enum-test.proto @@ -0,0 +1,31 @@ +edition = "2023"; + +package io.github.timortel.kmpgrpc.test.editions; + +option java_outer_classname = "OpenEnumTest"; + +enum OpenEnum { + option features.enum_type = OPEN; + + DEFAULT = 0; + ONE = 1; +} + +message MessageWithOpenEnum { + OpenEnum a = 1; +} + +message MessageWithRepeatedOpenEnum { + repeated OpenEnum a = 1 [packed = false]; + repeated OpenEnum b = 2 [packed = true]; +} + +message MessageWithOpenEnumMap { + map a = 1; +} + +message MessageWithOpenOneOf { + oneof a { + OpenEnum b = 1; + } +} diff --git a/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/OpenClosedEnumMapFieldSerializationTest.kt b/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/OpenClosedEnumMapFieldSerializationTest.kt new file mode 100644 index 00000000..6dddd34f --- /dev/null +++ b/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/OpenClosedEnumMapFieldSerializationTest.kt @@ -0,0 +1,95 @@ +package io.github.timortel.kotlin_multiplatform_grpc_plugin.test.serialization + +import io.github.timortel.kmpgrpc.test.editions.ClosedEnumTest +import io.github.timortel.kmpgrpc.test.editions.OpenEnumTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class OpenClosedEnumMapFieldSerializationTest { + + @Test + fun testClosedMapDeserializationScenarios() { + runClosedMapDeserializationTest( + fields = listOf( + 1 to 0, + 2 to 1 + ), + expectedValues = mapOf( + 1 to ClosedEnumTest.ClosedEnum.DEFAULT, + 2 to ClosedEnumTest.ClosedEnum.ONE, + ), + expectedUnknownFieldCount = 0 + ) + + runClosedMapDeserializationTest( + fields = listOf( + 1 to -1, + 2 to 1, + 3 to 2 + ), + expectedValues = mapOf( + 2 to ClosedEnumTest.ClosedEnum.ONE + ), + expectedUnknownFieldCount = 2 + ) + } + + private fun runClosedMapDeserializationTest( + fields: List>, + expectedValues: Map, + expectedUnknownFieldCount: Int + ) { + val dummyMessage = ClosedEnumTest.MessageWithDummyMap( + aMap = fields.toMap() + ) + + val msg = ClosedEnumTest.MessageWithClosedEnumMap.deserialize(dummyMessage.serialize()) + + assertEquals(expectedValues, msg.aMap, "Expected field values to be equal") + assertEquals(expectedUnknownFieldCount, msg.unknownFields.size, "Expected unknown field count to be equal") + } + + @Test + fun testOpenMapDeserializationScenarios() { + runOpenMapDeserializationTest( + fields = listOf( + 1 to 0, + 2 to 1 + ), + expectedValues = mapOf( + 1 to OpenEnumTest.OpenEnum.DEFAULT, + 2 to OpenEnumTest.OpenEnum.ONE, + ), + expectedUnknownFieldCount = 0 + ) + + runOpenMapDeserializationTest( + fields = listOf( + 1 to -1, + 2 to 1, + 3 to 2 + ), + expectedValues = mapOf( + 1 to OpenEnumTest.OpenEnum.UNRECOGNIZED, + 2 to OpenEnumTest.OpenEnum.ONE, + 3 to OpenEnumTest.OpenEnum.UNRECOGNIZED + ), + expectedUnknownFieldCount = 0 + ) + } + + private fun runOpenMapDeserializationTest( + fields: List>, + expectedValues: Map, + expectedUnknownFieldCount: Int + ) { + val dummyMessage = ClosedEnumTest.MessageWithDummyMap( + aMap = fields.toMap() + ) + + val msg = OpenEnumTest.MessageWithOpenEnumMap.deserialize(dummyMessage.serialize()) + + assertEquals(expectedValues, msg.aMap, "Expected field values to be equal") + assertEquals(expectedUnknownFieldCount, msg.unknownFields.size, "Expected unknown field count to be equal") + } +} diff --git a/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/OpenClosedEnumOneOfSerializationTest.kt b/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/OpenClosedEnumOneOfSerializationTest.kt new file mode 100644 index 00000000..48bfd8ba --- /dev/null +++ b/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/OpenClosedEnumOneOfSerializationTest.kt @@ -0,0 +1,82 @@ +package io.github.timortel.kotlin_multiplatform_grpc_plugin.test.serialization + +import io.github.timortel.kmpgrpc.core.message.UnknownField +import io.github.timortel.kmpgrpc.test.editions.ClosedEnumTest +import io.github.timortel.kmpgrpc.test.editions.OpenEnumTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class OpenClosedEnumOneOfSerializationTest { + + @Test + fun testClosedOneOfDeserializationScenarios() { + runClosedOneOfDeserializationTest( + UnknownField.Varint(1, 0), + ClosedEnumTest.MessageWithClosedOneOf.A.B( + ClosedEnumTest.ClosedEnum.DEFAULT + ), + emptyList() + ) + runClosedOneOfDeserializationTest( + UnknownField.Varint(1, 1), + ClosedEnumTest.MessageWithClosedOneOf.A.B( + ClosedEnumTest.ClosedEnum.ONE + ), + emptyList() + ) + runClosedOneOfDeserializationTest( + UnknownField.Varint(1, 2), + ClosedEnumTest.MessageWithClosedOneOf.A.NotSet, + listOf(UnknownField.Varint(1, 2)) + ) + } + + private fun runClosedOneOfDeserializationTest( + field: UnknownField.Varint, + expectedValue: ClosedEnumTest.MessageWithClosedOneOf.A, + expectedUnknownFields: List + ) { + val msg = ClosedEnumTest.MessageWithClosedOneOf.deserialize( + ClosedEnumTest.MessageWithClosedOneOf(unknownFields = listOf(field)).serialize() + ) + + assertEquals(expectedValue, msg.a, "Expected field value to be equal") + assertEquals(expectedUnknownFields, msg.unknownFields, "Expected unknown fields to be equal") + } + + @Test + fun testOpenOneOfDeserializationScenarios() { + runOpenOneOfDeserializationTest( + UnknownField.Varint(1, 0), + OpenEnumTest.MessageWithOpenOneOf.A.B( + OpenEnumTest.OpenEnum.DEFAULT + ), + emptyList() + ) + runOpenOneOfDeserializationTest( + UnknownField.Varint(1, 1), + OpenEnumTest.MessageWithOpenOneOf.A.B( + OpenEnumTest.OpenEnum.ONE + ), + emptyList() + ) + runOpenOneOfDeserializationTest( + UnknownField.Varint(1, 2), + OpenEnumTest.MessageWithOpenOneOf.A.B(OpenEnumTest.OpenEnum.UNRECOGNIZED), + emptyList() + ) + } + + private fun runOpenOneOfDeserializationTest( + field: UnknownField.Varint, + expectedValue: OpenEnumTest.MessageWithOpenOneOf.A, + expectedUnknownFields: List + ) { + val msg = OpenEnumTest.MessageWithOpenOneOf.deserialize( + OpenEnumTest.MessageWithOpenOneOf(unknownFields = listOf(field)).serialize() + ) + + assertEquals(expectedValue, msg.a, "Expected field value to be equal") + assertEquals(expectedUnknownFields, msg.unknownFields, "Expected unknown fields to be equal") + } +} diff --git a/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/OpenClosedEnumRepeatedFieldSerializationTest.kt b/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/OpenClosedEnumRepeatedFieldSerializationTest.kt new file mode 100644 index 00000000..cf9c56bc --- /dev/null +++ b/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/OpenClosedEnumRepeatedFieldSerializationTest.kt @@ -0,0 +1,110 @@ +package io.github.timortel.kotlin_multiplatform_grpc_plugin.test.serialization + +import io.github.timortel.kmpgrpc.core.message.UnknownField +import io.github.timortel.kmpgrpc.test.editions.ClosedEnumTest +import io.github.timortel.kmpgrpc.test.editions.OpenEnumTest +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals + +class OpenClosedEnumRepeatedFieldSerializationTest { + + @Test + fun testClosedRepeatedDeserializationScenarios() { + runClosedRepeatedDeserializationTest( + fields = listOf(0), + expectedValues = listOf(ClosedEnumTest.ClosedEnum.DEFAULT), + expectedUnknownFields = emptyList() + ) + + runClosedRepeatedDeserializationTest( + fields = listOf(0, 1, 0), + expectedValues = listOf( + ClosedEnumTest.ClosedEnum.DEFAULT, + ClosedEnumTest.ClosedEnum.ONE, + ClosedEnumTest.ClosedEnum.DEFAULT + ), + expectedUnknownFields = emptyList() + ) + + runClosedRepeatedDeserializationTest( + fields = listOf(-4, 3, 1), + expectedValues = listOf( + ClosedEnumTest.ClosedEnum.ONE + ), + expectedUnknownFields = listOf( + UnknownField.Varint(1, -4), + UnknownField.Varint(1, 3), + UnknownField.Varint(2, -4), + UnknownField.Varint(2, 3), + ) + ) + } + + private fun runClosedRepeatedDeserializationTest( + fields: List, + expectedValues: List, + expectedUnknownFields: List + ) { + val unknownFields = fields.flatMap { + listOf( + UnknownField.Varint(1, it), + UnknownField.Varint(2, it) + ) + } + + val msg = ClosedEnumTest.MessageWithRepeatedClosedEnum.deserialize( + ClosedEnumTest.MessageWithRepeatedClosedEnum(unknownFields = unknownFields).serialize() + ) + + assertEquals(expectedValues, msg.aList, "Expected field A values to be equal") + assertEquals(expectedValues, msg.bList, "Expected field B values to be equal") + expectedUnknownFields.forEach { + assertContains(msg.unknownFields, it, "Expected to be contained") + } + } + + @Test + fun testOpenRepeatedDeserializationScenarios() { + runOpenRepeatedDeserializationTest( + fields = listOf(0), + expectedValues = listOf(OpenEnumTest.OpenEnum.DEFAULT), + expectedUnknownFields = emptyList() + ) + + runOpenRepeatedDeserializationTest( + fields = listOf(0, 1, 0), + expectedValues = listOf( + OpenEnumTest.OpenEnum.DEFAULT, + OpenEnumTest.OpenEnum.ONE, + OpenEnumTest.OpenEnum.DEFAULT + ), + expectedUnknownFields = emptyList() + ) + + runOpenRepeatedDeserializationTest( + fields = listOf(-4, 3, 1), + expectedValues = listOf( + OpenEnumTest.OpenEnum.UNRECOGNIZED, + OpenEnumTest.OpenEnum.UNRECOGNIZED, + OpenEnumTest.OpenEnum.ONE, + ), + expectedUnknownFields = emptyList() + ) + } + + private fun runOpenRepeatedDeserializationTest( + fields: List, + expectedValues: List, + expectedUnknownFields: List + ) { + val unknownFields = fields.map { UnknownField.Varint(1, it) } + + val msg = OpenEnumTest.MessageWithRepeatedOpenEnum.deserialize( + OpenEnumTest.MessageWithRepeatedOpenEnum(unknownFields = unknownFields).serialize() + ) + + assertEquals(expectedValues, msg.aList, "Expected field values to be equal") + assertEquals(expectedUnknownFields, msg.unknownFields, "Expected unknown fields to be equal") + } +} diff --git a/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/OpenClosedEnumScalarFieldSerializationTest.kt b/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/OpenClosedEnumScalarFieldSerializationTest.kt new file mode 100644 index 00000000..e6baacec --- /dev/null +++ b/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/OpenClosedEnumScalarFieldSerializationTest.kt @@ -0,0 +1,58 @@ +package io.github.timortel.kotlin_multiplatform_grpc_plugin.test.serialization + +import io.github.timortel.kmpgrpc.core.message.UnknownField +import io.github.timortel.kmpgrpc.test.editions.ClosedEnumTest +import io.github.timortel.kmpgrpc.test.editions.OpenEnumTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class OpenClosedEnumScalarFieldSerializationTest { + + @Test + fun testClosedScalarDeserializationScenarios() { + runClosedScalarDeserializationTest(UnknownField.Varint(1, 0), ClosedEnumTest.ClosedEnum.DEFAULT, emptyList()) + runClosedScalarDeserializationTest(UnknownField.Varint(1, 1), ClosedEnumTest.ClosedEnum.ONE, emptyList()) + runClosedScalarDeserializationTest( + UnknownField.Varint(1, 2), + ClosedEnumTest.ClosedEnum.DEFAULT, + listOf(UnknownField.Varint(1, 2)) + ) + } + + private fun runClosedScalarDeserializationTest( + field: UnknownField.Varint, + expectedValue: ClosedEnumTest.ClosedEnum, + expectedUnknownFields: List + ) { + val msg = ClosedEnumTest.MessageWithClosedEnum.deserialize( + ClosedEnumTest.MessageWithClosedEnum(unknownFields = listOf(field)).serialize() + ) + + assertEquals(expectedValue, msg.a, "Expected field value to be equal") + assertEquals(expectedUnknownFields, msg.unknownFields, "Expected unknown fields to be equal") + } + + @Test + fun testOpenScalarDeserializationScenarios() { + runOpenScalarDeserializationTest(UnknownField.Varint(1, 0), OpenEnumTest.OpenEnum.DEFAULT, emptyList()) + runOpenScalarDeserializationTest(UnknownField.Varint(1, 1), OpenEnumTest.OpenEnum.ONE, emptyList()) + runOpenScalarDeserializationTest( + UnknownField.Varint(1, 2), + OpenEnumTest.OpenEnum.UNRECOGNIZED, + emptyList() + ) + } + + private fun runOpenScalarDeserializationTest( + field: UnknownField.Varint, + expectedValue: OpenEnumTest.OpenEnum, + expectedUnknownFields: List + ) { + val msg = OpenEnumTest.MessageWithOpenEnum.deserialize( + OpenEnumTest.MessageWithOpenEnum(unknownFields = listOf(field)).serialize() + ) + + assertEquals(expectedValue, msg.a, "Expected field value to be equal") + assertEquals(expectedUnknownFields, msg.unknownFields, "Expected unknown fields to be equal") + } +} diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/CompilationException.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/CompilationException.kt index 6f3797c3..89c0ed89 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/CompilationException.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/CompilationException.kt @@ -24,6 +24,7 @@ sealed class CompilationException(val msg: String, val filePath: String, val ctx // Enum class EnumIllegalFirstField(message: String, file: ProtoFile, ctx: ParserRuleContext) : CompilationException(message, file, ctx) class EnumNoFields(message: String, file: ProtoFile, ctx: ParserRuleContext) : CompilationException(message, file, ctx) + class IllegalClosedEnumImport(message: String, file: ProtoFile, ctx: ParserRuleContext) : CompilationException(message, file, ctx) // Name Resolving class ResolvedToPackage(message: String, file: ProtoFile, ctx: ParserRuleContext) : CompilationException(message, file, ctx) diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/constants/Const.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/constants/Const.kt index f6f4d662..c6fc6340 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/constants/Const.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/constants/Const.kt @@ -38,7 +38,14 @@ object Const { } object Message { - val reservedAttributeNames = setOf("fullName", "requiredSize", Companion.WrapperDeserializationFunction.TAG_LOCAL_VARIABLE, Constructor.UnknownFields.name) + val reservedAttributeNames = setOf( + "fullName", + "requiredSize", + Companion.WrapperDeserializationFunction.TAG_LOCAL_VARIABLE, + Companion.WrapperDeserializationFunction.ENUM_NUMBER_VALUE_LOCAL_VARIABLE, + Companion.WrapperDeserializationFunction.ENUM_VALUE_LOCAL_VARIABLE, + Constructor.UnknownFields.name + ) val fullNameProperty = Property.of("fullName", STRING) @@ -91,6 +98,8 @@ object Const { val EXTENSION_REGISTRY_PARAM = Property.of("extensionRegistry", kmExtensionRegistry) const val TAG_LOCAL_VARIABLE = "tag_" + const val ENUM_NUMBER_VALUE_LOCAL_VARIABLE = "enumNumberValue_" + const val ENUM_VALUE_LOCAL_VARIABLE = "enumValue_" const val UNKNOWN_FIELDS_LOCAL_VARIABLE = "unknownFields" const val EXTENSION_BUILDER_LOCAL_VARIABLE = "extensionBuilder" } @@ -104,6 +113,7 @@ object Const { object Enum { const val GET_ENUM_FOR_FUNCTION_NAME = "getEnumForNumber" + const val GET_ENUM_FOR_OR_NULL_FUNCTION_NAME = "getEnumForNumberOrNull" const val NUMBER_PROPERTY_NAME = "number" } -} \ No newline at end of file +} diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/constants/library_fields.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/constants/library_fields.kt index 11476ae8..9939dab5 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/constants/library_fields.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/constants/library_fields.kt @@ -31,6 +31,7 @@ val kmExtensionRegistry = ClassName(PACKAGE_MESSAGE_EXTENSIONS, "ExtensionRegist val kmExtensionBuilder = ClassName(PACKAGE_MESSAGE_EXTENSIONS, "MessageExtensionsBuilder") val unknownField = ClassName(PACKAGE_MESSAGE, "UnknownField") +val unknownFieldVarint = unknownField.nestedClass("Varint") val CodedOutputStream = ClassName(PACKAGE_IO, "CodedOutputStream") val CodedInputStream = ClassName(PACKAGE_IO, "CodedInputStream") @@ -68,3 +69,4 @@ val fieldTypeBytes = fieldType.nestedClass("Bytes") // util val mergeUnknownFieldOrExtension = MemberName(PACKAGE_MESSAGE, "mergeUnknownFieldOrExtension") +val readMapEntry = MemberName(PACKAGE_IO, "readMapEntry") diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/enumeration/ProtoEnumerationWriter.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/enumeration/ProtoEnumerationWriter.kt index 9d1e6f5f..bdda489e 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/enumeration/ProtoEnumerationWriter.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/enumeration/ProtoEnumerationWriter.kt @@ -86,36 +86,57 @@ abstract class ProtoEnumerationWriter(val isActual: Boolean) { if (isActual) addModifiers(KModifier.ACTUAL) } .addFunction( - FunSpec - .builder(Const.Enum.GET_ENUM_FOR_FUNCTION_NAME) - .addModifiers(KModifier.OVERRIDE) - .returns(protoEnum.className) - .addParameter("num", INT) - .apply { - if (supplyImplementation) { - addCode( - CodeBlock - .builder() - .add("return ") - .beginControlFlow("when(num)") - .apply { - protoEnum.fields.forEach { field -> - add("%L -> %N\n", field.number, field.name) - } - add("else -> %N\n", ProtoEnum.UNRECOGNIZED_FIELD_NAME) - } - .endControlFlow() - .build() - ) - - addModifiers(this@ProtoEnumerationWriter.modifiers) - } - } - .build() + buildGetEnumForNumberFunction( + protoEnum = protoEnum, + orNullVersion = false, + supplyImplementation = supplyImplementation + ) + ) + .addFunction( + buildGetEnumForNumberFunction( + protoEnum = protoEnum, + orNullVersion = true, + supplyImplementation = supplyImplementation + ) ) .build() ) .build() } + + private fun buildGetEnumForNumberFunction( + protoEnum: ProtoEnum, + orNullVersion: Boolean, + supplyImplementation: Boolean + ): FunSpec = FunSpec + .builder(if (orNullVersion) Const.Enum.GET_ENUM_FOR_OR_NULL_FUNCTION_NAME else Const.Enum.GET_ENUM_FOR_FUNCTION_NAME) + .addModifiers(KModifier.OVERRIDE) + .returns(protoEnum.className.copy(nullable = orNullVersion)) + .addParameter("num", INT) + .apply { + if (supplyImplementation) { + addCode( + CodeBlock + .builder() + .add("return ") + .beginControlFlow("when(num)") + .apply { + protoEnum.fields.forEach { field -> + add("%L -> %N\n", field.number, field.name) + } + if (orNullVersion) { + add("else -> null\n") + } else { + add("else -> %N\n", ProtoEnum.UNRECOGNIZED_FIELD_NAME) + } + } + .endControlFlow() + .build() + ) + + addModifiers(this@ProtoEnumerationWriter.modifiers) + } + } + .build() } diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/message/extensions/serialization/DeserializationFunctionExtension.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/message/extensions/serialization/DeserializationFunctionExtension.kt index b1ffb5cd..4ad66bcb 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/message/extensions/serialization/DeserializationFunctionExtension.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/message/extensions/serialization/DeserializationFunctionExtension.kt @@ -10,6 +10,7 @@ import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.Prot import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.message.field.ProtoFieldCardinality import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.message.field.ProtoMessageField import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.message.field.ProtoRegularField +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.file.ProtoFile import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.type.ProtoType import io.github.timortel.kmpgrpc.plugin.sourcegeneration.util.joinCodeBlocks import io.github.timortel.kmpgrpc.plugin.sourcegeneration.util.joinToCodeBlock @@ -121,13 +122,25 @@ class DeserializationFunctionExtension : BaseSerializationExtension() { message.oneOfs.forEach { oneOf -> oneOf.fields.forEach { field -> addCode(buildFieldTagCode(field, field.isPacked)) + + beginControlFlow("·->·") + addCode( - "·->·%N·=·%T(", - oneOf.attributeName, - field.sealedClassChildName + buildAssignScalarFieldValueCodeBlock( + type = field.type, + file = field.file, + fieldNumber = field.number, + variableName = oneOf.attributeName, + assignMode = "=", + constructType = { + add("%T(", field.sealedClassChildName) + it() + add(")") + } + ) ) - addCode(buildReadScalarFieldDataCode(field)) - addCode(")\n") + + endControlFlow() } } } @@ -145,16 +158,87 @@ class DeserializationFunctionExtension : BaseSerializationExtension() { } } + private fun buildAssignScalarFieldValueCodeBlock( + type: ProtoType, + file: ProtoFile, + fieldNumber: Int, + variableName: String, + assignMode: String, + constructType: CodeBlock.Builder.(insertValue: CodeBlock.Builder.() -> Unit) -> Unit + ): CodeBlock { + val enumNumberVar = + Const.Message.Companion.WrapperDeserializationFunction.ENUM_NUMBER_VALUE_LOCAL_VARIABLE + val enumVar = Const.Message.Companion.WrapperDeserializationFunction.ENUM_VALUE_LOCAL_VARIABLE + + val isClosedEnum = isClosedEnum(type, file) + + return CodeBlock.builder().apply { + if (isClosedEnum) { + addStatement("val·%N·=·%N.readEnum()", enumNumberVar, wrapperParamName) + addStatement( + "val·%N·=·%T.%N(%N)", + enumVar, + type.resolve(), + Const.Enum.GET_ENUM_FOR_OR_NULL_FUNCTION_NAME, + enumNumberVar + ) + + add("if·(%N·!=·null)·%N·$assignMode·", enumVar, variableName) + constructType { add("%N", enumVar)} + add("\n") + + addStatement( + "else·%N·+=·%T(%L, %N.toLong())", + Const.Message.Companion.WrapperDeserializationFunction.UNKNOWN_FIELDS_LOCAL_VARIABLE, + unknownFieldVarint, + fieldNumber, + enumNumberVar + ) + } else { + add("%N·$assignMode·", variableName) + constructType { + add( + when (val type = type) { + is ProtoType.DefType -> when (val decl = type.resolveDeclaration()) { + is ProtoEnum -> buildReadScalarFieldOpenEnumTypeCode(type) + is ProtoMessage -> buildReadScalarFieldMessageTypeCode(type, decl) + } + + is ProtoType.NonDeclType -> { + buildReadScalarFieldBasicTypeCode(type) + } + } + ) + } + add("\n") + } + } + .build() + } + + private fun isClosedEnum(type: ProtoType, file: ProtoFile): Boolean { + return type is ProtoType.DefType && (type.resolveDeclaration() as? ProtoEnum)?.isOpen(file.languageVersion) == false + } + private fun FunSpec.Builder.declareWhenEntryForField(field: ProtoMessageField, isPacked: Boolean) { addCode(buildFieldTagCode(field, isPacked)) - addCode(" -> ") + addCode("·->·") when (field.cardinality) { is ProtoFieldCardinality.Singular -> { - addCode("%N·=·", field.attributeName) - addCode(buildReadScalarFieldDataCode(field)) - addCode("\n") + beginControlFlow("") + addCode( + buildAssignScalarFieldValueCodeBlock( + type = field.type, + file = field.file, + fieldNumber = field.number, + variableName = field.attributeName, + assignMode = "=", + constructType = { it() } + ) + ) + endControlFlow() } ProtoFieldCardinality.Repeated -> { @@ -193,12 +277,15 @@ class DeserializationFunctionExtension : BaseSerializationExtension() { //Enums need to first get mapped if (field.type.isEnum) { - addStatement( - "%N += %T.%N(%N.readEnum())", - field.attributeName, - field.type.resolve(), - Const.Enum.GET_ENUM_FOR_FUNCTION_NAME, - wrapperParamName + addCode( + buildAssignScalarFieldValueCodeBlock( + type = field.type, + file = field.file, + fieldNumber = field.number, + variableName = field.attributeName, + assignMode = "+=", + constructType = { it() } + ) ) } else { val functionName = getReadScalarFunctionName(field.type) @@ -220,12 +307,14 @@ class DeserializationFunctionExtension : BaseSerializationExtension() { // not packed if (field.type.isEnum) { addCode( - "%N·+=·%T.%N(%N.%N())\n", - field.attributeName, - field.type.resolve(), - Const.Enum.GET_ENUM_FOR_FUNCTION_NAME, - wrapperParamName, - getReadScalarFunctionName(field.type) + buildAssignScalarFieldValueCodeBlock( + type = field.type, + file = field.file, + fieldNumber = field.number, + variableName = field.attributeName, + assignMode = "+=", + constructType = { it() } + ) ) } else { addCode( @@ -250,10 +339,12 @@ class DeserializationFunctionExtension : BaseSerializationExtension() { addCode(" -> ") addCode( - "%N.%N(%N, %T.%N, %T.%N, ", + "%M(%N, %L, %N, %N, %T.%N, %T.%N, ", + readMapEntry, wrapperParamName, - "readMapEntry", + mapField.number, mapField.attributeName, + Const.Message.Companion.WrapperDeserializationFunction.UNKNOWN_FIELDS_LOCAL_VARIABLE, DataType::class.asTypeName(), mapField.keyType.wireType.name, DataType::class.asTypeName(), @@ -398,44 +489,36 @@ class DeserializationFunctionExtension : BaseSerializationExtension() { } /** - * @return a CodeBlock that reads the data for [field] under the assumption that it is a scalar field + * @return a CodeBlock that reads the data for [type] under the assumption that it is a scalar field */ - private fun buildReadScalarFieldDataCode(field: ProtoRegularField): CodeBlock { - return when (val type = field.type) { - is ProtoType.NonDeclType -> { - CodeBlock.of( - "%N.%N()", - wrapperParamName, - getReadScalarFunctionName(field.type) - ) - } + private fun buildReadScalarFieldBasicTypeCode(type: ProtoType.NonDeclType): CodeBlock { + return CodeBlock.of( + "%N.%N()", + wrapperParamName, + getReadScalarFunctionName(type) + ) + } - is ProtoType.DefType -> { - when (val decl = type.resolveDeclaration()) { - is ProtoMessage -> { - CodeBlock.builder() - .add( - "%N.%N(%T.Companion, ", - wrapperParamName, - "readMessage", - field.type.resolve() - ) - .add(buildExtensionRegistryCodeForMessage(decl)) - .add(")") - .build() - } + private fun buildReadScalarFieldMessageTypeCode(type: ProtoType.DefType, message: ProtoMessage): CodeBlock { + return CodeBlock.builder() + .add( + "%N.%N(%T.Companion, ", + wrapperParamName, + "readMessage", + type.resolve() + ) + .add(buildExtensionRegistryCodeForMessage(message)) + .add(")") + .build() + } - is ProtoEnum -> { - CodeBlock.of( - "%T.%N(%N.readEnum())", - field.type.resolve(), - Const.Enum.GET_ENUM_FOR_FUNCTION_NAME, - wrapperParamName - ) - } - } - } - } + private fun buildReadScalarFieldOpenEnumTypeCode(type: ProtoType.DefType): CodeBlock { + return CodeBlock.of( + "%T.%N(%N.readEnum())", + type.resolve(), + Const.Enum.GET_ENUM_FOR_FUNCTION_NAME, + wrapperParamName + ) } private fun buildReadMapFieldDataCode(type: ProtoType): CodeBlock { @@ -465,7 +548,8 @@ class DeserializationFunctionExtension : BaseSerializationExtension() { CodeBlock.of( "{·%T.%N(readEnum())·}", type.resolve(), - Const.Enum.GET_ENUM_FOR_FUNCTION_NAME + if (decl.isOpen(type.file.languageVersion)) Const.Enum.GET_ENUM_FOR_FUNCTION_NAME + else Const.Enum.GET_ENUM_FOR_OR_NULL_FUNCTION_NAME ) } } @@ -503,7 +587,8 @@ class DeserializationFunctionExtension : BaseSerializationExtension() { return if (message.isExtendable) { CodeBlock.of( "%M", - message.className.nestedClass("Companion").member(Const.Message.Companion.defaultExtensionRegistryProperty.name) + message.className.nestedClass("Companion") + .member(Const.Message.Companion.defaultExtensionRegistryProperty.name) ) } else { CodeBlock.of("%T.empty()", kmExtensionRegistry) diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/ProtoExtensionDefinition.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/ProtoExtensionDefinition.kt index be1e2be1..9afba55a 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/ProtoExtensionDefinition.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/ProtoExtensionDefinition.kt @@ -41,6 +41,8 @@ data class ProtoExtensionDefinition( } fields.forEach { it.validate() } + + messageType.validate() } sealed interface Parent : DeclarationResolver { diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoEnum.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoEnum.kt index bfbead13..fa1ce65e 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoEnum.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoEnum.kt @@ -4,6 +4,7 @@ import io.github.timortel.kmpgrpc.plugin.sourcegeneration.CompilationException import io.github.timortel.kmpgrpc.plugin.sourcegeneration.Warnings import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.BaseDeclarationResolver import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.ProtoDeclParent +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.ProtoLanguageVersion import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.ProtoOption import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.ProtoOptionsHolder import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.enumeration.ProtoEnumField @@ -11,6 +12,7 @@ import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.mess import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.file.ProtoFile import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.option.OptionTarget import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.option.Options +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.option.ProtoEnumType import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.type.ProtoType import io.github.timortel.kmpgrpc.plugin.sourcegeneration.util.toFilePositionString import org.antlr.v4.runtime.ParserRuleContext @@ -61,6 +63,30 @@ data class ProtoEnum( fields.forEach { it.enum = this } } + // Implements: https://protobuf.dev/programming-guides/enum/#spec + /** + * @param userLanguage the language of whoever wants to use the enum. + */ + fun isOpen(userLanguage: ProtoLanguageVersion): Boolean { + val getFeatureIsOpen = { + when (Options.Feature.enumType.get(this)) { + ProtoEnumType.OPEN -> true + ProtoEnumType.CLOSED -> false + } + } + + return when (userLanguage) { + ProtoLanguageVersion.PROTO3 -> when (file.languageVersion) { + ProtoLanguageVersion.PROTO3 -> true + ProtoLanguageVersion.EDITION2023, ProtoLanguageVersion.EDITION2024 -> getFeatureIsOpen() + } + ProtoLanguageVersion.EDITION2023, ProtoLanguageVersion.EDITION2024 -> when (file.languageVersion) { + ProtoLanguageVersion.PROTO3 -> true + ProtoLanguageVersion.EDITION2023, ProtoLanguageVersion.EDITION2024 -> getFeatureIsOpen() + } + } + } + override fun validate() { super.validate() super.validate() diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/message/field/ProtoMapField.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/message/field/ProtoMapField.kt index 61482637..5bdef819 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/message/field/ProtoMapField.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/message/field/ProtoMapField.kt @@ -43,4 +43,11 @@ data class ProtoMapField( keyType.parent = ProtoType.Parent.MapField(this) valuesType.parent = ProtoType.Parent.MapField(this) } + + override fun validate() { + super.validate() + + keyType.validate() + valuesType.validate() + } } diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/message/field/ProtoRegularField.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/message/field/ProtoRegularField.kt index bcff44d4..4478ba29 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/message/field/ProtoRegularField.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/message/field/ProtoRegularField.kt @@ -6,4 +6,10 @@ sealed class ProtoRegularField : ProtoBaseField() { abstract val type: ProtoType abstract val isPacked: Boolean + + override fun validate() { + super.validate() + + type.validate() + } } diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/Options.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/Options.kt index 791011b9..4a09cfd3 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/Options.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/Options.kt @@ -106,6 +106,14 @@ object Options { edition2023Config = LangConfig.Unavailable(), edition2024Config = LangConfig.Available(defaultValue = ProtoNestInFileClass.NO) ) + + val enumType = FeatureProtoOption( + name = "enum_type", + parse = { value -> ProtoEnumType.entries.firstOrNull { it.name == value } }, + targets = listOf(OptionTargetMatcher.FILE, OptionTargetMatcher.ENUM()), + edition2023Config = LangConfig.Available(defaultValue = ProtoEnumType.OPEN), + edition2024Config = LangConfig.Available(defaultValue = ProtoEnumType.OPEN), + ) } val options = listOf( @@ -118,7 +126,8 @@ object Options { Feature.fieldPresence, Feature.repeatedFieldEncoding, Feature.defaultSymbolVisibility, - Feature.nestInFileClass + Feature.nestInFileClass, + Feature.enumType ) /** diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/ProtoEnumType.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/ProtoEnumType.kt new file mode 100644 index 00000000..64700549 --- /dev/null +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/ProtoEnumType.kt @@ -0,0 +1,6 @@ +package io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.option + +enum class ProtoEnumType { + OPEN, + CLOSED +} diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/service/ProtoRpc.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/service/ProtoRpc.kt index 0a65f0cf..c3c77243 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/service/ProtoRpc.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/service/ProtoRpc.kt @@ -38,6 +38,13 @@ data class ProtoRpc( returnType.parent = ProtoType.Parent.Rpc(this) } + override fun validate() { + super.validate() + + sendType.validate() + returnType.validate() + } + enum class MethodType(val jvmMethodType: String) { UNARY("UNARY"), CLIENT_STREAMING("CLIENT_STREAMING"), diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/type/ProtoType.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/type/ProtoType.kt index 0569bc1f..9b0b7715 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/type/ProtoType.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/type/ProtoType.kt @@ -4,6 +4,8 @@ import com.squareup.kotlinpoet.* import io.github.timortel.kmpgrpc.plugin.sourcegeneration.CompilationException import io.github.timortel.kmpgrpc.plugin.sourcegeneration.constants.* import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.ProtoExtensionDefinition +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.ProtoLanguageVersion +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.ProtoNode import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.ProtoProject import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.ProtoDeclaration import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.ProtoEnum @@ -19,7 +21,7 @@ import org.antlr.v4.runtime.ParserRuleContext /** * General representation of a type in the proto language, for example bool, message, enum, etc. */ -sealed interface ProtoType { +sealed interface ProtoType : ProtoNode { var parent: Parent @@ -77,6 +79,8 @@ sealed interface ProtoType { messageDefaultValue: MessageDefaultValue = MessageDefaultValue.NULL ): CodeBlock + override fun validate() = Unit + enum class MessageDefaultValue { NULL, EMPTY @@ -292,6 +296,22 @@ sealed interface ProtoType { } } + override fun validate() { + when (val decl = resolveDeclaration()) { + is ProtoEnum -> { + val isClosed = !decl.isOpen(file.languageVersion) + if (file.languageVersion == ProtoLanguageVersion.PROTO3 && isClosed) { + throw CompilationException.IllegalClosedEnumImport( + message = "Illegal import of closed enum in proto3 file", + file = file, + ctx = ctx + ) + } + } + is ProtoMessage -> {} + } + } + enum class DeclarationType(val isPackable: Boolean) { MESSAGE(false), ENUM(true) diff --git a/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/EnumImportValidationTest.kt b/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/EnumImportValidationTest.kt new file mode 100644 index 00000000..050651ce --- /dev/null +++ b/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/EnumImportValidationTest.kt @@ -0,0 +1,80 @@ +package io.github.timortel.kotlin_multiplatform_grpc_plugin.validation + +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.CompilationException +import io.github.timortel.kotlin_multiplatform_grpc_plugin.FakeInputDirectory +import io.github.timortel.kotlin_multiplatform_grpc_plugin.createProtoFile +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +class EnumImportValidationTest : BaseValidationTest() { + + @Test + fun `test WHEN proto3 imports closed enum THEN error is thrown`() { + assertThrows { + runGenerator( + listOf( + FakeInputDirectory( + name = "dir", + files = listOf( + createProtoFile( + fileHeader = ProtoVersion.EDITION2024.header, + content = """ + enum A { + option features.enum_type = CLOSED; + DEFAULT = 0; + } + """.trimIndent(), + name = "file1.proto" + ), + createProtoFile( + fileHeader = ProtoVersion.PROTO3.header, + content = """ + import "file1.proto"; + + message B { + A a = 1; + } + """.trimIndent(), + name = "file2.proto" + ) + ) + ) + ) + ) + } + } + + @Test + fun `test WHEN proto3 imports open enum THEN no error is thrown`() { + runGenerator( + listOf( + FakeInputDirectory( + name = "dir", + files = listOf( + createProtoFile( + fileHeader = ProtoVersion.EDITION2024.header, + content = """ + enum A { + option features.enum_type = OPEN; + DEFAULT = 0; + } + """.trimIndent(), + name = "file1.proto" + ), + createProtoFile( + fileHeader = ProtoVersion.PROTO3.header, + content = """ + import "file1.proto"; + + message B { + A a = 1; + } + """.trimIndent(), + name = "file2.proto" + ) + ) + ) + ) + ) + } +} diff --git a/kmp-grpc-shared/src/commonMain/io/github/timortel/kmpgrpc/shared/internal/io/DataType.kt b/kmp-grpc-shared/src/commonMain/io/github/timortel/kmpgrpc/shared/internal/io/DataType.kt index 938bab52..c1928e6a 100644 --- a/kmp-grpc-shared/src/commonMain/io/github/timortel/kmpgrpc/shared/internal/io/DataType.kt +++ b/kmp-grpc-shared/src/commonMain/io/github/timortel/kmpgrpc/shared/internal/io/DataType.kt @@ -25,4 +25,4 @@ enum class DataType { SFIXED64, SINT32, SINT64 -} \ No newline at end of file +} diff --git a/readme.md b/readme.md index f6a4cc6e..8702a394 100644 --- a/readme.md +++ b/readme.md @@ -75,12 +75,13 @@ Please note that not all features may be available even if the protobuf version |--------------------------------|--------------|--------------| | `field_presence` | ✅ | ✅ | | `repeated_field_encoding` | ✅ | ✅ | -| `enum_type` | ❌ | ❌ | -| `json_format` | ❌ | ❌ | -| `message_encoding` | ❌ | ❌ | +| `enum_type` | ✅ | ✅ | +| `json_format` | ⏳ Planned | ⏳ Planned | +| `message_encoding` | ⏳ Planned | ⏳ Planned | | `utf8_validation` | ❌ | ❌ | | `default_symbol_visibility` | | ✅ | | `(pb.java).nest_in_file_class` | | ✅ | +| `enforce_naming_style` | | ⏳ Planned | From b9d7ad213a88bf893f02aadb6cf73cd213dc81a9 Mon Sep 17 00:00:00 2001 From: Tim Ortel <100865202+TimOrtel@users.noreply.github.com> Date: Sun, 21 Dec 2025 13:13:51 +0100 Subject: [PATCH 07/23] Set packed option unavailable on proto editions. --- .../commonMain/proto/editions/closed-enum-test.proto | 4 ++-- .../commonMain/proto/editions/open-enum-test.proto | 4 ++-- .../declaration/message/field/ProtoMessageField.kt | 11 +++++------ .../plugin/sourcegeneration/model/option/Options.kt | 2 +- 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/kmp-grpc-internal-test/src/commonMain/proto/editions/closed-enum-test.proto b/kmp-grpc-internal-test/src/commonMain/proto/editions/closed-enum-test.proto index 917f1b2c..a598ba89 100644 --- a/kmp-grpc-internal-test/src/commonMain/proto/editions/closed-enum-test.proto +++ b/kmp-grpc-internal-test/src/commonMain/proto/editions/closed-enum-test.proto @@ -16,8 +16,8 @@ message MessageWithClosedEnum { } message MessageWithRepeatedClosedEnum { - repeated ClosedEnum a = 1 [packed = false]; - repeated ClosedEnum b = 2 [packed = true]; + repeated ClosedEnum a = 1 [features.repeated_field_encoding = EXPANDED]; + repeated ClosedEnum b = 2 [features.repeated_field_encoding = PACKED]; } message MessageWithClosedEnumMap { diff --git a/kmp-grpc-internal-test/src/commonMain/proto/editions/open-enum-test.proto b/kmp-grpc-internal-test/src/commonMain/proto/editions/open-enum-test.proto index 7773c1df..e414b584 100644 --- a/kmp-grpc-internal-test/src/commonMain/proto/editions/open-enum-test.proto +++ b/kmp-grpc-internal-test/src/commonMain/proto/editions/open-enum-test.proto @@ -16,8 +16,8 @@ message MessageWithOpenEnum { } message MessageWithRepeatedOpenEnum { - repeated OpenEnum a = 1 [packed = false]; - repeated OpenEnum b = 2 [packed = true]; + repeated OpenEnum a = 1 [features.repeated_field_encoding = EXPANDED]; + repeated OpenEnum b = 2 [features.repeated_field_encoding = PACKED]; } message MessageWithOpenEnumMap { diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/message/field/ProtoMessageField.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/message/field/ProtoMessageField.kt index c4c59dce..08d24f00 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/message/field/ProtoMessageField.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/message/field/ProtoMessageField.kt @@ -119,12 +119,11 @@ class ProtoMessageField( override val isPacked: Boolean get() = cardinality == ProtoFieldCardinality.Repeated && type.isPackable && when (file.languageVersion) { ProtoLanguageVersion.PROTO3 -> Options.Basic.packed.get(this) - ProtoLanguageVersion.EDITION2023, ProtoLanguageVersion.EDITION2024 -> when (Options.Feature.repeatedFieldEncoding.get( - this - )) { - ProtoRepeatedFieldEncoding.PACKED -> true - ProtoRepeatedFieldEncoding.EXPANDED -> false - } + ProtoLanguageVersion.EDITION2023, ProtoLanguageVersion.EDITION2024 -> + when (Options.Feature.repeatedFieldEncoding.get(this)) { + ProtoRepeatedFieldEncoding.PACKED -> true + ProtoRepeatedFieldEncoding.EXPANDED -> false + } } val memberName: MemberName diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/Options.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/Options.kt index 4a09cfd3..cc98784c 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/Options.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/Options.kt @@ -55,7 +55,7 @@ object Options { parse = String::toBooleanStrictOrNull, targets = listOf(OptionTargetMatcher.FIELD(restriction = OptionTargetMatcher.FIELD.Restriction.OnlyOnRepeated(forcePackable = true))), proto3Config = LangConfig.Available(defaultValue = true), - editionConfig = LangConfig.Available(defaultValue = true, isLocked = true) + editionConfig = LangConfig.Unavailable() ) } From 0a8dcda5892cc186fdd751f51966b9662a2c97e1 Mon Sep 17 00:00:00 2001 From: Tim Ortel <100865202+TimOrtel@users.noreply.github.com> Date: Sun, 21 Dec 2025 13:18:04 +0100 Subject: [PATCH 08/23] Fix enum scoping. --- .../src/commonMain/proto/editions/closed-enum-test.proto | 2 +- .../src/commonMain/proto/editions/open-enum-test.proto | 2 +- .../serialization/OpenClosedEnumMapFieldSerializationTest.kt | 4 ++-- .../serialization/OpenClosedEnumOneOfSerializationTest.kt | 4 ++-- .../OpenClosedEnumRepeatedFieldSerializationTest.kt | 4 ++-- .../OpenClosedEnumScalarFieldSerializationTest.kt | 4 ++-- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/kmp-grpc-internal-test/src/commonMain/proto/editions/closed-enum-test.proto b/kmp-grpc-internal-test/src/commonMain/proto/editions/closed-enum-test.proto index a598ba89..440070c4 100644 --- a/kmp-grpc-internal-test/src/commonMain/proto/editions/closed-enum-test.proto +++ b/kmp-grpc-internal-test/src/commonMain/proto/editions/closed-enum-test.proto @@ -1,6 +1,6 @@ edition = "2023"; -package io.github.timortel.kmpgrpc.test.editions; +package io.github.timortel.kmpgrpc.test.editions.closedtest; option java_outer_classname = "ClosedEnumTest"; diff --git a/kmp-grpc-internal-test/src/commonMain/proto/editions/open-enum-test.proto b/kmp-grpc-internal-test/src/commonMain/proto/editions/open-enum-test.proto index e414b584..a496a269 100644 --- a/kmp-grpc-internal-test/src/commonMain/proto/editions/open-enum-test.proto +++ b/kmp-grpc-internal-test/src/commonMain/proto/editions/open-enum-test.proto @@ -1,6 +1,6 @@ edition = "2023"; -package io.github.timortel.kmpgrpc.test.editions; +package io.github.timortel.kmpgrpc.test.editions.opentest; option java_outer_classname = "OpenEnumTest"; diff --git a/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/OpenClosedEnumMapFieldSerializationTest.kt b/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/OpenClosedEnumMapFieldSerializationTest.kt index 6dddd34f..b41fd924 100644 --- a/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/OpenClosedEnumMapFieldSerializationTest.kt +++ b/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/OpenClosedEnumMapFieldSerializationTest.kt @@ -1,7 +1,7 @@ package io.github.timortel.kotlin_multiplatform_grpc_plugin.test.serialization -import io.github.timortel.kmpgrpc.test.editions.ClosedEnumTest -import io.github.timortel.kmpgrpc.test.editions.OpenEnumTest +import io.github.timortel.kmpgrpc.test.editions.closedtest.ClosedEnumTest +import io.github.timortel.kmpgrpc.test.editions.opentest.OpenEnumTest import kotlin.test.Test import kotlin.test.assertEquals diff --git a/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/OpenClosedEnumOneOfSerializationTest.kt b/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/OpenClosedEnumOneOfSerializationTest.kt index 48bfd8ba..960495be 100644 --- a/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/OpenClosedEnumOneOfSerializationTest.kt +++ b/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/OpenClosedEnumOneOfSerializationTest.kt @@ -1,8 +1,8 @@ package io.github.timortel.kotlin_multiplatform_grpc_plugin.test.serialization import io.github.timortel.kmpgrpc.core.message.UnknownField -import io.github.timortel.kmpgrpc.test.editions.ClosedEnumTest -import io.github.timortel.kmpgrpc.test.editions.OpenEnumTest +import io.github.timortel.kmpgrpc.test.editions.closedtest.ClosedEnumTest +import io.github.timortel.kmpgrpc.test.editions.opentest.OpenEnumTest import kotlin.test.Test import kotlin.test.assertEquals diff --git a/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/OpenClosedEnumRepeatedFieldSerializationTest.kt b/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/OpenClosedEnumRepeatedFieldSerializationTest.kt index cf9c56bc..62f97a43 100644 --- a/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/OpenClosedEnumRepeatedFieldSerializationTest.kt +++ b/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/OpenClosedEnumRepeatedFieldSerializationTest.kt @@ -1,8 +1,8 @@ package io.github.timortel.kotlin_multiplatform_grpc_plugin.test.serialization import io.github.timortel.kmpgrpc.core.message.UnknownField -import io.github.timortel.kmpgrpc.test.editions.ClosedEnumTest -import io.github.timortel.kmpgrpc.test.editions.OpenEnumTest +import io.github.timortel.kmpgrpc.test.editions.closedtest.ClosedEnumTest +import io.github.timortel.kmpgrpc.test.editions.opentest.OpenEnumTest import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertEquals diff --git a/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/OpenClosedEnumScalarFieldSerializationTest.kt b/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/OpenClosedEnumScalarFieldSerializationTest.kt index e6baacec..3e722bb0 100644 --- a/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/OpenClosedEnumScalarFieldSerializationTest.kt +++ b/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/OpenClosedEnumScalarFieldSerializationTest.kt @@ -1,8 +1,8 @@ package io.github.timortel.kotlin_multiplatform_grpc_plugin.test.serialization import io.github.timortel.kmpgrpc.core.message.UnknownField -import io.github.timortel.kmpgrpc.test.editions.ClosedEnumTest -import io.github.timortel.kmpgrpc.test.editions.OpenEnumTest +import io.github.timortel.kmpgrpc.test.editions.closedtest.ClosedEnumTest +import io.github.timortel.kmpgrpc.test.editions.opentest.OpenEnumTest import kotlin.test.Test import kotlin.test.assertEquals From 980b041554e6bc7e9d7a70a823acef2a3d0f6d29 Mon Sep 17 00:00:00 2001 From: Tim Ortel <100865202+TimOrtel@users.noreply.github.com> Date: Tue, 23 Dec 2025 13:36:58 +0100 Subject: [PATCH 09/23] Breaking: Proto enums are now generated as sealed interfaces. Removed Proto One Of unknown option. --- .gitignore | 2 + .../compose-example/gradle/libs.versions.toml | 2 +- gradle/libs.versions.toml | 2 +- .../kmpgrpc/core/message/EnumCompanion.kt | 8 +- ...OpenClosedEnumMapFieldSerializationTest.kt | 4 +- .../OpenClosedEnumOneOfSerializationTest.kt | 2 +- ...losedEnumRepeatedFieldSerializationTest.kt | 4 +- ...nClosedEnumScalarFieldSerializationTest.kt | 2 +- .../enumeration/ProtoEnumerationWriter.kt | 93 +++++++++++-------- .../protofile/oneof/ProtoOneOfWriter.kt | 1 - .../model/declaration/ProtoEnum.kt | 7 +- .../declaration/enumeration/ProtoEnumField.kt | 3 + .../model/declaration/message/ProtoOneOf.kt | 2 - .../DeprecatedOptionGenerationTest.kt | 4 +- .../NestInFileClassGenerationTest.kt | 58 +++++++++--- .../validation/MessageValidationTests.kt | 2 +- .../OptionHolderTargetValidationTests.kt | 22 ++++- readme.md | 2 +- 18 files changed, 142 insertions(+), 78 deletions(-) diff --git a/.gitignore b/.gitignore index badbce25..805a5c56 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,5 @@ playground .DS_Store **/.secrets + +.run diff --git a/examples/compose-example/gradle/libs.versions.toml b/examples/compose-example/gradle/libs.versions.toml index adc118ce..86ac19d6 100644 --- a/examples/compose-example/gradle/libs.versions.toml +++ b/examples/compose-example/gradle/libs.versions.toml @@ -13,7 +13,7 @@ grpc = "1.76.0" junit = "4.13.2" kotlin = "2.2.21" kotlinx-coroutines = "1.10.2" -kmpGrpc = "1.5.0" +kmpGrpc = "2.0.0" protobuf = "3.25.6" robolectric = "4.15.1" uiTestManifest = "1.8.3" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b3c7f187..e976043c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -grpcKotlinMultiplatform = "1.5.0" +grpcKotlinMultiplatform = "2.0.0" androidGradlePlugin = "8.11.2" diff --git a/kmp-grpc-core/src/commonMain/kotlin/io/github/timortel/kmpgrpc/core/message/EnumCompanion.kt b/kmp-grpc-core/src/commonMain/kotlin/io/github/timortel/kmpgrpc/core/message/EnumCompanion.kt index 6ede9fed..eb92f988 100644 --- a/kmp-grpc-core/src/commonMain/kotlin/io/github/timortel/kmpgrpc/core/message/EnumCompanion.kt +++ b/kmp-grpc-core/src/commonMain/kotlin/io/github/timortel/kmpgrpc/core/message/EnumCompanion.kt @@ -7,11 +7,13 @@ package io.github.timortel.kmpgrpc.core.message interface EnumCompanion { /** - * Retrieves the enumeration value corresponding to the given numeric value. + * Returns the enum value for [num]. + * + * - Open enums: returns the enum's unrecognized value if [num] is unknown. + * - Closed enums: throws [IllegalArgumentException] if [num] is unknown. * * @param num The numeric value for which the corresponding enumeration value is to be retrieved. - * @return The enumeration value of type T corresponding to the provided numeric value, - * or `UNRECOGNIZED` if no corresponding value is found + * @throws IllegalArgumentException if the enum is closed and [num] is not recognized. */ fun getEnumForNumber(num: Int): T diff --git a/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/OpenClosedEnumMapFieldSerializationTest.kt b/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/OpenClosedEnumMapFieldSerializationTest.kt index b41fd924..2f92eccc 100644 --- a/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/OpenClosedEnumMapFieldSerializationTest.kt +++ b/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/OpenClosedEnumMapFieldSerializationTest.kt @@ -70,9 +70,9 @@ class OpenClosedEnumMapFieldSerializationTest { 3 to 2 ), expectedValues = mapOf( - 1 to OpenEnumTest.OpenEnum.UNRECOGNIZED, + 1 to OpenEnumTest.OpenEnum.Unrecognized(-1), 2 to OpenEnumTest.OpenEnum.ONE, - 3 to OpenEnumTest.OpenEnum.UNRECOGNIZED + 3 to OpenEnumTest.OpenEnum.Unrecognized(2) ), expectedUnknownFieldCount = 0 ) diff --git a/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/OpenClosedEnumOneOfSerializationTest.kt b/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/OpenClosedEnumOneOfSerializationTest.kt index 960495be..32b092bd 100644 --- a/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/OpenClosedEnumOneOfSerializationTest.kt +++ b/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/OpenClosedEnumOneOfSerializationTest.kt @@ -62,7 +62,7 @@ class OpenClosedEnumOneOfSerializationTest { ) runOpenOneOfDeserializationTest( UnknownField.Varint(1, 2), - OpenEnumTest.MessageWithOpenOneOf.A.B(OpenEnumTest.OpenEnum.UNRECOGNIZED), + OpenEnumTest.MessageWithOpenOneOf.A.B(OpenEnumTest.OpenEnum.Unrecognized(2)), emptyList() ) } diff --git a/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/OpenClosedEnumRepeatedFieldSerializationTest.kt b/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/OpenClosedEnumRepeatedFieldSerializationTest.kt index 62f97a43..cac267c7 100644 --- a/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/OpenClosedEnumRepeatedFieldSerializationTest.kt +++ b/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/OpenClosedEnumRepeatedFieldSerializationTest.kt @@ -85,8 +85,8 @@ class OpenClosedEnumRepeatedFieldSerializationTest { runOpenRepeatedDeserializationTest( fields = listOf(-4, 3, 1), expectedValues = listOf( - OpenEnumTest.OpenEnum.UNRECOGNIZED, - OpenEnumTest.OpenEnum.UNRECOGNIZED, + OpenEnumTest.OpenEnum.Unrecognized(-4), + OpenEnumTest.OpenEnum.Unrecognized(3), OpenEnumTest.OpenEnum.ONE, ), expectedUnknownFields = emptyList() diff --git a/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/OpenClosedEnumScalarFieldSerializationTest.kt b/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/OpenClosedEnumScalarFieldSerializationTest.kt index 3e722bb0..b1c97e38 100644 --- a/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/OpenClosedEnumScalarFieldSerializationTest.kt +++ b/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/OpenClosedEnumScalarFieldSerializationTest.kt @@ -38,7 +38,7 @@ class OpenClosedEnumScalarFieldSerializationTest { runOpenScalarDeserializationTest(UnknownField.Varint(1, 1), OpenEnumTest.OpenEnum.ONE, emptyList()) runOpenScalarDeserializationTest( UnknownField.Varint(1, 2), - OpenEnumTest.OpenEnum.UNRECOGNIZED, + OpenEnumTest.OpenEnum.Unrecognized(2), emptyList() ) } diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/enumeration/ProtoEnumerationWriter.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/enumeration/ProtoEnumerationWriter.kt index bdda489e..e8151be9 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/enumeration/ProtoEnumerationWriter.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/enumeration/ProtoEnumerationWriter.kt @@ -22,60 +22,65 @@ abstract class ProtoEnumerationWriter(val isActual: Boolean) { val supplyImplementation = isActual || !protoEnum.isNested return TypeSpec - .enumBuilder(protoEnum.className) + .interfaceBuilder(protoEnum.className) .addModifiers(protoEnum.visibility.modifier) + .addModifiers(KModifier.SEALED) .addSuperinterface(kmEnum) - .addProperty( - PropertySpec - .builder(Const.Enum.NUMBER_PROPERTY_NAME, INT, KModifier.OVERRIDE) - .apply { - if (supplyImplementation) initializer(Const.Enum.NUMBER_PROPERTY_NAME) - addModifiers(this@ProtoEnumerationWriter.modifiers) - } - .build() - ) .apply { if (supplyImplementation) { - primaryConstructor( - FunSpec - .constructorBuilder() - .addParameter(Const.Enum.NUMBER_PROPERTY_NAME, INT) - .build() - ) - addModifiers(this@ProtoEnumerationWriter.modifiers) } protoEnum.fields.forEach { enumField -> - addEnumConstant( - enumField.name, - TypeSpec - .anonymousClassBuilder() - .apply { - if (supplyImplementation) { - addSuperclassConstructorParameter("%L", enumField.number) - } + addType( + TypeSpec.objectBuilder(enumField.className) + .addModifiers(KModifier.DATA) + .addModifiers(this@ProtoEnumerationWriter.modifiers) + .addProperty( + PropertySpec + .builder(Const.Enum.NUMBER_PROPERTY_NAME, INT, KModifier.OVERRIDE) + .apply { + if (supplyImplementation) initializer("%L", enumField.number) + addModifiers(this@ProtoEnumerationWriter.modifiers) - if (Options.Basic.deprecated.get(enumField)) { - addAnnotation(DefaultAnnotations.Deprecated) - } - } + if (Options.Basic.deprecated.get(enumField)) { + addAnnotation(DefaultAnnotations.Deprecated) + } + } + .build() + ) + .addSuperinterface(protoEnum.className) .build() ) } // unrecognizedEnumField - addEnumConstant( - name = ProtoEnum.UNRECOGNIZED_FIELD_NAME, - typeSpec = TypeSpec - .anonymousClassBuilder() - .apply { - if (supplyImplementation) { - addSuperclassConstructorParameter("-1") + if (protoEnum.isOpen(protoEnum.file.languageVersion)) { + addType( + TypeSpec + .classBuilder(protoEnum.unrecognizedSubtypeClassName) + .addModifiers(this@ProtoEnumerationWriter.modifiers) + .addProperty( + PropertySpec.builder(Const.Enum.NUMBER_PROPERTY_NAME, INT, KModifier.OVERRIDE) + .addModifiers(this@ProtoEnumerationWriter.modifiers) + .apply { + if (supplyImplementation) initializer("%N", Const.Enum.NUMBER_PROPERTY_NAME) + } + .build() + ) + .primaryConstructor( + FunSpec.constructorBuilder() + .addParameter(Const.Enum.NUMBER_PROPERTY_NAME, INT) + .addModifiers(this@ProtoEnumerationWriter.modifiers) + .build() + ) + .addSuperinterface(protoEnum.className) + .apply { + if (supplyImplementation) addModifiers(KModifier.DATA) } - } - .build() - ) + .build() + ) + } } //The method that will return the correct enum for the given num .addType( @@ -128,7 +133,15 @@ abstract class ProtoEnumerationWriter(val isActual: Boolean) { if (orNullVersion) { add("else -> null\n") } else { - add("else -> %N\n", ProtoEnum.UNRECOGNIZED_FIELD_NAME) + if (protoEnum.isOpen(protoEnum.file.languageVersion)) { + add("else -> %T(num)\n", protoEnum.unrecognizedSubtypeClassName) + } else { + add( + "else -> throw %T(%P)", + IllegalArgumentException::class.asClassName(), + $$"Unknown numeric value $num for closed enum $${protoEnum.name}." + ) + } } } .endControlFlow() diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/oneof/ProtoOneOfWriter.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/oneof/ProtoOneOfWriter.kt index 71dcd4dc..f1bfd6c2 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/oneof/ProtoOneOfWriter.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/oneof/ProtoOneOfWriter.kt @@ -99,7 +99,6 @@ abstract class ProtoOneOfWriter(private val isActual: Boolean) { } // Unknown - addObject(protoOneOf.sealedClassNameUnknown, ChildClassType.Unknown) addObject(protoOneOf.sealedClassNameNotSet, ChildClassType.NotSet) } .build() diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoEnum.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoEnum.kt index fa1ce65e..ed4779e8 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoEnum.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoEnum.kt @@ -1,5 +1,6 @@ package io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration +import com.squareup.kotlinpoet.ClassName import io.github.timortel.kmpgrpc.plugin.sourcegeneration.CompilationException import io.github.timortel.kmpgrpc.plugin.sourcegeneration.Warnings import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.BaseDeclarationResolver @@ -26,8 +27,8 @@ data class ProtoEnum( override val ctx: ParserRuleContext ) : ProtoDeclaration, BaseDeclarationResolver, ProtoFieldHolder { - companion object { - const val UNRECOGNIZED_FIELD_NAME = "UNRECOGNIZED" + private companion object { + private const val UNRECOGNIZED_FILE_NAME = "Unrecognized" } override lateinit var parent: ProtoDeclParent @@ -59,6 +60,8 @@ data class ProtoEnum( is ProtoDeclParent.Message -> p.message } + val unrecognizedSubtypeClassName: ClassName get() = className.nestedClass(UNRECOGNIZED_FILE_NAME) + init { fields.forEach { it.enum = this } } diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/enumeration/ProtoEnumField.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/enumeration/ProtoEnumField.kt index 1aeb79cd..54d4b20e 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/enumeration/ProtoEnumField.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/enumeration/ProtoEnumField.kt @@ -1,5 +1,6 @@ package io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.enumeration +import com.squareup.kotlinpoet.ClassName import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.ProtoOption import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.ProtoOptionsHolder import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.ProtoEnum @@ -23,4 +24,6 @@ data class ProtoEnumField( get() = enum.file override val optionTarget: OptionTarget get() = OptionTarget.ENUM_ENTRY + + val className: ClassName get() = enum.className.nestedClass(name) } diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/message/ProtoOneOf.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/message/ProtoOneOf.kt index 5b79cad0..dbb71b82 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/message/ProtoOneOf.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/message/ProtoOneOf.kt @@ -19,7 +19,6 @@ data class ProtoOneOf( val options: List ) : ProtoMessageProperty, ProtoNode, ProtoChildPropertyNameResolver { companion object { - private const val UNKNOWN_CLASS_NAME = "Unknown" private const val UNSET_CLASS_NAME = "NotSet" } @@ -30,7 +29,6 @@ data class ProtoOneOf( val sealedClassName: ClassName get() = message.className.nestedClass(name.capitalize()) val sealedClassNameNotSet: ClassName get() = sealedClassName.nestedClass(UNSET_CLASS_NAME) - val sealedClassNameUnknown: ClassName get() = sealedClassName.nestedClass(UNKNOWN_CLASS_NAME) override val desiredAttributeName: String = name diff --git a/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/generation/DeprecatedOptionGenerationTest.kt b/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/generation/DeprecatedOptionGenerationTest.kt index 05f9ffc7..97bb7833 100644 --- a/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/generation/DeprecatedOptionGenerationTest.kt +++ b/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/generation/DeprecatedOptionGenerationTest.kt @@ -66,8 +66,8 @@ class DeprecatedOptionGenerationTest : BaseGenerationTest() { val enumClazz = enumFile.members.first() assertInstanceOf(enumClazz) - enumClazz.enumConstants["DEFAULT"]!!.annotations.assertNotDeprecated() - enumClazz.enumConstants["DEPRECATED_OPTION"]!!.annotations.assertDeprecated() + enumClazz.typeSpecs.first { it.name == "DEFAULT" }.annotations.assertNotDeprecated() + enumClazz.typeSpecs.first { it.name == "DEPRECATED_OPTION" }.annotations.assertNotDeprecated() } private fun List.assertDeprecated() { diff --git a/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/generation/NestInFileClassGenerationTest.kt b/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/generation/NestInFileClassGenerationTest.kt index e830fb69..bb396ee6 100644 --- a/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/generation/NestInFileClassGenerationTest.kt +++ b/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/generation/NestInFileClassGenerationTest.kt @@ -3,6 +3,7 @@ package io.github.timortel.kotlin_multiplatform_grpc_plugin.generation import com.google.testing.junit.testparameterinjector.junit5.TestParameter import com.google.testing.junit.testparameterinjector.junit5.TestParameterInjectorTest import io.github.timortel.kmpgrpc.plugin.sourcegeneration.ProtoSourceGenerator +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.SourceTarget import io.github.timortel.kotlin_multiplatform_grpc_plugin.createSingleFileProtoFolder import io.github.timortel.kotlin_multiplatform_grpc_plugin.validation.BaseValidationTest import org.junit.jupiter.api.Assertions @@ -57,11 +58,21 @@ class NestInFileClassGenerationTest : BaseGenerationTest() { internalVisibility = false ) - fileMap.values.forEach { collection -> - assertEquals(4, collection.size) - Assertions.assertTrue { collection.any { it.name == "A" } } - Assertions.assertTrue { collection.any { it.name == "C" } } - Assertions.assertTrue { collection.any { it.name == "DStub" } } + fileMap.entries.forEach { (target, collection) -> + when (target) { + SourceTarget.Common -> { + assertEquals(4, collection.size) + Assertions.assertTrue { collection.any { it.name == "A" } } + Assertions.assertTrue { collection.any { it.name == "C" } } + Assertions.assertTrue { collection.any { it.name == "DStub" } } + } + is SourceTarget.Actual -> { + assertEquals(3, collection.size) + Assertions.assertTrue { collection.any { it.name == "A" } } + Assertions.assertTrue { collection.none { it.name == "C" } } + Assertions.assertTrue { collection.any { it.name == "DStub" } } + } + } } } @@ -86,11 +97,21 @@ class NestInFileClassGenerationTest : BaseGenerationTest() { internalVisibility = false ) - fileMap.values.forEach { collection -> - assertEquals(4, collection.size) - Assertions.assertTrue { collection.any { it.name == "A" } } - Assertions.assertTrue { collection.any { it.name == "C" } } - Assertions.assertTrue { collection.any { it.name == "DStub" } } + fileMap.entries.forEach { (target, collection) -> + when (target) { + SourceTarget.Common -> { + assertEquals(4, collection.size) + Assertions.assertTrue { collection.any { it.name == "A" } } + Assertions.assertTrue { collection.any { it.name == "C" } } + Assertions.assertTrue { collection.any { it.name == "DStub" } } + } + is SourceTarget.Actual -> { + assertEquals(3, collection.size) + Assertions.assertTrue { collection.any { it.name == "A" } } + Assertions.assertTrue { collection.none { it.name == "C" } } + Assertions.assertTrue { collection.any { it.name == "DStub" } } + } + } } } @@ -154,10 +175,19 @@ class NestInFileClassGenerationTest : BaseGenerationTest() { internalVisibility = false ) - fileMap.values.forEach { collection -> - assertEquals(3, collection.size) - Assertions.assertTrue { collection.any { it.name == "ProtoFile" } } - Assertions.assertTrue { collection.any { it.name == "C" } } + fileMap.entries.forEach { (target, collection) -> + when (target) { + SourceTarget.Common -> { + assertEquals(3, collection.size) + Assertions.assertTrue { collection.any { it.name == "ProtoFile" } } + Assertions.assertTrue { collection.any { it.name == "C" } } + } + is SourceTarget.Actual -> { + assertEquals(2, collection.size) + Assertions.assertTrue { collection.any { it.name == "ProtoFile" } } + Assertions.assertTrue { collection.none { it.name == "C" } } + } + } } } } diff --git a/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/MessageValidationTests.kt b/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/MessageValidationTests.kt index dae5b99f..7209ab6a 100644 --- a/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/MessageValidationTests.kt +++ b/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/MessageValidationTests.kt @@ -13,7 +13,7 @@ class MessageValidationTests : BaseValidationTest() { """ message TestMessage { string a = 1; - boolean a = 2; + bool a = 2; } """.trimIndent() ) diff --git a/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/options/OptionHolderTargetValidationTests.kt b/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/options/OptionHolderTargetValidationTests.kt index 6bf38e73..5bc3b69e 100644 --- a/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/options/OptionHolderTargetValidationTests.kt +++ b/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/options/OptionHolderTargetValidationTests.kt @@ -7,6 +7,7 @@ import io.github.timortel.kmpgrpc.plugin.sourcegeneration.CompilationException import io.github.timortel.kotlin_multiplatform_grpc_plugin.validation.BaseValidationTest import io.mockk.verify import org.junit.jupiter.api.assertThrows +import kotlin.collections.plus class OptionHolderTargetValidationTests : BaseValidationTest() { @@ -17,13 +18,16 @@ class OptionHolderTargetValidationTests : BaseValidationTest() { val allVersionScenarios = listOf( """ option java_package = foo.bar; - """.trimIndent(), + """.trimIndent() + ) + + val proto3Scenarios = listOf( """ message A { repeated int32 a = 1 [packed = true]; } """.trimIndent() - ) + ) val featureScenarios = listOf( """ @@ -38,10 +42,11 @@ class OptionHolderTargetValidationTests : BaseValidationTest() { message A { repeated int32 a = 1 [features.repeated_field_encoding = PACKED]; } - """.trimIndent(), + """.trimIndent() ) return allVersionScenarios.flatMap { ProtoVersion.entries.map { version -> OptionApplicationScenario(it, version) } } + + proto3Scenarios.map { content -> OptionApplicationScenario(content, ProtoVersion.PROTO3) } + featureScenarios.flatMap { listOf(ProtoVersion.EDITION2023, ProtoVersion.EDITION2024).map { version -> OptionApplicationScenario(it, version) } } } } @@ -65,7 +70,10 @@ class OptionHolderTargetValidationTests : BaseValidationTest() { message A { option java_package = foo.bar; } - """.trimIndent(), + """.trimIndent() + ) + + val proto3Scenarios = listOf( """ message A { int32 a = 1 [packed = true]; @@ -89,10 +97,16 @@ class OptionHolderTargetValidationTests : BaseValidationTest() { message A { repeated string a = 1 [features.repeated_field_encoding = PACKED]; } + """.trimIndent(), + """ + message A { + int32 a = 1 [features.repeated_field_encoding = PACKED]; + } """.trimIndent() ) return allVersionScenarios.flatMap { ProtoVersion.entries.map { version -> OptionApplicationScenario(it, version) } } + + proto3Scenarios.map { content -> OptionApplicationScenario(content, ProtoVersion.PROTO3) } + featureScenarios.flatMap { listOf(ProtoVersion.EDITION2023, ProtoVersion.EDITION2024).map { version -> OptionApplicationScenario(it, version) } } } } diff --git a/readme.md b/readme.md index 8702a394..70b0d23f 100644 --- a/readme.md +++ b/readme.md @@ -1,6 +1,6 @@ [![GitHub license](https://img.shields.io/badge/license-Apache%20License%202.0-blue.svg?style=flat)](http://www.apache.org/licenses/LICENSE-2.0) [![Download](https://img.shields.io/maven-central/v/io.github.timortel/kmp-grpc-core) ](https://central.sonatype.com/artifact/io.github.timortel/kmp-grpc-core) -![version](https://img.shields.io/badge/version-1.5.0-blue) +![version](https://img.shields.io/badge/version-2.0.0-blue) ![badge][badge-android] ![badge][badge-jvm] From dc394aac111fd1a5621705533bd29a597ec62486 Mon Sep 17 00:00:00 2001 From: Tim Ortel <100865202+TimOrtel@users.noreply.github.com> Date: Fri, 26 Dec 2025 10:49:39 +0100 Subject: [PATCH 10/23] Add proto2 parsing --- .../test-server/build.gradle.kts | 2 +- .../sourcegeneration/ProtoSourceGenerator.kt | 96 ++-- .../model/ProtoLanguageVersion.kt | 1 + .../model/declaration/ProtoBaseDeclaration.kt | 2 +- .../model/declaration/ProtoEnum.kt | 8 + .../model/declaration/ProtoMessage.kt | 6 + .../message/field/ProtoMessageField.kt | 8 +- .../model/file/ProtoImport.kt | 2 +- .../model/option/FeatureProtoOption.kt | 1 + .../sourcegeneration/model/option/Options.kt | 10 + .../model/option/SimpleProtoOption.kt | 4 + .../parsing/ProtobufModelBuilderVisitor.kt | 426 +++++++++++++++++- .../validation/BaseValidationTest.kt | 1 + .../validation/EnumImportValidationTest.kt | 37 +- .../validation/EnumValidationTests.kt | 79 ++-- .../ExtensionDefinitionValidationTests.kt | 114 +++-- 16 files changed, 686 insertions(+), 111 deletions(-) diff --git a/kmp-grpc-internal-test/test-server/build.gradle.kts b/kmp-grpc-internal-test/test-server/build.gradle.kts index 85ef6ff1..9c20c5c9 100644 --- a/kmp-grpc-internal-test/test-server/build.gradle.kts +++ b/kmp-grpc-internal-test/test-server/build.gradle.kts @@ -45,7 +45,7 @@ dependencies { sourceSets { main { proto { - srcDirs("../src/commonMain/proto/general") + srcDirs("../src/commonMain/proto/general", "../src/commonMain/proto/proto2") } kotlin.srcDir(layout.buildDirectory.dir("generated/source/proto/main/grpc")) kotlin.srcDir(layout.buildDirectory.dir("generated/source/proto/main/grpckt")) diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/ProtoSourceGenerator.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/ProtoSourceGenerator.kt index 3fb80022..cadafe13 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/ProtoSourceGenerator.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/ProtoSourceGenerator.kt @@ -1,6 +1,8 @@ package io.github.timortel.kmpgrpc.plugin.sourcegeneration import com.squareup.kotlinpoet.FileSpec +import io.github.timortel.kmpgrpc.anltr.Protobuf2Lexer +import io.github.timortel.kmpgrpc.anltr.Protobuf2Parser import io.github.timortel.kmpgrpc.anltr.Protobuf3Lexer import io.github.timortel.kmpgrpc.anltr.Protobuf3Parser import io.github.timortel.kmpgrpc.anltr.ProtobufEditionsLexer @@ -16,16 +18,19 @@ import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.Visibility import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.file.ProtoFile import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.structure.ProtoFolder import io.github.timortel.kmpgrpc.plugin.sourcegeneration.parsing.ProtobufModelBuilderVisitor +import org.antlr.v4.runtime.TokenStream +import org.antlr.v4.runtime.CharStream import org.antlr.v4.runtime.CharStreams import org.antlr.v4.runtime.CommonTokenStream +import org.antlr.v4.runtime.Lexer import org.slf4j.Logger import java.io.File object ProtoSourceGenerator { // regexes that allow for arbitrarily many whitespaces and comments in the proto file, but expect the syntax/edition statement first. - private val proto3Regex = - "^\\s*(?:(?://[^\\n]*|/\\*[\\s\\S]*?\\*/)\\s*|\\s*\\n)*syntax\\s*=\\s*[\"']proto3[\"']\\s*;[\\s\\S]*$".toRegex() + private val protoSyntaxRegex = + "^\\s*(?:(?://[^\\n]*|/\\*[\\s\\S]*?\\*/)\\s*|\\s*\\n)*syntax\\s*=\\s*[\"'](proto2|proto3)[\"']\\s*;[\\s\\S]*$".toRegex() private val protoEditionsRegex = "^\\s*(?:(?://[^\\n]*|/\\*[\\s\\S]*?\\*/)\\s*|\\s*\\n)*edition\\s*=\\s*[\"'](\\d+)[\"']\\s*;[\\s\\S]*$".toRegex() @@ -161,50 +166,65 @@ object ProtoSourceGenerator { val fileText = file.inputStream().use { inputStream -> inputStream.reader().use { reader -> reader.readText() } } - return when { - fileText.matches(proto3Regex) -> { - val visitor = ProtobufModelBuilderVisitor( - filePath = file.path, - fileNameWithoutExtension = file.nameWithoutExtension, - fileName = file.name, - protoLanguageVersion = ProtoLanguageVersion.PROTO3 - ) - - val proto3Lexer = Protobuf3Lexer(CharStreams.fromStream(file.inputStream())) - val proto3Parser = Protobuf3Parser(CommonTokenStream(proto3Lexer)) - val proto3File = proto3Parser.proto() - - visitor.visitProto(proto3File) + val languageVersion = when { + fileText.matches(protoSyntaxRegex) -> { + when (val versionGroup = protoSyntaxRegex.matchEntire(fileText)!!.groups[1]!!.value) { + "proto2" -> ProtoLanguageVersion.PROTO2 + "proto3" -> ProtoLanguageVersion.PROTO3 + else -> { + logger.warn("File $file uses unsupported proto language version $versionGroup. Only ${ProtoLanguageVersion.entries} are supported.") + return null + } + } } fileText.matches(protoEditionsRegex) -> { - val versionGroup = protoEditionsRegex.matchEntire(fileText)!!.groups[1]!!.value - - val visitor = ProtobufModelBuilderVisitor( - filePath = file.path, - fileNameWithoutExtension = file.nameWithoutExtension, - fileName = file.name, - protoLanguageVersion = when (versionGroup) { - "2023" -> ProtoLanguageVersion.EDITION2023 - "2024" -> ProtoLanguageVersion.EDITION2024 - else -> { - logger.warn("File $file uses unsupported proto editions $versionGroup. Only ${ProtoLanguageVersion.entries} are supported.") - return null - } + when (val versionGroup = protoEditionsRegex.matchEntire(fileText)!!.groups[1]!!.value) { + "2023" -> ProtoLanguageVersion.EDITION2023 + "2024" -> ProtoLanguageVersion.EDITION2024 + else -> { + logger.warn("File $file uses unsupported proto editions $versionGroup. Only ${ProtoLanguageVersion.entries} are supported.") + return null } - ) - - val protoEditionsLexer = ProtobufEditionsLexer(CharStreams.fromStream(file.inputStream())) - val protoEditionsParser = ProtobufEditionsParser(CommonTokenStream(protoEditionsLexer)) - val protoEditionsFile = protoEditionsParser.proto() - - visitor.visitProto(protoEditionsFile) + } } else -> { - logger.warn("File $file does not seem to conform to any known proto syntax. Only proto3 and proto editions files are currently supported. Ignoring file.") - null + logger.debug("Ignoring file {}, as it is not recognized as a valid proto file", file) + return null } } + + data class Toolchain( + val lexerFactory: (CharStream) -> Lexer, + val visitProto: (ProtobufModelBuilderVisitor, TokenStream) -> ProtoFile + ) + + val toolchain = when (languageVersion) { + ProtoLanguageVersion.PROTO2 -> Toolchain( + lexerFactory = ::Protobuf2Lexer, + visitProto = { visitor, tokenStream -> visitor.visitProto(Protobuf2Parser(tokenStream).proto()) } + ) + + ProtoLanguageVersion.PROTO3 -> Toolchain( + lexerFactory = ::Protobuf3Lexer, + visitProto = { visitor, tokenStream -> visitor.visitProto(Protobuf3Parser(tokenStream).proto()) } + ) + + ProtoLanguageVersion.EDITION2023, ProtoLanguageVersion.EDITION2024 -> Toolchain( + lexerFactory = ::ProtobufEditionsLexer, + visitProto = { visitor, tokenStream -> visitor.visitProto(ProtobufEditionsParser(tokenStream).proto()) } + ) + } + + val visitor = ProtobufModelBuilderVisitor( + filePath = file.path, + fileNameWithoutExtension = file.nameWithoutExtension, + fileName = file.name, + protoLanguageVersion = languageVersion + ) + + val lexer = toolchain.lexerFactory(CharStreams.fromStream(file.inputStream())) + return toolchain.visitProto(visitor, CommonTokenStream(lexer)) } } diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/ProtoLanguageVersion.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/ProtoLanguageVersion.kt index a6f688f9..1c529532 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/ProtoLanguageVersion.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/ProtoLanguageVersion.kt @@ -1,6 +1,7 @@ package io.github.timortel.kmpgrpc.plugin.sourcegeneration.model enum class ProtoLanguageVersion { + PROTO2, PROTO3, EDITION2023, EDITION2024 diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoBaseDeclaration.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoBaseDeclaration.kt index d6c0d762..3166456f 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoBaseDeclaration.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoBaseDeclaration.kt @@ -47,7 +47,7 @@ interface ProtoBaseDeclaration : ProtoOptionsHolder, ProtoVisibilityHolder { */ val isNested: Boolean get() = when (file.languageVersion) { - ProtoLanguageVersion.PROTO3, ProtoLanguageVersion.EDITION2023 -> !Options.Basic.javaMultipleFiles.get(file) + ProtoLanguageVersion.PROTO2, ProtoLanguageVersion.PROTO3, ProtoLanguageVersion.EDITION2023 -> !Options.Basic.javaMultipleFiles.get(file) ProtoLanguageVersion.EDITION2024 -> when (Options.Feature.nestInFileClass.get(this)) { ProtoNestInFileClass.YES -> true ProtoNestInFileClass.NO -> false diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoEnum.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoEnum.kt index ed4779e8..bff5b4b8 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoEnum.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoEnum.kt @@ -79,11 +79,19 @@ data class ProtoEnum( } return when (userLanguage) { + ProtoLanguageVersion.PROTO2 -> when (file.languageVersion) { + ProtoLanguageVersion.PROTO2 -> false + ProtoLanguageVersion.PROTO3 -> true + ProtoLanguageVersion.EDITION2023, ProtoLanguageVersion.EDITION2024 -> getFeatureIsOpen() + } + // Closed enum imports are illegal. We still return closed here and throw an exception in ProtoType#validate ProtoLanguageVersion.PROTO3 -> when (file.languageVersion) { + ProtoLanguageVersion.PROTO2 -> false ProtoLanguageVersion.PROTO3 -> true ProtoLanguageVersion.EDITION2023, ProtoLanguageVersion.EDITION2024 -> getFeatureIsOpen() } ProtoLanguageVersion.EDITION2023, ProtoLanguageVersion.EDITION2024 -> when (file.languageVersion) { + ProtoLanguageVersion.PROTO2 -> false ProtoLanguageVersion.PROTO3 -> true ProtoLanguageVersion.EDITION2023, ProtoLanguageVersion.EDITION2024 -> getFeatureIsOpen() } diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoMessage.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoMessage.kt index 0b87c75e..356274bb 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoMessage.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoMessage.kt @@ -31,6 +31,7 @@ data class ProtoMessage( override val extensionDefinitions: List, val extensionRange: ProtoExtensionRanges, override val symbolVisibility: ProtoSymbolVisibility?, + val type: Type, override val ctx: ParserRuleContext ) : ProtoDeclaration, FileBasedDeclarationResolver, ProtoFieldHolder, ProtoChildPropertyNameResolver, ProtoExtensionDefinitionHolder, ProtoExtensionDefinitionFinder { @@ -174,4 +175,9 @@ data class ProtoMessage( throw CompilationException.FieldNumberConflict(message, file, ctx) } } + + enum class Type { + DEFAULT, + GROUP + } } diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/message/field/ProtoMessageField.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/message/field/ProtoMessageField.kt index 08d24f00..060d0b30 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/message/field/ProtoMessageField.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/message/field/ProtoMessageField.kt @@ -63,9 +63,10 @@ class ProtoMessageField( val cardinality: ProtoFieldCardinality get() = when (file.languageVersion) { - ProtoLanguageVersion.PROTO3 -> when (fieldCardinality) { + ProtoLanguageVersion.PROTO3, ProtoLanguageVersion.PROTO2 -> when (fieldCardinality) { FieldCardinality.SINGULAR -> ProtoFieldCardinality.Singular(ProtoFieldPresence.IMPLICIT) FieldCardinality.SINGULAR_OPTIONAL -> ProtoFieldCardinality.Singular(ProtoFieldPresence.EXPLICIT) + FieldCardinality.SINGULAR_REQUIRED -> ProtoFieldCardinality.Singular(ProtoFieldPresence.LEGACY_REQUIRED) FieldCardinality.REPEATED -> ProtoFieldCardinality.Repeated } @@ -75,7 +76,7 @@ class ProtoMessageField( ) FieldCardinality.REPEATED -> ProtoFieldCardinality.Repeated - FieldCardinality.SINGULAR_OPTIONAL -> throw IllegalArgumentException("FieldCardinality.SINGULAR_OPTIONAL is illegal for edition versions.") + FieldCardinality.SINGULAR_OPTIONAL, FieldCardinality.SINGULAR_REQUIRED -> throw IllegalArgumentException("field cardinality $fieldCardinality is illegal for edition versions.") } } @@ -118,7 +119,7 @@ class ProtoMessageField( */ override val isPacked: Boolean get() = cardinality == ProtoFieldCardinality.Repeated && type.isPackable && when (file.languageVersion) { - ProtoLanguageVersion.PROTO3 -> Options.Basic.packed.get(this) + ProtoLanguageVersion.PROTO3, ProtoLanguageVersion.PROTO2 -> Options.Basic.packed.get(this) ProtoLanguageVersion.EDITION2023, ProtoLanguageVersion.EDITION2024 -> when (Options.Feature.repeatedFieldEncoding.get(this)) { ProtoRepeatedFieldEncoding.PACKED -> true @@ -191,6 +192,7 @@ class ProtoMessageField( enum class FieldCardinality { SINGULAR, SINGULAR_OPTIONAL, + SINGULAR_REQUIRED, REPEATED } } diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/file/ProtoImport.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/file/ProtoImport.kt index 6567b153..e6afb8a0 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/file/ProtoImport.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/file/ProtoImport.kt @@ -20,7 +20,7 @@ data class ProtoImport(val identifier: String, val type: Type, val ctx: ParserRu } Type.OPTION -> { when (file.languageVersion) { - ProtoLanguageVersion.PROTO3, ProtoLanguageVersion.EDITION2023 -> throw CompilationException.UnsupportedLanguageFeatureUsed( + ProtoLanguageVersion.PROTO2, ProtoLanguageVersion.PROTO3, ProtoLanguageVersion.EDITION2023 -> throw CompilationException.UnsupportedLanguageFeatureUsed( message = "Option imports are not available in language version ${file.languageVersion}", file = file, ctx = ctx diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/FeatureProtoOption.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/FeatureProtoOption.kt index 1adba00c..7c319959 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/FeatureProtoOption.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/FeatureProtoOption.kt @@ -31,6 +31,7 @@ class FeatureProtoOption( name = name, parse = parse, languageConfigurationMap = mapOf( + ProtoLanguageVersion.PROTO2 to LangConfig.Unavailable(), ProtoLanguageVersion.PROTO3 to LangConfig.Unavailable(), ProtoLanguageVersion.EDITION2023 to edition2023Config, ProtoLanguageVersion.EDITION2024 to edition2024Config, diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/Options.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/Options.kt index cc98784c..e64ae0d4 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/Options.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/Options.kt @@ -12,6 +12,7 @@ object Options { name = "java_multiple_files", parse = String::toBooleanStrictOrNull, targets = listOf(OptionTargetMatcher.FILE), + proto2Config = LangConfig.Available(defaultValue = false), proto3Config = LangConfig.Available(defaultValue = false), edition2023Config = LangConfig.Available(defaultValue = false), edition2024Config = LangConfig.Available(defaultValue = false) @@ -21,6 +22,7 @@ object Options { name = "java_package", parse = { it }, targets = listOf(OptionTargetMatcher.FILE), + proto2Config = LangConfig.Available(defaultValue = null), proto3Config = LangConfig.Available(defaultValue = null), editionConfig = LangConfig.Available(defaultValue = null) ) @@ -29,6 +31,7 @@ object Options { name = "java_outer_classname", parse = { it }, targets = listOf(OptionTargetMatcher.FILE), + proto2Config = LangConfig.Available(defaultValue = null), proto3Config = LangConfig.Available(defaultValue = null), editionConfig = LangConfig.Available(defaultValue = null) ) @@ -37,6 +40,7 @@ object Options { name = "allow_alias", parse = String::toBooleanStrictOrNull, targets = listOf(OptionTargetMatcher.ENUM(restrictToTopLevel = false)), + proto2Config = LangConfig.Available(defaultValue = false), proto3Config = LangConfig.Available(defaultValue = false), editionConfig = LangConfig.Available(defaultValue = false) ) @@ -45,6 +49,7 @@ object Options { name = "deprecated", parse = String::toBooleanStrictOrNull, targets = listOf(OptionTargetMatcher.FIELD(), OptionTargetMatcher.ENUM_ENTRY), + proto2Config = LangConfig.Available(defaultValue = false), proto3Config = LangConfig.Available(defaultValue = false), editionConfig = LangConfig.Available(defaultValue = false), failOnInvalidTargetUsage = false @@ -54,6 +59,7 @@ object Options { name = "packed", parse = String::toBooleanStrictOrNull, targets = listOf(OptionTargetMatcher.FIELD(restriction = OptionTargetMatcher.FIELD.Restriction.OnlyOnRepeated(forcePackable = true))), + proto2Config = LangConfig.Available(defaultValue = true), proto3Config = LangConfig.Available(defaultValue = true), editionConfig = LangConfig.Unavailable() ) @@ -80,6 +86,10 @@ object Options { name = "default_symbol_visibility", parse = { value -> ProtoDefaultSymbolVisibility.entries.firstOrNull { it.name == value } }, languageConfigurationMap = mapOf( + ProtoLanguageVersion.PROTO2 to LangConfig.Available( + defaultValue = ProtoDefaultSymbolVisibility.EXPORT_ALL, + isLocked = true + ), ProtoLanguageVersion.PROTO3 to LangConfig.Available( defaultValue = ProtoDefaultSymbolVisibility.EXPORT_ALL, isLocked = true diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/SimpleProtoOption.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/SimpleProtoOption.kt index f96a31b9..c924ac8e 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/SimpleProtoOption.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/SimpleProtoOption.kt @@ -19,12 +19,14 @@ class SimpleProtoOption( name: String, parse: (String) -> T?, targets: List, + proto2Config: LangConfig, proto3Config: LangConfig, editionConfig: LangConfig, failOnInvalidTargetUsage: Boolean = true ) : this( name = name, parse = parse, + proto2Config = proto2Config, proto3Config = proto3Config, edition2023Config = editionConfig, edition2024Config = editionConfig, @@ -36,6 +38,7 @@ class SimpleProtoOption( name: String, parse: (String) -> T?, targets: List, + proto2Config: LangConfig, proto3Config: LangConfig, edition2023Config: LangConfig, edition2024Config: LangConfig, @@ -44,6 +47,7 @@ class SimpleProtoOption( name = name, parse = parse, languageConfigurationMap = mapOf( + ProtoLanguageVersion.PROTO2 to proto2Config, ProtoLanguageVersion.PROTO3 to proto3Config, ProtoLanguageVersion.EDITION2023 to edition2023Config, ProtoLanguageVersion.EDITION2024 to edition2024Config diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/parsing/ProtobufModelBuilderVisitor.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/parsing/ProtobufModelBuilderVisitor.kt index e4a2b355..996fbeac 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/parsing/ProtobufModelBuilderVisitor.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/parsing/ProtobufModelBuilderVisitor.kt @@ -1,5 +1,7 @@ package io.github.timortel.kmpgrpc.plugin.sourcegeneration.parsing +import io.github.timortel.kmpgrpc.anltr.Protobuf2Parser +import io.github.timortel.kmpgrpc.anltr.Protobuf2Visitor import io.github.timortel.kmpgrpc.anltr.Protobuf3Parser import io.github.timortel.kmpgrpc.anltr.Protobuf3Visitor import io.github.timortel.kmpgrpc.anltr.ProtobufEditionsParser @@ -40,7 +42,7 @@ class ProtobufModelBuilderVisitor( private val fileName: String, private val fileNameWithoutExtension: String, private val protoLanguageVersion: ProtoLanguageVersion -) : Protobuf3Visitor, ProtobufEditionsVisitor { +) : Protobuf3Visitor, ProtobufEditionsVisitor, Protobuf2Visitor { private fun visitProto( ctx: ParserRuleContext, @@ -118,6 +120,29 @@ class ProtobufModelBuilderVisitor( ) } + override fun visitProto(ctx: Protobuf2Parser.ProtoContext): ProtoFile { + val imports = ctx.importStatement().map { visitImportStatement(it) } + val options = ctx.optionStatement().map { visitOptionStatement(it) } + + val messages = ctx.topLevelDef().mapNotNull { it.messageDef() }.map { visitMessageDef(it) } + val topLevelEnums = ctx.topLevelDef().mapNotNull { it.enumDef() }.map { visitEnumDef(it) } + val services = ctx.topLevelDef().mapNotNull { it.serviceDef() }.map { visitServiceDef(it) } + val extensionDefinitionsData = ctx.topLevelDef().mapNotNull { it.extendDef() }.map { visitExtendDef(it) } + + val packages = ctx.packageStatement().mapNotNull { it.fullIdent()?.text } + + return visitProto( + ctx = ctx, + imports = imports, + options = options, + messages = messages + extensionDefinitionsData.flatMap { it.groupMessages }, + topLevelEnums = topLevelEnums, + services = services, + packages = packages, + extensionDefinitions = extensionDefinitionsData.map { it.extensionDefinition } + ) + } + private fun visitImportStatement(ctx: ParserRuleContext, identifier: String, type: ProtoImport.Type): ProtoImport { return ProtoImport(identifier = identifier, type = type, ctx = ctx) } @@ -141,6 +166,15 @@ class ProtobufModelBuilderVisitor( return visitImportStatement(ctx, ctx.strLit().text, type) } + override fun visitImportStatement(ctx: Protobuf2Parser.ImportStatementContext): ProtoImport { + val type = when { + ctx.PUBLIC() != null -> ProtoImport.Type.PUBLIC + else -> ProtoImport.Type.DEFAULT + } + + return visitImportStatement(ctx, ctx.strLit().text, type) + } + private fun visitOption(ctx: ParserRuleContext, name: String, constant: String): ProtoOption { val value = if (constant.startsWith("\"") && constant.endsWith("\"")) { constant.substring(1, constant.length - 1) @@ -157,6 +191,10 @@ class ProtobufModelBuilderVisitor( return visitOption(ctx, ctx.optionName().text, ctx.constant().text) } + override fun visitOptionStatement(ctx: Protobuf2Parser.OptionStatementContext): ProtoOption { + return visitOption(ctx, ctx.optionName().text, ctx.constant().text) + } + override fun visitSymbolVisibility(ctx: ProtobufEditionsParser.SymbolVisibilityContext?): ProtoSymbolVisibility? { return when { ctx?.LOCAL() != null -> ProtoSymbolVisibility.LOCAL @@ -165,7 +203,9 @@ class ProtobufModelBuilderVisitor( } } + // ------------------------- // Message parsing + // ------------------------- override fun visitMessageDef(ctx: ProtobufEditionsParser.MessageDefContext): ProtoMessage { val name = ctx.messageName().text @@ -199,6 +239,7 @@ class ProtobufModelBuilderVisitor( extensionDefinitions = extensionDefinitions, extensionRange = extensionRange, symbolVisibility = symbolVisibility, + type = ProtoMessage.Type.DEFAULT, ctx = ctx ) } @@ -233,10 +274,101 @@ class ProtobufModelBuilderVisitor( extensionDefinitions = extensionDefinitions, extensionRange = ProtoExtensionRanges(), symbolVisibility = null, + type = ProtoMessage.Type.DEFAULT, + ctx = ctx + ) + } + + private fun visitProto2MessageFromElements( + name: String, + elements: List, + type: ProtoMessage.Type, + ctx: ParserRuleContext + ): ProtoMessage { + val nestedMessages = elements.mapNotNull { it.messageDef() }.map { visitMessageDef(it) } + val nestedEnums = elements.mapNotNull { it.enumDef() }.map { visitEnumDef(it) } + + val directFields = elements.mapNotNull { it.field() }.map { visitField(it) } + val mapFields = elements.mapNotNull { it.mapField() }.map { visitMapField(it) } + val oneOfs = elements.mapNotNull { it.oneof() }.map { visitOneof(it) } + + val parsedGroups = elements.mapNotNull { it.group() }.map { visitGroup(it) } + val groupFields = parsedGroups.map { it.field } + val groupMessages = parsedGroups.map { it.message } + + val reservation = elements.mapNotNull { it.reserved() }.map { visitReserved(it) }.fold() + val options = elements.mapNotNull { it.optionStatement() }.map { visitOptionStatement(it) } + + val extensionDefinitionsData = elements.mapNotNull { it.extendDef() }.map { visitExtendDef(it) } + val extensionRange = elements.mapNotNull { it.extensions() }.map { visitExtensions(it) }.fold() + + return ProtoMessage( + name = name, + messages = nestedMessages + groupMessages + extensionDefinitionsData.flatMap { it.groupMessages }, + enums = nestedEnums, + fields = directFields + groupFields, + oneOfs = oneOfs, + mapFields = mapFields, + reservation = reservation, + options = options, + extensionDefinitions = extensionDefinitionsData.map { it.extensionDefinition }, + extensionRange = extensionRange, + symbolVisibility = null, + type = type, + ctx = ctx + ) + } + + override fun visitMessageDef(ctx: Protobuf2Parser.MessageDefContext): ProtoMessage { + val name = ctx.messageName().text + val elements = ctx.messageBody().messageElement() + + return visitProto2MessageFromElements( + name = name, + elements = elements, + type = ProtoMessage.Type.DEFAULT, ctx = ctx ) } + private fun visitGroupFieldCardinality(label: Protobuf2Parser.FieldLabelContext?): ProtoMessageField.FieldCardinality { + return when { + label?.REPEATED() != null -> ProtoMessageField.FieldCardinality.REPEATED + label?.OPTIONAL() != null -> ProtoMessageField.FieldCardinality.SINGULAR_OPTIONAL + label?.REQUIRED() != null -> ProtoMessageField.FieldCardinality.SINGULAR_REQUIRED + else -> throw IllegalStateException("field cardinality must be one of: repeated, optional, required") + } + } + + override fun visitGroup(ctx: Protobuf2Parser.GroupContext): ParsedGroup { + val label = ctx.fieldLabel() + val fieldCardinality = visitGroupFieldCardinality(label) + + val groupName = ctx.groupName().text + val number = ctx.fieldNumber().parseInt() + + val options = visitFieldOptions(ctx.fieldOptions()) + + val field = ProtoMessageField( + type = ProtoType.DefType(groupName, ctx), + name = groupName, + number = number, + options = options, + fieldCardinality = fieldCardinality, + ctx = ctx + ) + + val groupElements = ctx.messageBody().messageElement() + val groupMessage = visitProto2MessageFromElements( + name = groupName, + elements = groupElements, + type = ProtoMessage.Type.GROUP, + ctx = ctx + ) + + return ParsedGroup(field = field, message = groupMessage) + } + override fun visitReserved(ctx: ProtobufEditionsParser.ReservedContext): ProtoReservation { return when { ctx.ranges() != null -> ProtoReservation(ranges = visitRanges(ctx.ranges())) @@ -253,6 +385,14 @@ class ProtobufModelBuilderVisitor( } } + override fun visitReserved(ctx: Protobuf2Parser.ReservedContext): ProtoReservation { + return when { + ctx.ranges() != null -> ProtoReservation(ranges = visitRanges(ctx.ranges())) + ctx.reservedFieldNames() != null -> visitReservedFieldNames(ctx.reservedFieldNames()) + else -> throw ParseException("Could not read reserved field", ctx) + } + } + override fun visitRanges(ctx: ProtobufEditionsParser.RangesContext): List { return ctx.range_().map { visitRange_(it) } } @@ -261,6 +401,10 @@ class ProtobufModelBuilderVisitor( return ctx.range_().map { visitRange_(it) } } + override fun visitRanges(ctx: Protobuf2Parser.RangesContext): List { + return ctx.range_().map { visitRange_(it) } + } + private fun visitRange(start: Int, end: Int?, isMax: Boolean, ctx: ParserRuleContext): ProtoRange { val end = when { isMax -> Const.FIELD_NUMBER_MAX_VALUE @@ -279,6 +423,10 @@ class ProtobufModelBuilderVisitor( return visitRange(ctx.intLit(0).parseInt(), ctx.intLit(1)?.parseInt(), ctx.MAX() != null, ctx) } + override fun visitRange_(ctx: Protobuf2Parser.Range_Context): ProtoRange { + return visitRange(ctx.intLit(0).parseInt(), ctx.intLit(1)?.parseInt(), ctx.MAX() != null, ctx) + } + private fun visitReservedFieldNames(names: List): ProtoReservation { return ProtoReservation( // Remove "" @@ -294,13 +442,21 @@ class ProtobufModelBuilderVisitor( return visitReservedFieldNames(ctx.strLit().map { it.text }) } + override fun visitReservedFieldNames(ctx: Protobuf2Parser.ReservedFieldNamesContext): ProtoReservation { + return visitReservedFieldNames(ctx.strLit().map { it.text }) + } + override fun visitMessageBody(ctx: ProtobufEditionsParser.MessageBodyContext): Any = Unit override fun visitMessageBody(ctx: Protobuf3Parser.MessageBodyContext): Any = Unit + override fun visitMessageBody(ctx: Protobuf2Parser.MessageBodyContext): Any = Unit override fun visitMessageElement(ctx: ProtobufEditionsParser.MessageElementContext): Any = Unit override fun visitMessageElement(ctx: Protobuf3Parser.MessageElementContext?): Any = Unit + override fun visitMessageElement(ctx: Protobuf2Parser.MessageElementContext?): Any = Unit + // ------------------------- // Enum Parsing + // ------------------------- override fun visitEnumDef(ctx: ProtobufEditionsParser.EnumDefContext): ProtoEnum { val name = ctx.enumName().text @@ -340,7 +496,27 @@ class ProtobufModelBuilderVisitor( ) } + override fun visitEnumDef(ctx: Protobuf2Parser.EnumDefContext): ProtoEnum { + val name = ctx.enumName().text + val elements = ctx.enumBody().enumElement() + + val options = elements.mapNotNull { it.optionStatement() }.map { visitOptionStatement(it) } + val fields = elements.mapNotNull { it.enumField() }.map { visitEnumField(it) } + val reservation = elements.mapNotNull { it.reserved() }.map { visitReserved(it) }.fold() + + return ProtoEnum( + name = name, + fields = fields, + options = options, + reservation = reservation, + symbolVisibility = null, + ctx = ctx + ) + } + + // ------------------------- // Field parsing + // ------------------------- override fun visitField(ctx: ProtobufEditionsParser.FieldContext): ProtoMessageField { val label = ctx.fieldLabel() @@ -391,6 +567,32 @@ class ProtobufModelBuilderVisitor( ) } + override fun visitField(ctx: Protobuf2Parser.FieldContext): ProtoMessageField { + val label = ctx.fieldLabel() + + val fieldCardinality = when { + label?.REPEATED() != null -> ProtoMessageField.FieldCardinality.REPEATED + label?.OPTIONAL() != null -> ProtoMessageField.FieldCardinality.SINGULAR_OPTIONAL + label?.REQUIRED() != null -> ProtoMessageField.FieldCardinality.SINGULAR_REQUIRED + else -> throw IllegalStateException("field cardinality must be one of: repeated, optional, required") + } + + val type = visitType_(ctx.type_()) + val name = ctx.fieldName().text + val number = ctx.fieldNumber().parseInt() + + val options = visitFieldOptions(ctx.fieldOptions()) + + return ProtoMessageField( + type = type, + name = name, + number = number, + options = options, + fieldCardinality = fieldCardinality, + ctx = ctx + ) + } + override fun visitMapField(ctx: ProtobufEditionsParser.MapFieldContext): ProtoMapField { val keyType = visitKeyType(ctx.keyType()) val valuesType = visitType_(ctx.type_()) @@ -429,6 +631,25 @@ class ProtobufModelBuilderVisitor( ) } + override fun visitMapField(ctx: Protobuf2Parser.MapFieldContext): ProtoMapField { + val keyType = visitKeyType(ctx.keyType()) + val valuesType = visitType_(ctx.type_()) + + val name = ctx.mapName().text + val number = ctx.fieldNumber().parseInt() + + val options = visitFieldOptions(ctx.fieldOptions()) + + return ProtoMapField( + name = name, + number = number, + options = options, + keyType = keyType, + valuesType = valuesType, + ctx = ctx + ) + } + // Nullability is required - otherwise NPE override fun visitFieldOptions(ctx: ProtobufEditionsParser.FieldOptionsContext?): List { return ctx?.fieldOption().orEmpty().map { visitFieldOption(it) } @@ -438,6 +659,10 @@ class ProtobufModelBuilderVisitor( return ctx?.fieldOption().orEmpty().map { visitFieldOption(it) } } + override fun visitFieldOptions(ctx: Protobuf2Parser.FieldOptionsContext?): List { + return ctx?.fieldOption().orEmpty().map { visitFieldOption(it) } + } + private fun visitFieldOption(ctx: ParserRuleContext, name: String, value: String): ProtoOption { return ProtoOption(name, value, ctx) } @@ -450,6 +675,10 @@ class ProtobufModelBuilderVisitor( return visitFieldOption(ctx, ctx.optionName().text, ctx.constant().text) } + override fun visitFieldOption(ctx: Protobuf2Parser.FieldOptionContext): ProtoOption { + return visitFieldOption(ctx, ctx.optionName().text, ctx.constant().text) + } + private fun visitEnumField( ctx: ParserRuleContext, name: String, @@ -481,6 +710,14 @@ class ProtobufModelBuilderVisitor( return visitEnumField(ctx, name, number, options, ctx.MINUS() != null) } + override fun visitEnumField(ctx: Protobuf2Parser.EnumFieldContext): ProtoEnumField { + val name = ctx.ident().text + val number = ctx.intLit().parseInt() + val options = visitEnumValueOptions(ctx.enumValueOptions()) + + return visitEnumField(ctx, name, number, options, ctx.MINUS() != null) + } + // Nullability is required - otherwise NPE override fun visitEnumValueOptions(ctx: ProtobufEditionsParser.EnumValueOptionsContext?): List { return ctx?.enumValueOption().orEmpty().map { visitEnumValueOption(it) } @@ -490,6 +727,10 @@ class ProtobufModelBuilderVisitor( return ctx?.enumValueOption().orEmpty().map { visitEnumValueOption(it) } } + override fun visitEnumValueOptions(ctx: Protobuf2Parser.EnumValueOptionsContext?): List { + return ctx?.enumValueOption().orEmpty().map { visitEnumValueOption(it) } + } + override fun visitEnumValueOption(ctx: ProtobufEditionsParser.EnumValueOptionContext): ProtoOption { return ProtoOption(name = ctx.optionName().text, value = ctx.constant().text, ctx) } @@ -498,7 +739,13 @@ class ProtobufModelBuilderVisitor( return ProtoOption(name = ctx.optionName().text, value = ctx.constant().text, ctx) } + override fun visitEnumValueOption(ctx: Protobuf2Parser.EnumValueOptionContext): ProtoOption { + return ProtoOption(name = ctx.optionName().text, value = ctx.constant().text, ctx) + } + + // ------------------------- // One-Of Parsing + // ------------------------- override fun visitOneof(ctx: ProtobufEditionsParser.OneofContext): ProtoOneOf { val name = ctx.oneofName().text @@ -518,6 +765,15 @@ class ProtobufModelBuilderVisitor( return ProtoOneOf(name = name, fields = fields, options = options) } + override fun visitOneof(ctx: Protobuf2Parser.OneofContext): ProtoOneOf { + val name = ctx.oneofName().text + + val options = ctx.optionStatement().map { visitOptionStatement(it) } + val fields = ctx.oneofField().map { visitOneofField(it) } + + return ProtoOneOf(name = name, fields = fields, options = options) + } + override fun visitOneofField(ctx: ProtobufEditionsParser.OneofFieldContext): ProtoOneOfField { val type = visitType_(ctx.type_()) val name = ctx.fieldName().text @@ -550,7 +806,25 @@ class ProtobufModelBuilderVisitor( ) } + override fun visitOneofField(ctx: Protobuf2Parser.OneofFieldContext): ProtoOneOfField { + val type = visitType_(ctx.type_()) + val name = ctx.fieldName().text + val number = ctx.fieldNumber().parseInt() + + val options = visitFieldOptions(ctx.fieldOptions()) + + return ProtoOneOfField( + type = type, + name = name, + number = number, + options = options, + ctx = ctx + ) + } + + // ------------------------- // Service parsing + // ------------------------- override fun visitServiceDef(ctx: ProtobufEditionsParser.ServiceDefContext): ProtoService { val name = ctx.serviceName().text @@ -578,6 +852,19 @@ class ProtobufModelBuilderVisitor( ) } + override fun visitServiceDef(ctx: Protobuf2Parser.ServiceDefContext): ProtoService { + val name = ctx.serviceName().text + val options = ctx.serviceElement().mapNotNull { it.optionStatement() }.map { visitOptionStatement(it) } + val rpcs = ctx.serviceElement().mapNotNull { it.rpc() }.map { visitRpc(it) } + + return ProtoService( + name = name, + options = options, + rpcs = rpcs, + ctx = ctx + ) + } + override fun visitRpc(ctx: ProtobufEditionsParser.RpcContext): ProtoRpc { val name = ctx.rpcName().text val clientType = visitMessageType(ctx.messageType(0)) @@ -614,10 +901,31 @@ class ProtobufModelBuilderVisitor( ) } + override fun visitRpc(ctx: Protobuf2Parser.RpcContext): ProtoRpc { + val name = ctx.rpcName().text + val clientType = visitMessageType(ctx.messageType(0)) + val serverType = visitMessageType(ctx.messageType(1)) + val isClientStream = ctx.clientStream != null + val isServerStream = ctx.serverStream != null + val options = ctx.optionStatement().map { visitOptionStatement(it) } + + return ProtoRpc( + name = name, + sendType = clientType, + returnType = serverType, + isSendingStream = isClientStream, + isReceivingStream = isServerStream, + options = options + ) + } + override fun visitServiceElement(ctx: ProtobufEditionsParser.ServiceElementContext?): Any = Unit override fun visitServiceElement(ctx: Protobuf3Parser.ServiceElementContext?): Any = Unit + override fun visitServiceElement(ctx: Protobuf2Parser.ServiceElementContext): Any = Unit + // ------------------------- // Extensions + // ------------------------- override fun visitExtendDef(ctx: ProtobufEditionsParser.ExtendDefContext): ProtoExtensionDefinition { val messageDef = ctx.messageType().text @@ -633,11 +941,37 @@ class ProtobufModelBuilderVisitor( return ProtoExtensionDefinition(ProtoType.DefType(messageDef, ctx.messageType()), fields, ctx) } + override fun visitExtendDef(ctx: Protobuf2Parser.ExtendDefContext): Proto2ExtendDefinitionData { + val messageDef = ctx.messageType().text + + val elements = ctx.extendElement() + val fieldsFromFields = elements.mapNotNull { it.field() }.map { visitField(it) } + // Groups inside extend: best-effort mapping to a field (group message is not modeled in ProtoExtensionDefinition). + val groups = elements.mapNotNull { it.group() }.map { grp -> visitGroup(grp) } + + val extensionDefinition = ProtoExtensionDefinition( + messageType = ProtoType.DefType(messageDef, ctx.messageType()), + fields = fieldsFromFields + groups.map { it.field }, + ctx = ctx + ) + + return Proto2ExtendDefinitionData( + extensionDefinition = extensionDefinition, + groupMessages = groups.map { it.message } + ) + } + override fun visitExtensions(ctx: ProtobufEditionsParser.ExtensionsContext): ProtoExtensionRanges { return ProtoExtensionRanges(ranges = visitRanges(ctx.ranges())) } + override fun visitExtensions(ctx: Protobuf2Parser.ExtensionsContext): ProtoExtensionRanges { + return ProtoExtensionRanges(ranges = visitRanges(ctx.ranges())) + } + + // ------------------------- // Type parsing + // ------------------------- override fun visitType_(ctx: ProtobufEditionsParser.Type_Context): ProtoType { return when { @@ -683,6 +1017,28 @@ class ProtobufModelBuilderVisitor( } } + override fun visitType_(ctx: Protobuf2Parser.Type_Context): ProtoType { + return when { + ctx.messageType() != null || ctx.enumType() != null -> ProtoType.DefType(ctx.text, ctx) + ctx.DOUBLE() != null -> ProtoType.DoubleType + ctx.FLOAT() != null -> ProtoType.FloatType + ctx.INT32() != null -> ProtoType.Int32Type + ctx.INT64() != null -> ProtoType.Int64Type + ctx.UINT32() != null -> ProtoType.UInt32Type + ctx.UINT64() != null -> ProtoType.UInt64Type + ctx.SINT32() != null -> ProtoType.SInt32Type + ctx.SINT64() != null -> ProtoType.SInt64Type + ctx.FIXED32() != null -> ProtoType.Fixed32Type + ctx.FIXED64() != null -> ProtoType.Fixed64Type + ctx.SFIXED32() != null -> ProtoType.SFixed32Type + ctx.SFIXED64() != null -> ProtoType.SFixed64Type + ctx.BOOL() != null -> ProtoType.BoolType + ctx.STRING() != null -> ProtoType.StringType + ctx.BYTES() != null -> ProtoType.BytesType + else -> throw ParseException("Unknown type found.", ctx) + } + } + override fun visitKeyType(ctx: ProtobufEditionsParser.KeyTypeContext): ProtoType.MapKeyType { return when { ctx.INT32() != null -> ProtoType.Int32Type @@ -719,6 +1075,24 @@ class ProtobufModelBuilderVisitor( } } + override fun visitKeyType(ctx: Protobuf2Parser.KeyTypeContext): ProtoType.MapKeyType { + return when { + ctx.INT32() != null -> ProtoType.Int32Type + ctx.INT64() != null -> ProtoType.Int64Type + ctx.UINT32() != null -> ProtoType.UInt32Type + ctx.UINT64() != null -> ProtoType.UInt64Type + ctx.SINT32() != null -> ProtoType.SInt32Type + ctx.SINT64() != null -> ProtoType.SInt64Type + ctx.FIXED32() != null -> ProtoType.Fixed32Type + ctx.FIXED64() != null -> ProtoType.Fixed64Type + ctx.SFIXED32() != null -> ProtoType.SFixed32Type + ctx.SFIXED64() != null -> ProtoType.SFixed64Type + ctx.BOOL() != null -> ProtoType.BoolType + ctx.STRING() != null -> ProtoType.StringType + else -> throw ParseException("Unknown type found.", ctx) + } + } + override fun visitMessageType(ctx: ProtobufEditionsParser.MessageTypeContext): ProtoType.DefType { return ProtoType.DefType(ctx.text, ctx) } @@ -727,6 +1101,14 @@ class ProtobufModelBuilderVisitor( return ProtoType.DefType(ctx.text, ctx) } + override fun visitMessageType(ctx: Protobuf2Parser.MessageTypeContext): ProtoType.DefType { + return ProtoType.DefType(ctx.text, ctx) + } + + // ------------------------- + // Remaining visitor methods (stubs) + // ------------------------- + override fun visit(tree: ParseTree): Any = Unit override fun visitChildren(node: RuleNode?): Any = Unit @@ -737,83 +1119,125 @@ class ProtobufModelBuilderVisitor( override fun visitEdition(ctx: ProtobufEditionsParser.EditionContext?): Any = Unit override fun visitSyntax(ctx: Protobuf3Parser.SyntaxContext?): Any = Unit + override fun visitSyntax(ctx: Protobuf2Parser.SyntaxContext?): Any = Unit override fun visitPackageStatement(ctx: ProtobufEditionsParser.PackageStatementContext?): Any = Unit override fun visitPackageStatement(ctx: Protobuf3Parser.PackageStatementContext?): Any = Unit + override fun visitPackageStatement(ctx: Protobuf2Parser.PackageStatementContext?): Any = Unit override fun visitOptionName(ctx: ProtobufEditionsParser.OptionNameContext?): Any = Unit override fun visitOptionName(ctx: Protobuf3Parser.OptionNameContext?): Any = Unit + override fun visitOptionName(ctx: Protobuf2Parser.OptionNameContext?): Any = Unit override fun visitFieldLabel(ctx: ProtobufEditionsParser.FieldLabelContext?): Any = Unit override fun visitFieldLabel(ctx: Protobuf3Parser.FieldLabelContext?): Any = Unit + override fun visitFieldLabel(ctx: Protobuf2Parser.FieldLabelContext?): Any = Unit override fun visitFieldNumber(ctx: ProtobufEditionsParser.FieldNumberContext?): Any = Unit override fun visitFieldNumber(ctx: Protobuf3Parser.FieldNumberContext?): Any = Unit + override fun visitFieldNumber(ctx: Protobuf2Parser.FieldNumberContext?): Any = Unit override fun visitTopLevelDef(ctx: ProtobufEditionsParser.TopLevelDefContext?): Any = Unit override fun visitTopLevelDef(ctx: Protobuf3Parser.TopLevelDefContext?): Any = Unit + override fun visitTopLevelDef(ctx: Protobuf2Parser.TopLevelDefContext?): Any = Unit override fun visitEnumBody(ctx: ProtobufEditionsParser.EnumBodyContext?): Any = Unit override fun visitEnumBody(ctx: Protobuf3Parser.EnumBodyContext?): Any = Unit + override fun visitEnumBody(ctx: Protobuf2Parser.EnumBodyContext?): Any = Unit override fun visitEnumElement(ctx: ProtobufEditionsParser.EnumElementContext?): Any = Unit override fun visitEnumElement(ctx: Protobuf3Parser.EnumElementContext?): Any = Unit + override fun visitEnumElement(ctx: Protobuf2Parser.EnumElementContext?): Any = Unit override fun visitConstant(ctx: ProtobufEditionsParser.ConstantContext?): Any = Unit override fun visitConstant(ctx: Protobuf3Parser.ConstantContext?): Any = Unit + override fun visitConstant(ctx: Protobuf2Parser.ConstantContext?): Any = Unit override fun visitBlockLit(ctx: ProtobufEditionsParser.BlockLitContext?): Any = Unit override fun visitBlockLit(ctx: Protobuf3Parser.BlockLitContext?): Any = Unit + override fun visitBlockLit(ctx: Protobuf2Parser.BlockLitContext?): Any = Unit override fun visitEmptyStatement_(ctx: ProtobufEditionsParser.EmptyStatement_Context?): Any = Unit override fun visitEmptyStatement_(ctx: Protobuf3Parser.EmptyStatement_Context?): Any = Unit + override fun visitEmptyStatement_(ctx: Protobuf2Parser.EmptyStatement_Context?): Any = Unit override fun visitIdent(ctx: ProtobufEditionsParser.IdentContext?): Any = Unit override fun visitIdent(ctx: Protobuf3Parser.IdentContext?): Any = Unit + override fun visitIdent(ctx: Protobuf2Parser.IdentContext?): Any = Unit override fun visitFullIdent(ctx: ProtobufEditionsParser.FullIdentContext?): Any = Unit override fun visitFullIdent(ctx: Protobuf3Parser.FullIdentContext?): Any = Unit + override fun visitFullIdent(ctx: Protobuf2Parser.FullIdentContext?): Any = Unit override fun visitMessageName(ctx: ProtobufEditionsParser.MessageNameContext?): Any = Unit override fun visitMessageName(ctx: Protobuf3Parser.MessageNameContext?): Any = Unit + override fun visitMessageName(ctx: Protobuf2Parser.MessageNameContext?): Any = Unit override fun visitEnumName(ctx: ProtobufEditionsParser.EnumNameContext?): Any = Unit override fun visitEnumName(ctx: Protobuf3Parser.EnumNameContext?): Any = Unit + override fun visitEnumName(ctx: Protobuf2Parser.EnumNameContext?): Any = Unit override fun visitFieldName(ctx: ProtobufEditionsParser.FieldNameContext?): Any = Unit override fun visitFieldName(ctx: Protobuf3Parser.FieldNameContext?): Any = Unit + override fun visitFieldName(ctx: Protobuf2Parser.FieldNameContext?): Any = Unit override fun visitOneofName(ctx: ProtobufEditionsParser.OneofNameContext?): Any = Unit override fun visitOneofName(ctx: Protobuf3Parser.OneofNameContext?): Any = Unit + override fun visitOneofName(ctx: Protobuf2Parser.OneofNameContext?): Any = Unit override fun visitMapName(ctx: ProtobufEditionsParser.MapNameContext?): Any = Unit override fun visitMapName(ctx: Protobuf3Parser.MapNameContext?): Any = Unit + override fun visitMapName(ctx: Protobuf2Parser.MapNameContext?): Any = Unit override fun visitServiceName(ctx: ProtobufEditionsParser.ServiceNameContext?): Any = Unit override fun visitServiceName(ctx: Protobuf3Parser.ServiceNameContext?): Any = Unit + override fun visitServiceName(ctx: Protobuf2Parser.ServiceNameContext?): Any = Unit override fun visitRpcName(ctx: ProtobufEditionsParser.RpcNameContext?): Any = Unit override fun visitRpcName(ctx: Protobuf3Parser.RpcNameContext?): Any = Unit + override fun visitRpcName(ctx: Protobuf2Parser.RpcNameContext?): Any = Unit override fun visitEnumType(ctx: ProtobufEditionsParser.EnumTypeContext?): Any = Unit override fun visitEnumType(ctx: Protobuf3Parser.EnumTypeContext?): Any = Unit + override fun visitEnumType(ctx: Protobuf2Parser.EnumTypeContext?): Any = Unit override fun visitIntLit(ctx: ProtobufEditionsParser.IntLitContext?): Any = Unit override fun visitIntLit(ctx: Protobuf3Parser.IntLitContext?): Any = Unit + override fun visitIntLit(ctx: Protobuf2Parser.IntLitContext?): Any = Unit override fun visitStrLit(ctx: ProtobufEditionsParser.StrLitContext?): Any = Unit override fun visitStrLit(ctx: Protobuf3Parser.StrLitContext?): Any = Unit + override fun visitStrLit(ctx: Protobuf2Parser.StrLitContext?): Any = Unit override fun visitBoolLit(ctx: ProtobufEditionsParser.BoolLitContext?): Any = Unit override fun visitBoolLit(ctx: Protobuf3Parser.BoolLitContext?): Any = Unit + override fun visitBoolLit(ctx: Protobuf2Parser.BoolLitContext?): Any = Unit override fun visitFloatLit(ctx: ProtobufEditionsParser.FloatLitContext?): Any = Unit override fun visitFloatLit(ctx: Protobuf3Parser.FloatLitContext?): Any = Unit + override fun visitFloatLit(ctx: Protobuf2Parser.FloatLitContext?): Any = Unit override fun visitKeywords(ctx: ProtobufEditionsParser.KeywordsContext?): Any = Unit override fun visitKeywords(ctx: Protobuf3Parser.KeywordsContext?): Any = Unit + override fun visitKeywords(ctx: Protobuf2Parser.KeywordsContext?): Any = Unit + + // Protobuf2-only nodes (stubs / safety) + override fun visitExtendElement(ctx: Protobuf2Parser.ExtendElementContext?): Any = Unit + override fun visitStream(ctx: Protobuf2Parser.StreamContext?): Any = Unit + override fun visitGroupName(ctx: Protobuf2Parser.GroupNameContext?): Any = Unit + override fun visitStreamName(ctx: Protobuf2Parser.StreamNameContext?): Any = Unit private fun ParserRuleContext.parseInt(): Int { return text.toIntOrNull() ?: throw ParseException("Could not parse integer", this) } + + data class ParsedGroup( + val field: ProtoMessageField, + val message: ProtoMessage + ) + + data class Proto2ExtendDefinitionData( + val extensionDefinition: ProtoExtensionDefinition, + val groupMessages: List + ) } diff --git a/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/BaseValidationTest.kt b/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/BaseValidationTest.kt index 40086100..c910af79 100644 --- a/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/BaseValidationTest.kt +++ b/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/BaseValidationTest.kt @@ -32,6 +32,7 @@ abstract class BaseValidationTest { } enum class ProtoVersion(val header: String) { + PROTO2("syntax = \"proto2\";"), PROTO3("syntax = \"proto3\";"), EDITION2023("edition = \"2023\";"), EDITION2024("edition = \"2024\";") diff --git a/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/EnumImportValidationTest.kt b/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/EnumImportValidationTest.kt index 050651ce..d2227ca3 100644 --- a/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/EnumImportValidationTest.kt +++ b/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/EnumImportValidationTest.kt @@ -9,7 +9,7 @@ import org.junit.jupiter.api.assertThrows class EnumImportValidationTest : BaseValidationTest() { @Test - fun `test WHEN proto3 imports closed enum THEN error is thrown`() { + fun `test WHEN proto3 imports editions closed enum THEN error is thrown`() { assertThrows { runGenerator( listOf( @@ -44,6 +44,41 @@ class EnumImportValidationTest : BaseValidationTest() { } } + @Test + fun `test WHEN proto3 imports proto2 enum THEN error is thrown`() { + assertThrows { + runGenerator( + listOf( + FakeInputDirectory( + name = "dir", + files = listOf( + createProtoFile( + fileHeader = ProtoVersion.PROTO2.header, + content = """ + enum A { + DEFAULT = 0; + } + """.trimIndent(), + name = "file1.proto" + ), + createProtoFile( + fileHeader = ProtoVersion.PROTO3.header, + content = """ + import "file1.proto"; + + message B { + A a = 1; + } + """.trimIndent(), + name = "file2.proto" + ) + ) + ) + ) + ) + } + } + @Test fun `test WHEN proto3 imports open enum THEN no error is thrown`() { runGenerator( diff --git a/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/EnumValidationTests.kt b/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/EnumValidationTests.kt index 0a481cb6..0ba5ef3d 100644 --- a/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/EnumValidationTests.kt +++ b/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/EnumValidationTests.kt @@ -1,16 +1,19 @@ package io.github.timortel.kotlin_multiplatform_grpc_plugin.validation -import io.github.timortel.kotlin_multiplatform_grpc_plugin.matchWarning +import com.google.testing.junit.testparameterinjector.junit5.TestParameter +import com.google.testing.junit.testparameterinjector.junit5.TestParameterInjectorTest import io.github.timortel.kmpgrpc.plugin.sourcegeneration.CompilationException import io.github.timortel.kmpgrpc.plugin.sourcegeneration.Warnings +import io.github.timortel.kotlin_multiplatform_grpc_plugin.matchWarning import io.mockk.verify -import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows class EnumValidationTests : BaseValidationTest() { - @Test - fun `test WHEN enum has two fields with the same name THEN a compilation exception is thrown`() { + @TestParameterInjectorTest + fun `test WHEN enum has two fields with the same name THEN a compilation exception is thrown`( + @TestParameter protoVersion: ProtoVersion + ) { assertThrows { runGenerator( """ @@ -19,13 +22,16 @@ class EnumValidationTests : BaseValidationTest() { b = 1; b = 2; } - """.trimIndent() + """.trimIndent(), + protoVersion ) } } - @Test - fun `test WHEN enum uses a directly reserved number THEN a compilation exception is thrown`() { + @TestParameterInjectorTest + fun `test WHEN enum uses a directly reserved number THEN a compilation exception is thrown`( + @TestParameter protoVersion: ProtoVersion + ) { assertThrows { runGenerator( """ @@ -34,13 +40,16 @@ class EnumValidationTests : BaseValidationTest() { a = 0; b = 1; } - """.trimIndent() + """.trimIndent(), + protoVersion ) } } - @Test - fun `test WHEN enum uses a reserved number in range THEN a compilation exception is thrown`() { + @TestParameterInjectorTest + fun `test WHEN enum uses a reserved number in range THEN a compilation exception is thrown`( + @TestParameter protoVersion: ProtoVersion + ) { assertThrows { runGenerator( """ @@ -49,13 +58,16 @@ class EnumValidationTests : BaseValidationTest() { a = 0; b = 14; } - """.trimIndent() + """.trimIndent(), + protoVersion ) } } - @Test - fun `test WHEN enum uses a reserved field name THEN a compilation exception is thrown`() { + @TestParameterInjectorTest + fun `test WHEN enum uses a reserved field name THEN a compilation exception is thrown`( + @TestParameter protoVersion: ProtoVersion + ) { assertThrows { runGenerator( """ @@ -64,39 +76,48 @@ class EnumValidationTests : BaseValidationTest() { a = 0; b = 1; } - """.trimIndent() + """.trimIndent(), + protoVersion ) } } - @Test - fun `test WHEN enum does not have default field THEN a compilation exception is thrown`() { + @TestParameterInjectorTest + fun `test WHEN enum does not have default field THEN a compilation exception is thrown`( + @TestParameter protoVersion: ProtoVersion + ) { assertThrows { runGenerator( """ enum TestEnum { field = 1; } - """.trimIndent() + """.trimIndent(), + protoVersion ) } } - @Test - fun `test WHEN enum has no field THEN compilation exception is thrown`() { + @TestParameterInjectorTest + fun `test WHEN enum has no field THEN compilation exception is thrown`( + @TestParameter protoVersion: ProtoVersion + ) { assertThrows { runGenerator( """ enum TestEnum { } - """.trimIndent() + """.trimIndent(), + protoVersion ) } } - @Test - fun `test WHEN enum has enum aliases but without the option THEN a warning is printed`() { + @TestParameterInjectorTest + fun `test WHEN enum has enum aliases but without the option THEN a warning is printed`( + @TestParameter protoVersion: ProtoVersion + ) { runGenerator( """ enum TestEnum { @@ -104,14 +125,17 @@ class EnumValidationTests : BaseValidationTest() { field2 = 1; field3 = 1; } - """.trimIndent() + """.trimIndent(), + protoVersion ) verify(atLeast = 1) { logger.warn(matchWarning(Warnings.enumAliasWithoutOption)) } } - @Test - fun `test WHEN enum has enum aliases and the option THEN no warning is printed`() { + @TestParameterInjectorTest + fun `test WHEN enum has enum aliases and the option THEN no warning is printed`( + @TestParameter protoVersion: ProtoVersion + ) { runGenerator( """ enum TestEnum { @@ -120,9 +144,10 @@ class EnumValidationTests : BaseValidationTest() { field2 = 1; field3 = 1; } - """.trimIndent() + """.trimIndent(), + protoVersion ) verify(atLeast = 0) { logger.warn(matchWarning(Warnings.enumAliasWithoutOption)) } } -} \ No newline at end of file +} diff --git a/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/ExtensionDefinitionValidationTests.kt b/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/ExtensionDefinitionValidationTests.kt index eae90e8e..68effe7e 100644 --- a/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/ExtensionDefinitionValidationTests.kt +++ b/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/ExtensionDefinitionValidationTests.kt @@ -1,15 +1,20 @@ package io.github.timortel.kotlin_multiplatform_grpc_plugin.validation +import com.google.testing.junit.testparameterinjector.junit5.TestParameter +import com.google.testing.junit.testparameterinjector.junit5.TestParameterInjectorTest import io.github.timortel.kmpgrpc.plugin.sourcegeneration.CompilationException import io.github.timortel.kotlin_multiplatform_grpc_plugin.FakeInputDirectory import io.github.timortel.kotlin_multiplatform_grpc_plugin.createProtoFile -import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows class ExtensionDefinitionValidationTests : BaseValidationTest() { - @Test - fun `test GIVEN an extension references an enum WHEN generating the code THEN an error is thrown`() { + @TestParameterInjectorTest + fun `test GIVEN an extension references an enum WHEN generating the code THEN an error is thrown`( + @TestParameter(value = ["PROTO2", "EDITION2023", "EDITION2024"]) protoVersion: ProtoVersion + ) { + val declarationPrefix = getDeclarationPrefix(protoVersion) + assertThrows { runGenerator( """ @@ -18,16 +23,20 @@ class ExtensionDefinitionValidationTests : BaseValidationTest() { } extend A { - string a = 1; + $declarationPrefix string a = 1; } """.trimIndent(), - protoVersion = ProtoVersion.EDITION2023, + protoVersion = protoVersion ) } } - @Test - fun `test GIVEN duplicated extensions in the same extension WHEN generating the code THEN an error is thrown`() { + @TestParameterInjectorTest + fun `test GIVEN duplicated extensions in the same extension WHEN generating the code THEN an error is thrown`( + @TestParameter(value = ["PROTO2", "EDITION2023", "EDITION2024"]) protoVersion: ProtoVersion + ) { + val declarationPrefix = getDeclarationPrefix(protoVersion) + assertThrows { runGenerator( """ @@ -36,17 +45,21 @@ class ExtensionDefinitionValidationTests : BaseValidationTest() { } extend A { - string a = 1; - string a = 2; + $declarationPrefix string a = 1; + $declarationPrefix string a = 2; } """.trimIndent(), - protoVersion = ProtoVersion.EDITION2023, + protoVersion = protoVersion, ) } } - @Test - fun `test GIVEN duplicated extensions in different extensions WHEN generating the code THEN an error is thrown`() { + @TestParameterInjectorTest + fun `test GIVEN duplicated extensions in different extensions WHEN generating the code THEN an error is thrown`( + @TestParameter(value = ["PROTO2", "EDITION2023", "EDITION2024"]) protoVersion: ProtoVersion + ) { + val declarationPrefix = getDeclarationPrefix(protoVersion) + assertThrows { runGenerator( """ @@ -59,20 +72,24 @@ class ExtensionDefinitionValidationTests : BaseValidationTest() { } extend A { - string a = 1; + $declarationPrefix string a = 1; } extend B { - string a = 2; + $declarationPrefix string a = 2; } """.trimIndent(), - protoVersion = ProtoVersion.EDITION2023, + protoVersion = protoVersion, ) } } - @Test - fun `test GIVEN a message that is not extendable and a defined extension for the message WHEN generating the code THEN an error is thrown`() { + @TestParameterInjectorTest + fun `test GIVEN a message that is not extendable and a defined extension for the message WHEN generating the code THEN an error is thrown`( + @TestParameter(value = ["PROTO2", "EDITION2023", "EDITION2024"]) protoVersion: ProtoVersion + ) { + val declarationPrefix = getDeclarationPrefix(protoVersion) + assertThrows { runGenerator( """ @@ -81,16 +98,20 @@ class ExtensionDefinitionValidationTests : BaseValidationTest() { } extend A { - string a = 1; + $declarationPrefix string a = 1; } """.trimIndent(), - protoVersion = ProtoVersion.EDITION2023, + protoVersion = protoVersion, ) } } - @Test - fun `test GIVEN reused field numbers in extension in the extension definition WHEN generating the code THEN an error is thrown`() { + @TestParameterInjectorTest + fun `test GIVEN reused field numbers in extension in the extension definition WHEN generating the code THEN an error is thrown`( + @TestParameter(value = ["PROTO2", "EDITION2023", "EDITION2024"]) protoVersion: ProtoVersion + ) { + val declarationPrefix = getDeclarationPrefix(protoVersion) + assertThrows { runGenerator( """ @@ -99,40 +120,44 @@ class ExtensionDefinitionValidationTests : BaseValidationTest() { } extend A { - string a = 1; - string b = 1; + $declarationPrefix string a = 1; + $declarationPrefix string b = 1; } """.trimIndent(), - protoVersion = ProtoVersion.EDITION2023, + protoVersion = protoVersion, ) } } - @Test - fun `test GIVEN reused field numbers in extension in the extension definitions across multiple files WHEN generating the code THEN an error is thrown`() { + @TestParameterInjectorTest + fun `test GIVEN reused field numbers in extension in the extension definitions across multiple files WHEN generating the code THEN an error is thrown`( + @TestParameter(value = ["PROTO2", "EDITION2023", "EDITION2024"]) protoVersion: ProtoVersion + ) { + val declarationPrefix = getDeclarationPrefix(protoVersion) + val folder = FakeInputDirectory( name = "dir", files = listOf( createProtoFile( - fileHeader = ProtoVersion.EDITION2023.header, + fileHeader = protoVersion.header, """ message A { extensions 1 to 5; } extend A { - string a = 1; + $declarationPrefix string a = 1; } """.trimIndent(), name = "file1" ), createProtoFile( - fileHeader = ProtoVersion.EDITION2023.header, + fileHeader = protoVersion.header, """ import "file1"; extend A { - string b = 1; + $declarationPrefix string b = 1; } """.trimIndent(), name = "file2" @@ -145,8 +170,12 @@ class ExtensionDefinitionValidationTests : BaseValidationTest() { } } - @Test - fun `test WHEN message has a extension definition with a minimum field number smaller than 1 THEN a compilation exception is thrown`() { + @TestParameterInjectorTest + fun `test WHEN message has a extension definition with a minimum field number smaller than 1 THEN a compilation exception is thrown`( + @TestParameter(value = ["PROTO2", "EDITION2023", "EDITION2024"]) protoVersion: ProtoVersion + ) { + val declarationPrefix = getDeclarationPrefix(protoVersion) + assertThrows { runGenerator( """ @@ -155,16 +184,20 @@ class ExtensionDefinitionValidationTests : BaseValidationTest() { } extend A { - string a = 1; + $declarationPrefix string a = 1; } """.trimIndent(), - protoVersion = ProtoVersion.EDITION2023 + protoVersion = protoVersion ) } } - @Test - fun `test WHEN message has field with field number greater than max field number THEN a compilation exception is thrown`() { + @TestParameterInjectorTest + fun `test WHEN message has field with field number greater than max field number THEN a compilation exception is thrown`( + @TestParameter(value = ["PROTO2", "EDITION2023", "EDITION2024"]) protoVersion: ProtoVersion + ) { + val declarationPrefix = getDeclarationPrefix(protoVersion) + assertThrows { runGenerator( """ @@ -173,11 +206,16 @@ class ExtensionDefinitionValidationTests : BaseValidationTest() { } extend A { - string a = 1; + $declarationPrefix string a = 1; } """.trimIndent(), - protoVersion = ProtoVersion.EDITION2023 + protoVersion = protoVersion ) } } + + private fun getDeclarationPrefix(protoVersion: ProtoVersion): String = when (protoVersion) { + ProtoVersion.PROTO2 -> "optional" + else -> "" + } } From 8c0498340f75a0d7a5eedcfab8bd24c99ce1eb65 Mon Sep 17 00:00:00 2001 From: Tim Ortel <100865202+TimOrtel@users.noreply.github.com> Date: Fri, 26 Dec 2025 10:50:44 +0100 Subject: [PATCH 11/23] Add proto2 grammar --- .../timortel/kmpgrpc/anltr/Protobuf2.g4 | 719 ++++++++++++++++++ 1 file changed, 719 insertions(+) create mode 100644 kmp-grpc-plugin/src/main/antlr/io/github/timortel/kmpgrpc/anltr/Protobuf2.g4 diff --git a/kmp-grpc-plugin/src/main/antlr/io/github/timortel/kmpgrpc/anltr/Protobuf2.g4 b/kmp-grpc-plugin/src/main/antlr/io/github/timortel/kmpgrpc/anltr/Protobuf2.g4 new file mode 100644 index 00000000..543ee34c --- /dev/null +++ b/kmp-grpc-plugin/src/main/antlr/io/github/timortel/kmpgrpc/anltr/Protobuf2.g4 @@ -0,0 +1,719 @@ +/** + * A Protocol Buffers 2 grammar + * + * Original source: https://developers.google.com/protocol-buffers/docs/reference/proto2-spec + * + * follow by the style of Protobuf3.g4 written by the author @anatawa12 + * + * @author Boyce-Lee + * + * Direct copy from https://github.com/antlr/grammars-v4/blob/b3ba447883223e376ef003b1d1a32c80b2aad0f1/protobuf/protobuf2/Protobuf2.g4 + * Changes from the source above: + * - Added package header + * - Adapted rpc definition to expose clientStream and serverStream attributes + * - Added field options to group declarations + * @author Tim Ortel + */ + +// $antlr-format alignTrailingComments true, columnLimit 150, minEmptyLines 1, maxEmptyLinesToKeep 1, reflowComments false, useTab false +// $antlr-format allowShortRulesOnASingleLine false, allowShortBlocksOnASingleLine true, alignSemicolons hanging, alignColons hanging + +grammar Protobuf2; + +@header { package io.github.timortel.kmpgrpc.anltr; } + +proto + : syntax? (importStatement | packageStatement | optionStatement | topLevelDef | emptyStatement_)* EOF + ; + +// Syntax + +syntax + : SYNTAX EQ (PROTO2_LIT_SINGLE | PROTO2_LIT_DOUBLE) SEMI + ; + +// Import Statement + +importStatement + : IMPORT (WEAK | PUBLIC)? strLit SEMI + ; + +// Package + +packageStatement + : PACKAGE fullIdent SEMI + ; + +// Option + +optionStatement + : OPTION optionName EQ constant SEMI + ; + +optionName + : fullIdent + | ( ident | LP fullIdent RP) ( DOT fullIdent)? + ; + +// Normal Field + +fieldLabel + : REQUIRED + | OPTIONAL + | REPEATED + ; + +field + : fieldLabel type_ fieldName EQ fieldNumber (LB fieldOptions RB)? SEMI + ; + +fieldOptions + : fieldOption (COMMA fieldOption)* + ; + +fieldOption + : optionName EQ constant + ; + +fieldNumber + : intLit + ; + +// Group field + +group + : fieldLabel GROUP groupName EQ fieldNumber (LB fieldOptions RB)? messageBody + ; + +// Oneof and oneof field + +oneof + : ONEOF oneofName LC (optionStatement | oneofField | emptyStatement_)* RC + ; + +oneofField + : type_ fieldName EQ fieldNumber (LB fieldOptions RB)? SEMI + ; + +// Map field + +mapField + : MAP LT keyType COMMA type_ GT mapName EQ fieldNumber (LB fieldOptions RB)? SEMI + ; + +keyType + : INT32 + | INT64 + | UINT32 + | UINT64 + | SINT32 + | SINT64 + | FIXED32 + | FIXED64 + | SFIXED32 + | SFIXED64 + | BOOL + | STRING + ; + +// field types + +type_ + : DOUBLE + | FLOAT + | INT32 + | INT64 + | UINT32 + | UINT64 + | SINT32 + | SINT64 + | FIXED32 + | FIXED64 + | SFIXED32 + | SFIXED64 + | BOOL + | STRING + | BYTES + | messageType + | enumType + ; + +// Extensions + +extensions + : EXTENSIONS ranges SEMI + ; + +// Reserved + +reserved + : RESERVED (ranges | reservedFieldNames) SEMI + ; + +ranges + : range_ (COMMA range_)* + ; + +range_ + : intLit (TO ( intLit | MAX))? + ; + +reservedFieldNames + : strLit (COMMA strLit)* + ; + +// Top Level definitions + +topLevelDef + : messageDef + | enumDef + | serviceDef + | extendDef + ; + +// enum + +enumDef + : ENUM enumName enumBody + ; + +enumBody + : LC enumElement* RC + ; + +enumElement + : optionStatement + | enumField + | reserved + | emptyStatement_ + ; + +enumField + : ident EQ MINUS? intLit enumValueOptions? SEMI + ; + +enumValueOptions + : LB enumValueOption (COMMA enumValueOption)* RB + ; + +enumValueOption + : optionName EQ constant + ; + +// message + +messageDef + : MESSAGE messageName messageBody + ; + +messageBody + : LC messageElement* RC + ; + +messageElement + : field + | enumDef + | messageDef + | extendDef + | optionStatement + | oneof + | mapField + | extensions + | group + | reserved + | emptyStatement_ + ; + +// extend + +extendDef + : EXTEND messageType LC extendElement* RC + ; + +extendElement + : field + | group + | emptyStatement_ + ; + +// service + +serviceDef + : SERVICE serviceName LC serviceElement* RC + ; + +serviceElement + : optionStatement + | rpc + | stream + | emptyStatement_ + ; + +rpc + : RPC rpcName LP clientStream=STREAM? messageType RP RETURNS LP serverStream=STREAM? messageType RP ( + LC ( optionStatement | emptyStatement_)* RC + | SEMI + ) + ; + +stream + : STREAM streamName LP messageType COMMA messageType RP ( + LC ( optionStatement | emptyStatement_)* RC + | SEMI + ) + ; + +// lexical + +constant + : fullIdent + | (MINUS | PLUS)? intLit + | ( MINUS | PLUS)? floatLit + | strLit + | boolLit + | blockLit + ; + +// not specified in specification but used in tests +blockLit + : LC (ident COLON constant (COMMA)?)* RC + ; + +emptyStatement_ + : SEMI + ; + +// Lexical elements + +ident + : IDENTIFIER + | keywords + ; + +fullIdent + : ident (DOT ident)* + ; + +messageName + : ident + ; + +enumName + : ident + ; + +fieldName + : ident + ; + +groupName + : ident + ; + +oneofName + : ident + ; + +mapName + : ident + ; + +serviceName + : ident + ; + +rpcName + : ident + ; + +streamName + : ident + ; + +messageType + : DOT? (ident DOT)* messageName + ; + +enumType + : DOT? (ident DOT)* enumName + ; + +intLit + : INT_LIT + ; + +strLit + : STR_LIT + | PROTO2_LIT_SINGLE + | PROTO2_LIT_DOUBLE + ; + +boolLit + : BOOL_LIT + ; + +floatLit + : FLOAT_LIT + ; + +// keywords +SYNTAX + : 'syntax' + ; + +IMPORT + : 'import' + ; + +WEAK + : 'weak' + ; + +PUBLIC + : 'public' + ; + +PACKAGE + : 'package' + ; + +OPTION + : 'option' + ; + +REPEATED + : 'repeated' + ; + +OPTIONAL + : 'optional' + ; + +REQUIRED + : 'required' + ; + +GROUP + : 'group' + ; + +ONEOF + : 'oneof' + ; + +MAP + : 'map' + ; + +INT32 + : 'int32' + ; + +INT64 + : 'int64' + ; + +UINT32 + : 'uint32' + ; + +UINT64 + : 'uint64' + ; + +SINT32 + : 'sint32' + ; + +SINT64 + : 'sint64' + ; + +FIXED32 + : 'fixed32' + ; + +FIXED64 + : 'fixed64' + ; + +SFIXED32 + : 'sfixed32' + ; + +SFIXED64 + : 'sfixed64' + ; + +BOOL + : 'bool' + ; + +STRING + : 'string' + ; + +DOUBLE + : 'double' + ; + +FLOAT + : 'float' + ; + +BYTES + : 'bytes' + ; + +RESERVED + : 'reserved' + ; + +EXTENSIONS + : 'extensions' + ; + +TO + : 'to' + ; + +MAX + : 'max' + ; + +ENUM + : 'enum' + ; + +EXTEND + : 'extend' + ; + +MESSAGE + : 'message' + ; + +SERVICE + : 'service' + ; + +RPC + : 'rpc' + ; + +STREAM + : 'stream' + ; + +RETURNS + : 'returns' + ; + +PROTO2_LIT_SINGLE + : '"proto2"' + ; + +PROTO2_LIT_DOUBLE + : '\'proto2\'' + ; + +// symbols + +SEMI + : ';' + ; + +EQ + : '=' + ; + +LP + : '(' + ; + +RP + : ')' + ; + +LB + : '[' + ; + +RB + : ']' + ; + +LC + : '{' + ; + +RC + : '}' + ; + +LT + : '<' + ; + +GT + : '>' + ; + +DOT + : '.' + ; + +COMMA + : ',' + ; + +COLON + : ':' + ; + +PLUS + : '+' + ; + +MINUS + : '-' + ; + +STR_LIT + : '\'' CHAR_VALUE*? '\'' + | '"' CHAR_VALUE*? '"' + ; + +fragment CHAR_VALUE + : HEX_ESCAPE + | OCT_ESCAPE + | CHAR_ESCAPE + | ~[\u0000\n\\] + ; + +fragment HEX_ESCAPE + : '\\' ('x' | 'X') HEX_DIGIT HEX_DIGIT + ; + +fragment OCT_ESCAPE + : '\\' OCTAL_DIGIT OCTAL_DIGIT OCTAL_DIGIT + ; + +fragment CHAR_ESCAPE + : '\\' ('a' | 'b' | 'f' | 'n' | 'r' | 't' | 'v' | '\\' | '\'' | '"') + ; + +BOOL_LIT + : 'true' + | 'false' + ; + +FLOAT_LIT + : DECIMALS DOT DECIMALS? EXPONENT? + | DECIMALS EXPONENT + | DOT DECIMALS EXPONENT? + | 'inf' + | 'nan' + ; + +fragment EXPONENT + : ('e' | 'E') (PLUS | MINUS)? DECIMALS + ; + +fragment DECIMALS + : DECIMAL_DIGIT+ + ; + +INT_LIT + : DECIMAL_LIT + | OCTAL_LIT + | HEX_LIT + ; + +fragment DECIMAL_LIT + : [1-9] DECIMAL_DIGIT* + ; + +fragment OCTAL_LIT + : '0' OCTAL_DIGIT* + ; + +fragment HEX_LIT + : '0' ('x' | 'X') HEX_DIGIT+ + ; + +IDENTIFIER + : LETTER (LETTER | DECIMAL_DIGIT)* + ; + +fragment LETTER + : [A-Za-z_] + ; + +fragment DECIMAL_DIGIT + : [0-9] + ; + +fragment OCTAL_DIGIT + : [0-7] + ; + +fragment HEX_DIGIT + : [0-9A-Fa-f] + ; + +// comments +WS + : [ \t\r\n\u000C]+ -> skip + ; + +LINE_COMMENT + : '//' ~[\r\n]* -> skip + ; + +COMMENT + : '/*' .*? '*/' -> skip + ; + +keywords + : SYNTAX + | IMPORT + | WEAK + | PUBLIC + | PACKAGE + | OPTION + | REPEATED + | OPTIONAL + | REQUIRED + | GROUP + | ONEOF + | MAP + | INT32 + | INT64 + | UINT32 + | UINT64 + | SINT32 + | SINT64 + | FIXED32 + | FIXED64 + | SFIXED32 + | SFIXED64 + | BOOL + | STRING + | DOUBLE + | FLOAT + | BYTES + | RESERVED + | EXTENSIONS + | TO + | MAX + | ENUM + | MESSAGE + | EXTEND + | SERVICE + | RPC + | STREAM + | STREAM + | RETURNS + | BOOL_LIT + ; From 02ae5c1db4c85ac6f68fdcc779b78cd282e82d46 Mon Sep 17 00:00:00 2001 From: Tim Ortel <100865202+TimOrtel@users.noreply.github.com> Date: Fri, 26 Dec 2025 12:43:23 +0100 Subject: [PATCH 12/23] Add support for group serialization --- .../kmpgrpc/core/io/CodedInputStream.kt | 9 +++ .../kmpgrpc/core/io/CodedOutputStream.kt | 4 ++ .../core/io/internal/CodedOutputStreamImpl.kt | 10 ++++ .../timortel/kmpgrpc/core/message/util.kt | 6 +- kmp-grpc-internal-test/build.gradle.kts | 9 ++- .../proto/proto2/proto2-group-test.proto | 43 +++++++++++++ .../proto/proto2/proto2-test-service.proto | 11 ++++ .../test/defaults.kt | 42 +++++++++++-- .../test/integration/RpcTest.kt | 11 ++++ .../test/model/EqTest.kt | 35 +++++++++++ .../SelfMessageSerializationTest.kt | 21 +++++++ .../test/ClientCredentialsRpcTest.kt | 6 +- .../test/CustomCertificatesRpcTest.kt | 4 +- .../timortel/kmpgrpc/testserver/TestServer.kt | 7 +++ .../DeserializationFunctionExtension.kt | 60 +++++++++---------- .../SerializationFunctionExtension.kt | 34 +++++++---- .../sourcegeneration/model/type/ProtoType.kt | 9 ++- .../parsing/ProtobufModelBuilderVisitor.kt | 3 +- 18 files changed, 262 insertions(+), 62 deletions(-) create mode 100644 kmp-grpc-internal-test/src/commonMain/proto/proto2/proto2-group-test.proto create mode 100644 kmp-grpc-internal-test/src/commonMain/proto/proto2/proto2-test-service.proto diff --git a/kmp-grpc-core/src/commonMain/kotlin/io/github/timortel/kmpgrpc/core/io/CodedInputStream.kt b/kmp-grpc-core/src/commonMain/kotlin/io/github/timortel/kmpgrpc/core/io/CodedInputStream.kt index d6f0005d..466e0cca 100644 --- a/kmp-grpc-core/src/commonMain/kotlin/io/github/timortel/kmpgrpc/core/io/CodedInputStream.kt +++ b/kmp-grpc-core/src/commonMain/kotlin/io/github/timortel/kmpgrpc/core/io/CodedInputStream.kt @@ -64,6 +64,15 @@ abstract class CodedInputStream { return recursiveRead { deserializer.deserialize(this, extensionRegistry) } } + fun readGroup(deserializer: MessageDeserializer, extensionRegistry: ExtensionRegistry, fieldNumber: Int): M { + checkRecursionLimit() + recursionDepth++ + val message = deserializer.deserialize(this, extensionRegistry) + checkLastTagWas(wireFormatMakeTag(fieldNumber, WireFormat.END_GROUP)) + recursionDepth-- + return message + } + abstract fun readBytes(): ByteArray abstract fun readUInt32(): UInt diff --git a/kmp-grpc-core/src/commonMain/kotlin/io/github/timortel/kmpgrpc/core/io/CodedOutputStream.kt b/kmp-grpc-core/src/commonMain/kotlin/io/github/timortel/kmpgrpc/core/io/CodedOutputStream.kt index d6e5ac89..66c6713d 100644 --- a/kmp-grpc-core/src/commonMain/kotlin/io/github/timortel/kmpgrpc/core/io/CodedOutputStream.kt +++ b/kmp-grpc-core/src/commonMain/kotlin/io/github/timortel/kmpgrpc/core/io/CodedOutputStream.kt @@ -77,8 +77,12 @@ interface CodedOutputStream { fun writeMessage(fieldNumber: Int, value: Message) + fun writeGroup(fieldNumber: Int, value: Message) + fun writeMessageArray(fieldNumber: Int, values: List) + fun writeGroupArray(fieldNumber: Int, values: List) + fun writeSFixed32(fieldNumber: Int, value: Int) fun writeSFixed32Array(fieldNumber: Int, values: List, tag: UInt) diff --git a/kmp-grpc-core/src/commonMain/kotlin/io/github/timortel/kmpgrpc/core/io/internal/CodedOutputStreamImpl.kt b/kmp-grpc-core/src/commonMain/kotlin/io/github/timortel/kmpgrpc/core/io/internal/CodedOutputStreamImpl.kt index b68f331f..e11690ef 100644 --- a/kmp-grpc-core/src/commonMain/kotlin/io/github/timortel/kmpgrpc/core/io/internal/CodedOutputStreamImpl.kt +++ b/kmp-grpc-core/src/commonMain/kotlin/io/github/timortel/kmpgrpc/core/io/internal/CodedOutputStreamImpl.kt @@ -217,6 +217,16 @@ internal class CodedOutputStreamImpl(private val sink: Sink) : CodedOutputStream values.forEach { writeMessage(fieldNumber, it) } } + override fun writeGroup(fieldNumber: Int, value: Message) { + writeTag(fieldNumber, WireFormat.START_GROUP) + value.serialize(this) + writeTag(fieldNumber, WireFormat.END_GROUP) + } + + override fun writeGroupArray(fieldNumber: Int, values: List) { + values.forEach { writeGroup(fieldNumber, it) } + } + override fun writeMap( fieldNumber: Int, map: Map, diff --git a/kmp-grpc-core/src/commonMain/kotlin/io/github/timortel/kmpgrpc/core/message/util.kt b/kmp-grpc-core/src/commonMain/kotlin/io/github/timortel/kmpgrpc/core/message/util.kt index 74141bbf..920061a2 100644 --- a/kmp-grpc-core/src/commonMain/kotlin/io/github/timortel/kmpgrpc/core/message/util.kt +++ b/kmp-grpc-core/src/commonMain/kotlin/io/github/timortel/kmpgrpc/core/message/util.kt @@ -9,7 +9,7 @@ fun mergeUnknownFieldOrExtension( fieldOrExtension: UnknownFieldOrExtension?, unknownFields: MutableList, extensionBuilder: MessageExtensionsBuilder -) { +): Boolean { when (fieldOrExtension) { is UnknownFieldOrExtension.UnknownField -> unknownFields.add(fieldOrExtension.field) is UnknownFieldOrExtension.RepeatedExtension -> { @@ -18,6 +18,8 @@ fun mergeUnknownFieldOrExtension( is UnknownFieldOrExtension.ScalarExtension -> { extensionBuilder[fieldOrExtension.extension] = fieldOrExtension.value } - null -> {} + null -> return false } + + return true } diff --git a/kmp-grpc-internal-test/build.gradle.kts b/kmp-grpc-internal-test/build.gradle.kts index 494bb502..4dc58cd9 100644 --- a/kmp-grpc-internal-test/build.gradle.kts +++ b/kmp-grpc-internal-test/build.gradle.kts @@ -114,11 +114,16 @@ kmpGrpc { includeWellKnownTypes = true - protoSourceFolders = project.files("src/commonMain/proto/general", "src/commonMain/proto/unknownfield", "src/commonMain/proto/editions") + protoSourceFolders = project.files( + "src/commonMain/proto/general", + "src/commonMain/proto/unknownfield", + "src/commonMain/proto/editions", + "src/commonMain/proto/proto2" + ) } buildConfig { - packageName("iio.github.timortel.kmpgrpc.internal.test") + packageName("io.github.timortel.kmpgrpc.internal.test") useKotlinOutput { internalVisibility = true diff --git a/kmp-grpc-internal-test/src/commonMain/proto/proto2/proto2-group-test.proto b/kmp-grpc-internal-test/src/commonMain/proto/proto2/proto2-group-test.proto new file mode 100644 index 00000000..74d3d8fe --- /dev/null +++ b/kmp-grpc-internal-test/src/commonMain/proto/proto2/proto2-group-test.proto @@ -0,0 +1,43 @@ +syntax = "proto2"; + +package io.github.timortel.kmpgrpc.test.proto2; + +option java_outer_classname = "Proto2GroupTest"; + +message A { + optional group B = 1 { + optional string field1 = 1; + optional int32 field2 = 2; + + optional group C = 3 { + optional string field1 = 1; + + optional string field2 = 2; + } + } + + repeated group D = 2 { + optional string field1 = 1; + optional int32 field2 = 2; + } +} + +message E { + optional string field1 = 1; + optional group G = 2 { + optional string field1 = 1; + extensions 2 to max; + } + + extensions 3 to max; +} + +extend E { + optional group F = 4 { + optional string field1 = 1; + } +} + +extend E.G { + optional string field2 = 2; +} diff --git a/kmp-grpc-internal-test/src/commonMain/proto/proto2/proto2-test-service.proto b/kmp-grpc-internal-test/src/commonMain/proto/proto2/proto2-test-service.proto new file mode 100644 index 00000000..c7db32b3 --- /dev/null +++ b/kmp-grpc-internal-test/src/commonMain/proto/proto2/proto2-test-service.proto @@ -0,0 +1,11 @@ +syntax = "proto2"; + +package io.github.timortel.kmpgrpc.test.proto2; + +import "proto2-group-test.proto"; + +option java_multiple_files = true; + +service Proto2TestService { + rpc sendMessageWithNestedGroups (A) returns (A); +} diff --git a/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/defaults.kt b/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/defaults.kt index f4bdc570..71fe0d36 100644 --- a/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/defaults.kt +++ b/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/defaults.kt @@ -3,6 +3,7 @@ package io.github.timortel.kotlin_multiplatform_grpc_plugin.test import ExtensionsTest import io.github.timortel.kmpgrpc.core.message.extensions.buildExtensions import io.github.timortel.kmpgrpc.test.* +import io.github.timortel.kmpgrpc.test.proto2.Proto2GroupTest fun createScalarMessage() = scalarTypes { field1 = "Test" @@ -102,11 +103,13 @@ fun createMessageWithAllExtensions() = ExtensionsTest.MessageWithEveryExtension( set(ExtensionsTest.field33, listOf(0uL, 134uL, 353111345134uL)) set(ExtensionsTest.field34, listOf(-14, 0, 1241522)) set(ExtensionsTest.field35, listOf(-154L, 0L, 4514124121L)) - set(ExtensionsTest.field36, listOf( - byteArrayOf(0, -127, 127), - byteArrayOf(-123, 1, 2), - byteArrayOf(3, 3, -6) - )) + set( + ExtensionsTest.field36, listOf( + byteArrayOf(0, -127, 127), + byteArrayOf(-123, 1, 2), + byteArrayOf(3, 3, -6) + ) + ) } ) @@ -188,3 +191,32 @@ fun createEditionsNonPackedTypesMessage(): EditionsNonPackedTypesMessage = Editi field12List = field12, field13List = field13, ) + +fun createProto2NestedGroupMessage() = Proto2GroupTest.A( + b = Proto2GroupTest.A.B( + field1 = "text1", + field2 = 2, + c = Proto2GroupTest.A.B.C(field1 = "text3", field2 = "text4") + ), + dList = listOf( + Proto2GroupTest.A.D(field1 = "text5", field2 = 6), + Proto2GroupTest.A.D(field1 = "text7", field2 = 8) + ) +) + +fun createProto2GroupMessageWithExtensions() = Proto2GroupTest.E( + field1 = "text1", + g = Proto2GroupTest.E.G( + field1 = "text2", + extensions = buildExtensions { + set(Proto2GroupTest.field2, "text3") + } + ), + extensions = buildExtensions { + set( + Proto2GroupTest.f, Proto2GroupTest.F( + field1 = "text4" + ) + ) + } +) diff --git a/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/integration/RpcTest.kt b/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/integration/RpcTest.kt index 69ffa9bc..9f803ec4 100644 --- a/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/integration/RpcTest.kt +++ b/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/integration/RpcTest.kt @@ -5,7 +5,9 @@ import io.github.timortel.kmpgrpc.core.Code import io.github.timortel.kmpgrpc.core.StatusException import io.github.timortel.kmpgrpc.core.message.UnknownField import io.github.timortel.kmpgrpc.test.* +import io.github.timortel.kmpgrpc.test.proto2.Proto2TestServiceStub import io.github.timortel.kotlin_multiplatform_grpc_plugin.test.createMessageWithAllTypes +import io.github.timortel.kotlin_multiplatform_grpc_plugin.test.createProto2NestedGroupMessage import io.github.timortel.kotlin_multiplatform_grpc_plugin.test.createScalarMessage import kotlinx.coroutines.* import kotlinx.coroutines.flow.Flow @@ -141,6 +143,15 @@ abstract class RpcTest : ServerTest { assertEquals(baseMessage, returnedMessage) } + @Test + fun testSendNestedGroupMessage() = runTest { + val message = createProto2NestedGroupMessage() + + val stub = Proto2TestServiceStub(channel) + val returnedMessage = stub.sendMessageWithNestedGroups(message) + assertEquals(message, returnedMessage) + } + @Test fun testFailedRpcThrowsKmStatusException() = runTest { val message = simpleMessage { } diff --git a/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/model/EqTest.kt b/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/model/EqTest.kt index c53568ae..f6f23f6d 100644 --- a/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/model/EqTest.kt +++ b/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/model/EqTest.kt @@ -5,9 +5,12 @@ import io.github.timortel.kmpgrpc.core.message.UnknownField import io.github.timortel.kmpgrpc.core.message.extensions.buildExtensions import io.github.timortel.kmpgrpc.test.Unknownfield import io.github.timortel.kmpgrpc.test.emptyMessage +import io.github.timortel.kmpgrpc.test.proto2.Proto2GroupTest import io.github.timortel.kmpgrpc.test.simpleMessage import io.github.timortel.kotlin_multiplatform_grpc_plugin.test.createMessageWithAllExtensions import io.github.timortel.kotlin_multiplatform_grpc_plugin.test.createMessageWithAllTypes +import io.github.timortel.kotlin_multiplatform_grpc_plugin.test.createProto2GroupMessageWithExtensions +import io.github.timortel.kotlin_multiplatform_grpc_plugin.test.createProto2NestedGroupMessage import io.github.timortel.kotlin_multiplatform_grpc_plugin.test.createScalarMessage import kotlin.test.Test import kotlin.test.assertEquals @@ -98,4 +101,36 @@ class EqTest { assertNotEquals(msg1, msg2) } + + @Test + fun messageWithNestedGroupsEqual() { + val msg1 = createProto2NestedGroupMessage() + val msg2 = createProto2NestedGroupMessage() + + assertEquals(msg1, msg2) + } + + @Test + fun messageWithNestedGroupsDiffer() { + val msg1 = createProto2NestedGroupMessage() + val msg2 = Proto2GroupTest.A() + + assertNotEquals(msg1, msg2) + } + + @Test + fun messageWithGroupMessageExtensionsEqual() { + val msg1 = createProto2GroupMessageWithExtensions() + val msg2 = createProto2GroupMessageWithExtensions() + + assertEquals(msg1, msg2) + } + + @Test + fun messageWithGroupMessageExtensionsDiffer() { + val msg1 = createProto2GroupMessageWithExtensions() + val msg2 = msg1.copy(extensions = buildExtensions { }) + + assertNotEquals(msg1, msg2) + } } diff --git a/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/SelfMessageSerializationTest.kt b/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/SelfMessageSerializationTest.kt index ee41f7a5..fa3a97c4 100644 --- a/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/SelfMessageSerializationTest.kt +++ b/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/SelfMessageSerializationTest.kt @@ -16,12 +16,15 @@ import io.github.timortel.kmpgrpc.test.Unknownfield import io.github.timortel.kmpgrpc.test.longMessage import io.github.timortel.kmpgrpc.test.messageWithSubMessage import io.github.timortel.kmpgrpc.test.oneOfMessage +import io.github.timortel.kmpgrpc.test.proto2.Proto2GroupTest import io.github.timortel.kmpgrpc.test.repeatedLongMessage import io.github.timortel.kmpgrpc.test.simpleMessage import io.github.timortel.kotlin_multiplatform_grpc_plugin.test.createComplexRepeated import io.github.timortel.kotlin_multiplatform_grpc_plugin.test.createMessageWithAllExtensions import io.github.timortel.kotlin_multiplatform_grpc_plugin.test.createMessageWithAllTypes import io.github.timortel.kotlin_multiplatform_grpc_plugin.test.createNonPackedTypesMessage +import io.github.timortel.kotlin_multiplatform_grpc_plugin.test.createProto2GroupMessageWithExtensions +import io.github.timortel.kotlin_multiplatform_grpc_plugin.test.createProto2NestedGroupMessage import io.github.timortel.kotlin_multiplatform_grpc_plugin.test.createScalarMessage import kotlin.test.Test import kotlin.test.assertEquals @@ -190,4 +193,22 @@ class SelfMessageSerializationTest { assertEquals(msg, reconstructed) } + + @Test + fun testGroupSerialization() { + val msg = createProto2NestedGroupMessage() + + val reconstructed = Proto2GroupTest.A.deserialize(msg.serialize()) + + assertEquals(msg, reconstructed) + } + + @Test + fun testGroupExtensionSerialization() { + val msg = createProto2GroupMessageWithExtensions() + + val reconstructed = Proto2GroupTest.E.deserialize(msg.serialize()) + + assertEquals(msg, reconstructed) + } } diff --git a/kmp-grpc-internal-test/src/nativeJvmTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/ClientCredentialsRpcTest.kt b/kmp-grpc-internal-test/src/nativeJvmTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/ClientCredentialsRpcTest.kt index 6e8cf616..8de0941c 100644 --- a/kmp-grpc-internal-test/src/nativeJvmTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/ClientCredentialsRpcTest.kt +++ b/kmp-grpc-internal-test/src/nativeJvmTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/ClientCredentialsRpcTest.kt @@ -1,8 +1,8 @@ package io.github.timortel.kotlin_multiplatform_grpc_plugin.test -import iio.github.timortel.kmpgrpc.internal.test.CA_CERTIFICATE -import iio.github.timortel.kmpgrpc.internal.test.CLIENT_CERTIFICATE -import iio.github.timortel.kmpgrpc.internal.test.CLIENT_KEY +import io.github.timortel.kmpgrpc.internal.test.CA_CERTIFICATE +import io.github.timortel.kmpgrpc.internal.test.CLIENT_CERTIFICATE +import io.github.timortel.kmpgrpc.internal.test.CLIENT_KEY import io.github.timortel.kmpgrpc.core.Certificate import io.github.timortel.kmpgrpc.core.Channel import io.github.timortel.kmpgrpc.core.PrivateKey diff --git a/kmp-grpc-internal-test/src/nativeJvmTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/CustomCertificatesRpcTest.kt b/kmp-grpc-internal-test/src/nativeJvmTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/CustomCertificatesRpcTest.kt index dccd27dd..a9be594c 100644 --- a/kmp-grpc-internal-test/src/nativeJvmTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/CustomCertificatesRpcTest.kt +++ b/kmp-grpc-internal-test/src/nativeJvmTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/CustomCertificatesRpcTest.kt @@ -1,7 +1,7 @@ package io.github.timortel.kotlin_multiplatform_grpc_plugin.test -import iio.github.timortel.kmpgrpc.internal.test.CA_CERTIFICATE -import iio.github.timortel.kmpgrpc.internal.test.STANDALONE_LEAF_CERTIFICATE +import io.github.timortel.kmpgrpc.internal.test.CA_CERTIFICATE +import io.github.timortel.kmpgrpc.internal.test.STANDALONE_LEAF_CERTIFICATE import io.github.timortel.kmpgrpc.core.Certificate import io.github.timortel.kmpgrpc.core.Channel import io.github.timortel.kmpgrpc.core.StatusException diff --git a/kmp-grpc-internal-test/test-server/src/main/kotlin/io/github/timortel/kmpgrpc/testserver/TestServer.kt b/kmp-grpc-internal-test/test-server/src/main/kotlin/io/github/timortel/kmpgrpc/testserver/TestServer.kt index 33eecdac..edbbffef 100644 --- a/kmp-grpc-internal-test/test-server/src/main/kotlin/io/github/timortel/kmpgrpc/testserver/TestServer.kt +++ b/kmp-grpc-internal-test/test-server/src/main/kotlin/io/github/timortel/kmpgrpc/testserver/TestServer.kt @@ -1,6 +1,8 @@ package io.github.timortel.kmpgrpc.testserver import io.github.timortel.kmpgrpc.test.* +import io.github.timortel.kmpgrpc.test.proto2.Proto2GroupTest +import io.github.timortel.kmpgrpc.test.proto2.Proto2TestServiceGrpcKt import io.grpc.* import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder @@ -212,6 +214,11 @@ object TestServer { } } ) + .addService(object : Proto2TestServiceGrpcKt.Proto2TestServiceCoroutineImplBase() { + override suspend fun sendMessageWithNestedGroups(request: Proto2GroupTest.A): Proto2GroupTest.A { + return request + } + }) .intercept( object : ServerInterceptor { override fun interceptCall( diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/message/extensions/serialization/DeserializationFunctionExtension.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/message/extensions/serialization/DeserializationFunctionExtension.kt index 4ad66bcb..ae1ae68a 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/message/extensions/serialization/DeserializationFunctionExtension.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/message/extensions/serialization/DeserializationFunctionExtension.kt @@ -1,7 +1,6 @@ package io.github.timortel.kmpgrpc.plugin.sourcegeneration.generators.protofile.message.extensions.serialization import com.squareup.kotlinpoet.* -import com.squareup.kotlinpoet.MemberName.Companion.member import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy import io.github.timortel.kmpgrpc.plugin.sourcegeneration.SourceTarget import io.github.timortel.kmpgrpc.plugin.sourcegeneration.constants.* @@ -102,7 +101,7 @@ class DeserializationFunctionExtension : BaseSerializationExtension() { // Unknown field or extension addStatement( - "else -> %M(%N.%N(%N, %N), %N, %N)", + "else -> if·(!%M(%N.%N(%N, %N), %N, %N))·break", mergeUnknownFieldOrExtension, wrapperParamName, "readUnknownFieldOrExtension", @@ -201,7 +200,7 @@ class DeserializationFunctionExtension : BaseSerializationExtension() { when (val type = type) { is ProtoType.DefType -> when (val decl = type.resolveDeclaration()) { is ProtoEnum -> buildReadScalarFieldOpenEnumTypeCode(type) - is ProtoMessage -> buildReadScalarFieldMessageTypeCode(type, decl) + is ProtoMessage -> buildReadScalarFieldMessageTypeCode(type, decl, fieldNumber) } is ProtoType.NonDeclType -> { @@ -248,17 +247,9 @@ class DeserializationFunctionExtension : BaseSerializationExtension() { field.type is ProtoType.DefType && field.type.isMessage -> { val message = field.type.resolveDeclaration() as ProtoMessage - addCode( - "%N·+=·%N.%N(%T.Companion, ", - field.attributeName, - wrapperParamName, - "readMessage", - field.type.resolve() - ) - - addCode(buildExtensionRegistryCodeForMessage(message)) - - addCode(")\n") + addCode("%N·+=·", field.attributeName) + addCode(buildReadScalarFieldMessageTypeCode(field.type, message, field.number)) + addCode("\n") } isPacked -> { @@ -363,9 +354,9 @@ class DeserializationFunctionExtension : BaseSerializationExtension() { addCode(", ") addCode(getDefaultEntry(mapField.valuesType)) addCode(", ") - addCode(buildReadMapFieldDataCode(mapField.keyType)) + addCode(buildReadMapFieldDataCode(mapField.keyType, 1)) addCode(", ") - addCode(buildReadMapFieldDataCode(mapField.valuesType)) + addCode(buildReadMapFieldDataCode(mapField.valuesType, 2)) addCode(")\n") } } @@ -499,15 +490,20 @@ class DeserializationFunctionExtension : BaseSerializationExtension() { ) } - private fun buildReadScalarFieldMessageTypeCode(type: ProtoType.DefType, message: ProtoMessage): CodeBlock { + private fun buildReadScalarFieldMessageTypeCode(type: ProtoType.DefType, message: ProtoMessage, fieldNumber: Int): CodeBlock { return CodeBlock.builder() .add( "%N.%N(%T.Companion, ", wrapperParamName, - "readMessage", + getReadScalarFunctionName(type), type.resolve() ) .add(buildExtensionRegistryCodeForMessage(message)) + .apply { + if (message.type == ProtoMessage.Type.GROUP) { + add(", %L", fieldNumber) + } + } .add(")") .build() } @@ -521,7 +517,7 @@ class DeserializationFunctionExtension : BaseSerializationExtension() { ) } - private fun buildReadMapFieldDataCode(type: ProtoType): CodeBlock { + private fun buildReadMapFieldDataCode(type: ProtoType, fieldNumber: Int): CodeBlock { return when (type) { is ProtoType.NonDeclType -> { CodeBlock.of( @@ -534,13 +530,9 @@ class DeserializationFunctionExtension : BaseSerializationExtension() { when (val decl = type.resolveDeclaration()) { is ProtoMessage -> { CodeBlock.builder() - .add( - "{·%N(%T.Companion, ", - "readMessage", - type.resolve() - ) - .add(buildExtensionRegistryCodeForMessage(decl)) - .add(")}") + .add("{·") + .add(buildReadScalarFieldMessageTypeCode(type, decl, fieldNumber)) + .add("}") .build() } @@ -575,9 +567,12 @@ class DeserializationFunctionExtension : BaseSerializationExtension() { ProtoType.StringType -> "readString" ProtoType.BytesType -> "readBytes" is ProtoType.DefType -> { - when (protoType.declType) { - ProtoType.DefType.DeclarationType.MESSAGE -> "readMessage" - ProtoType.DefType.DeclarationType.ENUM -> "readEnum" + when (val decl = protoType.resolveDeclaration()) { + is ProtoEnum -> "readEnum" + is ProtoMessage -> when (decl.type) { + ProtoMessage.Type.DEFAULT -> "readMessage" + ProtoMessage.Type.GROUP -> "readGroup" + } } } } @@ -585,10 +580,11 @@ class DeserializationFunctionExtension : BaseSerializationExtension() { private fun buildExtensionRegistryCodeForMessage(message: ProtoMessage): CodeBlock { return if (message.isExtendable) { + // bug in KotlinPoet: Member declaration resolves incorrectly, so we use %T.%N CodeBlock.of( - "%M", - message.className.nestedClass("Companion") - .member(Const.Message.Companion.defaultExtensionRegistryProperty.name) + "%T.%N", + message.className.nestedClass("Companion"), + Const.Message.Companion.defaultExtensionRegistryProperty.name ) } else { CodeBlock.of("%T.empty()", kmExtensionRegistry) diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/message/extensions/serialization/SerializationFunctionExtension.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/message/extensions/serialization/SerializationFunctionExtension.kt index 6ec9fd8b..0d987fe5 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/message/extensions/serialization/SerializationFunctionExtension.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/message/extensions/serialization/SerializationFunctionExtension.kt @@ -6,6 +6,7 @@ import com.squareup.kotlinpoet.KModifier import com.squareup.kotlinpoet.TypeSpec import io.github.timortel.kmpgrpc.plugin.sourcegeneration.SourceTarget import io.github.timortel.kmpgrpc.plugin.sourcegeneration.constants.* +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.ProtoEnum import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.ProtoMessage import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.message.field.* import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.type.ProtoType @@ -193,7 +194,8 @@ class SerializationFunctionExtension : BaseSerializationExtension() { when (type.declType) { ProtoType.DefType.DeclarationType.MESSAGE -> { addCode( - "{·fieldNumber,·msg·-> writeMessage(fieldNumber, msg)·}" + "{·fieldNumber,·msg·-> %N(fieldNumber, msg)·}", + getWriteScalarFunctionName(type) ) } @@ -238,9 +240,12 @@ class SerializationFunctionExtension : BaseSerializationExtension() { ProtoType.StringType -> "writeStringArray" ProtoType.BytesType -> "writeBytesArray" is ProtoType.DefType -> { - when (protoType.declType) { - ProtoType.DefType.DeclarationType.MESSAGE -> "writeMessageArray" - ProtoType.DefType.DeclarationType.ENUM -> "writeEnumArray" + when (val decl = protoType.resolveDeclaration()) { + is ProtoMessage -> when (decl.type) { + ProtoMessage.Type.DEFAULT -> "writeMessageArray" + ProtoMessage.Type.GROUP -> "writeGroupArray" + } + is ProtoEnum -> "writeEnumArray" } } } @@ -266,17 +271,19 @@ class SerializationFunctionExtension : BaseSerializationExtension() { } is ProtoType.DefType -> { - when (type.declType) { - ProtoType.DefType.DeclarationType.MESSAGE -> { + when (type.resolveDeclaration()) { + is ProtoMessage -> { + val functionName = getWriteScalarFunctionName(type) + CodeBlock.of( - "%N.writeMessage(%L, %N)", + "%N.%N(%L, %N)", streamParam, + functionName, field.number, field.attributeName ) } - - ProtoType.DefType.DeclarationType.ENUM -> { + is ProtoEnum -> { CodeBlock.of( "%N.writeEnum(%L, %N.%N)\n", streamParam, @@ -321,9 +328,12 @@ class SerializationFunctionExtension : BaseSerializationExtension() { ProtoType.StringType -> "writeString" ProtoType.BytesType -> "writeBytes" is ProtoType.DefType -> { - when (protoType.declType) { - ProtoType.DefType.DeclarationType.MESSAGE -> "writeMessage" - ProtoType.DefType.DeclarationType.ENUM -> "writeEnum" + when (val decl = protoType.resolveDeclaration()) { + is ProtoEnum -> "writeEnum" + is ProtoMessage -> when (decl.type) { + ProtoMessage.Type.DEFAULT -> "writeMessage" + ProtoMessage.Type.GROUP -> "writeGroup" + } } } } diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/type/ProtoType.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/type/ProtoType.kt index 9b0b7715..ee447e37 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/type/ProtoType.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/type/ProtoType.kt @@ -196,9 +196,12 @@ sealed interface ProtoType : ProtoNode { override val isEnum: Boolean get() = declType == DeclarationType.ENUM override val wireType: DataType - get() = when (declType) { - DeclarationType.MESSAGE -> DataType.MESSAGE - DeclarationType.ENUM -> DataType.ENUM + get() = when (val decl = resolveDeclaration()) { + is ProtoEnum -> DataType.ENUM + is ProtoMessage -> when (decl.type) { + ProtoMessage.Type.DEFAULT -> DataType.MESSAGE + ProtoMessage.Type.GROUP -> DataType.GROUP + } } override val fieldType: TypeName diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/parsing/ProtobufModelBuilderVisitor.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/parsing/ProtobufModelBuilderVisitor.kt index 996fbeac..2c73afa4 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/parsing/ProtobufModelBuilderVisitor.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/parsing/ProtobufModelBuilderVisitor.kt @@ -29,6 +29,7 @@ import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.mess import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.message.field.ProtoOneOfField import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.service.ProtoRpc import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.service.ProtoService +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.util.decapitalize import org.antlr.v4.runtime.ParserRuleContext import org.antlr.v4.runtime.tree.ErrorNode import org.antlr.v4.runtime.tree.ParseTree @@ -351,7 +352,7 @@ class ProtobufModelBuilderVisitor( val field = ProtoMessageField( type = ProtoType.DefType(groupName, ctx), - name = groupName, + name = groupName.decapitalize(), number = number, options = options, fieldCardinality = fieldCardinality, From 8565b90cd1758ba3ff574feb2a5abc050defa901 Mon Sep 17 00:00:00 2001 From: Tim Ortel <100865202+TimOrtel@users.noreply.github.com> Date: Fri, 26 Dec 2025 12:46:43 +0100 Subject: [PATCH 13/23] Use kotlin IllegalArgumentException. --- .../generators/protofile/enumeration/ProtoEnumerationWriter.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/enumeration/ProtoEnumerationWriter.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/enumeration/ProtoEnumerationWriter.kt index e8151be9..51b43ed9 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/enumeration/ProtoEnumerationWriter.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/enumeration/ProtoEnumerationWriter.kt @@ -138,7 +138,7 @@ abstract class ProtoEnumerationWriter(val isActual: Boolean) { } else { add( "else -> throw %T(%P)", - IllegalArgumentException::class.asClassName(), + ClassName("kotlin", "IllegalArgumentException"), $$"Unknown numeric value $num for closed enum $${protoEnum.name}." ) } From fd247339ab275de78a024d16b2836e5c26527083 Mon Sep 17 00:00:00 2001 From: Tim Ortel <100865202+TimOrtel@users.noreply.github.com> Date: Tue, 6 Jan 2026 13:33:25 +0100 Subject: [PATCH 14/23] Add descriptor.proto to well known types. --- kmp-grpc-internal-test/test-server-python/gensources.sh | 2 ++ .../timortel/kmpgrpc/plugin/DownloadWellKnownTypesTask.kt | 1 + 2 files changed, 3 insertions(+) diff --git a/kmp-grpc-internal-test/test-server-python/gensources.sh b/kmp-grpc-internal-test/test-server-python/gensources.sh index 1f771beb..56891a09 100755 --- a/kmp-grpc-internal-test/test-server-python/gensources.sh +++ b/kmp-grpc-internal-test/test-server-python/gensources.sh @@ -1,6 +1,8 @@ #!/usr/bin/env bash set -e # Exit on error +source .venv/bin/activate + python -m grpc_tools.protoc -I ../src/commonMain/proto/general --python_out=server --grpc_python_out=server ../src/commonMain/proto/general/* python -m grpc_tools.protoc -I ../src/commonMain/proto/editions/ -I ../src/commonMain/proto/general --python_out=server --grpc_python_out=server ../src/commonMain/proto/editions/* diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/DownloadWellKnownTypesTask.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/DownloadWellKnownTypesTask.kt index d8f60267..e3d98864 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/DownloadWellKnownTypesTask.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/DownloadWellKnownTypesTask.kt @@ -19,6 +19,7 @@ abstract class DownloadWellKnownTypesTask @Inject constructor(objectFactory: Obj private val wellKnownTypes = listOf( "any.proto", "api.proto", + "descriptor.proto", "duration.proto", "empty.proto", "field_mask.proto", From e467a11d3fba0f03556cc23d2afc0abb8d54831c Mon Sep 17 00:00:00 2001 From: Tim Ortel <100865202+TimOrtel@users.noreply.github.com> Date: Tue, 6 Jan 2026 16:35:34 +0100 Subject: [PATCH 15/23] Add proto options import support. --- buildSrc/build.gradle.kts | 1 - kmp-grpc-plugin/build.gradle.kts | 14 ++ kmp-grpc-plugin/buildSrc/build.gradle.kts | 8 ++ kmp-grpc-plugin/settings.gradle.kts | 2 +- .../timortel/kmpgrpc/anltr/Protobuf2.g4 | 5 +- .../sourcegeneration/CompilationException.kt | 2 +- .../model/DeclarationResolver.kt | 42 ++++-- .../model/ProtoOptionsHolder.kt | 6 + .../model/declaration/ProtoEnum.kt | 20 +-- .../sourcegeneration/model/option/Options.kt | 1 - .../parsing/ProtobufModelBuilderVisitor.kt | 72 +++++----- .../parsing/ProtobufParserException.kt | 6 +- .../WellKnownTypesFolder.kt | 23 ++++ .../validation/OptionImportTest.kt | 126 ++++++++++++++++++ readme.md | 23 ++-- 15 files changed, 285 insertions(+), 66 deletions(-) create mode 100644 kmp-grpc-plugin/buildSrc/build.gradle.kts create mode 100644 kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/WellKnownTypesFolder.kt create mode 100644 kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/OptionImportTest.kt diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 42426cc9..ada8d837 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -1,4 +1,3 @@ - plugins { `kotlin-dsl` } diff --git a/kmp-grpc-plugin/build.gradle.kts b/kmp-grpc-plugin/build.gradle.kts index 3f85cd7c..387b40e3 100644 --- a/kmp-grpc-plugin/build.gradle.kts +++ b/kmp-grpc-plugin/build.gradle.kts @@ -1,3 +1,5 @@ +import io.github.timortel.kmpgrpc.plugin.DownloadWellKnownTypesTask + plugins { kotlin("jvm") version libs.versions.kotlin.get() id("java-gradle-plugin") @@ -47,6 +49,10 @@ kotlin { main { kotlin.srcDir(layout.projectDirectory.dir("../kmp-grpc-shared/src/commonMain")) } + + test { + resources.srcDir(layout.buildDirectory.dir("wkt")) + } } jvmToolchain(17) @@ -111,3 +117,11 @@ tasks.withType { exclude("**/Protobuf3BaseVisitor.java") exclude("**/Protobuf3BaseListener.java") } + +val downloadWellKnownTypesTask = tasks.register("downloadWellKnownTypes", DownloadWellKnownTypesTask::class.java) { + outputDir.set(layout.buildDirectory.dir("wkt")) +} + +tasks.named("processTestResources") { + dependsOn(downloadWellKnownTypesTask) +} diff --git a/kmp-grpc-plugin/buildSrc/build.gradle.kts b/kmp-grpc-plugin/buildSrc/build.gradle.kts new file mode 100644 index 00000000..270b3e29 --- /dev/null +++ b/kmp-grpc-plugin/buildSrc/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + `kotlin-dsl` +} + +repositories { + gradlePluginPortal() + google() +} diff --git a/kmp-grpc-plugin/settings.gradle.kts b/kmp-grpc-plugin/settings.gradle.kts index fa8bc749..b5a0fabf 100644 --- a/kmp-grpc-plugin/settings.gradle.kts +++ b/kmp-grpc-plugin/settings.gradle.kts @@ -4,4 +4,4 @@ dependencyResolutionManagement { from(files("../gradle/libs.versions.toml")) } } -} \ No newline at end of file +} diff --git a/kmp-grpc-plugin/src/main/antlr/io/github/timortel/kmpgrpc/anltr/Protobuf2.g4 b/kmp-grpc-plugin/src/main/antlr/io/github/timortel/kmpgrpc/anltr/Protobuf2.g4 index 543ee34c..abbcc6f3 100644 --- a/kmp-grpc-plugin/src/main/antlr/io/github/timortel/kmpgrpc/anltr/Protobuf2.g4 +++ b/kmp-grpc-plugin/src/main/antlr/io/github/timortel/kmpgrpc/anltr/Protobuf2.g4 @@ -12,6 +12,7 @@ * - Added package header * - Adapted rpc definition to expose clientStream and serverStream attributes * - Added field options to group declarations + * - Added support for options on extension declarations. * @author Tim Ortel */ @@ -141,7 +142,7 @@ type_ // Extensions extensions - : EXTENSIONS ranges SEMI + : EXTENSIONS ranges (LB fieldOptions RB)? SEMI ; // Reserved @@ -343,7 +344,7 @@ intLit ; strLit - : STR_LIT + : STR_LIT+ | PROTO2_LIT_SINGLE | PROTO2_LIT_DOUBLE ; diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/CompilationException.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/CompilationException.kt index 89c0ed89..92897954 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/CompilationException.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/CompilationException.kt @@ -26,7 +26,7 @@ sealed class CompilationException(val msg: String, val filePath: String, val ctx class EnumNoFields(message: String, file: ProtoFile, ctx: ParserRuleContext) : CompilationException(message, file, ctx) class IllegalClosedEnumImport(message: String, file: ProtoFile, ctx: ParserRuleContext) : CompilationException(message, file, ctx) - // Name Resolving + // Name Resolution class ResolvedToPackage(message: String, file: ProtoFile, ctx: ParserRuleContext) : CompilationException(message, file, ctx) class ConflictingResolution(message: String, file: ProtoFile, ctx: ParserRuleContext) : CompilationException(message, file, ctx) class UnresolvedReference(message: String, file: ProtoFile, ctx: ParserRuleContext) : CompilationException(message, file, ctx) diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/DeclarationResolver.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/DeclarationResolver.kt index 0b1d605e..1cb23df7 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/DeclarationResolver.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/DeclarationResolver.kt @@ -4,12 +4,14 @@ import io.github.timortel.kmpgrpc.plugin.sourcegeneration.CompilationException import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.ProtoDeclaration import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.ProtoEnum import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.ProtoMessage +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.file.ProtoFile +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.file.ProtoImport import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.structure.ProtoPackage import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.type.ProtoType import io.github.timortel.kmpgrpc.plugin.sourcegeneration.util.toFilePositionString /** - * Implementation for resoling in both messages and enums + * Implementation for resolving declarations in both messages and enums */ interface DeclarationResolver : BaseDeclarationResolver { @@ -27,13 +29,27 @@ interface DeclarationResolver : BaseDeclarationResolver { // Search scope val identifier = type.declaration - val allowedFiles = listOf(type.file) + type.file.importedFiles + val fileToImport = type.file.imports.associateBy { import -> + type.file.project.rootFolder.resolveImport(import.path) + ?: throw CompilationException.UnresolvedImport( + "Unable to resolve import ${import.identifier}", + type.file, + import.ctx + ) + } // Only allow candidates from the file itself or from imported files val allowedCandidates = candidates.filter { candidate -> when (candidate) { - is Candidate.Message -> candidate.message.file in allowedFiles - is Candidate.Enum -> candidate.enum.file in allowedFiles + is Candidate.Message, is Candidate.Enum -> { + when { + candidate.file == type.file -> true + else -> { + val import = fileToImport[candidate.file] + import != null && import.type == ProtoImport.Type.DEFAULT + } + } + } is Candidate.Package -> true // Packages are always allowed } } @@ -47,11 +63,11 @@ interface DeclarationResolver : BaseDeclarationResolver { validateCandidates(type, matchingCandidates) - return when { + when { matchingCandidates.isNotEmpty() -> { val newType = type.copy(declaration = remainingIdentifier) - // Go deeper into the three. No turning back. There must be exactly one element in the list. + // Go deeper into the tree. No turning back. There must be exactly one element in the list. when (val candidate = matchingCandidates.first()) { is Candidate.Message -> candidate.message.resolveDeclaration(newType, false) is Candidate.Enum -> candidate.enum.resolveDeclaration(newType) @@ -128,17 +144,27 @@ interface DeclarationResolver : BaseDeclarationResolver { fun getLocation(): String - data class Message(val message: ProtoMessage) : Candidate { + sealed interface FileBasedCandidate : Candidate { + val file: ProtoFile + } + + data class Message(val message: ProtoMessage) : FileBasedCandidate { override val name: String get() = message.name + override val file: ProtoFile + get() = message.file + override fun getLocation(): String = message.ctx.toFilePositionString(message.file.path) } - data class Enum(val enum: ProtoEnum) : Candidate { + data class Enum(val enum: ProtoEnum) : FileBasedCandidate { override val name: String get() = enum.name + override val file: ProtoFile + get() = enum.file + override fun getLocation(): String = enum.ctx.toFilePositionString(enum.file.path) } diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/ProtoOptionsHolder.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/ProtoOptionsHolder.kt index a734dad6..48ddb5a5 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/ProtoOptionsHolder.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/ProtoOptionsHolder.kt @@ -10,6 +10,10 @@ import io.github.timortel.kmpgrpc.plugin.sourcegeneration.util.toFilePositionStr interface ProtoOptionsHolder : ProtoNode { + companion object { + private val ignoredPackages = listOf("google.protobuf") + } + val options: List val file: ProtoFile @@ -18,6 +22,8 @@ interface ProtoOptionsHolder : ProtoNode { val optionTarget: OptionTarget override fun validate() { + if (file.`package` in ignoredPackages) return + options.forEach { option -> val isIgnored = option.name in Options.ignoredOptions val relatedOption = Options.options.firstOrNull { it.name == option.name } diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoEnum.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoEnum.kt index bff5b4b8..83c6da45 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoEnum.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoEnum.kt @@ -41,10 +41,9 @@ data class ProtoEnum( val defaultField: ProtoEnumField get() = - fields - .firstOrNull { it.number == 0 } + fields.firstOrNull() ?: throw CompilationException.EnumIllegalFirstField( - "Enumeration does not have field with value 0.", + "Enumeration does not have any entries.", file, ctx ) @@ -108,11 +107,16 @@ data class ProtoEnum( ctx = ctx ) - if (fields.first().number != 0) throw CompilationException.EnumIllegalFirstField( - message = "The first value defined in an enumeration must have value 0", - file = file, - ctx = ctx - ) + when (file.languageVersion) { + ProtoLanguageVersion.PROTO2 -> {} + ProtoLanguageVersion.PROTO3, ProtoLanguageVersion.EDITION2023,ProtoLanguageVersion.EDITION2024 -> { + if (fields.first().number != 0) throw CompilationException.EnumIllegalFirstField( + message = "The first value defined in an enumeration must have value 0", + file = file, + ctx = ctx + ) + } + } val allowAlias = Options.Basic.allowAlias.get(this) fields diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/Options.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/Options.kt index e64ae0d4..c3f43ff8 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/Options.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/Options.kt @@ -150,7 +150,6 @@ object Options { "cc_enable_arenas" ) - sealed interface LangConfig { class Unavailable : LangConfig diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/parsing/ProtobufModelBuilderVisitor.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/parsing/ProtobufModelBuilderVisitor.kt index 2c73afa4..455d634b 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/parsing/ProtobufModelBuilderVisitor.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/parsing/ProtobufModelBuilderVisitor.kt @@ -346,7 +346,7 @@ class ProtobufModelBuilderVisitor( val fieldCardinality = visitGroupFieldCardinality(label) val groupName = ctx.groupName().text - val number = ctx.fieldNumber().parseInt() + val number = visitIntLit(ctx.fieldNumber().intLit()) val options = visitFieldOptions(ctx.fieldOptions()) @@ -374,7 +374,7 @@ class ProtobufModelBuilderVisitor( return when { ctx.ranges() != null -> ProtoReservation(ranges = visitRanges(ctx.ranges())) ctx.reservedFieldNames() != null -> visitReservedFieldNames(ctx.reservedFieldNames()) - else -> throw ParseException("Could not read reserved field", ctx) + else -> throw ParseException("Could not read reserved field", ctx, filePath) } } @@ -382,7 +382,7 @@ class ProtobufModelBuilderVisitor( return when { ctx.ranges() != null -> ProtoReservation(ranges = visitRanges(ctx.ranges())) ctx.reservedFieldNames() != null -> visitReservedFieldNames(ctx.reservedFieldNames()) - else -> throw ParseException("Could not read reserved field", ctx) + else -> throw ParseException("Could not read reserved field", ctx, filePath) } } @@ -390,7 +390,7 @@ class ProtobufModelBuilderVisitor( return when { ctx.ranges() != null -> ProtoReservation(ranges = visitRanges(ctx.ranges())) ctx.reservedFieldNames() != null -> visitReservedFieldNames(ctx.reservedFieldNames()) - else -> throw ParseException("Could not read reserved field", ctx) + else -> throw ParseException("Could not read reserved field", ctx, filePath) } } @@ -417,15 +417,15 @@ class ProtobufModelBuilderVisitor( } override fun visitRange_(ctx: ProtobufEditionsParser.Range_Context): ProtoRange { - return visitRange(ctx.intLit(0).parseInt(), ctx.intLit(1)?.parseInt(), ctx.MAX() != null, ctx) + return visitRange(visitIntLit(ctx.intLit(0)), ctx.intLit(1)?.let(::visitIntLit), ctx.MAX() != null, ctx) } override fun visitRange_(ctx: Protobuf3Parser.Range_Context): ProtoRange { - return visitRange(ctx.intLit(0).parseInt(), ctx.intLit(1)?.parseInt(), ctx.MAX() != null, ctx) + return visitRange(visitIntLit(ctx.intLit(0)), ctx.intLit(1)?.let(::visitIntLit), ctx.MAX() != null, ctx) } override fun visitRange_(ctx: Protobuf2Parser.Range_Context): ProtoRange { - return visitRange(ctx.intLit(0).parseInt(), ctx.intLit(1)?.parseInt(), ctx.MAX() != null, ctx) + return visitRange(visitIntLit(ctx.intLit(0)), ctx.intLit(1)?.let(::visitIntLit), ctx.MAX() != null, ctx) } private fun visitReservedFieldNames(names: List): ProtoReservation { @@ -529,7 +529,7 @@ class ProtobufModelBuilderVisitor( val type = visitType_(ctx.type_()) val name = ctx.fieldName().text - val number = ctx.fieldNumber().parseInt() + val number = visitIntLit(ctx.fieldNumber().intLit()) val options = visitFieldOptions(ctx.fieldOptions()) @@ -554,7 +554,7 @@ class ProtobufModelBuilderVisitor( val type = visitType_(ctx.type_()) val name = ctx.fieldName().text - val number = ctx.fieldNumber().parseInt() + val number = visitIntLit(ctx.fieldNumber().intLit()) val options = visitFieldOptions(ctx.fieldOptions()) @@ -580,7 +580,7 @@ class ProtobufModelBuilderVisitor( val type = visitType_(ctx.type_()) val name = ctx.fieldName().text - val number = ctx.fieldNumber().parseInt() + val number = visitIntLit(ctx.fieldNumber().intLit()) val options = visitFieldOptions(ctx.fieldOptions()) @@ -599,7 +599,7 @@ class ProtobufModelBuilderVisitor( val valuesType = visitType_(ctx.type_()) val name = ctx.mapName().text - val number = ctx.fieldNumber().parseInt() + val number = visitIntLit(ctx.fieldNumber().intLit()) val options = visitFieldOptions(ctx.fieldOptions()) @@ -618,7 +618,7 @@ class ProtobufModelBuilderVisitor( val valuesType = visitType_(ctx.type_()) val name = ctx.mapName().text - val number = ctx.fieldNumber().parseInt() + val number = visitIntLit(ctx.fieldNumber().intLit()) val options = visitFieldOptions(ctx.fieldOptions()) @@ -637,7 +637,7 @@ class ProtobufModelBuilderVisitor( val valuesType = visitType_(ctx.type_()) val name = ctx.mapName().text - val number = ctx.fieldNumber().parseInt() + val number = visitIntLit(ctx.fieldNumber().intLit()) val options = visitFieldOptions(ctx.fieldOptions()) @@ -697,7 +697,7 @@ class ProtobufModelBuilderVisitor( override fun visitEnumField(ctx: ProtobufEditionsParser.EnumFieldContext): ProtoEnumField { val name = ctx.ident().text - val number = ctx.intLit().parseInt() + val number = visitIntLit(ctx.intLit()) val options = visitEnumValueOptions(ctx.enumValueOptions()) return visitEnumField(ctx, name, number, options, ctx.MINUS() != null) @@ -705,7 +705,7 @@ class ProtobufModelBuilderVisitor( override fun visitEnumField(ctx: Protobuf3Parser.EnumFieldContext): ProtoEnumField { val name = ctx.ident().text - val number = ctx.intLit().parseInt() + val number = visitIntLit(ctx.intLit()) val options = visitEnumValueOptions(ctx.enumValueOptions()) return visitEnumField(ctx, name, number, options, ctx.MINUS() != null) @@ -713,7 +713,7 @@ class ProtobufModelBuilderVisitor( override fun visitEnumField(ctx: Protobuf2Parser.EnumFieldContext): ProtoEnumField { val name = ctx.ident().text - val number = ctx.intLit().parseInt() + val number = visitIntLit(ctx.intLit()) val options = visitEnumValueOptions(ctx.enumValueOptions()) return visitEnumField(ctx, name, number, options, ctx.MINUS() != null) @@ -778,7 +778,7 @@ class ProtobufModelBuilderVisitor( override fun visitOneofField(ctx: ProtobufEditionsParser.OneofFieldContext): ProtoOneOfField { val type = visitType_(ctx.type_()) val name = ctx.fieldName().text - val number = ctx.fieldNumber().parseInt() + val number = visitIntLit(ctx.fieldNumber().intLit()) val options = visitFieldOptions(ctx.fieldOptions()) @@ -794,7 +794,7 @@ class ProtobufModelBuilderVisitor( override fun visitOneofField(ctx: Protobuf3Parser.OneofFieldContext): ProtoOneOfField { val type = visitType_(ctx.type_()) val name = ctx.fieldName().text - val number = ctx.fieldNumber().parseInt() + val number = visitIntLit(ctx.fieldNumber().intLit()) val options = visitFieldOptions(ctx.fieldOptions()) @@ -810,7 +810,7 @@ class ProtobufModelBuilderVisitor( override fun visitOneofField(ctx: Protobuf2Parser.OneofFieldContext): ProtoOneOfField { val type = visitType_(ctx.type_()) val name = ctx.fieldName().text - val number = ctx.fieldNumber().parseInt() + val number = visitIntLit(ctx.fieldNumber().intLit()) val options = visitFieldOptions(ctx.fieldOptions()) @@ -992,7 +992,7 @@ class ProtobufModelBuilderVisitor( ctx.BOOL() != null -> ProtoType.BoolType ctx.STRING() != null -> ProtoType.StringType ctx.BYTES() != null -> ProtoType.BytesType - else -> throw ParseException("Unknown type found.", ctx) + else -> throw ParseException("Unknown type found.", ctx, filePath) } } @@ -1014,7 +1014,7 @@ class ProtobufModelBuilderVisitor( ctx.BOOL() != null -> ProtoType.BoolType ctx.STRING() != null -> ProtoType.StringType ctx.BYTES() != null -> ProtoType.BytesType - else -> throw ParseException("Unknown type found.", ctx) + else -> throw ParseException("Unknown type found.", ctx, filePath) } } @@ -1036,7 +1036,7 @@ class ProtobufModelBuilderVisitor( ctx.BOOL() != null -> ProtoType.BoolType ctx.STRING() != null -> ProtoType.StringType ctx.BYTES() != null -> ProtoType.BytesType - else -> throw ParseException("Unknown type found.", ctx) + else -> throw ParseException("Unknown type found.", ctx, filePath) } } @@ -1054,7 +1054,7 @@ class ProtobufModelBuilderVisitor( ctx.SFIXED64() != null -> ProtoType.SFixed64Type ctx.BOOL() != null -> ProtoType.BoolType ctx.STRING() != null -> ProtoType.StringType - else -> throw ParseException("Unknown type found.", ctx) + else -> throw ParseException("Unknown type found.", ctx, filePath) } } @@ -1072,7 +1072,7 @@ class ProtobufModelBuilderVisitor( ctx.SFIXED64() != null -> ProtoType.SFixed64Type ctx.BOOL() != null -> ProtoType.BoolType ctx.STRING() != null -> ProtoType.StringType - else -> throw ParseException("Unknown type found.", ctx) + else -> throw ParseException("Unknown type found.", ctx, filePath) } } @@ -1090,7 +1090,7 @@ class ProtobufModelBuilderVisitor( ctx.SFIXED64() != null -> ProtoType.SFixed64Type ctx.BOOL() != null -> ProtoType.BoolType ctx.STRING() != null -> ProtoType.StringType - else -> throw ParseException("Unknown type found.", ctx) + else -> throw ParseException("Unknown type found.", ctx, filePath) } } @@ -1202,9 +1202,21 @@ class ProtobufModelBuilderVisitor( override fun visitEnumType(ctx: Protobuf3Parser.EnumTypeContext?): Any = Unit override fun visitEnumType(ctx: Protobuf2Parser.EnumTypeContext?): Any = Unit - override fun visitIntLit(ctx: ProtobufEditionsParser.IntLitContext?): Any = Unit - override fun visitIntLit(ctx: Protobuf3Parser.IntLitContext?): Any = Unit - override fun visitIntLit(ctx: Protobuf2Parser.IntLitContext?): Any = Unit + override fun visitIntLit(ctx: ProtobufEditionsParser.IntLitContext): Int = visitIntLit(ctx.text, ctx) + override fun visitIntLit(ctx: Protobuf3Parser.IntLitContext): Int = visitIntLit(ctx.text, ctx) + override fun visitIntLit(ctx: Protobuf2Parser.IntLitContext): Int = visitIntLit(ctx.text, ctx) + + private fun visitIntLit(text: String, ctx: ParserRuleContext): Int { + return when { + text.startsWith("0x") || text.startsWith("0X") -> { + text.substring(2).toIntOrNull(16) + } + text.startsWith('0') -> { + text.toIntOrNull(8) + } + else -> text.toIntOrNull() + } ?: throw ParseException("Could not parse integer", ctx, filePath) + } override fun visitStrLit(ctx: ProtobufEditionsParser.StrLitContext?): Any = Unit override fun visitStrLit(ctx: Protobuf3Parser.StrLitContext?): Any = Unit @@ -1228,10 +1240,6 @@ class ProtobufModelBuilderVisitor( override fun visitGroupName(ctx: Protobuf2Parser.GroupNameContext?): Any = Unit override fun visitStreamName(ctx: Protobuf2Parser.StreamNameContext?): Any = Unit - private fun ParserRuleContext.parseInt(): Int { - return text.toIntOrNull() ?: throw ParseException("Could not parse integer", this) - } - data class ParsedGroup( val field: ProtoMessageField, val message: ProtoMessage diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/parsing/ProtobufParserException.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/parsing/ProtobufParserException.kt index 32b941ca..09e14e9d 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/parsing/ProtobufParserException.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/parsing/ProtobufParserException.kt @@ -1,5 +1,9 @@ package io.github.timortel.kmpgrpc.plugin.sourcegeneration.parsing +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.util.toFilePositionString import org.antlr.v4.runtime.ParserRuleContext -class ProtobufParserException(override val message: String?, val ctx: ParserRuleContext) : Exception() +class ProtobufParserException(val msg: String?, val ctx: ParserRuleContext, val filePath: String) : Exception() { + override val message: String + get() ="${ctx.toFilePositionString(filePath)}: $msg" +} diff --git a/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/WellKnownTypesFolder.kt b/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/WellKnownTypesFolder.kt new file mode 100644 index 00000000..08098084 --- /dev/null +++ b/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/WellKnownTypesFolder.kt @@ -0,0 +1,23 @@ +package io.github.timortel.kotlin_multiplatform_grpc_plugin + +val wellKnownTypesFolder = FakeInputDirectory( + name = "google", + path = "google", + files = listOf( + FakeInputDirectory( + name = "protobuf", + path = "protobuf", + files = listOf( + FakeInputFile( + name = "descriptor.proto", + content = Thread.currentThread().contextClassLoader.getResourceAsStream("google/protobuf/descriptor.proto") + .use { inputStream -> + inputStream!!.bufferedReader().use { bufferedReader -> + bufferedReader.readText() + } + } + ) + ) + ) + ) +) diff --git a/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/OptionImportTest.kt b/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/OptionImportTest.kt new file mode 100644 index 00000000..5f591c94 --- /dev/null +++ b/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/OptionImportTest.kt @@ -0,0 +1,126 @@ +package io.github.timortel.kotlin_multiplatform_grpc_plugin.validation + +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.CompilationException +import io.github.timortel.kotlin_multiplatform_grpc_plugin.FakeInputDirectory +import io.github.timortel.kotlin_multiplatform_grpc_plugin.createProtoFile +import io.github.timortel.kotlin_multiplatform_grpc_plugin.wellKnownTypesFolder +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +class OptionImportTest : BaseValidationTest() { + + @Test + fun `test GIVEN file with custom option WHEN importing it normally THEN all declarations are available`() { + runGenerator( + listOf( + FakeInputDirectory( + name = "dir", + files = listOf( + createProtoFile( + fileHeader = ProtoVersion.PROTO2.header, + content = """ + import "google/protobuf/descriptor.proto"; + extend google.protobuf.MessageOptions { + optional string custom_option = 51234; + } + + message B {} + """.trimIndent(), + name = "file1.proto" + ), + createProtoFile( + fileHeader = ProtoVersion.PROTO3.header, + content = """ + import "file1.proto"; + + message A { + option (custom_option) = "some value"; + B b = 1; + } + """.trimIndent(), + name = "file2.proto" + ), + wellKnownTypesFolder + ) + ) + ) + ) + } + + @Test + fun `test GIVEN file with custom option WHEN importing it as an option import and still using the declared message THEN an exception is thrown`() { + assertThrows { + runGenerator( + listOf( + FakeInputDirectory( + name = "dir", + files = listOf( + createProtoFile( + fileHeader = ProtoVersion.PROTO2.header, + content = """ + import "google/protobuf/descriptor.proto"; + extend google.protobuf.MessageOptions { + optional string custom_option = 51234; + } + + message B {} + """.trimIndent(), + name = "file1.proto" + ), + createProtoFile( + fileHeader = ProtoVersion.EDITION2024.header, + content = """ + import option "file1.proto"; + + message A { + option (custom_option) = "some value"; + B b = 1; + } + """.trimIndent(), + name = "file2.proto" + ), + wellKnownTypesFolder + ) + ) + ) + ) + } + } + + @Test + fun `test GIVEN file with custom option WHEN importing it as an option import and not using the declared message THEN no exception is thrown`() { + runGenerator( + listOf( + FakeInputDirectory( + name = "dir", + files = listOf( + createProtoFile( + fileHeader = ProtoVersion.PROTO2.header, + content = """ + import "google/protobuf/descriptor.proto"; + extend google.protobuf.MessageOptions { + optional string custom_option = 51234; + } + + message B {} + """.trimIndent(), + name = "file1.proto" + ), + createProtoFile( + fileHeader = ProtoVersion.EDITION2024.header, + content = """ + import option "file1.proto"; + + message A { + option (custom_option) = "some value"; + } + """.trimIndent(), + name = "file2.proto" + ), + wellKnownTypesFolder + ) + ) + ) + ) + } +} diff --git a/readme.md b/readme.md index 70b0d23f..8e008eb7 100644 --- a/readme.md +++ b/readme.md @@ -95,18 +95,19 @@ Please note that not all features may be available even if the protobuf version ### Well-known types: For reference, see [the official documentation](https://protobuf.dev/reference/protobuf/google.protobuf/). Well-known types support must be enabled in your gradle config (see [Setup](#setup)). -| Protobuf Type | Supported | -|----------------------|---------------| -| `any.proto` | ✅ Supported | -| `api.proto` | ✅ Supported | -| `duration.proto` | ✅ Supported | -| `empty.proto` | ✅ Supported | -| `field_mask.proto` | ✅ Supported | +| Protobuf Type | Supported | +|------------------------|---------------| +| `any.proto` | ✅ Supported | +| `api.proto` | ✅ Supported | +| `descriptor.proto` | ✅ Supported | +| `duration.proto` | ✅ Supported | +| `empty.proto` | ✅ Supported | +| `field_mask.proto` | ✅ Supported | | `source_context.proto` | ✅ Supported | -| `struct.proto` | ✅ Supported | -| `timestamp.proto` | ✅ Supported | -| `type.proto` | ✅ Supported | -| `wrappers.proto` | ✅ Supported | +| `struct.proto` | ✅ Supported | +| `timestamp.proto` | ✅ Supported | +| `type.proto` | ✅ Supported | +| `wrappers.proto` | ✅ Supported | ### Additional Features - ✅ Generates DSL syntax to create messages From f453641190a199258d18021529b48ee50b7255e4 Mon Sep 17 00:00:00 2001 From: Tim Ortel <100865202+TimOrtel@users.noreply.github.com> Date: Sat, 7 Feb 2026 14:03:42 +0100 Subject: [PATCH 16/23] Support default option on enums. --- .../model/declaration/ProtoEnum.kt | 9 --- .../sourcegeneration/model/option/Options.kt | 10 +++ .../sourcegeneration/model/type/ProtoType.kt | 26 +++++- .../modeltree/DefaultEnumValueTest.kt | 80 +++++++++++++++++++ 4 files changed, 114 insertions(+), 11 deletions(-) create mode 100644 kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/modeltree/DefaultEnumValueTest.kt diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoEnum.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoEnum.kt index 83c6da45..339549a3 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoEnum.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoEnum.kt @@ -39,15 +39,6 @@ data class ProtoEnum( is ProtoDeclParent.Message -> p.message.file } - val defaultField: ProtoEnumField - get() = - fields.firstOrNull() - ?: throw CompilationException.EnumIllegalFirstField( - "Enumeration does not have any entries.", - file, - ctx - ) - override val heldFields: List = fields diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/Options.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/Options.kt index c3f43ff8..bfe85875 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/Options.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/option/Options.kt @@ -63,6 +63,15 @@ object Options { proto3Config = LangConfig.Available(defaultValue = true), editionConfig = LangConfig.Unavailable() ) + + val default = SimpleProtoOption( + name = "default", + parse = { it }, + targets = listOf(OptionTargetMatcher.FIELD()), + proto2Config = LangConfig.Available(defaultValue = null), + proto3Config = LangConfig.Unavailable(), + editionConfig = LangConfig.Available(defaultValue = null) + ) } object Feature { @@ -133,6 +142,7 @@ object Options { Basic.allowAlias, Basic.deprecated, Basic.packed, + Basic.default, Feature.fieldPresence, Feature.repeatedFieldEncoding, Feature.defaultSymbolVisibility, diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/type/ProtoType.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/type/ProtoType.kt index ee447e37..1ab4cd4d 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/type/ProtoType.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/type/ProtoType.kt @@ -6,6 +6,7 @@ import io.github.timortel.kmpgrpc.plugin.sourcegeneration.constants.* import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.ProtoExtensionDefinition import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.ProtoLanguageVersion import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.ProtoNode +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.ProtoOptionsHolder import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.ProtoProject import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.ProtoDeclaration import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.ProtoEnum @@ -14,6 +15,7 @@ import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.mess import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.message.field.ProtoMessageField import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.message.field.ProtoOneOfField import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.file.ProtoFile +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.option.Options import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.service.ProtoRpc import io.github.timortel.kmpgrpc.shared.internal.io.DataType import org.antlr.v4.runtime.ParserRuleContext @@ -213,8 +215,28 @@ sealed interface ProtoType : ProtoNode { override fun defaultValue(messageDefaultValue: MessageDefaultValue): CodeBlock { return when (val decl = resolveDeclaration()) { is ProtoEnum -> { - val defaultField = decl.defaultField - CodeBlock.of("%T.%N", decl.className, defaultField.name) + val optionsHolder: ProtoOptionsHolder = when (val p = parent) { + is Parent.MessageField -> p.field + is Parent.MapField -> p.field + is Parent.OneOfField -> p.field + is Parent.Rpc -> throw IllegalStateException("Enum cannot have rpc as parent") + is Parent.ExtensionDefinition -> throw IllegalStateException("Cannot get default value with extension definition parent") + } + + val defaultValueAsString = when (file.languageVersion) { + ProtoLanguageVersion.PROTO3 -> null + ProtoLanguageVersion.PROTO2, ProtoLanguageVersion.EDITION2023, ProtoLanguageVersion.EDITION2024 -> { + Options.Basic.default.get(optionsHolder) + } + } + + val defaultValue = if (defaultValueAsString == null) decl.fields.first() + else { + decl.fields.firstOrNull { it.name == defaultValueAsString } + ?: throw CompilationException.UnresolvedReference("Could not find enum entry with name $defaultValueAsString", file, ctx) + } + + CodeBlock.of("%T.%N", decl.className, defaultValue.name) } is ProtoMessage -> { diff --git a/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/modeltree/DefaultEnumValueTest.kt b/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/modeltree/DefaultEnumValueTest.kt new file mode 100644 index 00000000..f7811432 --- /dev/null +++ b/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/modeltree/DefaultEnumValueTest.kt @@ -0,0 +1,80 @@ +package io.github.timortel.kotlin_multiplatform_grpc_plugin.modeltree + +import com.google.testing.junit.testparameterinjector.junit5.TestParameter +import com.google.testing.junit.testparameterinjector.junit5.TestParameterInjectorTest +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.message.field.ProtoMessageField +import io.github.timortel.kotlin_multiplatform_grpc_plugin.validation.BaseValidationTest +import org.junit.jupiter.api.Assertions + +class DefaultEnumValueTest : BaseModelTreeTest() { + + @TestParameterInjectorTest + fun `test USING proto langauge version WHEN proto enum field is used without default value THEN the first entry is the default value`( + @TestParameter version: BaseValidationTest.ProtoVersion + ) { + val fieldPrefix = when (version) { + BaseValidationTest.ProtoVersion.PROTO2 -> "required" + else -> "" + } + + assertDefaultEnumValue( + proto = """ + ${if (version == BaseValidationTest.ProtoVersion.EDITION2024) "option features.(pb.java).nest_in_file_class = YES;" else ""} + + enum A { + A = 0; + B = 1; + } + + message C { + $fieldPrefix A a = 1; + } + """, + version = version, + expectedDefaultValue = "A.A" + ) + } + + @TestParameterInjectorTest + fun `test USING langauge version WHEN proto enum field is used with default value THEN the correct default value is chosen`( + @TestParameter(value = ["PROTO2", "EDITION2023", "EDITION2024"]) version: BaseValidationTest.ProtoVersion + ) { + val fieldPrefix = when (version) { + BaseValidationTest.ProtoVersion.PROTO2 -> "required" + else -> "" + } + + assertDefaultEnumValue( + proto = """ + ${if (version == BaseValidationTest.ProtoVersion.EDITION2024) "option features.(pb.java).nest_in_file_class = YES;" else ""} + enum A { + A = 0; + B = 1; + } + + message C { + $fieldPrefix A a = 1 [default = B]; + } + """, + version = version, + expectedDefaultValue = "A.B" + ) + } + + private fun assertDefaultEnumValue( + proto: String, + version: BaseValidationTest.ProtoVersion, + expectedDefaultValue: String + ) { + val project = buildProject(proto.trimIndent(), version) + + val field = project + .findMessage("C") + .findField("a") + .assertIsInstance() + + val defaultValueCode = field.defaultValue().toString() + + Assertions.assertEquals("TestFile.$expectedDefaultValue", defaultValueCode) + } +} From 14d0e6f3bef584a5815ec9ca1d29064693ec81ec Mon Sep 17 00:00:00 2001 From: Tim Ortel <100865202+TimOrtel@users.noreply.github.com> Date: Sun, 8 Feb 2026 12:08:44 +0100 Subject: [PATCH 17/23] Add symlink --- .../timortel/kmpgrpc/plugin/DownloadWellKnownTypesTask.kt | 1 + .../timortel/kmpgrpc/plugin/DownloadWellKnownTypesTask.kt | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) create mode 120000 kmp-grpc-plugin/buildSrc/src/main/java/io/github/timortel/kmpgrpc/plugin/DownloadWellKnownTypesTask.kt diff --git a/kmp-grpc-plugin/buildSrc/src/main/java/io/github/timortel/kmpgrpc/plugin/DownloadWellKnownTypesTask.kt b/kmp-grpc-plugin/buildSrc/src/main/java/io/github/timortel/kmpgrpc/plugin/DownloadWellKnownTypesTask.kt new file mode 120000 index 00000000..6ad2929d --- /dev/null +++ b/kmp-grpc-plugin/buildSrc/src/main/java/io/github/timortel/kmpgrpc/plugin/DownloadWellKnownTypesTask.kt @@ -0,0 +1 @@ +../../../../../../../../../src/main/java/io/github/timortel/kmpgrpc/plugin/DownloadWellKnownTypesTask.kt \ No newline at end of file diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/DownloadWellKnownTypesTask.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/DownloadWellKnownTypesTask.kt index e3d98864..a7836a23 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/DownloadWellKnownTypesTask.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/DownloadWellKnownTypesTask.kt @@ -5,6 +5,7 @@ import org.gradle.api.file.DirectoryProperty import org.gradle.api.model.ObjectFactory import org.gradle.api.tasks.OutputDirectory import org.gradle.api.tasks.TaskAction +import java.net.URI import java.net.URL import javax.inject.Inject @@ -40,7 +41,7 @@ abstract class DownloadWellKnownTypesTask @Inject constructor(objectFactory: Obj wellKnownTypes.forEach { protoFile -> val protoFileUrl = "$WELL_KNOW_BASE_URL/$protoFile" - val url = URL(protoFileUrl) + val url = URI(protoFileUrl).toURL() val outputFile = outputDir.file("$WELL_KNOWN_TYPES_RELATIVE_PATH/$protoFile").get().asFile logger.info("Downloading $protoFileUrl into ${outputFile.path}") From 1f16aa8ec64f230854b99c3a5d47185a4458f8f4 Mon Sep 17 00:00:00 2001 From: Tim Ortel <100865202+TimOrtel@users.noreply.github.com> Date: Sat, 14 Feb 2026 18:37:03 +0100 Subject: [PATCH 18/23] Add required field parsing support. --- .../timortel/kmpgrpc/core/message/Message.kt | 2 + .../core/UninitializedMessageException.kt | 21 +++ .../timortel/kmpgrpc/core/message/Message.kt | 5 + .../timortel/kmpgrpc/core/message/Message.kt | 2 + .../timortel/kmpgrpc/core/message/Message.kt | 2 + .../proto/proto2/proto2-required-fields.proto | 22 +++ .../test/model/IsInitializedTest.kt | 73 ++++++++++ .../test/model/UninitializedBuilderTest.kt | 88 ++++++++++++ .../RequiredFieldSerializationTests.kt | 85 ++++++++++++ .../sourcegeneration/constants/Const.kt | 23 +-- .../constants/library_fields.kt | 3 + .../MessageConstructorCallWriter.kt | 91 ++++++++++++ .../generators/dsl/ActualProtoDslWriter.kt | 67 ++++----- .../protofile/message/ProtoMessageWriter.kt | 5 +- .../FieldPropertyConstructorExtension.kt | 131 +++++++++++++++++- .../extensions/IsInitializedFieldExtension.kt | 92 ++++++++++++ .../DeserializationFunctionExtension.kt | 65 ++++----- .../protofile/oneof/ActualProtoOneOfWriter.kt | 19 ++- .../message/field/ProtoMessageField.kt | 26 +++- 19 files changed, 730 insertions(+), 92 deletions(-) create mode 100644 kmp-grpc-core/src/commonMain/kotlin/io/github/timortel/kmpgrpc/core/UninitializedMessageException.kt create mode 100644 kmp-grpc-internal-test/src/commonMain/proto/proto2/proto2-required-fields.proto create mode 100644 kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/model/IsInitializedTest.kt create mode 100644 kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/model/UninitializedBuilderTest.kt create mode 100644 kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/RequiredFieldSerializationTests.kt create mode 100644 kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/MessageConstructorCallWriter.kt create mode 100644 kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/message/extensions/IsInitializedFieldExtension.kt diff --git a/kmp-grpc-core/src/androidJvmCommon/kotlin/io/github/timortel/kmpgrpc/core/message/Message.kt b/kmp-grpc-core/src/androidJvmCommon/kotlin/io/github/timortel/kmpgrpc/core/message/Message.kt index 10a461fb..ed4d1fed 100644 --- a/kmp-grpc-core/src/androidJvmCommon/kotlin/io/github/timortel/kmpgrpc/core/message/Message.kt +++ b/kmp-grpc-core/src/androidJvmCommon/kotlin/io/github/timortel/kmpgrpc/core/message/Message.kt @@ -11,6 +11,8 @@ actual interface Message { actual val fullName: String + actual val isInitialized: Boolean + actual fun serialize(): ByteArray { val buffer = Buffer() serialize(CodedOutputStreamImpl(buffer)) diff --git a/kmp-grpc-core/src/commonMain/kotlin/io/github/timortel/kmpgrpc/core/UninitializedMessageException.kt b/kmp-grpc-core/src/commonMain/kotlin/io/github/timortel/kmpgrpc/core/UninitializedMessageException.kt new file mode 100644 index 00000000..3a825fc3 --- /dev/null +++ b/kmp-grpc-core/src/commonMain/kotlin/io/github/timortel/kmpgrpc/core/UninitializedMessageException.kt @@ -0,0 +1,21 @@ +package io.github.timortel.kmpgrpc.core + +import io.github.timortel.kmpgrpc.core.message.Message + +/** + * Thrown when a Protocol Buffers message is missing one or more required fields. + * + * In `proto2`, fields marked as `required` must be populated before a message + * can be fully initialized or serialized. This exception typically occurs during + * a DSL `build()` operation or when parsing a message that violates these + * presence constraints. + * + * @property msg The incomplete [Message] instance that triggered this exception. + * Note that accessing fields on this instance is safe, but it is considered + * semantically invalid according to the schema. + */ +class UninitializedMessageException( + val msg: Message, +) : RuntimeException( + "Message ${msg::class.simpleName} is missing required fields." +) diff --git a/kmp-grpc-core/src/commonMain/kotlin/io/github/timortel/kmpgrpc/core/message/Message.kt b/kmp-grpc-core/src/commonMain/kotlin/io/github/timortel/kmpgrpc/core/message/Message.kt index 385f59d0..41729222 100644 --- a/kmp-grpc-core/src/commonMain/kotlin/io/github/timortel/kmpgrpc/core/message/Message.kt +++ b/kmp-grpc-core/src/commonMain/kotlin/io/github/timortel/kmpgrpc/core/message/Message.kt @@ -17,6 +17,11 @@ expect interface Message { */ val fullName: String + /** + * If all required fields for this message have been set. + */ + val isInitialized: Boolean + /** * Serializes this message and returns it as a [ByteArray]. * diff --git a/kmp-grpc-core/src/jsTargetCommon/kotlin/io/github/timortel/kmpgrpc/core/message/Message.kt b/kmp-grpc-core/src/jsTargetCommon/kotlin/io/github/timortel/kmpgrpc/core/message/Message.kt index ff8ccc0c..d9f8d184 100644 --- a/kmp-grpc-core/src/jsTargetCommon/kotlin/io/github/timortel/kmpgrpc/core/message/Message.kt +++ b/kmp-grpc-core/src/jsTargetCommon/kotlin/io/github/timortel/kmpgrpc/core/message/Message.kt @@ -11,6 +11,8 @@ actual interface Message { actual val requiredSize: Int + actual val isInitialized: Boolean + actual fun serialize(): ByteArray { val buffer = Buffer() serialize(CodedOutputStreamImpl(buffer)) diff --git a/kmp-grpc-core/src/nativeMain/kotlin/io/github/timortel/kmpgrpc/core/message/Message.kt b/kmp-grpc-core/src/nativeMain/kotlin/io/github/timortel/kmpgrpc/core/message/Message.kt index ff8ccc0c..d9f8d184 100644 --- a/kmp-grpc-core/src/nativeMain/kotlin/io/github/timortel/kmpgrpc/core/message/Message.kt +++ b/kmp-grpc-core/src/nativeMain/kotlin/io/github/timortel/kmpgrpc/core/message/Message.kt @@ -11,6 +11,8 @@ actual interface Message { actual val requiredSize: Int + actual val isInitialized: Boolean + actual fun serialize(): ByteArray { val buffer = Buffer() serialize(CodedOutputStreamImpl(buffer)) diff --git a/kmp-grpc-internal-test/src/commonMain/proto/proto2/proto2-required-fields.proto b/kmp-grpc-internal-test/src/commonMain/proto/proto2/proto2-required-fields.proto new file mode 100644 index 00000000..02099df0 --- /dev/null +++ b/kmp-grpc-internal-test/src/commonMain/proto/proto2/proto2-required-fields.proto @@ -0,0 +1,22 @@ +syntax = "proto2"; + +package io.github.timortel.kmpgrpc.test.proto2; + +option java_outer_classname = "Proto2RequiredFields"; + +message Proto2MessageWithMixedFields { + required string field1 = 1; + optional int32 field2 = 2; +} + +message Proto2MessageWithRequiredFields { + required string field1 = 1; + required Proto2MessageWithMixedFields field2 = 2; + repeated Proto2MessageWithRequiredFields field3 = 3; + map field4 = 4; + + oneof x { + Proto2MessageWithMixedFields field5 = 5; + string field6 = 6; + } +} diff --git a/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/model/IsInitializedTest.kt b/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/model/IsInitializedTest.kt new file mode 100644 index 00000000..7975686c --- /dev/null +++ b/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/model/IsInitializedTest.kt @@ -0,0 +1,73 @@ +package io.github.timortel.kotlin_multiplatform_grpc_plugin.test.model + +import io.github.timortel.kmpgrpc.test.proto2.Proto2RequiredFields +import io.github.timortel.kmpgrpc.test.proto2.Proto2RequiredFields.Proto2MessageWithRequiredFields +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class IsInitializedTest { + + @Test + fun testDefaultIsInitialized() { + // invoke() uses default values that satisfy required fields + val msg = Proto2MessageWithRequiredFields() + assertTrue(msg.isInitialized, "Default message should be initialized") + } + + @Test + fun testMissingLocalRequiredField() { + // field1 is required. Passing null via createPartial should make it uninitialized. + val msg = Proto2MessageWithRequiredFields.createPartial(field1 = null) + assertFalse(msg.isInitialized, "Message should be uninitialized if local required field is missing") + } + + @Test + fun testUninitializedNestedMessage() { + // field2 is set, but the nested message itself is missing its own required field1 + val incompleteNested = Proto2RequiredFields.Proto2MessageWithMixedFields.createPartial(field1 = null) + val msg = Proto2MessageWithRequiredFields.createPartial( + field1 = "valid", + field2 = incompleteNested + ) + + assertFalse(msg.isInitialized, "Message should be uninitialized if a nested message is uninitialized") + } + + @Test + fun testUninitializedMessageInList() { + val incomplete = Proto2MessageWithRequiredFields.createPartial(field1 = null) + val msg = Proto2MessageWithRequiredFields( + field3List = listOf(incomplete) + ) + + assertFalse(msg.isInitialized, "Message should be uninitialized if any element in a repeated field is uninitialized") + } + + @Test + fun testUninitializedMessageInMap() { + val incomplete = Proto2MessageWithRequiredFields.createPartial(field1 = null) + val msg = Proto2MessageWithRequiredFields( + field4Map = mapOf("key" to incomplete) + ) + + assertFalse(msg.isInitialized, "Message should be uninitialized if any value in a map is uninitialized") + } + + @Test + fun testOneOfInitialization() { + // x.field5 is a message type. If that message is incomplete, the parent is incomplete. + val incompleteMixed = Proto2RequiredFields.Proto2MessageWithMixedFields.createPartial(field1 = null) + val msg = Proto2MessageWithRequiredFields( + x = Proto2MessageWithRequiredFields.X.Field5(incompleteMixed) + ) + + assertFalse(msg.isInitialized, "Message should be uninitialized if a message inside a OneOf is uninitialized") + + // x.field6 is a string (primitive-like), so it's always considered initialized if the case is set + val msg2 = Proto2MessageWithRequiredFields( + x = Proto2MessageWithRequiredFields.X.Field6("hello") + ) + assertTrue(msg2.isInitialized, "Message should be initialized if OneOf contains a valid string") + } +} diff --git a/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/model/UninitializedBuilderTest.kt b/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/model/UninitializedBuilderTest.kt new file mode 100644 index 00000000..c35c44f4 --- /dev/null +++ b/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/model/UninitializedBuilderTest.kt @@ -0,0 +1,88 @@ +package io.github.timortel.kotlin_multiplatform_grpc_plugin.test.model + +import io.github.timortel.kmpgrpc.core.UninitializedMessageException +import io.github.timortel.kmpgrpc.test.proto2.Proto2RequiredFields +import io.github.timortel.kmpgrpc.test.proto2.proto2MessageWithMixedFields +import io.github.timortel.kmpgrpc.test.proto2.proto2MessageWithRequiredFields +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull + +class UninitializedBuilderTest { + + @Test + fun testSuccessfulBuild() { + // Should not throw because all required fields are set + val msg = proto2MessageWithRequiredFields { + field1 = "top level" + field2 = proto2MessageWithMixedFields { + field1 = "nested required" + } + } + assertNotNull(msg) + } + + @Test + fun testMissingTopLevelRequiredField() { + assertFailsWith("Should throw if top-level required field1 is missing") { + proto2MessageWithRequiredFields { + // field1 is missing + field2 = proto2MessageWithMixedFields { field1 = "valid" } + } + } + } + + @Test + fun testMissingNestedRequiredField() { + assertFailsWith("Should throw if a required field inside field2 is missing") { + proto2MessageWithRequiredFields { + field1 = "valid" + field2 = proto2MessageWithMixedFields { + // field1 is required in MixedFields but missing here + field2 = 123 + } + } + } + } + + @Test + fun testUninitializedInList() { + assertFailsWith("Should throw if an element in the list is uninitialized") { + proto2MessageWithRequiredFields { + field1 = "valid" + field2 = proto2MessageWithMixedFields { field1 = "valid" } + + // Add an incomplete message to the list + field3List.add(Proto2RequiredFields.Proto2MessageWithRequiredFields.createPartial(field1 = null)) + } + } + } + + @Test + fun testUninitializedInMap() { + assertFailsWith("Should throw if a map value is uninitialized") { + proto2MessageWithRequiredFields { + field1 = "valid" + field2 = proto2MessageWithMixedFields { field1 = "valid" } + + // Add an incomplete message to the map + field4Map["key"] = Proto2RequiredFields.Proto2MessageWithRequiredFields.createPartial(field1 = null) + } + } + } + + @Test + fun testUninitializedInOneOf() { + assertFailsWith("Should throw if the chosen OneOf case is uninitialized") { + proto2MessageWithRequiredFields { + field1 = "valid" + field2 = proto2MessageWithMixedFields { field1 = "valid" } + + // x is set to a Field5 which contains an uninitialized message + x = Proto2RequiredFields.Proto2MessageWithRequiredFields.X.Field5( + Proto2RequiredFields.Proto2MessageWithMixedFields.createPartial(field1 = null) + ) + } + } + } +} diff --git a/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/RequiredFieldSerializationTests.kt b/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/RequiredFieldSerializationTests.kt new file mode 100644 index 00000000..51efa493 --- /dev/null +++ b/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/serialization/RequiredFieldSerializationTests.kt @@ -0,0 +1,85 @@ +package io.github.timortel.kotlin_multiplatform_grpc_plugin.test.serialization + +import io.github.timortel.kmpgrpc.test.proto2.Proto2RequiredFields.Proto2MessageWithMixedFields +import io.github.timortel.kmpgrpc.test.proto2.Proto2RequiredFields.Proto2MessageWithRequiredFields +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class RequiredFieldSerializationTests { + + @Test + fun testPartialMessageRoundTripRemainsUninitialized() { + // 1. Create a partial message missing a required field (field1) + val original = Proto2MessageWithRequiredFields.createPartial( + field1 = null, + field2 = Proto2MessageWithMixedFields("valid") + ) + assertFalse(original.isInitialized, "Original should be uninitialized") + + // 2. Serialize to bytes + val bytes = original.serialize() + + // 3. Deserialize back + val deserialized = Proto2MessageWithRequiredFields.deserialize(bytes) + + // 4. Verify state is preserved + assertFalse(deserialized.isInitialized, "Deserialized message should still be uninitialized") + assertEquals(original.field2, deserialized.field2, "Other data should remain intact") + } + + @Test + fun testNestedUninitializedMessageRoundTrip() { + // 1. Create a message where the parent is "complete" but the child is "partial" + val partialChild = Proto2MessageWithMixedFields.createPartial(field1 = null) + val original = Proto2MessageWithRequiredFields.createPartial( + field1 = "parent-valid", + field2 = partialChild + ) + assertFalse(original.isInitialized, "Parent should be uninitialized because child is uninitialized") + + // 2. Round trip + val bytes = original.serialize() + val deserialized = Proto2MessageWithRequiredFields.deserialize(bytes) + + // 3. Verify + assertFalse(deserialized.isInitialized, "Deserialized parent should still be uninitialized") + assertFalse(deserialized.field2.isInitialized, "Deserialized child should still be uninitialized") + } + + @Test + fun testFullyInitializedRoundTrip() { + // 1. Create a fully valid message + val original = Proto2MessageWithRequiredFields( + field1 = "valid", + field2 = Proto2MessageWithMixedFields(field1 = "nested-valid") + ) + assertTrue(original.isInitialized) + + // 2. Round trip + val bytes = original.serialize() + val deserialized = Proto2MessageWithRequiredFields.deserialize(bytes) + + // 3. Verify + assertTrue(deserialized.isInitialized, "Deserialized message should be fully initialized") + assertEquals("valid", deserialized.field1) + } + + @Test + fun testEmptyRepeatedAndMapRoundTrip() { + // In proto2, empty repeated/map fields are initialized by default + // as long as the local required fields are present. + val original = Proto2MessageWithRequiredFields.createPartial( + field1 = "valid", + field2 = Proto2MessageWithMixedFields("valid"), + field3List = emptyList(), + field4Map = emptyMap() + ) + + assertTrue(original.isInitialized) + + val deserialized = Proto2MessageWithRequiredFields.deserialize(original.serialize()) + assertTrue(deserialized.isInitialized, "Message with empty collections should stay initialized") + } +} diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/constants/Const.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/constants/Const.kt index c6fc6340..11d4b24c 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/constants/Const.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/constants/Const.kt @@ -1,5 +1,6 @@ package io.github.timortel.kmpgrpc.plugin.sourcegeneration.constants +import com.squareup.kotlinpoet.BOOLEAN import com.squareup.kotlinpoet.LIST import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy import com.squareup.kotlinpoet.STRING @@ -38,17 +39,10 @@ object Const { } object Message { - val reservedAttributeNames = setOf( - "fullName", - "requiredSize", - Companion.WrapperDeserializationFunction.TAG_LOCAL_VARIABLE, - Companion.WrapperDeserializationFunction.ENUM_NUMBER_VALUE_LOCAL_VARIABLE, - Companion.WrapperDeserializationFunction.ENUM_VALUE_LOCAL_VARIABLE, - Constructor.UnknownFields.name - ) - val fullNameProperty = Property.of("fullName", STRING) + val isInitializedProperty = Property.of("isInitialized", BOOLEAN) + object Constructor { val UnknownFields = Property.of("unknownFields", LIST.parameterizedBy(unknownField)) val MessageExtensions = Property.of("extensions", kmMessageExtensions) @@ -63,6 +57,7 @@ object Const { val reservedAttributeNames = setOf("requiredSize") const val REQUIRED_SIZE_PROPERTY_NAME = "requiredSize" + val isInitializedProperty = Property.of("isInitialized", BOOLEAN) const val SERIALIZE_FUNCTION_NAME = "serialize" const val SERIALIZE_FUNCTION_STREAM_PARAM_NAME = "stream" @@ -104,6 +99,16 @@ object Const { const val EXTENSION_BUILDER_LOCAL_VARIABLE = "extensionBuilder" } } + + val reservedAttributeNames = setOf( + fullNameProperty.name, + "requiredSize", + isInitializedProperty.name, + Companion.WrapperDeserializationFunction.TAG_LOCAL_VARIABLE, + Companion.WrapperDeserializationFunction.ENUM_NUMBER_VALUE_LOCAL_VARIABLE, + Companion.WrapperDeserializationFunction.ENUM_VALUE_LOCAL_VARIABLE, + Constructor.UnknownFields.name + ) } object DSL { diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/constants/library_fields.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/constants/library_fields.kt index 9939dab5..51823959 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/constants/library_fields.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/constants/library_fields.kt @@ -70,3 +70,6 @@ val fieldTypeBytes = fieldType.nestedClass("Bytes") // util val mergeUnknownFieldOrExtension = MemberName(PACKAGE_MESSAGE, "mergeUnknownFieldOrExtension") val readMapEntry = MemberName(PACKAGE_IO, "readMapEntry") + +// exceptions +val uninitializedMessageException = ClassName(PACKAGE_BASE, "UninitializedMessageException") diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/MessageConstructorCallWriter.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/MessageConstructorCallWriter.kt new file mode 100644 index 00000000..1d329f31 --- /dev/null +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/MessageConstructorCallWriter.kt @@ -0,0 +1,91 @@ +package io.github.timortel.kmpgrpc.plugin.sourcegeneration.generators + +import com.squareup.kotlinpoet.CodeBlock +import com.squareup.kotlinpoet.MemberName.Companion.member +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.constants.Const +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.ProtoMessage +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.message.ProtoOneOf +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.message.field.ProtoMapField +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.message.field.ProtoMessageField +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.util.joinToCodeBlock + +object MessageConstructorCallWriter { + + enum class ConstructorType { + DIRECT, + BUILD, + BUILD_PARTIAL + } + + fun getConstructorCallCode( + message: ProtoMessage, + type: ConstructorType, + getFieldParameter: (ProtoMessageField) -> CodeBlock, + getMapFieldParameter: (ProtoMapField) -> CodeBlock, + getOneOfFieldParameter: (ProtoOneOf) -> CodeBlock, + getUnknownFieldsParameter: () -> CodeBlock?, + getExtensionParameter: () -> CodeBlock, + ): CodeBlock { + return CodeBlock.builder() + .apply { + val companion = message.className.nestedClass("Companion") + + when (type) { + ConstructorType.DIRECT -> add("%T(", message.className) + ConstructorType.BUILD -> add("%M(", companion.member("invoke")) + ConstructorType.BUILD_PARTIAL -> add("%M(", companion.member("createPartial")) + } + + add("\n") + indent() + + val separator = ",\n" + + val fields = message.fields.joinToCodeBlock(separator) { field -> + add("%N = ", field.attributeName) + add(getFieldParameter(field)) + } + + val mapFields = message.mapFields.joinToCodeBlock(separator) { field -> + add("%N = ", field.attributeName) + add(getMapFieldParameter(field)) + } + + val oneOfFields = message.oneOfs.joinToCodeBlock(separator) { oneOf -> + add("%N = ", oneOf.attributeName) + add(getOneOfFieldParameter(oneOf)) + } + + val extensionBlock = CodeBlock.builder() + .add("%N = ", Const.Message.Constructor.MessageExtensions.name) + .add(getExtensionParameter()) + .build() + + val unknownFields = getUnknownFieldsParameter()?.let { + listOf( + CodeBlock.builder() + .add("%N = ", Const.Message.Constructor.UnknownFields.name) + .add(it) + .build() + ) + }.orEmpty() + + val blocks = listOf(fields, mapFields, oneOfFields) + unknownFields + + if (message.isExtendable) listOf(extensionBlock) else emptyList() + + add( + blocks + .filter { it.isNotEmpty() } + .joinToCodeBlock(separator) { add(it) } + ) + + if (fields.isNotEmpty() || mapFields.isNotEmpty() || oneOfFields.isNotEmpty() || message.isExtendable) { + add("\n") + } + + unindent() + add(")") + } + .build() + } +} diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/dsl/ActualProtoDslWriter.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/dsl/ActualProtoDslWriter.kt index f3f20ef9..81cd18f0 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/dsl/ActualProtoDslWriter.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/dsl/ActualProtoDslWriter.kt @@ -3,54 +3,47 @@ package io.github.timortel.kmpgrpc.plugin.sourcegeneration.generators.dsl import com.squareup.kotlinpoet.CodeBlock import com.squareup.kotlinpoet.FunSpec import io.github.timortel.kmpgrpc.plugin.sourcegeneration.constants.Const -import io.github.timortel.kmpgrpc.plugin.sourcegeneration.util.joinToCodeBlock +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.constants.uninitializedMessageException +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.generators.MessageConstructorCallWriter import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.ProtoMessage +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.message.field.ProtoMessageField object ActualProtoDslWriter : ProtoDslWriter(true) { override fun modifyBuildFunction(builder: FunSpec.Builder, message: ProtoMessage) { builder.apply { - addCode("return %T(", message.className) + addCode("val msg = ") - val separator = ",\n" - - val fields = message.fields.joinToCodeBlock(separator) { field -> - add("%N = %N ?: ", field.attributeName, field.attributeName) - add(field.defaultValue()) - } - - val mapFields = message.mapFields.joinToCodeBlock(separator) { field -> - add("%N = %N ?: emptyMap()", field.attributeName, field.attributeName) - } - - val oneOfFields = message.oneOfs.joinToCodeBlock(separator) { oneOf -> - add( - "%N = %N", - oneOf.attributeName, - oneOf.attributeName + addCode( + MessageConstructorCallWriter.getConstructorCallCode( + message = message, + type = MessageConstructorCallWriter.ConstructorType.BUILD_PARTIAL, + getFieldParameter = { field -> + if (field.isConstructorParameterNullable(ProtoMessageField.ConstructorParameterType.CREATE_PARTIAL)) { + CodeBlock.of("%N", field.attributeName) + } else { + CodeBlock.builder() + .add("%N ?: ", field.attributeName) + .add(field.defaultValue()) + .build() + } + }, + getMapFieldParameter = { field -> + CodeBlock.of("%N ?: emptyMap()", field.attributeName) + }, + getOneOfFieldParameter = { oneOf -> + CodeBlock.of("%N", oneOf.attributeName) + }, + getUnknownFieldsParameter = { null }, + getExtensionParameter = { CodeBlock.of("%N.build()", Const.DSL.MessageExtensions.name) } ) - } - - val extensionBlock = CodeBlock.of( - "%N = %N.build()", - Const.Message.Constructor.MessageExtensions.name, - Const.DSL.MessageExtensions.name ) - val blocks = listOf(fields, mapFields, oneOfFields) + - if (message.isExtendable) listOf(extensionBlock) else emptyList() - - addCode( - blocks - .filter { it.isNotEmpty() } - .joinToCodeBlock(separator) { add(it) } - ) + addCode("\n") - if (fields.isNotEmpty() || mapFields.isNotEmpty() || oneOfFields.isNotEmpty() || message.isExtendable) { - addCode("\n") - } + addStatement("if (!msg.isInitialized) throw %T(msg)", uninitializedMessageException) - addCode(")") + addStatement("return msg") } } -} \ No newline at end of file +} diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/message/ProtoMessageWriter.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/message/ProtoMessageWriter.kt index a6fc2c26..735cfe0b 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/message/ProtoMessageWriter.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/message/ProtoMessageWriter.kt @@ -13,6 +13,7 @@ import io.github.timortel.kmpgrpc.plugin.sourcegeneration.constants.kmMessageWit import io.github.timortel.kmpgrpc.plugin.sourcegeneration.generators.protofile.enumeration.ProtoEnumerationWriter import io.github.timortel.kmpgrpc.plugin.sourcegeneration.generators.protofile.field.ProtoFieldWriter import io.github.timortel.kmpgrpc.plugin.sourcegeneration.generators.protofile.message.extensions.FieldPropertyConstructorExtension +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.generators.protofile.message.extensions.IsInitializedFieldExtension import io.github.timortel.kmpgrpc.plugin.sourcegeneration.generators.protofile.message.extensions.MessageWriterExtension import io.github.timortel.kmpgrpc.plugin.sourcegeneration.generators.protofile.message.extensions.UnknownFieldsExtension import io.github.timortel.kmpgrpc.plugin.sourcegeneration.generators.protofile.message.extensions.functions.CopyFunctionExtension @@ -55,7 +56,8 @@ abstract class ProtoMessageWriter(private val isActual: Boolean) { UnknownFieldsExtension, ExtensionsPropertyExtension, ExtensionDefinitionExtension, - DefaultExtensionRegistryExtension + DefaultExtensionRegistryExtension, + IsInitializedFieldExtension ) /** @@ -77,6 +79,7 @@ abstract class ProtoMessageWriter(private val isActual: Boolean) { primaryConstructor( FunSpec .constructorBuilder() + .addModifiers(KModifier.PRIVATE) .apply { if (isActual) { addModifiers(KModifier.ACTUAL) diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/message/extensions/FieldPropertyConstructorExtension.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/message/extensions/FieldPropertyConstructorExtension.kt index 7ce72b21..33ee23c5 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/message/extensions/FieldPropertyConstructorExtension.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/message/extensions/FieldPropertyConstructorExtension.kt @@ -3,19 +3,136 @@ package io.github.timortel.kmpgrpc.plugin.sourcegeneration.generators.protofile. import com.squareup.kotlinpoet.* import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy import io.github.timortel.kmpgrpc.plugin.sourcegeneration.SourceTarget +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.constants.Const +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.constants.kmMessageExtensions +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.generators.MessageConstructorCallWriter import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.ProtoMessage import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.message.field.ProtoFieldCardinality +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.message.field.ProtoMessageField +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.type.ProtoType object FieldPropertyConstructorExtension : MessageWriterExtension { override fun applyToConstructor(builder: FunSpec.Builder, message: ProtoMessage, sourceTarget: SourceTarget) { + addConstructorParameters( + builder = builder, + message = message, + sourceTarget = sourceTarget, + type = ProtoMessageField.ConstructorParameterType.CONSTRUCTOR + ) + } + + override fun applyToCompanionObject(builder: TypeSpec.Builder, message: ProtoMessage, sourceTarget: SourceTarget) { + addCompanionObjectBuildFunction( + name = "invoke", + type = ProtoMessageField.ConstructorParameterType.CREATE, + builder = builder, + message = message, + sourceTarget = sourceTarget, + modifiers = listOf(KModifier.OPERATOR) + ) + + addCompanionObjectBuildFunction( + name = "createPartial", + type = ProtoMessageField.ConstructorParameterType.CREATE_PARTIAL, + builder = builder, + message = message, + sourceTarget = sourceTarget, + modifiers = emptyList() + ) + } + + private fun addCompanionObjectBuildFunction( + name: String, + type: ProtoMessageField.ConstructorParameterType, + builder: TypeSpec.Builder, + message: ProtoMessage, + sourceTarget: SourceTarget, + modifiers: List + ) { val isActual = sourceTarget is SourceTarget.Actual + builder.addFunction( + FunSpec.builder(name) + .addModifiers(modifiers) + .returns(message.className) + .apply { + addConstructorParameters( + builder = this, + message = message, + sourceTarget = sourceTarget, + type = type + ) + + if (message.isExtendable) { + addParameter( + Const.Message.Constructor.MessageExtensions + .parametrizedBy(message.className) + .toParamSpecBuilder() + .apply { + if (!isActual) defaultValue(CodeBlock.of("%T()", kmMessageExtensions)) + } + .build() + ) + } + + addParameter(Const.Message.Constructor.UnknownFields + .toParamSpecBuilder() + .apply { + if (!isActual) defaultValue("emptyList()") + } + .build() + ) + + if (isActual) { + addModifiers(KModifier.ACTUAL) + + addCode("return ") + addCode( + MessageConstructorCallWriter.getConstructorCallCode( + message = message, + type = MessageConstructorCallWriter.ConstructorType.DIRECT, + getFieldParameter = { field -> CodeBlock.of("%N", field.attributeName) }, + getMapFieldParameter = { field -> CodeBlock.of("%N", field.attributeName) }, + getOneOfFieldParameter = { field -> CodeBlock.of("%N", field.attributeName) }, + getUnknownFieldsParameter = { + CodeBlock.of( + "%N", + Const.Message.Constructor.UnknownFields.name + ) + }, + getExtensionParameter = { + CodeBlock.of( + "%N", + Const.Message.Constructor.MessageExtensions.name + ) + }, + ) + ) + } + } + .build() + ) + } + + private fun addConstructorParameters( + builder: FunSpec.Builder, + message: ProtoMessage, + sourceTarget: SourceTarget, + type: ProtoMessageField.ConstructorParameterType + ) { + val isActual = sourceTarget is SourceTarget.Actual + + val addDefaultValues = when (type) { + ProtoMessageField.ConstructorParameterType.CONSTRUCTOR -> false + ProtoMessageField.ConstructorParameterType.CREATE, ProtoMessageField.ConstructorParameterType.CREATE_PARTIAL -> true + } + //one of attributes do not get a parameter, as they get the one of parameter message.fields.forEach { field -> when (field.cardinality) { is ProtoFieldCardinality.Singular -> { - val isParamNullable = field.needsIsSetProperty + val isParamNullable = field.isConstructorParameterNullable(type) val type = if (isParamNullable) field.type.resolve().copy(nullable = true) else field.type.resolve() @@ -24,13 +141,15 @@ object FieldPropertyConstructorExtension : MessageWriterExtension { ParameterSpec .builder(field.attributeName, type) .apply { - if (!isActual) { + if (!isActual && addDefaultValues) { defaultValue( // If the field needs a isSet property, then the constructor must pass null by default if (isParamNullable) { CodeBlock.of("null") } else { - field.type.defaultValue() + field.type.defaultValue( + messageDefaultValue = ProtoType.MessageDefaultValue.EMPTY + ) } ) } @@ -44,7 +163,7 @@ object FieldPropertyConstructorExtension : MessageWriterExtension { ParameterSpec .builder(field.attributeName, LIST.parameterizedBy(field.type.resolve())) .apply { - if (!isActual) defaultValue("emptyList()") + if (!isActual && addDefaultValues) defaultValue("emptyList()") } .build() ) @@ -63,7 +182,7 @@ object FieldPropertyConstructorExtension : MessageWriterExtension { ) ) .apply { - if (!isActual) defaultValue("emptyMap()") + if (!isActual && addDefaultValues) defaultValue("emptyMap()") } .build() ) @@ -77,7 +196,7 @@ object FieldPropertyConstructorExtension : MessageWriterExtension { oneOf.sealedClassName ) .apply { - if (!isActual) defaultValue("%T", oneOf.sealedClassNameNotSet) + if (!isActual && addDefaultValues) defaultValue("%T", oneOf.sealedClassNameNotSet) } .build() ) diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/message/extensions/IsInitializedFieldExtension.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/message/extensions/IsInitializedFieldExtension.kt new file mode 100644 index 00000000..6ac021fc --- /dev/null +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/message/extensions/IsInitializedFieldExtension.kt @@ -0,0 +1,92 @@ +package io.github.timortel.kmpgrpc.plugin.sourcegeneration.generators.protofile.message.extensions + +import com.squareup.kotlinpoet.CodeBlock +import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.TypeSpec +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.SourceTarget +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.constants.Const +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.ProtoMessage +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.message.field.ProtoFieldCardinality +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.message.field.isLegacyRequired +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.util.joinCodeBlocks +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.util.joinToCodeBlock + +object IsInitializedFieldExtension : MessageWriterExtension { + + override fun applyToClass(builder: TypeSpec.Builder, message: ProtoMessage, sourceTarget: SourceTarget) { + val isActual = sourceTarget is SourceTarget.Actual + + builder.addProperty( + Const.Message.isInitializedProperty.toPropertySpecBuilder(KModifier.OVERRIDE) + .apply { + if (isActual) { + addModifiers(KModifier.ACTUAL) + + initializer( + CodeBlock.builder().apply { + val requiredFields = message.fields.filter { field -> + field.cardinality.isLegacyRequired + } + + val subMessageFields = message.fields.filter { it.type.isMessage } + val subMessageMapFields = message.mapFields.filter { it.valuesType.isMessage } + val oneOfs = message.oneOfs.filter { oneOf -> oneOf.fields.any { it.type.isMessage } } + + val subMessages = subMessageFields + subMessageMapFields + oneOfs + + if (requiredFields.isEmpty() && subMessages.isEmpty()) { + add("true") + } else { + val separator = " && " + val requiredFieldsBool = requiredFields.joinToCodeBlock(separator) { + add("%N", it.isSetProperty.name) + } + + val subMessageFieldsBool = subMessageFields.joinToCodeBlock(separator) { + when (it.cardinality) { + is ProtoFieldCardinality.Singular -> { + add( + "(%1N == null || %1N.%2N)", + it.attributeName, + Const.Message.isInitializedProperty.name + ) + } + ProtoFieldCardinality.Repeated -> { + add( + "%N.all { it.%N }", + it.attributeName, + Const.Message.isInitializedProperty.name + ) + } + } + } + + val subMessageOneOfFieldsBool = oneOfs.joinToCodeBlock(separator) { + add( + "%N.%N", + it.attributeName, + Const.Message.OneOf.isInitializedProperty.name + ) + } + + val subMessageMapFieldsBool = subMessageMapFields.joinToCodeBlock(separator) { + add( + "%N.values.all { it.%N }", + it.attributeName, + Const.Message.isInitializedProperty.name + ) + } + + val impl = listOf(requiredFieldsBool, subMessageFieldsBool, subMessageOneOfFieldsBool, subMessageMapFieldsBool).joinCodeBlocks(separator) + + add(impl) + } + } + .build() + ) + } + } + .build() + ) + } +} diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/message/extensions/serialization/DeserializationFunctionExtension.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/message/extensions/serialization/DeserializationFunctionExtension.kt index ae1ae68a..aa96d509 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/message/extensions/serialization/DeserializationFunctionExtension.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/message/extensions/serialization/DeserializationFunctionExtension.kt @@ -4,6 +4,7 @@ import com.squareup.kotlinpoet.* import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy import io.github.timortel.kmpgrpc.plugin.sourcegeneration.SourceTarget import io.github.timortel.kmpgrpc.plugin.sourcegeneration.constants.* +import io.github.timortel.kmpgrpc.plugin.sourcegeneration.generators.MessageConstructorCallWriter import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.ProtoEnum import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.ProtoMessage import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.message.field.ProtoFieldCardinality @@ -11,8 +12,6 @@ import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.mess import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.declaration.message.field.ProtoRegularField import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.file.ProtoFile import io.github.timortel.kmpgrpc.plugin.sourcegeneration.model.type.ProtoType -import io.github.timortel.kmpgrpc.plugin.sourcegeneration.util.joinCodeBlocks -import io.github.timortel.kmpgrpc.plugin.sourcegeneration.util.joinToCodeBlock import io.github.timortel.kmpgrpc.shared.internal.io.DataType import io.github.timortel.kmpgrpc.shared.internal.io.wireFormatForType import io.github.timortel.kmpgrpc.shared.internal.io.wireFormatMakeTag @@ -183,7 +182,7 @@ class DeserializationFunctionExtension : BaseSerializationExtension() { ) add("if·(%N·!=·null)·%N·$assignMode·", enumVar, variableName) - constructType { add("%N", enumVar)} + constructType { add("%N", enumVar) } add("\n") addStatement( @@ -366,41 +365,29 @@ class DeserializationFunctionExtension : BaseSerializationExtension() { message: ProtoMessage ) { builder.apply { - addCode("return %T(", message.className) + addCode("return ") - val separator = ",\n" - - val fieldsBlock = (message.fields + message.mapFields + message.oneOfs) - .joinToCodeBlock(separator = separator) { field -> - add( - "%N·=·%N", - field.attributeName, - field.attributeName - ) - } - - val unknownFieldsBlock = - CodeBlock.of( - "%N·=·%N", - Const.Message.Constructor.UnknownFields.name, - Const.Message.Companion.WrapperDeserializationFunction.UNKNOWN_FIELDS_LOCAL_VARIABLE - ) - - val extensionsBlock = - CodeBlock.of( - "%N·=·%N.build()", - Const.Message.Constructor.MessageExtensions.name, - Const.Message.Companion.WrapperDeserializationFunction.EXTENSION_BUILDER_LOCAL_VARIABLE + addCode( + MessageConstructorCallWriter.getConstructorCallCode( + message = message, + type = MessageConstructorCallWriter.ConstructorType.BUILD_PARTIAL, + getFieldParameter = { CodeBlock.of("%N", it.attributeName) }, + getMapFieldParameter = { CodeBlock.of("%N", it.attributeName) }, + getOneOfFieldParameter = { CodeBlock.of("%N", it.attributeName) }, + getUnknownFieldsParameter = { + CodeBlock.of( + "%N", + Const.Message.Companion.WrapperDeserializationFunction.UNKNOWN_FIELDS_LOCAL_VARIABLE + ) + }, + getExtensionParameter = { + CodeBlock.of( + "%N.build()", + Const.Message.Companion.WrapperDeserializationFunction.EXTENSION_BUILDER_LOCAL_VARIABLE + ) + } ) - - val codeBlocks = listOf( - fieldsBlock, - unknownFieldsBlock - ) + if (message.isExtendable) listOf(extensionsBlock) else emptyList() - - addCode(codeBlocks.joinCodeBlocks(separator)) - - addCode(")\n") + ) } } @@ -490,7 +477,11 @@ class DeserializationFunctionExtension : BaseSerializationExtension() { ) } - private fun buildReadScalarFieldMessageTypeCode(type: ProtoType.DefType, message: ProtoMessage, fieldNumber: Int): CodeBlock { + private fun buildReadScalarFieldMessageTypeCode( + type: ProtoType.DefType, + message: ProtoMessage, + fieldNumber: Int + ): CodeBlock { return CodeBlock.builder() .add( "%N.%N(%T.Companion, ", diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/oneof/ActualProtoOneOfWriter.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/oneof/ActualProtoOneOfWriter.kt index 129c52ca..e8cc343e 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/oneof/ActualProtoOneOfWriter.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/generators/protofile/oneof/ActualProtoOneOfWriter.kt @@ -24,6 +24,7 @@ abstract class ActualProtoOneOfWriter : ProtoOneOfWriter(true) { addSerializeFunction(builder, listOf(KModifier.ABSTRACT)) {} builder.addProperty(Const.Message.OneOf.REQUIRED_SIZE_PROPERTY_NAME, INT, KModifier.ABSTRACT) + builder.addProperty(Const.Message.OneOf.isInitializedProperty.toPropertySpec(KModifier.ABSTRACT)) } override fun modifyChildClass(builder: TypeSpec.Builder, oneOf: ProtoOneOf, childClassType: ChildClassType) { @@ -65,6 +66,22 @@ abstract class ActualProtoOneOfWriter : ProtoOneOfWriter(true) { ) .build() ) + + builder.addProperty( + Const.Message.OneOf.isInitializedProperty.toPropertySpecBuilder(KModifier.OVERRIDE) + .apply { + when (childClassType) { + is ChildClassType.Normal -> if (childClassType.field.type.isMessage) { + initializer("%N.%N", childClassType.field.attributeName, Const.Message.isInitializedProperty.name) + } else { + initializer("true") + } + ChildClassType.NotSet -> initializer("true") + ChildClassType.Unknown -> initializer("true") + } + } + .build() + ) } private fun addSerializeFunction( @@ -81,4 +98,4 @@ abstract class ActualProtoOneOfWriter : ProtoOneOfWriter(true) { .build() ) } -} \ No newline at end of file +} diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/message/field/ProtoMessageField.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/message/field/ProtoMessageField.kt index 060d0b30..40c6f176 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/message/field/ProtoMessageField.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/message/field/ProtoMessageField.kt @@ -101,7 +101,15 @@ class ProtoMessageField( * If cardinality is either explicit or legacy, or if the type is a message and it is not repeated */ val needsIsSetProperty: Boolean - get() = cardinality.isExplicit || (type is ProtoType.DefType && type.isMessage && cardinality != ProtoFieldCardinality.Repeated) + get() { + val isSingularMessage = type is ProtoType.DefType && type.isMessage && cardinality != ProtoFieldCardinality.Repeated + + return when (file.languageVersion) { + ProtoLanguageVersion.PROTO2 -> cardinality is ProtoFieldCardinality.Singular + ProtoLanguageVersion.PROTO3 -> cardinality.isExplicit || isSingularMessage + ProtoLanguageVersion.EDITION2023, ProtoLanguageVersion.EDITION2024 -> !cardinality.isImplicit && cardinality != ProtoFieldCardinality.Repeated + } + } val isSetProperty: ExtraProperty get() = ExtraProperty( @@ -171,6 +179,22 @@ class ProtoMessageField( } } + fun isConstructorParameterNullable(type: ConstructorParameterType): Boolean { + return when (type) { + ConstructorParameterType.CONSTRUCTOR, ConstructorParameterType.CREATE_PARTIAL -> needsIsSetProperty + ConstructorParameterType.CREATE -> when (fieldCardinality) { + FieldCardinality.SINGULAR, FieldCardinality.SINGULAR_OPTIONAL, FieldCardinality.REPEATED -> needsIsSetProperty + FieldCardinality.SINGULAR_REQUIRED -> false + } + } + } + + enum class ConstructorParameterType { + CONSTRUCTOR, + CREATE, + CREATE_PARTIAL + } + data class ExtraProperty( override val desiredAttributeName: String, override val resolvingParent: ProtoChildPropertyNameResolver, From dc4546e78898bc50e59d40344076ea7f40033edb Mon Sep 17 00:00:00 2001 From: Tim Ortel <100865202+TimOrtel@users.noreply.github.com> Date: Sat, 14 Feb 2026 18:41:35 +0100 Subject: [PATCH 19/23] Revert source command. --- kmp-grpc-internal-test/test-server-python/gensources.sh | 2 -- 1 file changed, 2 deletions(-) diff --git a/kmp-grpc-internal-test/test-server-python/gensources.sh b/kmp-grpc-internal-test/test-server-python/gensources.sh index 56891a09..1f771beb 100755 --- a/kmp-grpc-internal-test/test-server-python/gensources.sh +++ b/kmp-grpc-internal-test/test-server-python/gensources.sh @@ -1,8 +1,6 @@ #!/usr/bin/env bash set -e # Exit on error -source .venv/bin/activate - python -m grpc_tools.protoc -I ../src/commonMain/proto/general --python_out=server --grpc_python_out=server ../src/commonMain/proto/general/* python -m grpc_tools.protoc -I ../src/commonMain/proto/editions/ -I ../src/commonMain/proto/general --python_out=server --grpc_python_out=server ../src/commonMain/proto/editions/* From b845d9e38497623da4409cbd41b7251b7479a128 Mon Sep 17 00:00:00 2001 From: Tim Ortel <100865202+TimOrtel@users.noreply.github.com> Date: Sat, 14 Feb 2026 18:57:00 +0100 Subject: [PATCH 20/23] Fix some tests. --- .../test/integration/EditionsRpcTest.kt | 9 +++++++-- .../sourcegeneration/model/declaration/ProtoEnum.kt | 3 ++- .../validation/BaseValidationTest.kt | 7 ++++++- .../validation/EnumValidationTests.kt | 2 +- .../validation/options/OptionHolderValidationTests.kt | 2 +- 5 files changed, 17 insertions(+), 6 deletions(-) diff --git a/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/integration/EditionsRpcTest.kt b/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/integration/EditionsRpcTest.kt index 87c274c3..9d317d13 100644 --- a/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/integration/EditionsRpcTest.kt +++ b/kmp-grpc-internal-test/src/commonTest/kotlin/io/github/timortel/kotlin_multiplatform_grpc_plugin/test/integration/EditionsRpcTest.kt @@ -1,6 +1,8 @@ package io.github.timortel.kotlin_multiplatform_grpc_plugin.test.integration import io.github.timortel.kmpgrpc.core.Channel +import io.github.timortel.kmpgrpc.core.Code +import io.github.timortel.kmpgrpc.core.StatusException import io.github.timortel.kmpgrpc.test.EditionsTestServiceStub import io.github.timortel.kmpgrpc.test.editions.EditionsLegacyField import io.github.timortel.kotlin_multiplatform_grpc_plugin.test.createEditionsNonPackedTypesMessage @@ -9,6 +11,7 @@ import io.github.timortel.kotlin_multiplatform_grpc_plugin.test.createMessageWit import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith abstract class EditionsRpcTest : ServerTest { @@ -39,9 +42,11 @@ abstract class EditionsRpcTest : ServerTest { @Test fun testLegacyRequiredFieldNoData() = runTest { val message = EditionsLegacyField() - val response = stub.sendLegacyRequiredField(message) + val exception = assertFailsWith { + stub.sendLegacyRequiredField(message) + } - assertEquals(message, response) + assertEquals(Code.INTERNAL, exception.status.code) } @Test diff --git a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoEnum.kt b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoEnum.kt index 339549a3..445246df 100644 --- a/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoEnum.kt +++ b/kmp-grpc-plugin/src/main/java/io/github/timortel/kmpgrpc/plugin/sourcegeneration/model/declaration/ProtoEnum.kt @@ -80,6 +80,7 @@ data class ProtoEnum( ProtoLanguageVersion.PROTO3 -> true ProtoLanguageVersion.EDITION2023, ProtoLanguageVersion.EDITION2024 -> getFeatureIsOpen() } + ProtoLanguageVersion.EDITION2023, ProtoLanguageVersion.EDITION2024 -> when (file.languageVersion) { ProtoLanguageVersion.PROTO2 -> false ProtoLanguageVersion.PROTO3 -> true @@ -100,7 +101,7 @@ data class ProtoEnum( when (file.languageVersion) { ProtoLanguageVersion.PROTO2 -> {} - ProtoLanguageVersion.PROTO3, ProtoLanguageVersion.EDITION2023,ProtoLanguageVersion.EDITION2024 -> { + ProtoLanguageVersion.PROTO3, ProtoLanguageVersion.EDITION2023, ProtoLanguageVersion.EDITION2024 -> { if (fields.first().number != 0) throw CompilationException.EnumIllegalFirstField( message = "The first value defined in an enumeration must have value 0", file = file, diff --git a/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/BaseValidationTest.kt b/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/BaseValidationTest.kt index c910af79..72fbf8df 100644 --- a/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/BaseValidationTest.kt +++ b/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/BaseValidationTest.kt @@ -35,6 +35,11 @@ abstract class BaseValidationTest { PROTO2("syntax = \"proto2\";"), PROTO3("syntax = \"proto3\";"), EDITION2023("edition = \"2023\";"), - EDITION2024("edition = \"2024\";") + EDITION2024("edition = \"2024\";"); + + val fieldPrefix: String get() = when (this) { + PROTO2 -> "optional" + else -> "" + } } } diff --git a/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/EnumValidationTests.kt b/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/EnumValidationTests.kt index 0ba5ef3d..00e4eedd 100644 --- a/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/EnumValidationTests.kt +++ b/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/EnumValidationTests.kt @@ -84,7 +84,7 @@ class EnumValidationTests : BaseValidationTest() { @TestParameterInjectorTest fun `test WHEN enum does not have default field THEN a compilation exception is thrown`( - @TestParameter protoVersion: ProtoVersion + @TestParameter(value = ["PROTO3", "EDITION2023", "EDITION2024"]) protoVersion: ProtoVersion ) { assertThrows { runGenerator( diff --git a/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/options/OptionHolderValidationTests.kt b/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/options/OptionHolderValidationTests.kt index cc76daaa..de5d63b1 100644 --- a/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/options/OptionHolderValidationTests.kt +++ b/kmp-grpc-plugin/src/test/java/io/github/timortel/kotlin_multiplatform_grpc_plugin/validation/options/OptionHolderValidationTests.kt @@ -87,7 +87,7 @@ class OptionHolderValidationTests : BaseValidationTest() { runGenerator( """ message TestMessage { - string field1 = 1 [foo="bar"]; + ${protoVersion.fieldPrefix} string field1 = 1 [foo="bar"]; } """.trimIndent(), protoVersion From d6391e88663dbc8cd98f341e4cef835e4cce0f10 Mon Sep 17 00:00:00 2001 From: Tim Ortel <100865202+TimOrtel@users.noreply.github.com> Date: Sun, 15 Feb 2026 11:44:13 +0100 Subject: [PATCH 21/23] Fix group message reading. --- .../io/github/timortel/kmpgrpc/core/io/CodedInputStream.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/kmp-grpc-core/src/commonMain/kotlin/io/github/timortel/kmpgrpc/core/io/CodedInputStream.kt b/kmp-grpc-core/src/commonMain/kotlin/io/github/timortel/kmpgrpc/core/io/CodedInputStream.kt index 466e0cca..3ff3187a 100644 --- a/kmp-grpc-core/src/commonMain/kotlin/io/github/timortel/kmpgrpc/core/io/CodedInputStream.kt +++ b/kmp-grpc-core/src/commonMain/kotlin/io/github/timortel/kmpgrpc/core/io/CodedInputStream.kt @@ -98,6 +98,7 @@ abstract class CodedInputStream { extensionRegistry: ExtensionRegistry ): UnknownFieldOrExtension? { val number = wireFormatGetTagFieldNumber(tag) + if (wireFormatGetTagWireType(tag) == WireFormat.END_GROUP.value) return null val extension = extensionRegistry.getExtensionForFieldNumber(number) @Suppress("UNCHECKED_CAST") From f5d24b382e81ce80c473fec56cc7be565ef47630 Mon Sep 17 00:00:00 2001 From: Tim Ortel <100865202+TimOrtel@users.noreply.github.com> Date: Sun, 15 Feb 2026 11:50:08 +0100 Subject: [PATCH 22/23] Update readme. --- readme.md | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/readme.md b/readme.md index 8e008eb7..b13b8b84 100644 --- a/readme.md +++ b/readme.md @@ -32,7 +32,7 @@ This projects implements client-side gRPC for Android, JVM, Native (including iO ### Supported protobuf versions | | Support status | |---------------|----------------| -| Proto2 | ⏳ Planned | +| Proto2 | ✅ Supported | | Proto3 | ✅ Supported | | Editions 2023 | ✅ Supported | | Editions 2024 | ✅ Supported | @@ -61,14 +61,15 @@ Please note that not all features may be available even if the protobuf version ### Supported proto options and features: ### Legacy options -| Proto Option | Proto3 | Edition 2023 | -|------------------------|--------|--------------| -| `java_package` | ✅ | ✅ | -| `java_outer_classname` | ✅ | ✅ | -| `java_multiple_files` | ✅ | ✅ | -| `deprecated` | ✅ | ✅ | -| `packed` | ✅ | ✅ | -| `optimize_for` | ❌ | ❌ | +| Proto Option | Proto2 | Proto3 | Edition 2023 | +|------------------------|--------|--------|--------------| +| `java_package` | ✅ | ✅ | ✅ | +| `java_outer_classname` | ✅ | ✅ | ✅ | +| `java_multiple_files` | ✅ | ✅ | ✅ | +| `deprecated` | ✅ | ✅ | ✅ | +| `packed` | ✅ | ✅ | ✅ | +| `default` enum-option | ✅ | ✅ | ✅ | +| `optimize_for` | ❌ | ❌ | ❌ | ### Features | Feature | Edition 2023 | Edition 2024 | @@ -457,7 +458,7 @@ The plugin generates kotlin code for all provided proto files. No `protoc` is ne by gRPC for JVM and by [tonic](https://github.com/hyperium/tonic) for all native targets. For JavaScript, the requests are handled by [ktor](https://github.com/ktorio/ktor). ## License -Copyright 2025 Tim Ortel +Copyright 2026 Tim Ortel 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 From dd0f18be4be1d236faf6dbcab7862e14ef17019e Mon Sep 17 00:00:00 2001 From: Tim Ortel <100865202+TimOrtel@users.noreply.github.com> Date: Sun, 15 Feb 2026 12:28:20 +0100 Subject: [PATCH 23/23] Filter keys with ':' when constructing metadata from io.grpc metadata. --- .../kotlin/io/github/timortel/kmpgrpc/core/conversionutil.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/kmp-grpc-core/src/androidJvmCommon/kotlin/io/github/timortel/kmpgrpc/core/conversionutil.kt b/kmp-grpc-core/src/androidJvmCommon/kotlin/io/github/timortel/kmpgrpc/core/conversionutil.kt index 591cf6d6..dd409db5 100644 --- a/kmp-grpc-core/src/androidJvmCommon/kotlin/io/github/timortel/kmpgrpc/core/conversionutil.kt +++ b/kmp-grpc-core/src/androidJvmCommon/kotlin/io/github/timortel/kmpgrpc/core/conversionutil.kt @@ -31,6 +31,9 @@ internal val Metadata.jvmMetadata: JvmMetadata internal val JvmMetadata.kmMetadata: Metadata get() { val entries: List> = keys().mapNotNull { keyName -> + // https://github.com/grpc/grpc-java/issues/11873#issuecomment-2639132154 + if (keyName.startsWith(':')) return@mapNotNull null + if (keyName.endsWith(BINARY_KEY_SUFFIX)) { val key = io.grpc.Metadata.Key.of(keyName, JvmMetadata.BINARY_BYTE_MARSHALLER)