diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7422b5a..73a28d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,8 +12,8 @@ jobs: name: Compile and test CaseComplete runs-on: ubuntu-latest # A LongChainSpec regression fails by hanging, so cap the job instead of burning - # GitHub's 360-minute default. - timeout-minutes: 15 + # GitHub's 360-minute default. Sized for JVM + JS + Native (linking dominates). + timeout-minutes: 30 steps: - uses: actions/checkout@v4 - name: Setup JDK 21, Scala, SBT @@ -27,7 +27,7 @@ jobs: - name: Compile (main & tests) shell: bash run: sbt 'compile; Test / compile' - - name: Unit tests + - name: Unit tests and binary compatibility (MiMa) shell: bash - run: sbt test + run: sbt 'test; mimaReportBinaryIssues' diff --git a/README.md b/README.md index 6bfc8ab..44bff9f 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,12 @@ A Scala 3 library that provides compile-time guarantees for complete case class libraryDependencies += "io.github.stivens" %% "casecomplete" % "0.3.0" ``` +CaseComplete is also published for Scala.js and Scala Native (see [Requirements](#requirements)); in a cross-built project use: + +```scala +libraryDependencies += "io.github.stivens" %%% "casecomplete" % "0.3.0" +``` + `scala-cli`: ```scala @@ -218,7 +224,7 @@ Registers a handler for optional fields, automatically handling `None`: ```scala builder.usingNonEmpty(_.optionalField)(value => transformedValue) -// equivalant to builder.using(_.optionalField)((_: Option[F]).map((value: F) => transformedValue)) +// equivalent to builder.using(_.optionalField)((_: Option[F]).map((value: F) => transformedValue)) ``` #### `ignoring(_.field)` @@ -247,9 +253,19 @@ val result = handler.eval(sourceInstance) // Returns: List[TargetType] ``` +Handlers are evaluated in the declaration order of the source type's fields. Handled fields +that are not primary-constructor fields come last, in the order they were registered. + ## Requirements - Scala >= 3.3 +- Platforms: JVM, Scala.js 1.x, Scala Native 0.5 + +## Compatibility policy + +CaseComplete follows [early semantic versioning](https://www.scala-lang.org/blog/2021/02/16/preventing-version-conflicts-with-versionscheme.html): +binary compatibility is preserved within a major version (post-1.0.0), and every release is +checked against the previous one with [MiMa](https://github.com/lightbend-labs/mima) in CI. ## Contributing diff --git a/build.sbt b/build.sbt index ec66198..94b8c5b 100644 --- a/build.sbt +++ b/build.sbt @@ -1,59 +1,65 @@ import xerial.sbt.Sonatype.sonatypeCentralHost -sonatypeCredentialHost := sonatypeCentralHost - -publishTo := sonatypePublishToBundle.value - -organization := "io.github.stivens" -name := "CaseComplete" -homepage := Some(url("https://github.com/stivens/CaseComplete")) -scmInfo := Some( - ScmInfo( - url("https://github.com/stivens/CaseComplete"), - "scm:git@github.com:stivens/CaseComplete.git" - ) -) -licenses := Seq("MIT" -> url("https://github.com/stivens/CaseComplete/blob/main/LICENSE")) -developers := List( - Developer( - id = "stivens", - name = "Jacek Bizub", - email = "jacekbizub@gmail.com", - url = url("https://github.com/stivens") - ) -) - -version := "0.3.0" - -scalaVersion := "3.3.8" - -resolvers += "shibboleth-releases" at "https://build.shibboleth.net/maven/releases" -resolvers += Resolver.sonatypeCentralSnapshots - -enablePlugins(ScalafixPlugin, SemanticdbPlugin) - inThisBuild( List( - semanticdbEnabled := true + organization := "io.github.stivens", + version := "0.3.0", + scalaVersion := "3.3.8", + homepage := Some(url("https://github.com/stivens/CaseComplete")), + scmInfo := Some( + ScmInfo( + url("https://github.com/stivens/CaseComplete"), + "scm:git@github.com:stivens/CaseComplete.git" + ) + ), + licenses := Seq("MIT" -> url("https://github.com/stivens/CaseComplete/blob/main/LICENSE")), + developers := List( + Developer( + id = "stivens", + name = "Jacek Bizub", + email = "jacekbizub@gmail.com", + url = url("https://github.com/stivens") + ) + ), + versionScheme := Some("early-semver"), + semanticdbEnabled := true, + sonatypeCredentialHost := sonatypeCentralHost ) ) -scalacOptions ++= Seq( - "-Wunused:imports", - "-feature", - "-language:implicitConversions", - "-no-indent", - "-Xmax-inlines", - "128", - "-Xfatal-warnings" -) - -libraryDependencies ++= Seq( - // scalatest - "org.scalactic" %% "scalactic" % "3.2.19", - "org.scalatest" %% "scalatest" % "3.2.19" % "test" -) +lazy val casecomplete = crossProject(JVMPlatform, JSPlatform, NativePlatform) + .crossType(CrossType.Pure) + .in(file(".")) + .settings( + name := "CaseComplete", + // sonatypePublishToBundle is defined per project by sbt-sonatype, so this cannot join + // sonatypeCredentialHost in inThisBuild. + publishTo := sonatypePublishToBundle.value, + scalacOptions ++= Seq( + "-Wunused:imports", + "-feature", + "-language:implicitConversions", + "-no-indent", + "-Xmax-inlines", + "128", + "-Xfatal-warnings" + ), + libraryDependencies += "org.scalatest" %%% "scalatest" % "3.2.19" % Test, + // Empty only while the next release is an intentional new binary-compatibility baseline. + // Once 1.0.0 ships, set: Set(organization.value %%% moduleName.value % "1.0.0") + mimaPreviousArtifacts := Set.empty + ) +// The CrossType.Pure platform projects live in ./.jvm, ./.js and ./.native and share ./src; +// this root exists only to aggregate them, so it must not compile ./src itself. lazy val root = project .in(file(".")) - .settings() + .aggregate(casecomplete.jvm, casecomplete.js, casecomplete.native) + .settings( + publish / skip := true, + // Not redundant: MiMa treats an unset mimaPreviousArtifacts as an error (mimaFailOnNoPrevious), + // but an explicitly empty one as "nothing to check". + mimaPreviousArtifacts := Set.empty, + Compile / unmanagedSourceDirectories := Nil, + Test / unmanagedSourceDirectories := Nil + ) diff --git a/project/plugins.sbt b/project/plugins.sbt index 944a2e2..a7a7b36 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -11,3 +11,13 @@ addSbtPlugin("net.vonbuchholtz" % "sbt-dependency-check" % "5.1.0") addSbtPlugin("com.github.sbt" % "sbt-pgp" % "2.3.1") addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "3.12.2") + +addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.19.0") + +addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.5.12") + +addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.3.2") + +addSbtPlugin("org.portable-scala" % "sbt-scala-native-crossproject" % "1.3.2") + +addSbtPlugin("com.typesafe" % "sbt-mima-plugin" % "1.1.4") diff --git a/src/main/scala/io/github/stivens/CaseComplete/CaseComplete.scala b/src/main/scala/io/github/stivens/CaseComplete/CaseComplete.scala index 17b361c..cae7a0a 100644 --- a/src/main/scala/io/github/stivens/CaseComplete/CaseComplete.scala +++ b/src/main/scala/io/github/stivens/CaseComplete/CaseComplete.scala @@ -3,6 +3,11 @@ package io.github.stivens.casecomplete import io.github.stivens.casecomplete.macros.CaseCompleteBuilder sealed abstract class CaseComplete[SOURCE_TYPE <: Product, TARGET_TYPE] { + + /** + * Applies every registered handler, ordered by the source type's field declaration order. + * Handled fields that are not primary-constructor fields come last, in registration order. + */ def eval(source: SOURCE_TYPE): List[TARGET_TYPE] } @@ -12,11 +17,8 @@ object CaseComplete { } private[casecomplete] class CaseCompleteImpl[SOURCE_TYPE <: Product, TARGET_TYPE]( - handlers: Map[String, SOURCE_TYPE => TARGET_TYPE] + orderedHandlers: List[SOURCE_TYPE => TARGET_TYPE] ) extends CaseComplete[SOURCE_TYPE, TARGET_TYPE] { - private val sortedHandlers: List[SOURCE_TYPE => TARGET_TYPE] = - handlers.toList.sortBy(_._1).map(_._2) - def eval(source: SOURCE_TYPE): List[TARGET_TYPE] = - sortedHandlers.map(_(source)) + orderedHandlers.map(_(source)) } diff --git a/src/main/scala/io/github/stivens/CaseComplete/macros/CaseCompleteBuilder.scala b/src/main/scala/io/github/stivens/CaseComplete/macros/CaseCompleteBuilder.scala index 7c1cd1a..a371cad 100644 --- a/src/main/scala/io/github/stivens/CaseComplete/macros/CaseCompleteBuilder.scala +++ b/src/main/scala/io/github/stivens/CaseComplete/macros/CaseCompleteBuilder.scala @@ -32,6 +32,10 @@ class CaseCompleteBuilder[SOURCE_TYPE <: Product, TARGET_TYPE, Handled <: Tuple] private[casecomplete] def markHandled[NewHandled <: Tuple]: CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, NewHandled] = new CaseCompleteBuilder(handlers) + // Fields ignored via `ignoring` appear in fieldOrder but have no handler, hence the flatMap. + private[casecomplete] def orderedHandlers(fieldOrder: List[String]): List[SOURCE_TYPE => TARGET_TYPE] = + fieldOrder.flatMap(handlers.get) + // `using`, `usingNonEmpty` and `ignoring` must stay methods on the class. A `transparent inline` // extension method binds its receiver to a parameter proxy carrying the refined type of the whole // preceding chain, which makes compiling a chain exponential in its length -- see LongChainSpec. @@ -216,9 +220,9 @@ object CaseCompleteBuilder { import q.reflect.* val handledFields = getHandledFields[Handled] - val caseClassFields = TypeRepr.of[SOURCE_TYPE].typeSymbol.caseFields.map(_.name).toSet + val caseClassFields = TypeRepr.of[SOURCE_TYPE].typeSymbol.caseFields.map(_.name) - val missingFields = caseClassFields -- handledFields + val missingFields = caseClassFields.diff(handledFields) if missingFields.nonEmpty then report.errorAndAbort(s""" |CaseComplete compilation failed: Missing handlers for ${missingFields.size} field(s) in class ${Type.show[SOURCE_TYPE]}. @@ -232,7 +236,9 @@ object CaseCompleteBuilder { | // ... other handlers | .compile""") - '{ new CaseCompleteImpl($builder.handlers) } + val fieldOrder = caseClassFields ++ handledFields.diff(caseClassFields) + + '{ new CaseCompleteImpl($builder.orderedHandlers(${ Expr(fieldOrder) })) } } // Decoded structurally rather than with quoted type patterns ('[head *: tail]): every chain step @@ -240,15 +246,17 @@ object CaseCompleteBuilder { // typer time at 96 fields. Unlike those patterns this decodes only the literal `*:` spine of // ConstantTypes that registerField emits, not Tuple2-sugar shapes -- safe because the constructor // and markHandled are package-private, so nothing else produces a Handled. - private def getHandledFields[Handled <: Tuple: Type](using q: Quotes): Set[String] = { + // Returns the fields in registration order (earliest first): the tuple is built by prepending, + // so prepending again while walking head-to-tail restores the original order. + private def getHandledFields[Handled <: Tuple: Type](using q: Quotes): List[String] = { import q.reflect.* val consSymbol = TypeRepr.of[Any *: Tuple].typeSymbol val emptyTupleSymbol = TypeRepr.of[EmptyTuple].dealias.typeSymbol - def loop(repr: TypeRepr, acc: Set[String]): Set[String] = repr.dealias match { + def loop(repr: TypeRepr, acc: List[String]): List[String] = repr.dealias match { case AppliedType(tycon, List(ConstantType(StringConstant(name)), tail)) if tycon.typeSymbol == consSymbol => - loop(tail, acc + name) + loop(tail, name :: acc) case empty if empty.typeSymbol == emptyTupleSymbol => acc case other if other.typeSymbol.isAbstractType => report.errorAndAbort( @@ -257,6 +265,6 @@ object CaseCompleteBuilder { case other => report.errorAndAbort(s"Internal error: unexpected Handled type: ${other.show}") } - loop(TypeRepr.of[Handled], Set.empty) + loop(TypeRepr.of[Handled], Nil) } } diff --git a/src/test/scala/externaluser/ExternalAccessSpec.scala b/src/test/scala/externaluser/ExternalAccessSpec.scala index 652c203..ae033a2 100644 --- a/src/test/scala/externaluser/ExternalAccessSpec.scala +++ b/src/test/scala/externaluser/ExternalAccessSpec.scala @@ -64,6 +64,13 @@ class ExternalAccessSpec extends AnyFunSpec with CompileErrorAssertions { "handlers" ) } + + it("should not expose the ordered handlers") { + assertInaccessible( + """CaseComplete.build[Filter, Option[String]].orderedHandlers(List("a", "b"))""", + "orderedHandlers" + ) + } } private inline def assertInaccessible(inline code: String, member: String): Unit = diff --git a/src/test/scala/io/github/stivens/CaseComplete/CaseCompleteSpec.scala b/src/test/scala/io/github/stivens/CaseComplete/CaseCompleteSpec.scala index 5ff3e1a..364443a 100644 --- a/src/test/scala/io/github/stivens/CaseComplete/CaseCompleteSpec.scala +++ b/src/test/scala/io/github/stivens/CaseComplete/CaseCompleteSpec.scala @@ -3,24 +3,26 @@ package io.github.stivens.casecomplete import org.scalatest.funspec.AnyFunSpec import testsupport.CompileErrorAssertions -import java.time.Year - class CaseCompleteSpec extends AnyFunSpec with CompileErrorAssertions { describe("CaseCompleteBuilder") { describe("when given a source type and a target type") { + // Ints rather than java.time.Year and Double: the JS and Native javalibs have no java.time, + // and Scala.js renders the Double 7.0 as "7", so either would leak the platform into the + // expected strings. case class MovieFilter( title_like: Option[String] = None, director_eq: Option[String] = None, - releaseYear_eq: Option[Year] = None, - rating_gte: Option[Double] = None + releaseYear_eq: Option[Int] = None, + rating_gte: Option[Int] = None ) val filter = MovieFilter( - releaseYear_eq = Some(Year.of(1999)), - rating_gte = Some(7.0) + releaseYear_eq = Some(1999), + rating_gte = Some(7) ) - val expectedOrder = List("rating >= 7.0", "releaseYear = 1999") + // Declaration order (releaseYear_eq before rating_gte); alphabetical order would swap them. + val expectedOrder = List("releaseYear = 1999", "rating >= 7") val expectedResult = expectedOrder.toSet val buildMovieFilterHandler = CaseComplete.build[MovieFilter, Option[String]] @@ -51,7 +53,7 @@ class CaseCompleteSpec extends AnyFunSpec with CompileErrorAssertions { assert(evaulated == expectedResult) } - it("should evaluate handlers in alphabetical order of field name, on every call") { + it("should evaluate handlers in field declaration order, on every call") { // Repeated so that caching the ordering as something single-use (a view, an iterator) fails here. assert(movieFilterHandler.eval(filter).flatten == expectedOrder) assert(movieFilterHandler.eval(filter).flatten == expectedOrder) @@ -76,10 +78,11 @@ class CaseCompleteSpec extends AnyFunSpec with CompileErrorAssertions { case class MovieFilter( title_like: Option[String] = None, director_eq: Option[String] = None, - releaseYear_eq: Option[Year] = None, - rating_gte: Option[Double] = None + releaseYear_eq: Option[Int] = None, + rating_gte: Option[Int] = None ) { lazy val isEmpty: Boolean = this == MovieFilter.empty + val extra: String = "extra" val foo: String = "bar" } @@ -101,12 +104,21 @@ class CaseCompleteSpec extends AnyFunSpec with CompileErrorAssertions { assert(true) // code compiles } - it("should allow the extra fields to be handled") { - val movieFilterHandler = allConstructorFieldsHandled + it("should evaluate extra-field handlers after the constructor fields, in registration order") { + // `foo` is registered first though `extra` precedes it both alphabetically and in + // declaration order, and both are registered before any constructor field: only + // "constructor fields first, then extras in registration order" yields this output. + val movieFilterHandler = CaseComplete + .build[MovieFilter, Option[String]] .using(_.foo)(Some(_)) + .using(_.extra)(Some(_)) + .using(_.title_like)(_ => Some("title")) + .using(_.director_eq)(_ => None) + .using(_.releaseYear_eq)(_ => None) + .using(_.rating_gte)(_ => None) .compile - assert(movieFilterHandler.eval(MovieFilter()).flatten == List("bar")) + assert(movieFilterHandler.eval(MovieFilter()).flatten == List("title", "bar", "extra")) } }