No description
  • Scala 97.7%
  • Shell 1.9%
  • Java 0.4%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Wtz_LASR fe3d26ed4f Let a consumer add generators to the protoc run
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.
2026-09-20 19:20:26 +08:00
bridge Move every package under nscn.polaris2.protoc 2026-09-20 17:45:48 +08:00
codegen Move every package under nscn.polaris2.protoc 2026-09-20 17:45:48 +08:00
e2e Move every package under nscn.polaris2.protoc 2026-09-20 17:45:48 +08:00
plugin Let a consumer add generators to the protoc run 2026-09-20 19:20:26 +08:00
runtime Move every package under nscn.polaris2.protoc 2026-09-20 17:45:48 +08:00
.gitignore Add the plugin integration test, README, NOTICE and Apache-2.0 LICENSE 2026-09-20 16:55:03 +08:00
.mill-version Port protoc-bridge into a Scala 3 bridge module with editions support 2026-09-20 15:12:20 +08:00
.scalafmt.conf Port protoc-bridge into a Scala 3 bridge module with editions support 2026-09-20 15:12:20 +08:00
build.mill Let a consumer add generators to the protoc run 2026-09-20 19:20:26 +08:00
DESIGN.md Move every package under nscn.polaris2.protoc 2026-09-20 17:45:48 +08:00
LICENSE Add the plugin integration test, README, NOTICE and Apache-2.0 LICENSE 2026-09-20 16:55:03 +08:00
mill Port protoc-bridge into a Scala 3 bridge module with editions support 2026-09-20 15:12:20 +08:00
NOTICE Move every package under nscn.polaris2.protoc 2026-09-20 17:45:48 +08:00
README.md Let a consumer add generators to the protoc run 2026-09-20 19:20:26 +08:00

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 service in a .proto is 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. toProtoString and parseFromProtoString cover text format by delegating to protobuf-java. JSON would be worth adding the same way.
  • No lenses. copy, and nested copy, cover the cases lenses were for.
  • No field_transformations, aux_*_options, bytes_type or (field).collection. The schema in options.proto carries 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/test hand-writes a Person covering 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.
  • e2e runs 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/test drives 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.