diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
new file mode 100644
index 0000000..0f673fe
--- /dev/null
+++ b/.github/workflows/docs.yml
@@ -0,0 +1,71 @@
+name: Docs
+
+on:
+ push:
+ branches:
+ - main
+ paths: &docs-paths
+ - 'website/**'
+ - 'src/main/**'
+ - 'build.sbt'
+ - 'project/**'
+ - '.github/workflows/docs.yml'
+ pull_request:
+ paths: *docs-paths
+
+concurrency:
+ group: docs-${{ github.ref }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+jobs:
+ build:
+ name: Build the documentation site
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - uses: actions/checkout@v7
+ - name: Setup JDK 21, Scala, SBT
+ uses: actions/setup-java@v6
+ with:
+ distribution: 'temurin'
+ java-version: '21'
+ cache: 'sbt'
+ # Differs from ci.yml's key on purpose: a shared key is saved once, by whichever workflow finishes first.
+ cache-dependency-path: |
+ **/*.sbt
+ project/**
+ .github/workflows/docs.yml
+ - name: Setup SBT
+ uses: sbt/setup-sbt@v1
+ - name: Type-check snippets (mdoc)
+ shell: bash
+ run: sbt docs/mdoc
+ - uses: actions/setup-python@v7
+ with:
+ python-version: '3.13'
+ cache: 'pip'
+ cache-dependency-path: website/requirements.txt
+ - name: Build site (MkDocs)
+ shell: bash
+ run: |
+ pip install -r website/requirements.txt
+ mkdocs build --strict -f website/mkdocs.yml
+ - if: github.event_name == 'push'
+ uses: actions/upload-pages-artifact@v5
+ with:
+ path: website/target/site
+
+ deploy:
+ name: Deploy to GitHub Pages
+ if: github.event_name == 'push'
+ needs: build
+ runs-on: ubuntu-latest
+ permissions:
+ pages: write
+ id-token: write
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - id: deployment
+ uses: actions/deploy-pages@v5
diff --git a/README.md b/README.md
index 037891c..ee0d31c 100644
--- a/README.md
+++ b/README.md
@@ -1,62 +1,39 @@
# CaseComplete
-A Scala 3 library that provides compile-time guarantees for complete case class field handling. CaseComplete ensures that all fields of a case class are processed by your transformation logic, preventing runtime errors from forgotten fields.
+A Scala 3 library that fails compilation when a case class field has no handler.
+You register one handler per field, and `.compile` rejects the code if any field is missing, so adding a field to a case class points you at every place that needs updating.
-
+**Documentation: **
-
-## Features
-
-- **Compile-time Safety**: Ensures all case class fields have corresponding handlers
-- **Optional Field Support**: Built-in support for `Option` fields with `usingNonEmpty`
-- **Builder Pattern**: Fluent API for building handlers
-- **Macro-based**: Leverages Scala 3 macros for compile-time validation
+
## Installation
[](https://maven-badges.sml.io/sonatype-central/io.github.stivens/casecomplete_3)
-`build.sbt`:
-
```scala
libraryDependencies += "io.github.stivens" %% "casecomplete" % "1.0.1"
```
-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.1"
-```
-
-`scala-cli`:
-
-```scala
-//> using lib "io.github.stivens::casecomplete:1.0.1"
-```
-
-`scala-cli REPL`:
+Use `%%%` for Scala.js and Scala Native. For scala-cli: `//> using dep io.github.stivens::casecomplete:1.0.1`.
-```bash
-scala-cli repl --dep io.github.stivens::casecomplete:1.0.1
-```
+Requires Scala 3.3 or newer.
-## Quick Start
+## Quick start
```scala
import io.github.stivens.casecomplete.CaseComplete
import doobie.*
-
-import java.time.Year
+import doobie.implicits.*
case class MovieFilter(
title_like: Option[String] = None,
director_eq: Option[String] = None,
- releaseYear_eq: Option[Year] = None,
+ releaseYear_eq: Option[Int] = None,
rating_gte: Option[Double] = None
)
-// Create a handler that transforms MovieFilter to SQL conditions
val movieFilterHandler = CaseComplete.build[MovieFilter, Option[Fragment]]
.usingNonEmpty(_.title_like)(title => fr"title ILIKE $title")
.usingNonEmpty(_.director_eq)(director => fr"director = $director")
@@ -64,213 +41,32 @@ val movieFilterHandler = CaseComplete.build[MovieFilter, Option[Fragment]]
.usingNonEmpty(_.rating_gte)(rating => fr"rating >= $rating")
.compile
-// Use the handler
-val filter = MovieFilter(
- releaseYear_eq = Some(Year.of(1999)),
- rating_gte = Some(7.0)
-)
-
-val conditions = movieFilterHandler.eval(filter).toSet.flatten
-// Returns: Set(fr"release_year = ${1999}", fr"rating >= ${7.0}")
-```
-
-## How It Works
-
-CaseComplete uses Scala 3's macro system to:
-
-1. **Track Handled Fields**: The builder tracks which fields have been handled through type parameters
-2. **Compile-time Validation**: When you call `.compile()`, it verifies all case class fields have handlers
-3. **Field Name Extraction**: Extracts field names from selectors like `_.fieldName` at compile time
-
-## CaseComplete vs Pattern Matching
-
-While pattern matching on case classes is a powerful Scala feature, it has limitations when it comes to ensuring complete field handling. CaseComplete provides **dual-purpose functionality**: it not only allows you to implement transformations that are validated at compile-time, but also provides an interface that guarantees every implementation will have these properties.
-
-### The Problem with Pattern Matching
-
-Pattern matching on case classes is just a specific implementation of `A => B` functions. **You cannot enforce the use of pattern matching at the interface level** - the interface only specifies the function signature, not how it should be implemented. This means there's no compile-time guarantee that all fields will be handled.
-
-```scala
-abstract class AbstractRepository[ENTITY_TYPE, FILTER_TYPE, UPDATE_TYPE](
- tableName: String,
- evalFilter: FILTER_TYPE => Set[Fragment],
- evalUpdate: UPDATE_TYPE => Set[Fragment]
-) {
- // some methods etc
-}
-
-case class MovieFilter(
- title_like: Option[String] = None,
- director_eq: Option[String] = None,
- releaseYear_eq: Option[Year] = None,
- rating_gte: Option[Double] = None
-)
-
-case class MovieUpdate(
- title: Option[String],
- rating: Option[Double],
- cast: Option[List[Person]]
-)
-
-object MovieRepository extends AbstractRepository[Movie, MovieFilter, MovieUpdate] (
- tableName = "movies",
- evalFilter = {
- case MovieFilter(director_eq, title_like, releaseYear_eq, rating_gte) => List(
- title_like.map(title => fr"title ILIKE $title"),
- director_eq.map(director => fr"director = $director"),
- releaseYear_eq.map(year => fr"release_year = $year"),
- rating_gte.map(rating => fr"rating >= $rating")
- ).flatten.toSet
- }, // This looks good at first glance, but notice the order mismatch:
- // - Pattern has: director_eq, title_like, releaseYear_eq, rating_gte
- // - Usage has: title_like, director_eq, releaseYear_eq, rating_gte
- // This will cause runtime bugs: fr"title ILIKE 'some director name'" and fr"director = 'some movie title'"
- evalUpdate = update => {
- List(
- update.title.map(title => fr"title = $title"),
- update.cast.map(cast => fr"cast = $cast")
- ).flatten.toSet
- } // Whoops - the `rating` field is not handled, but it still compiles!
-)
+movieFilterHandler.eval(MovieFilter(releaseYear_eq = Some(1999), rating_gte = Some(7.0))).flatten
+// List(Fragment("release_year = ? "), Fragment("rating >= ? "))
```
-### The CaseComplete Solution
+Leave out any of the four handlers and this fails to compile, with an error naming the missing field.
-With CaseComplete, you can define the `AbstractRepository` to enforce complete field handling:
+To find out more, see the documentation site:
-```scala
-abstract class AbstractRepository[ENTITY_TYPE, FILTER_TYPE, UPDATE_TYPE](
- tableName: String,
- evalFilter: CaseComplete[FILTER_TYPE, Option[Fragment]],
- evalUpdate: CaseComplete[UPDATE_TYPE, Option[Fragment]]
-) {
- // some methods etc
-}
-```
+- [Why not pattern matching?](https://stivens.github.io/CaseComplete/why/)
+- [API reference](https://stivens.github.io/CaseComplete/api/)
+- [Compatibility policy](https://stivens.github.io/CaseComplete/compatibility/)
-Now, providing an implementation that isn't validated at compile-time is **impossible**. All classes that inherit from `AbstractRepository` must provide implementations that handle every field:
+## Working on the docs
-```scala
-object MovieRepository extends AbstractRepository[Movie, MovieFilter, MovieUpdate] (
- tableName = "movies",
- evalFilter = CaseComplete.build[MovieFilter, Option[Fragment]]
- .usingNonEmpty(_.title_like)(title => fr"title ILIKE $title")
- .usingNonEmpty(_.director_eq)(director => fr"director = $director")
- .usingNonEmpty(_.releaseYear_eq)(year => fr"release_year = $year")
- .usingNonEmpty(_.rating_gte)(rating => fr"rating >= $rating")
- .compile,
- evalUpdate = CaseComplete.build[MovieUpdate, Option[Fragment]]
- .usingNonEmpty(_.title)(title => fr"title = $title")
- .usingNonEmpty(_.rating)(rating => fr"rating = $rating")
- .usingNonEmpty(_.cast)(cast => fr"cast = $cast")
- .compile
-)
-```
-
-## Pro tip: Make interfaces more expressive with type aliases
-
-```scala
-type AsFragments[A <: Product] = CaseComplete[A, Option[Fragment]]
-def toFragments[A <: Product]: CaseCompleteBuilder[A, Option[Fragment], EmptyTuple] = CaseComplete.build[A, Option[Fragment]]
+The site's sources are in [`website/docs`](website/docs). mdoc compiles every snippet against the library, and MkDocs Material renders the site.
-
-abstract class AbstractRepository[ENTITY_TYPE, FILTER_TYPE, UPDATE_TYPE](
- tableName: String,
- evalFilter: AsFragments[FILTER_TYPE],
- evalUpdate: AsFragments[UPDATE_TYPE]
-)
-
-object MovieRepository extends AbstractRepository[Movie, MovieFilter, MovieUpdate] (
- tableName = "movies",
- evalFilter = toFragments[MovieFilter]
- .usingNonEmpty(_.title_like)(title => fr"title ILIKE $title")
- .usingNonEmpty(_.director_eq)(director => fr"director = $director")
- .usingNonEmpty(_.releaseYear_eq)(year => fr"release_year = $year")
- .usingNonEmpty(_.rating_gte)(rating => fr"rating >= $rating")
- .compile,
- evalUpdate = toFragments[MovieUpdate]
- .usingNonEmpty(_.title)(title => fr"title = $title")
- .usingNonEmpty(_.rating)(rating => fr"rating = $rating")
- .usingNonEmpty(_.cast)(cast => fr"cast = $cast")
- .compile
-)
-```
-
-
-## API Reference
-
-### CaseComplete.build
-
-Creates a new builder instance:
-
-```scala
-object CaseComplete {
- def build[SOURCE_TYPE <: Product, TARGET_TYPE]: CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, EmptyTuple]
-```
-
-### Builder Methods
-
-#### `using(_.field)(handler)`
-
-Registers a handler for a specific field:
-
-```scala
-builder.using(_.fieldName)(value => transformedValue)
-```
-
-#### `usingNonEmpty(_.field)(handler)` (for Option fields)
-
-Registers a handler for optional fields, automatically handling `None`:
-
-```scala
-builder.usingNonEmpty(_.optionalField)(value => transformedValue)
-// equivalent to builder.using(_.optionalField)((_: Option[F]).map((value: F) => transformedValue))
-```
-
-#### `ignoring(_.field)`
-
-Explicitly marks a field as ignored during processing. This is useful when you want to intentionally skip a field (e.g., deprecated fields) while ensuring compile-time validation that you didn't forget to handle it:
-
-```scala
-builder.ignoring(_.deprecatedField)
-```
-
-**Why use `ignoring`?** When you have fields that you intentionally don't want to process (like deprecated fields, internal fields, or fields that don't apply to your use case), `ignoring` provides a clear, explicit way to indicate this intention. It guarantees that you made a conscious decision to ignore the field rather than accidentally forgetting to handle it.
-
-#### `compile`
-
-Compiles the handler and validates all fields are handled:
-
-```scala
-val builder: CaseCompleteBuilder[A, B, _] = ???
-val handler: CaseComplete[A, B] = builder.compile
-```
-
-### Handler Usage
-
-```scala
-val result = handler.eval(sourceInstance)
-// Returns: List[TargetType]
+```bash
+sbt docs/mdoc # or "docs/mdoc --watch"
+pip install -r website/requirements.txt
+mkdocs serve -f website/mkdocs.yml
```
-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
Contributions are welcome! Please feel free to submit a Pull Request.
## License
-This project is licensed under the MIT License - see the LICENSE file for details.
+MIT. See [LICENSE](LICENSE).
diff --git a/build.sbt b/build.sbt
index a6a3944..1184935 100644
--- a/build.sbt
+++ b/build.sbt
@@ -1,3 +1,5 @@
+val latestRelease = "1.0.1"
+
inThisBuild(
List(
organization := "io.github.stivens",
@@ -40,7 +42,7 @@ lazy val casecomplete = crossProject(JVMPlatform, JSPlatform, NativePlatform)
"-Xfatal-warnings"
),
libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.20" % Test,
- mimaPreviousArtifacts := Set((organization.value % moduleName.value % "1.0.0").cross(crossVersion.value))
+ mimaPreviousArtifacts := Set((organization.value % moduleName.value % latestRelease).cross(crossVersion.value))
)
.nativeSettings(
// test-interface declares a strict scheme, but Scala Native keeps 0.5.x binary compatible;
@@ -61,3 +63,16 @@ lazy val root = project
Compile / unmanagedSourceDirectories := Nil,
Test / unmanagedSourceDirectories := Nil
)
+
+// Not aggregated by root: building the site is the docs workflow's job, not `sbt test`'s.
+lazy val docs = project
+ .in(file("website"))
+ .enablePlugins(MdocPlugin)
+ .dependsOn(casecomplete.jvm)
+ .settings(
+ // mkdocs.yml's docs_dir points here, so it can't follow sbt 2's target/out/... layout.
+ mdocOut := baseDirectory.value / "target" / "mdoc",
+ mdocIn := baseDirectory.value / "docs",
+ mdocVariables := Map("VERSION" -> latestRelease),
+ libraryDependencies += "org.tpolecat" %% "doobie-core" % "1.0.0-RC12"
+ )
diff --git a/project/plugins.sbt b/project/plugins.sbt
index daa4611..e4d9415 100644
--- a/project/plugins.sbt
+++ b/project/plugins.sbt
@@ -15,3 +15,5 @@ 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")
+
+addSbtPlugin("org.scalameta" % "sbt-mdoc" % "2.9.2")
diff --git a/website/docs/api.md b/website/docs/api.md
new file mode 100644
index 0000000..9d8cac2
--- /dev/null
+++ b/website/docs/api.md
@@ -0,0 +1,114 @@
+# API reference
+
+The whole public API is one entry point, three ways to register a field, `compile`, and `eval`.
+The examples on this page use this class:
+
+```scala mdoc:silent
+import io.github.stivens.casecomplete.CaseComplete
+
+case class User(
+ name: String,
+ email: String,
+ nickname: Option[String],
+ legacyId: Long
+)
+```
+
+## `CaseComplete.build`
+
+```scala
+def build[SOURCE_TYPE <: Product, TARGET_TYPE]: CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, EmptyTuple]
+```
+
+Starts an empty builder. `SOURCE_TYPE` is the case class whose fields must all be handled, and `TARGET_TYPE` is what every handler returns.
+
+## `using`
+
+```scala
+builder.using(_.field)(value => result)
+```
+
+Registers a handler for one field. The handler gets the field's value and returns a `TARGET_TYPE`.
+
+## `usingNonEmpty`
+
+```scala
+builder.usingNonEmpty(_.optionalField)(value => result)
+// same as: builder.using(_.optionalField)(_.map(value => result))
+```
+
+For `Option` fields when `TARGET_TYPE` is itself an `Option`. The handler only runs for `Some`; `None` maps to `None`.
+
+## `ignoring`
+
+```scala
+builder.ignoring(_.field)
+```
+
+Marks a field as handled without registering a handler for it, so it contributes nothing to `eval`.
+Use it for deprecated or internal fields. It records that leaving the field out was a decision and not an oversight.
+
+## `compile`
+
+Checks at compile time that every field of `SOURCE_TYPE` has been handled or ignored, then produces the `CaseComplete[SOURCE_TYPE, TARGET_TYPE]`.
+
+## `eval`
+
+```scala
+def eval(source: SOURCE_TYPE): List[TARGET_TYPE]
+```
+
+Runs every registered handler. Results come back in the order the fields are declared in the case class, whatever order the handlers were registered in.
+
+```scala mdoc:silent
+val describe = CaseComplete.build[User, Option[String]]
+ .usingNonEmpty(_.nickname)(nick => s"aka $nick")
+ .using(_.email)(email => Some(s"<$email>"))
+ .using(_.name)(Some(_))
+ .ignoring(_.legacyId)
+ .compile
+```
+
+```scala mdoc
+describe.eval(User("Ada", "ada@example.com", Some("countess"), 42L))
+```
+
+A selector may also name a member that isn't a constructor field, such as a `val` in the class body.
+Those handlers run after all constructor fields, in registration order.
+
+## Compile errors
+
+Each of these mistakes is caught by the compiler. The errors below are real compiler output.
+
+### A field has no handler
+
+```scala mdoc:fail
+CaseComplete.build[User, Option[String]]
+ .using(_.name)(Some(_))
+ .using(_.email)(Some(_))
+ .compile
+```
+
+### A field is handled twice
+
+```scala mdoc:fail
+CaseComplete.build[User, String]
+ .using(_.name)(identity)
+ .using(_.name)(_.toUpperCase)
+```
+
+### The selector isn't a plain field access
+
+A nested selector like `_.name.length` would otherwise register `length` as if it were a field of `User`, so it is rejected:
+
+```scala mdoc:fail
+CaseComplete.build[User, Int]
+ .using(_.name.length)(identity)
+```
+
+### `usingNonEmpty` with a non-`Option` target
+
+```scala mdoc:fail
+CaseComplete.build[User, String]
+ .usingNonEmpty(_.nickname)(identity)
+```
diff --git a/screenshots/casecomplete.gif b/website/docs/assets/casecomplete.gif
similarity index 100%
rename from screenshots/casecomplete.gif
rename to website/docs/assets/casecomplete.gif
diff --git a/website/docs/assets/extra.css b/website/docs/assets/extra.css
new file mode 100644
index 0000000..e89dc1d
--- /dev/null
+++ b/website/docs/assets/extra.css
@@ -0,0 +1,154 @@
+:root {
+ --cc-proof: #0f8b8d;
+ --cc-error: #c8102e;
+ --cc-muted: #5b6178;
+ --cc-rule: #dfe2ee;
+}
+
+[data-md-color-scheme="default"] {
+ --md-primary-fg-color: #1b1e3f;
+ --md-primary-fg-color--dark: #10122a;
+ --md-accent-fg-color: var(--cc-proof);
+ --md-default-bg-color: #f7f8fc;
+ --md-default-fg-color--light: var(--cc-muted);
+ --md-typeset-a-color: #0b7476;
+ --md-code-bg-color: #eef0f7;
+}
+
+[data-md-color-scheme="slate"] {
+ --cc-proof: #3cc6c1;
+ --cc-error: #ff6b7d;
+ --cc-muted: #a3a8c3;
+ --cc-rule: #2b2f55;
+ --md-primary-fg-color: #171a36;
+ --md-primary-fg-color--dark: #0e1024;
+ --md-primary-bg-color: #e6e8f5;
+ --md-accent-fg-color: var(--cc-proof);
+ --md-default-bg-color: #12142b;
+ --md-default-fg-color: #e6e8f5;
+ --md-typeset-a-color: #5fd4cf;
+ --md-code-bg-color: #1b1e3f;
+}
+
+[data-md-color-scheme="slate"] .md-typeset .md-button {
+ color: var(--cc-proof);
+}
+
+[data-md-color-scheme="slate"] .md-typeset .md-button--primary {
+ background-color: var(--cc-proof);
+ border-color: var(--cc-proof);
+ color: #0e1024;
+}
+
+.md-typeset .md-button:focus-visible {
+ outline: 2px solid var(--cc-proof);
+ outline-offset: 2px;
+}
+
+.md-content__inner:has(.cc-hero) > .md-content__button {
+ display: none;
+}
+
+.cc-hero {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ gap: 2rem;
+ align-items: start;
+ padding: 1.5rem 0 2.5rem;
+}
+
+@media screen and (min-width: 60em) {
+ .cc-hero {
+ grid-template-columns: minmax(0, 5fr) minmax(0, 6fr);
+ gap: 3rem;
+ padding-top: 2.5rem;
+ }
+}
+
+.md-typeset .cc-hero h1 {
+ margin: 0 0 1rem;
+ color: var(--md-default-fg-color);
+ font-size: clamp(1.9rem, 4.2vw, 2.7rem);
+ font-weight: 600;
+ line-height: 1.08;
+ letter-spacing: -0.025em;
+}
+
+.md-typeset .cc-hero__pitch > p {
+ max-width: 34em;
+ font-size: 0.95rem;
+ line-height: 1.6;
+}
+
+.md-typeset .cc-hero__pitch .md-button {
+ margin: 0.3rem 0.4rem 0.3rem 0;
+}
+
+/* The proof block's only comments are the compiler's error, so they read as one. */
+.md-typeset .cc-hero__proof pre > code {
+ border-left: 3px solid var(--cc-error);
+ white-space: pre-wrap;
+}
+
+.md-typeset .cc-hero__proof .highlight .c1 {
+ color: var(--cc-error);
+ font-style: normal;
+}
+
+.md-typeset .cc-caption {
+ margin-top: 0.4rem;
+ color: var(--cc-muted);
+ font-size: 0.68rem;
+}
+
+.cc-section {
+ padding: 2.5rem 0 0.5rem;
+ border-top: 1px solid var(--cc-rule);
+}
+
+.md-typeset .cc-section > h2 {
+ margin-top: 0;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+}
+
+.md-typeset .cc-section > p:not(:has(.cc-demo)) {
+ max-width: 40em;
+}
+
+.cc-compare {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr));
+ gap: 0 2rem;
+}
+
+.md-typeset .cc-demo {
+ display: block;
+ width: 100%;
+ border-radius: 0.3rem;
+ box-shadow: 0 0 0 1px var(--cc-rule);
+}
+
+.cc-features {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
+ gap: 0.5rem 2rem;
+}
+
+.md-typeset .cc-features h3 {
+ display: flex;
+ gap: 0.5rem;
+ align-items: center;
+ margin: 0.8rem 0 0.3rem;
+ font-size: 0.85rem;
+ font-weight: 600;
+}
+
+.md-typeset .cc-features h3 .twemoji {
+ color: var(--cc-proof);
+}
+
+.md-typeset .cc-features p {
+ margin: 0;
+ color: var(--cc-muted);
+}
diff --git a/website/docs/assets/favicon.svg b/website/docs/assets/favicon.svg
new file mode 100644
index 0000000..69182b0
--- /dev/null
+++ b/website/docs/assets/favicon.svg
@@ -0,0 +1,7 @@
+
diff --git a/website/docs/assets/logo.svg b/website/docs/assets/logo.svg
new file mode 100644
index 0000000..e1ae300
--- /dev/null
+++ b/website/docs/assets/logo.svg
@@ -0,0 +1,4 @@
+
diff --git a/website/docs/compatibility.md b/website/docs/compatibility.md
new file mode 100644
index 0000000..6e55463
--- /dev/null
+++ b/website/docs/compatibility.md
@@ -0,0 +1,19 @@
+# Compatibility
+
+## Requirements
+
+- Scala 3.3 or newer. Releases are built on the 3.3 LTS line, so they work on every later Scala 3 version.
+- JVM, Scala.js 1.x and Scala Native 0.5.
+
+## Versioning
+
+CaseComplete follows [early semantic versioning](https://www.scala-lang.org/blog/2021/02/16/preventing-version-conflicts-with-versionscheme.html).
+Since 1.0.0, binary compatibility is kept within a major version.
+CI checks every change against the previous release with [MiMa](https://github.com/lightbend-labs/mima).
+
+The checks happen at compile time, so the wording of compile errors may change between minor versions.
+What compiles and what doesn't stays the same.
+
+## License
+
+[MIT](https://github.com/stivens/CaseComplete/blob/main/LICENSE).
diff --git a/website/docs/getting-started.md b/website/docs/getting-started.md
new file mode 100644
index 0000000..13a1230
--- /dev/null
+++ b/website/docs/getting-started.md
@@ -0,0 +1,87 @@
+# Getting started
+
+## Install
+
+CaseComplete needs Scala 3.3 or newer and is published for the JVM, Scala.js and Scala Native.
+
+=== "sbt"
+
+ ```scala
+ libraryDependencies += "io.github.stivens" %% "casecomplete" % "@VERSION@"
+ ```
+
+=== "sbt (cross-built)"
+
+ ```scala
+ libraryDependencies += "io.github.stivens" %%% "casecomplete" % "@VERSION@"
+ ```
+
+=== "scala-cli"
+
+ ```scala
+ //> using dep io.github.stivens::casecomplete:@VERSION@
+ ```
+
+=== "REPL"
+
+ ```bash
+ scala-cli repl --dep io.github.stivens::casecomplete:@VERSION@
+ ```
+
+## Your first handler
+
+Start a builder with `CaseComplete.build[Source, Target]`, register one handler per field, and finish with `.compile`.
+This one turns a search filter into SQL conditions with [doobie](https://typelevel.org/doobie/):
+
+```scala mdoc:silent
+import io.github.stivens.casecomplete.CaseComplete
+
+import doobie.*
+import doobie.implicits.*
+
+case class MovieFilter(
+ title_like: Option[String] = None,
+ director_eq: Option[String] = None,
+ releaseYear_eq: Option[Int] = None,
+ rating_gte: Option[Double] = None
+)
+
+val movieFilterHandler = CaseComplete.build[MovieFilter, Option[Fragment]]
+ .usingNonEmpty(_.title_like)(title => fr"title ILIKE $title")
+ .usingNonEmpty(_.director_eq)(director => fr"director = $director")
+ .usingNonEmpty(_.releaseYear_eq)(year => fr"release_year = $year")
+ .usingNonEmpty(_.rating_gte)(rating => fr"rating >= $rating")
+ .compile
+```
+
+`eval` runs every handler and returns the results in field declaration order.
+Fields left as `None` produce `None`, so `flatten` keeps only the conditions that apply:
+
+```scala mdoc
+val filter = MovieFilter(
+ releaseYear_eq = Some(1999),
+ rating_gte = Some(7.0)
+)
+
+movieFilterHandler.eval(filter).flatten
+```
+
+Add a field to `MovieFilter` and this code stops compiling until the new field has a handler.
+
+```scala mdoc:fail
+case class MovieFilterV2(
+ title_like: Option[String] = None,
+ rating_gte: Option[Double] = None,
+ genre_eq: Option[String] = None
+)
+
+CaseComplete.build[MovieFilterV2, Option[Fragment]]
+ .usingNonEmpty(_.title_like)(title => fr"title ILIKE $title")
+ .usingNonEmpty(_.rating_gte)(rating => fr"rating >= $rating")
+ .compile
+```
+
+## Next steps
+
+- [Why not pattern matching?](why.md) explains what a CaseComplete parameter guarantees and a plain function doesn't.
+- The [API reference](api.md) covers `using`, `usingNonEmpty`, `ignoring` and evaluation order.
diff --git a/website/docs/index.md b/website/docs/index.md
new file mode 100644
index 0000000..b9af02e
--- /dev/null
+++ b/website/docs/index.md
@@ -0,0 +1,151 @@
+---
+title: CaseComplete
+hide:
+ - navigation
+ - toc
+ - footer
+---
+
+```scala mdoc:invisible
+import io.github.stivens.casecomplete.CaseComplete
+```
+
+
+
+
+# Forgot a field? It won't compile.
+
+CaseComplete is a Scala 3 library for code that turns a case class into something else, such as SQL conditions, log lines or JSON fields.
+You register one handler per field, and the build fails if any field doesn't have one.
+
+[Get started](getting-started.md){ .md-button .md-button--primary }
+[View on GitHub](https://github.com/stivens/CaseComplete){ .md-button }
+
+
+
+## A plain function can't make this promise
+
+
+
+
+This compiles, and `coupon` is never read:
+
+```scala mdoc:silent
+val describe: Order => List[String] = order =>
+ List(s"id=${order.id}", s"items=${order.items.mkString(",")}")
+```
+
+
+
+
+This parameter only accepts a builder that handled every field of `Order`:
+
+```scala mdoc:compile-only
+def checkout(
+ order: Order,
+ describe: CaseComplete[Order, String]
+): Unit = ???
+```
+
+
+
+
+Make an interface take `CaseComplete[A, B]`, and every implementation has to be complete. When someone later adds a field to `A`, the compiler lists each place that needs a handler. [Why not pattern matching?](why.md)
+
+
+
+
+
+## Errors show up as you type
+
+{ .cc-demo loading=lazy }
+
+
+
+
+
+## What you get
+
+
+
+
+### :material-check-all: Every field, checked
+
+`compile` fails and names each field that has no handler.
+
+
+
+
+### :material-tag-outline: Matched by name
+
+Each handler is tied to `_.field`. Two fields of the same type can't be swapped by mistake, as they can in a positional pattern match.
+
+
+
+
+### :material-help-circle-outline: Built for `Option`
+
+`usingNonEmpty` runs your handler only for `Some`. `None` maps to `None`.
+
+
+
+
+### :material-eye-off-outline: Deliberate skips
+
+`ignoring(_.field)` records that leaving a field out was a decision, so it doesn't look like an oversight.
+
+
+
+
+### :material-file-tree-outline: Enforced by the signature
+
+A `CaseComplete[A, B]` parameter can't be satisfied by a function that skips fields.
+
+
+
+
+### :material-layers-triple-outline: JVM, JS and Native
+
+One dependency for Scala 3.3+ on every platform. MiMa checks binary compatibility between releases.
+
+
+
+
+
+
+
+
+## Install
+
+```scala
+libraryDependencies += "io.github.stivens" %% "casecomplete" % "@VERSION@"
+```
+
+For Scala.js, Scala Native, scala-cli and the REPL, see [Install](getting-started.md#install). The [getting started guide](getting-started.md) then walks you through your first handler.
+
+
diff --git a/website/docs/recipes.md b/website/docs/recipes.md
new file mode 100644
index 0000000..76daa0b
--- /dev/null
+++ b/website/docs/recipes.md
@@ -0,0 +1,48 @@
+# Type aliases
+
+When a codebase uses the same target type everywhere, naming it once makes signatures read like intent:
+
+```scala mdoc:silent
+import io.github.stivens.casecomplete.CaseComplete
+import io.github.stivens.casecomplete.macros.CaseCompleteBuilder
+
+import doobie.*
+import doobie.implicits.*
+
+type AsFragments[A <: Product] = CaseComplete[A, Option[Fragment]]
+
+def toFragments[A <: Product]: CaseCompleteBuilder[A, Option[Fragment], EmptyTuple] =
+ CaseComplete.build[A, Option[Fragment]]
+```
+
+```scala mdoc:invisible
+import examples.movies.*
+```
+
+```scala mdoc:silent
+abstract class AbstractRepository[ENTITY, FILTER <: Product, UPDATE <: Product](
+ tableName: String,
+ evalFilter: AsFragments[FILTER],
+ evalUpdate: AsFragments[UPDATE]
+)
+
+object MovieRepository extends AbstractRepository[Movie, MovieFilter, MovieUpdate](
+ tableName = "movies",
+ evalFilter = toFragments[MovieFilter]
+ .usingNonEmpty(_.title_like)(title => fr"title ILIKE $title")
+ .usingNonEmpty(_.director_eq)(director => fr"director = $director")
+ .usingNonEmpty(_.releaseYear_eq)(year => fr"release_year = $year")
+ .usingNonEmpty(_.rating_gte)(rating => fr"rating >= $rating")
+ .compile,
+ evalUpdate = toFragments[MovieUpdate]
+ .usingNonEmpty(_.title)(title => fr"title = $title")
+ .usingNonEmpty(_.director)(director => fr"director = $director")
+ .usingNonEmpty(_.rating)(rating => fr"rating = $rating")
+ .compile
+)
+```
+
+!!! warning "Keep the builder's inferred type"
+ The builder's type records which fields have been handled.
+ Don't store a half-built chain in a value typed `CaseCompleteBuilder[A, B, ?]`: calling `compile` on it is a compile error, because that list of handled fields is gone.
+ Returning a fresh builder typed with `EmptyTuple`, as `toFragments` does, is fine.
diff --git a/website/docs/why.md b/website/docs/why.md
new file mode 100644
index 0000000..9ebbc2d
--- /dev/null
+++ b/website/docs/why.md
@@ -0,0 +1,114 @@
+# Why not pattern matching?
+
+A pattern match on a case class can handle every field, but nothing makes it.
+To the compiler, a `MovieUpdate => Set[Fragment]` parameter is just a function, so an interface can't require that its implementations look at every field.
+CaseComplete's `CaseComplete[A, B]` type can only be produced by a builder that has seen every field, so the requirement lives in the signature.
+
+## The problem
+
+Here's a repository base class that takes plain functions:
+
+```scala mdoc:invisible
+import io.github.stivens.casecomplete.CaseComplete
+
+import doobie.*
+import doobie.implicits.*
+
+import examples.movies.*
+```
+
+```scala mdoc:silent
+abstract class AbstractRepository[ENTITY, FILTER, UPDATE](
+ tableName: String,
+ evalFilter: FILTER => Set[Fragment],
+ evalUpdate: UPDATE => Set[Fragment]
+)
+```
+
+Both implementations below compile, and both are wrong:
+
+```scala mdoc:silent
+object MovieRepository extends AbstractRepository[Movie, MovieFilter, MovieUpdate](
+ tableName = "movies",
+ evalFilter = {
+ case MovieFilter(director_eq, title_like, releaseYear_eq, rating_gte) =>
+ List(
+ title_like.map(title => fr"title ILIKE $title"),
+ director_eq.map(director => fr"director = $director"),
+ releaseYear_eq.map(year => fr"release_year = $year"),
+ rating_gte.map(rating => fr"rating >= $rating")
+ ).flatten.toSet
+ },
+ evalUpdate = update =>
+ List(
+ update.title.map(title => fr"title = $title"),
+ update.director.map(director => fr"director = $director")
+ ).flatten.toSet
+)
+```
+
+- **`evalFilter`** binds the fields positionally, and the first two names are swapped. A title search ends up filtering on `director`. Both fields are `Option[String]`, so the types can't catch it.
+- **`evalUpdate`** never reads `rating`. Updating a movie's rating silently does nothing.
+
+## The fix
+
+Declare the parameters as `CaseComplete` instead:
+
+```scala mdoc:reset:invisible
+import io.github.stivens.casecomplete.CaseComplete
+
+import doobie.*
+import doobie.implicits.*
+
+import examples.movies.*
+```
+
+```scala mdoc:silent
+abstract class AbstractRepository[ENTITY, FILTER <: Product, UPDATE <: Product](
+ tableName: String,
+ evalFilter: CaseComplete[FILTER, Option[Fragment]],
+ evalUpdate: CaseComplete[UPDATE, Option[Fragment]]
+)
+```
+
+Every subclass now has to hand over a compiled builder, and a builder only compiles once each field has a handler.
+Handlers are matched by field name, never by position, so the swap above can't happen.
+The forgotten `rating` is now a compile error:
+
+```scala mdoc:fail
+object MovieRepository extends AbstractRepository[Movie, MovieFilter, MovieUpdate](
+ tableName = "movies",
+ evalFilter = CaseComplete.build[MovieFilter, Option[Fragment]]
+ .usingNonEmpty(_.title_like)(title => fr"title ILIKE $title")
+ .usingNonEmpty(_.director_eq)(director => fr"director = $director")
+ .usingNonEmpty(_.releaseYear_eq)(year => fr"release_year = $year")
+ .usingNonEmpty(_.rating_gte)(rating => fr"rating >= $rating")
+ .compile,
+ evalUpdate = CaseComplete.build[MovieUpdate, Option[Fragment]]
+ .usingNonEmpty(_.title)(title => fr"title = $title")
+ .usingNonEmpty(_.director)(director => fr"director = $director")
+ .compile
+)
+```
+
+Add the missing handler and the repository compiles:
+
+```scala mdoc:silent
+object MovieRepository extends AbstractRepository[Movie, MovieFilter, MovieUpdate](
+ tableName = "movies",
+ evalFilter = CaseComplete.build[MovieFilter, Option[Fragment]]
+ .usingNonEmpty(_.title_like)(title => fr"title ILIKE $title")
+ .usingNonEmpty(_.director_eq)(director => fr"director = $director")
+ .usingNonEmpty(_.releaseYear_eq)(year => fr"release_year = $year")
+ .usingNonEmpty(_.rating_gte)(rating => fr"rating >= $rating")
+ .compile,
+ evalUpdate = CaseComplete.build[MovieUpdate, Option[Fragment]]
+ .usingNonEmpty(_.title)(title => fr"title = $title")
+ .usingNonEmpty(_.director)(director => fr"director = $director")
+ .usingNonEmpty(_.rating)(rating => fr"rating = $rating")
+ .compile
+)
+```
+
+If someone later adds a field to `MovieUpdate`, every repository that uses it stops compiling until the new field has a handler or an explicit [`ignoring`](api.md#ignoring).
+To make signatures like these shorter, see [Type aliases](recipes.md).
diff --git a/website/mkdocs.yml b/website/mkdocs.yml
new file mode 100644
index 0000000..4c829c6
--- /dev/null
+++ b/website/mkdocs.yml
@@ -0,0 +1,70 @@
+site_name: CaseComplete
+site_url: https://stivens.github.io/CaseComplete/
+site_description: A Scala 3 library that fails compilation when a case class field has no handler.
+repo_url: https://github.com/stivens/CaseComplete
+repo_name: stivens/CaseComplete
+edit_uri: edit/main/website/docs/
+copyright: Released under the MIT License.
+
+# The sources in docs/ are mdoc input; mkdocs builds from mdoc's output (`sbt docs/mdoc`).
+docs_dir: target/mdoc
+site_dir: target/site
+
+theme:
+ name: material
+ logo: assets/logo.svg
+ favicon: assets/favicon.svg
+ font:
+ text: IBM Plex Sans
+ code: IBM Plex Mono
+ palette:
+ - media: "(prefers-color-scheme: light)"
+ scheme: default
+ primary: custom
+ accent: custom
+ toggle:
+ icon: material/weather-night
+ name: Switch to dark mode
+ - media: "(prefers-color-scheme: dark)"
+ scheme: slate
+ primary: custom
+ accent: custom
+ toggle:
+ icon: material/weather-sunny
+ name: Switch to light mode
+ icon:
+ repo: fontawesome/brands/github
+ features:
+ - navigation.tabs
+ - navigation.footer
+ - navigation.top
+ - content.code.copy
+ - content.action.edit
+ - search.suggest
+ - search.highlight
+
+extra_css:
+ - assets/extra.css
+
+markdown_extensions:
+ - admonition
+ - attr_list
+ - md_in_html
+ - toc:
+ permalink: true
+ - pymdownx.highlight
+ - pymdownx.superfences
+ - pymdownx.tabbed:
+ alternate_style: true
+ - pymdownx.emoji:
+ emoji_index: !!python/name:material.extensions.emoji.twemoji
+ emoji_generator: !!python/name:material.extensions.emoji.to_svg
+
+nav:
+ - Home: index.md
+ - Docs:
+ - Getting started: getting-started.md
+ - Why not pattern matching?: why.md
+ - API reference: api.md
+ - Type aliases: recipes.md
+ - Compatibility: compatibility.md
diff --git a/website/requirements.txt b/website/requirements.txt
new file mode 100644
index 0000000..d3504c7
--- /dev/null
+++ b/website/requirements.txt
@@ -0,0 +1 @@
+mkdocs-material==9.7.7
diff --git a/website/src/main/scala/examples/movies.scala b/website/src/main/scala/examples/movies.scala
new file mode 100644
index 0000000..1c75d3f
--- /dev/null
+++ b/website/src/main/scala/examples/movies.scala
@@ -0,0 +1,16 @@
+package examples.movies
+
+case class Movie(title: String, director: String, releaseYear: Int, rating: Double)
+
+case class MovieFilter(
+ title_like: Option[String] = None,
+ director_eq: Option[String] = None,
+ releaseYear_eq: Option[Int] = None,
+ rating_gte: Option[Double] = None
+)
+
+case class MovieUpdate(
+ title: Option[String] = None,
+ director: Option[String] = None,
+ rating: Option[Double] = None
+)