- Scala 97.7%
- Shell 1.9%
- Java 0.4%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
ProtocModule ran exactly one target, so a second generator over the same schema meant a second protoc invocation with its own include path and its own copy of the option assembly - two places to keep in step, and nothing to notice when they drift. protocExtraTargets hands the assembled options to the extra generator, which is what keeps flat_package and the file options resolving to the same type names the message generator emitted. It is a plain def, not a Task: protocbridge's Target is not serializable, and Mill's codesig analysis invalidates protocGeneratedSources across a plain def anyway. dest is passed in rather than read from Task.dest so an implementation can pick a subdirectory and keep its output from colliding with a message file of the same name. The run now goes through the four-argument ProtocBridge.execute with a Coursier-backed ClassLoader, so a SandboxedJvmGenerator supplied this way resolves instead of throwing. |
||
| bridge | ||
| codegen | ||
| e2e | ||
| plugin | ||
| runtime | ||
| .gitignore | ||
| .mill-version | ||
| .scalafmt.conf | ||
| build.mill | ||
| DESIGN.md | ||
| LICENSE | ||
| mill | ||
| NOTICE | ||
| README.md | ||
mill-protoc
Protocol Buffers for Scala 3, as a Mill plugin.
Generates Scala 3 sources from .proto files — proto2, proto3 and editions — with a
runtime that is deliberately small and an API that reads like Scala rather than like
translated Java.
Requires Scala 3.9.0+, JDK 25+ and Mill 1.1.9+. Those are floors, not
suggestions: the generated code uses Scala 3 enums and the build targets class file
version 69.
//| mvnDeps: ["nscn.polaris2::mill-protoc_mill1:0.0.1"]
package build
import mill.*, scalalib.*
import nscn.polaris2.protoc.mill.ProtocModule
object app extends ScalaModule, ProtocModule {
def scalaVersion = "3.9.0"
def jvmVersion = "25"
// .proto files live in app/protobuf
}
That is the whole setup. The generated sources join generatedSources and
mill-protoc-runtime joins mvnDeps.
What the generated code looks like
syntax = "proto3";
package example;
import "google/protobuf/timestamp.proto";
message Person {
string name = 1;
optional int32 age = 2;
repeated string tags = 3;
map<string, int32> scores = 4;
google.protobuf.Timestamp created_at = 5;
Color color = 6;
oneof contact {
string email = 7;
string phone = 8;
}
}
enum Color {
COLOR_UNKNOWN = 0;
COLOR_RED = 1;
}
becomes, in essence:
final case class Person(
name: String = "",
age: Option[Int] = None,
tags: Vector[String] = Vector.empty,
scores: Map[String, Int] = Map.empty,
createdAt: Option[java.time.Instant] = None,
color: Color = Color.COLOR_UNKNOWN,
contact: Person.Contact = Person.Contact.Empty,
unknownFields: UnknownFieldSet = UnknownFieldSet.getDefaultInstance
) extends GeneratedMessage derives CanEqual
object Person extends GeneratedMessageCompanion[Person]:
enum Contact extends GeneratedOneof derives CanEqual:
case Empty
case Email(value: String)
case Phone(value: String)
enum Color(val value: Int, val name: String, val index: Int)
extends GeneratedEnum derives CanEqual:
case COLOR_UNKNOWN extends Color(0, "COLOR_UNKNOWN", 0)
case COLOR_RED extends Color(1, "COLOR_RED", 1)
case Unrecognized(unrecognizedValue: Int) extends Color(unrecognizedValue, ..., -1)
and is used like this:
val person = Person(name = "Ada", age = Some(36), contact = Person.Contact.Email("ada@x"))
val bytes = person.toByteArray
val parsed = Person.parseFrom(bytes)
// Exhaustive, with no "which getter is valid" question.
person.contact match
case Person.Contact.Email(address) => send(address)
case Person.Contact.Phone(number) => dial(number)
case Person.Contact.Empty => ()
// Shallow update is `copy`; nested update is `copy` all the way down.
val renamed = person.copy(name = "Ada Lovelace")
Presence
The Scala type says what the schema says, in every syntax:
| schema | Scala |
|---|---|
proto3 string name = 1; |
name: String = "" |
proto3 optional int32 age = 2; |
age: Option[Int] = None |
proto2 optional string s = 1; |
s: Option[String] = None |
proto2 required int32 n = 2; |
n: Int — no default, so forgetting it is a compile error |
| editions, unannotated | Option[...] — editions inherited proto2's EXPLICIT default |
editions [features.field_presence = IMPLICIT] |
bare value |
editions [features.field_presence = LEGACY_REQUIRED] |
bare value, no default |
repeated |
Vector[...] |
map<K, V> |
Map[K, V] |
The last three rows are the point of editions support. The generator never tests the syntax; it reads the resolved feature off the descriptor, which is the same question in all three cases.
A missing required field is a parse error, mirroring protobuf-java's build() refusing
an uninitialized message.
Well-Known Types
Mapped to standard library types by default:
| proto | Scala |
|---|---|
Timestamp |
java.time.Instant |
Duration |
java.time.Duration |
StringValue, Int32Value, BoolValue, ... |
Option[String], Option[Int], Option[Boolean], ... |
Empty |
Unit |
FieldMask |
Vector[String] |
Struct |
Map[String, ProtoValue] |
Value |
ProtoValue |
ListValue |
Vector[ProtoValue] |
Any |
ProtoAny |
ProtoValue is a JSON-like ADT, because Struct and Value exist to carry dynamically
typed data and generating case classes for them produces exactly the code a caller does
not want to write:
enum ProtoValue:
case Null
case Number(value: Double)
case Str(value: String)
case Bool(value: Boolean)
case Obj(fields: Map[String, ProtoValue])
case Arr(values: Vector[ProtoValue])
ProtoAny keeps the type check where it belongs:
val packed = ProtoAny.pack(Address("1 Main St", "London"))
packed.unpackTo[Address] // Address(...)
packed.unpackOption[Person] // None
packed.unpackTo[Person] // IllegalArgumentException, naming both types
Per-field or per-file opt-out, when byte-exact round-tripping matters more than
convenience — Instant normalizes, so a Timestamp with out-of-range nanos does not
survive unchanged:
google.protobuf.Timestamp raw = 1 [(nscn.polaris2.protoc.field).wkt_mapping = RAW];
RAW gives you protobuf-java's own com.google.protobuf.Timestamp, not a hand-written
Scala mirror of it. There is no second implementation of these types to drift.
Options
Import nscn/polaris2/protoc/options.proto — the plugin puts it on protoc's include path — and set
what you need:
import "nscn/polaris2/protoc/options.proto";
option (nscn.polaris2.protoc.file).package_name = "com.example.generated";
option (nscn.polaris2.protoc.file).flat_package = true;
option (nscn.polaris2.protoc.file).enum_strip_prefix = true;
message Event {
// A user-declared Scala type, with a `given TypeMapper` in scope.
int64 at = 1 [(nscn.polaris2.protoc.field).type = "com.example.Micros"];
// A different name in Scala, when the proto name is unsuitable.
string clazz = 2 [(nscn.polaris2.protoc.field).scala_name = "className"];
// Presence without an Option.
Metadata meta = 3 [(nscn.polaris2.protoc.field).no_box = true];
}
The full set is documented in
codegen/protobuf/nscn/polaris2/protoc/options.proto,
which is also the authoritative reference.
Build-level settings live on the module:
object app extends ScalaModule, ProtocModule {
def scalaVersion = "3.9.0"
def protocFlatPackage = true // drop the per-file package segment
def protocRetainSourceCodeInfo = true // keep comments in the descriptors
def protocVersion = Task { "4.36.2" } // which protoc to resolve
def protocPath = Task { Some("/usr/bin/protoc") } // or supply one
def protocProtoSources = Task.Sources("proto") // or move the sources
def protocOptions = Task { Seq("no_default_values_in_constructor") }
def protocArgs = Task { Seq("--experimental_allow_proto3_optional") } // for protoc itself
}
A second generator can join the same protoc run — that is how
mill-http4s-grpc adds gRPC
service bindings on top of these messages:
override def protocExtraTargets(dest: os.Path, options: Seq[String]): Seq[Target] = {
val out = dest / "my-generator"
os.makeDir.all(out) // protoc will not create it
Seq(Target(JvmGenerator("my-gen", MyGenerator), out.toIO, options))
}
options is what this module assembles for its own target, handed over so the extra
generator resolves the same names: a generator that computes flat_package differently
than the message generator emits code that does not compile. A subdirectory of dest
keeps its output from colliding with a message file of the same name. A
SandboxedJvmGenerator works here too — the module supplies a Coursier-backed
ClassLoader for its artifact.
Adding your own dependencies or generated sources must go through super:
def mvnDeps = Task { super.mvnDeps() ++ Seq(mvn"org.typelevel::cats-core:2.13.0") }
A bare def mvnDeps = Seq(...) compiles cleanly — Mill's build-file compiler inserts
override for you — and silently drops the runtime, which surfaces much later as
unresolved nscn.polaris2.protoc.runtime.* symbols.
What is not here
- No gRPC. Messages and enums only; a
servicein a.protois ignored. RPC stubs come from a separate generator. - No
ScalaPBC. Mill drives protoc; there is no standalone launcher. - No groups /
features.message_encoding = DELIMITED. Rejected with a message naming the field, rather than generating code that does not compile. - No extension accessors. Custom options are read at generation time, but generating accessors for arbitrary extensions is out of scope.
- No JSON.
toProtoStringandparseFromProtoStringcover text format by delegating to protobuf-java. JSON would be worth adding the same way. - No lenses.
copy, and nestedcopy, cover the cases lenses were for. - No
field_transformations,aux_*_options,bytes_typeor(field).collection. The schema inoptions.protocarries them, because the numbering follows ScalaPB's, but the generator does not act on them.
Everything in the last three entries is rejected with a message naming the element
rather than accepted and ignored. An option that looks applied and is not is the worst of
the three outcomes, and it is a mistake this project has already made once: custom options
were silently dropped for a while because protoc's request was parsed without an
ExtensionRegistry, and only an end-to-end test noticed.
Equality
Generated messages are plain case classes, so equality is Scala's. That differs from
protobuf-java in two places:
Person(d = Double.NaN) != Person(d = Double.NaN) // NaN != NaN in Scala
Person(d = -0.0) == Person(d = 0.0) // -0.0 == 0.0 in Scala
protobuf-java compares doubleToLongBits and says the opposite in both cases. The
encoding is unaffected — both survive a round-trip byte for byte, and -0.0 is written
where 0.0 is not.
Layout
bridge/ protoc-bridge's `bridge` + `protoc-gen`, merged and repackaged
runtime/ what generated code links against: base traits, TypeMapper, WKT mapping
codegen/ the generator: the ScalaPB compiler-plugin port
plugin/ the Mill plugin
e2e/ generate, compile, and check against protobuf-java
Two Scala versions are in play and the split is not cosmetic. bridge, codegen and
plugin are on 3.8.2, because that is what Mill 1.1.9 compiles build.mill with and
therefore the newest TASTy its build classloader will read. runtime and the generated
code are on 3.9.0. The generator never links against the runtime — it emits references to
it as text — so the two are coupled by the e2e tests rather than by the classpath.
Building
./mill __.compile
./mill -k __.test
250 tests. The ones worth knowing about:
runtime/testhand-writes aPersoncovering every field shape and checks it against protobuf-java's class generated from the same.proto: same bytes out, same values in, both directions. It is the specification the generator reproduces.e2eruns the real generator through real protoc for all three syntaxes, compiles the output against the real runtime, and does the same comparison. It is also the only thing keeping the generator's Well-Known Type table in step with the runtime's.plugin/testdrives a real Mill evaluation, so protoc resolution and the include path are exercised rather than assumed.
Credits
A port, not an original design. The protoc bridging is
protoc-bridge and the code generator is
ScalaPB's compiler-plugin, both by Nadav Samet,
Apache-2.0. What is new here is editions support, the Scala 3 output shape, the
standard-library Well-Known Type mapping, and the Mill integration.
License
Apache-2.0, matching ScalaPB and protoc-bridge. This is a derivative work of both, so the
license is inherited rather than chosen; NOTICE records what came from where.