Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@ 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
- uses: actions/checkout@v7
- name: Setup JDK 21, Scala, SBT
uses: actions/setup-java@v4
uses: actions/setup-java@v6
with:
distribution: 'temurin'
java-version: '21'
Expand All @@ -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'

2 changes: 1 addition & 1 deletion .scalafmt.conf
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
version = 3.9.3
version = 3.11.5
align.preset = more // For pretty alignment.
importSelectors = singleLine
maxColumn = 140 // For my wide 30 display.
Expand Down
24 changes: 20 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,25 @@ A Scala 3 library that provides compile-time guarantees for complete case class
`build.sbt`:

```scala
libraryDependencies += "io.github.stivens" %% "casecomplete" % "0.3.0"
libraryDependencies += "io.github.stivens" %% "casecomplete" % "1.0.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" % "1.0.0"
```

`scala-cli`:

```scala
//> using lib "io.github.stivens::casecomplete:0.3.0"
//> using lib "io.github.stivens::casecomplete:1.0.0"
```

`scala-cli REPL`:

```bash
scala-cli repl --dep io.github.stivens::casecomplete:0.3.0
scala-cli repl --dep io.github.stivens::casecomplete:1.0.0
```

## Quick Start
Expand Down Expand Up @@ -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)`
Expand Down Expand Up @@ -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

Expand Down
106 changes: 56 additions & 50 deletions build.sbt
Original file line number Diff line number Diff line change
@@ -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(
organization := "io.github.stivens",
version := "1.0.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
)
)

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",
publishTo := localStaging.value,
scalacOptions ++= Seq(
"-Wunused:imports",
"-feature",
"-language:implicitConversions",
"-no-indent",
"-Xmax-inlines",
"128",
"-Xfatal-warnings"
),
libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.20" % 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
)
.nativeSettings(
// test-interface declares a strict scheme, but Scala Native keeps 0.5.x binary compatible;
// without this, scalatest's older 0.5.x pin is a fatal eviction under sbt 2.
libraryDependencySchemes += "org.scala-native" % s"test-interface_${ScalaNativePlatform}_${scalaBinaryVersion.value}" % VersionScheme.Always
)

// 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
)
2 changes: 1 addition & 1 deletion project/build.properties
Original file line number Diff line number Diff line change
@@ -1 +1 @@
sbt.version=1.11.3
sbt.version=2.0.9
18 changes: 11 additions & 7 deletions project/plugins.sbt
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.5")
addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.6.2")

addSbtPlugin("com.timushev.sbt" % "sbt-updates" % "0.6.4")
addSbtPlugin("com.timushev.sbt" % "sbt-updates" % "0.7.0")

addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.14.3")
addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.14.9")

addDependencyTreePlugin
addSbtPlugin("com.github.sbt" % "sbt-pgp" % "2.3.2")

addSbtPlugin("net.vonbuchholtz" % "sbt-dependency-check" % "5.1.0")
addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.22.0")

addSbtPlugin("com.github.sbt" % "sbt-pgp" % "2.3.1")
addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.5.12")

addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "3.12.2")
addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.4.0")

addSbtPlugin("org.portable-scala" % "sbt-scala-native-crossproject" % "1.4.0")

addSbtPlugin("com.typesafe" % "sbt-mima-plugin" % "1.2.1")
12 changes: 7 additions & 5 deletions src/main/scala/io/github/stivens/CaseComplete/CaseComplete.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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]
}

Expand All @@ -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))
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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]}.
Expand All @@ -232,23 +236,27 @@ 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
// walks the whole accumulated tuple, and the type comparer those patterns invoke made this ~10% of
// 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(
Expand All @@ -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)
}
}
7 changes: 7 additions & 0 deletions src/test/scala/externaluser/ExternalAccessSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
Loading
Loading