From d906cd8a7ea2288d430a42ae497a3d7e67b94cb4 Mon Sep 17 00:00:00 2001 From: stivens Date: Thu, 6 Aug 2026 16:17:56 +0200 Subject: [PATCH 1/9] Fix/ exponential compile time in builder chains --- .../macros/CaseCompleteBuilder.scala | 226 ++++++++++-------- .../CaseComplete/CaseCompleteSpec.scala | 45 ++++ .../stivens/CaseComplete/LongChainSpec.scala | 96 ++++++++ 3 files changed, 269 insertions(+), 98 deletions(-) create mode 100644 src/test/scala/io/github/stivens/CaseComplete/LongChainSpec.scala 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 35c67ad..59c0ff3 100644 --- a/src/main/scala/io/github/stivens/CaseComplete/macros/CaseCompleteBuilder.scala +++ b/src/main/scala/io/github/stivens/CaseComplete/macros/CaseCompleteBuilder.scala @@ -40,6 +40,17 @@ class CaseCompleteBuilder[SOURCE_TYPE <: Product, TARGET_TYPE, Handled <: Tuple] val handlers: Map[String, SOURCE_TYPE => TARGET_TYPE] ) { + // Package-private so users cannot forge a Handled claim for a field that has no handler. Quoted + // calls resolve at macro-definition site, so the generated code can still reach these. + private[casecomplete] def addHandler[NewHandled <: Tuple]( + name: String, + handler: SOURCE_TYPE => TARGET_TYPE + ): CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, NewHandled] = + new CaseCompleteBuilder(handlers + (name -> handler)) + + private[casecomplete] def markHandled[NewHandled <: Tuple]: CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, NewHandled] = + new CaseCompleteBuilder(handlers) + /** * Registers a handler for a specific field of the source case class. * @@ -62,7 +73,29 @@ class CaseCompleteBuilder[SOURCE_TYPE <: Product, TARGET_TYPE, Handled <: Tuple] )( handler: FIELD => TARGET_TYPE ): CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, ?] = // The '?' hides the complex result type from the user - ${ CaseCompleteBuilder.usingImpl('this, 'field, '{ Some(handler) }) } + ${ CaseCompleteBuilder.usingImpl('this, 'field, 'handler) } + + /** + * Registers a handler for an optional field, automatically handling the None case. + * + * Only available when the target type is an `Option`; the handler produces the `Option`'s payload + * and `None` fields map to `None`. Equivalent to `using(_.field)(_.map(handler))`. + * + * This must stay a method on the class. As a `transparent inline` extension method it 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. + * + * @example + * {{{ + * builder.usingNonEmpty(_.releaseYear)(year => s"releaseYear = $year") + * }}} + */ + transparent inline def usingNonEmpty[FIELD]( + inline field: SOURCE_TYPE => Option[FIELD] + )( + handler: FIELD => CaseCompleteBuilder.OptionPayload[TARGET_TYPE] + ): CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, ?] = + ${ CaseCompleteBuilder.usingNonEmptyImpl('this, 'field, 'handler) } /** * Explicitly ignores a specific field of the source case class. @@ -83,7 +116,7 @@ class CaseCompleteBuilder[SOURCE_TYPE <: Product, TARGET_TYPE, Handled <: Tuple] transparent inline def ignoring[FIELD]( inline field: SOURCE_TYPE => FIELD ): CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, ?] = // The '?' hides the complex result type from the user - ${ CaseCompleteBuilder.usingImpl('this, 'field, '{ None }) } + ${ CaseCompleteBuilder.ignoringImpl('this, 'field) } /** * Compiles the handler, verifying at compile time that all fields have been handled. @@ -134,54 +167,17 @@ object CaseCompleteBuilder { def apply[SOURCE_TYPE <: Product, TARGET_TYPE]: CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, EmptyTuple] = new CaseCompleteBuilder(Map.empty[String, SOURCE_TYPE => TARGET_TYPE]) - /** - * Extension methods for CaseCompleteBuilder instances that handle optional target types. - * - * These extensions provide convenient methods for working with optional fields and - * optional target types. + /** + * The payload of an `Option` target type, used to type `usingNonEmpty`'s handler. + * + * The `Any` fallback keeps this reducible for non-`Option` targets so that `usingNonEmptyImpl` + * reports the mismatch; without it the user gets a raw "match type reduction failed" instead. */ - extension [SOURCE_TYPE <: Product, TARGET_TYPE, Handled <: Tuple]( - builderToOptional: CaseCompleteBuilder[SOURCE_TYPE, Option[TARGET_TYPE], Handled] - ) { - - /** - * Registers a handler for an optional field, automatically handling the None case. - * - * This method is useful when the source field is optional (Option[T]) and you want - * to provide a handler that only processes the Some case, automatically returning - * None for None values. - * - * @param field A field selector that extracts an Option[FIELD] from the source type - * @param handler A function that transforms the field value to the target type - * @tparam FIELD The type of the field when it's present - * @return A new CaseCompleteBuilder with the updated handlers - * - * @example - * {{{ - * handler.usingNonEmpty(_.releaseYear)(year => s"releaseYear = $year") - * }}} - */ - transparent inline def usingNonEmpty[FIELD]( - inline field: SOURCE_TYPE => Option[FIELD] - )(handler: FIELD => TARGET_TYPE): CaseCompleteBuilder[SOURCE_TYPE, Option[TARGET_TYPE], ?] = - builderToOptional.using[Option[FIELD]](field)(_.map(handler)) + type OptionPayload[T] = T match { + case Option[payload] => payload + case _ => Any } - /** - * Macro implementation for the `using` method. - * - * This macro extracts the field name from the field selector expression at compile time - * and constructs a new CaseCompleteBuilder with the updated handlers and type tracking. - * - * @param builder The current builder expression - * @param field The field selector expression - * @param fieldHandler The handler function expression - * @tparam SOURCE_TYPE The source case class type - * @tparam TARGET_TYPE The target type - * @tparam Handled The current handled fields tuple type - * @tparam FIELD The field type - * @return An expression for the new CaseCompleteBuilder - */ def usingImpl[ SOURCE_TYPE <: Product: Type, TARGET_TYPE: Type, @@ -190,19 +186,84 @@ object CaseCompleteBuilder { ]( builder: Expr[CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, Handled]], field: Expr[SOURCE_TYPE => FIELD], - fieldHandler: Expr[Option[FIELD => TARGET_TYPE]] + handler: Expr[FIELD => TARGET_TYPE] + )(using Quotes): Expr[CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, ?]] = { + val fieldName = extractFieldNameOrAbort(field) + checkNotAlreadyHandled[Handled](fieldName) + + addHandlerCall(builder, fieldName, '{ (s: SOURCE_TYPE) => $handler($field(s)) }) + } + + def usingNonEmptyImpl[ + SOURCE_TYPE <: Product: Type, + TARGET_TYPE: Type, + Handled <: Tuple: Type, + FIELD: Type + ]( + builder: Expr[CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, Handled]], + field: Expr[SOURCE_TYPE => Option[FIELD]], + handler: Expr[FIELD => OptionPayload[TARGET_TYPE]] )(using q: Quotes): Expr[CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, ?]] = { import q.reflect.* - /** - * Extracts the field name from a field selector term. - * - * This function recursively traverses the term tree to find the actual field name - * being selected, handling various AST transformations that might be applied. - * - * @param term The term to extract the field name from - * @return Some(fieldName) if successful, None otherwise - */ + val fieldName = extractFieldNameOrAbort(field) + checkNotAlreadyHandled[Handled](fieldName) + + Type.of[TARGET_TYPE] match { + case '[Option[payload]] => + // OptionPayload[TARGET_TYPE] reduces to `payload` exactly here, but only after TARGET_TYPE + // has been matched, which the compiler cannot see through in the quote below. + val fullHandler = + '{ (s: SOURCE_TYPE) => $field(s).map(${ handler.asExprOf[FIELD => payload] }) } + .asExprOf[SOURCE_TYPE => TARGET_TYPE] + + addHandlerCall(builder, fieldName, fullHandler) + case _ => + report.errorAndAbort( + s"usingNonEmpty requires the target type to be an Option, but it is ${Type.show[TARGET_TYPE]}. Use `using` instead." + ) + } + } + + def ignoringImpl[ + SOURCE_TYPE <: Product: Type, + TARGET_TYPE: Type, + Handled <: Tuple: Type + ]( + builder: Expr[CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, Handled]], + field: Expr[SOURCE_TYPE => ?] + )(using Quotes): Expr[CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, ?]] = { + val fieldName = extractFieldNameOrAbort(field) + checkNotAlreadyHandled[Handled](fieldName) + + newHandledType[Handled](fieldName) match { + case '[t] => '{ $builder.markHandled[t & Tuple] } + } + } + + /** + * Emits `builder.addHandler[fieldName *: Handled](fieldName, handler)`. + * + * `builder` is the whole preceding chain, so it must be spliced exactly once -- a second splice + * copies that tree. `t & Tuple` supplies the bound that a quoted type pattern cannot express + * before Scala 3.4. + */ + private def addHandlerCall[ + SOURCE_TYPE <: Product: Type, + TARGET_TYPE: Type, + Handled <: Tuple: Type + ]( + builder: Expr[CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, Handled]], + fieldName: String, + handler: Expr[SOURCE_TYPE => TARGET_TYPE] + )(using Quotes): Expr[CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, ?]] = + newHandledType[Handled](fieldName) match { + case '[t] => '{ $builder.addHandler[t & Tuple](${ Expr(fieldName) }, $handler) } + } + + private def extractFieldNameOrAbort(field: Expr[?])(using q: Quotes): String = { + import q.reflect.* + def extractFieldName(term: Term): Option[String] = term match { case Select(_, name) => Some(name) case Inlined(_, _, block) => extractFieldName(block) @@ -219,54 +280,23 @@ object CaseCompleteBuilder { } val fieldAsTerm = field.asTerm - val fieldName = extractFieldName(fieldAsTerm) match { + extractFieldName(fieldAsTerm) match { case Some(name) => name case None => report.errorAndAbort(s"Illegal expression: ${fieldAsTerm.show}, expected a field selector, e.g. `_.foo`") } + } - // Check if this field has already been handled - val handledFields = getHandledFields(Type.of[Handled]) - if handledFields.contains(fieldName) then { + private def checkNotAlreadyHandled[Handled <: Tuple: Type](fieldName: String)(using q: Quotes): Unit = { + import q.reflect.* + if getHandledFields(Type.of[Handled]).contains(fieldName) then { report.errorAndAbort(s"Field '$fieldName' has already been handled. Each field can only be handled once.") } + } - val fieldNameSingletonTypeRepr = ConstantType(StringConstant(fieldName)) - val handledTupleTypeRepr = TypeRepr.of[Handled] - val AppliedType(tycon, _) = TypeRepr.of[*:[?, ?]]: @unchecked - val newHandledTupleTypeRepr = AppliedType(tycon, List(fieldNameSingletonTypeRepr, handledTupleTypeRepr)) - - newHandledTupleTypeRepr.asType match { - case '[t] => - // Get TypeTrees for the type arguments [A, B, t] - val typeSource_TT = TypeTree.of[SOURCE_TYPE] - val typeTarget_TT = TypeTree.of[TARGET_TYPE] - val typeHandled_TT = TypeTree.of[t] - - // Get the symbol for the HandleAllFieldsBuilder type - val builderSymbol = TypeRepr.of[CaseCompleteBuilder].typeSymbol - // Get the constructor symbol - val constructor = builderSymbol.primaryConstructor - - // Create the type `HandleAllFieldsBuilder[A, B, t]` - val builderTypeTree = Applied(TypeIdent(builderSymbol), List(typeSource_TT, typeTarget_TT, typeHandled_TT)) - - // Construct the expression for the `newHandlers` map argument - val newHandlersExpr = '{ - $fieldHandler.fold($builder.handlers) { someFieldHandler => - $builder.handlers + (${ Expr(fieldName) } -> ((s: SOURCE_TYPE) => someFieldHandler($field(s)))) - } - } - - // Build the `new HandleAllFieldsBuilder[A, B, t](newHandlers)` expression tree - val newBuilderTerm = Apply( - TypeApply(Select(New(builderTypeTree), constructor), List(typeSource_TT, typeTarget_TT, typeHandled_TT)), - List(newHandlersExpr.asTerm) - ) - - // Convert the constructed Term back to an Expr and coerce its type to match the method signature - newBuilderTerm.asExprOf[CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, ?]] - case _ => - report.errorAndAbort("Internal macro error: Could not create a valid tuple type for handled fields.") + private def newHandledType[Handled <: Tuple: Type](fieldName: String)(using q: Quotes): Type[?] = { + import q.reflect.* + ConstantType(StringConstant(fieldName)).asType match { + case '[name] => Type.of[name *: Handled] } } diff --git a/src/test/scala/io/github/stivens/CaseComplete/CaseCompleteSpec.scala b/src/test/scala/io/github/stivens/CaseComplete/CaseCompleteSpec.scala index e45d561..27f8400 100644 --- a/src/test/scala/io/github/stivens/CaseComplete/CaseCompleteSpec.scala +++ b/src/test/scala/io/github/stivens/CaseComplete/CaseCompleteSpec.scala @@ -92,5 +92,50 @@ class CaseCompleteSpec extends AnyFunSpec { assert(true) // code compiles } } + + describe("when validating the chain at compile time") { + + it("should compile a chain that handles every field") { + assertCompiles(""" + CaseComplete.build[TwoFieldFilter, Option[String]] + .using(_.a)(identity) + .using(_.b)(identity) + .compile + """) + } + + it("should fail to compile when a field has no handler") { + assertDoesNotCompile(""" + CaseComplete.build[TwoFieldFilter, Option[String]] + .using(_.a)(identity) + .compile + """) + } + + it("should fail to compile when the same field is handled twice") { + assertDoesNotCompile(""" + CaseComplete.build[TwoFieldFilter, Option[String]] + .using(_.a)(identity) + .using(_.a)(identity) + """) + } + + it("should fail to compile when the selector is not a plain field access") { + assertDoesNotCompile(""" + CaseComplete.build[TwoFieldFilter, Option[String]] + .using(filter => filter.a.map(_.trim))(identity) + """) + } + + it("should fail to compile usingNonEmpty when the target type is not an Option") { + assertDoesNotCompile(""" + CaseComplete.build[TwoFieldFilter, String] + .usingNonEmpty(_.a)(value => value) + """) + } + } } } + +// Top-level so the assertDoesNotCompile snippets below can name it; never instantiated. +case class TwoFieldFilter(a: Option[String], b: Option[String]) diff --git a/src/test/scala/io/github/stivens/CaseComplete/LongChainSpec.scala b/src/test/scala/io/github/stivens/CaseComplete/LongChainSpec.scala new file mode 100644 index 0000000..7c91127 --- /dev/null +++ b/src/test/scala/io/github/stivens/CaseComplete/LongChainSpec.scala @@ -0,0 +1,96 @@ +package io.github.stivens.casecomplete + +import org.scalatest.funspec.AnyFunSpec + +/** + * Compile-time regression guard for the blowup described on `CaseCompleteBuilder.usingNonEmpty`. + * + * Chaining `transparent inline` extension methods costs ~1.9x per step: pre-fix, 16 steps spent 4.0 s + * in posttyper and 20 steps spent 54 s, so the 32 steps below would take hours. Post-fix the whole + * file costs ~0.25 s. All three chaining methods are interleaved because each is equally at risk. + * + * Note this fails by hanging rather than by a fast assertion, so it is only a usable CI signal once + * the workflow sets a job `timeout-minutes`. + */ +class LongChainSpec extends AnyFunSpec { + + describe("a builder chain with 32 steps") { + + case class WideFilter( + f01: Option[String] = None, + f02: Option[String] = None, + f03: Option[String] = None, + f04: Option[String] = None, + f05: Option[String] = None, + f06: Option[String] = None, + f07: Option[String] = None, + f08: Option[String] = None, + f09: Option[String] = None, + f10: Option[String] = None, + f11: Option[String] = None, + f12: Option[String] = None, + f13: Option[String] = None, + f14: Option[String] = None, + f15: Option[String] = None, + f16: Option[String] = None, + f17: Option[String] = None, + f18: Option[String] = None, + f19: Option[String] = None, + f20: Option[String] = None, + f21: Option[String] = None, + f22: Option[String] = None, + f23: Option[String] = None, + f24: Option[String] = None, + f25: Option[String] = None, + f26: Option[String] = None, + f27: Option[String] = None, + f28: Option[String] = None, + f29: Option[String] = None, + f30: Option[String] = None, + f31: Option[String] = None, + f32: Option[String] = None + ) + + it("should compile and evaluate every step") { + val handler = CaseComplete + .build[WideFilter, Option[String]] + .usingNonEmpty(_.f01)(v => s"f01 = $v") + .usingNonEmpty(_.f02)(v => s"f02 = $v") + .usingNonEmpty(_.f03)(v => s"f03 = $v") + .usingNonEmpty(_.f04)(v => s"f04 = $v") + .usingNonEmpty(_.f05)(v => s"f05 = $v") + .usingNonEmpty(_.f06)(v => s"f06 = $v") + .usingNonEmpty(_.f07)(v => s"f07 = $v") + .usingNonEmpty(_.f08)(v => s"f08 = $v") + .usingNonEmpty(_.f09)(v => s"f09 = $v") + .usingNonEmpty(_.f10)(v => s"f10 = $v") + .usingNonEmpty(_.f11)(v => s"f11 = $v") + .usingNonEmpty(_.f12)(v => s"f12 = $v") + .usingNonEmpty(_.f13)(v => s"f13 = $v") + .usingNonEmpty(_.f14)(v => s"f14 = $v") + .usingNonEmpty(_.f15)(v => s"f15 = $v") + .usingNonEmpty(_.f16)(v => s"f16 = $v") + .usingNonEmpty(_.f17)(v => s"f17 = $v") + .usingNonEmpty(_.f18)(v => s"f18 = $v") + .usingNonEmpty(_.f19)(v => s"f19 = $v") + .usingNonEmpty(_.f20)(v => s"f20 = $v") + .usingNonEmpty(_.f21)(v => s"f21 = $v") + .usingNonEmpty(_.f22)(v => s"f22 = $v") + .usingNonEmpty(_.f23)(v => s"f23 = $v") + .usingNonEmpty(_.f24)(v => s"f24 = $v") + .using(_.f25)(_.map(v => s"f25 = $v")) + .using(_.f26)(_.map(v => s"f26 = $v")) + .using(_.f27)(_.map(v => s"f27 = $v")) + .using(_.f28)(_.map(v => s"f28 = $v")) + .ignoring(_.f29) + .ignoring(_.f30) + .ignoring(_.f31) + .ignoring(_.f32) + .compile + + val evaluated = handler.eval(WideFilter(f01 = Some("a"), f26 = Some("b"), f32 = Some("z"))).flatten + + assert(evaluated == List("f01 = a", "f26 = b")) + } + } +} From 6a5001da2d05d4cf670495117b3086148bd66102 Mon Sep 17 00:00:00 2001 From: stivens Date: Thu, 6 Aug 2026 16:50:46 +0200 Subject: [PATCH 2/9] further improvements --- .github/workflows/ci.yml | 3 + README.md | 6 +- build.sbt | 2 +- .../stivens/CaseComplete/CaseComplete.scala | 13 ++-- .../macros/CaseCompleteBuilder.scala | 67 ++++++++----------- .../externaluser/ExternalAccessSpec.scala | 49 ++++++++++++++ .../CaseComplete/CaseCompleteSpec.scala | 57 ++++++++++------ .../stivens/CaseComplete/LongChainSpec.scala | 4 +- 8 files changed, 129 insertions(+), 72 deletions(-) create mode 100644 src/test/scala/externaluser/ExternalAccessSpec.scala diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00b9f7a..0d672a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,9 @@ jobs: ci: name: Compile and test CaseComplete runs-on: ubuntu-latest + # LongChainSpec guards against a compile-time blowup, so it fails by hanging rather than by + # an assertion. Without this it would hang for GitHub's 360-minute default. + timeout-minutes: 15 steps: - uses: actions/checkout@v4 - name: Setup JDK 21, Scala, SBT diff --git a/README.md b/README.md index 9e9db1a..6bfc8ab 100644 --- a/README.md +++ b/README.md @@ -19,19 +19,19 @@ A Scala 3 library that provides compile-time guarantees for complete case class `build.sbt`: ```scala -libraryDependencies += "io.github.stivens" %% "casecomplete" % "0.2.2" +libraryDependencies += "io.github.stivens" %% "casecomplete" % "0.3.0" ``` `scala-cli`: ```scala -//> using lib "io.github.stivens::casecomplete:0.2.2" +//> using lib "io.github.stivens::casecomplete:0.3.0" ``` `scala-cli REPL`: ```bash -scala-cli repl --dep io.github.stivens::casecomplete:0.2.2 +scala-cli repl --dep io.github.stivens::casecomplete:0.3.0 ``` ## Quick Start diff --git a/build.sbt b/build.sbt index fabc3bd..ec66198 100644 --- a/build.sbt +++ b/build.sbt @@ -23,7 +23,7 @@ developers := List( ) ) -version := "0.2.2" +version := "0.3.0" scalaVersion := "3.3.8" diff --git a/src/main/scala/io/github/stivens/CaseComplete/CaseComplete.scala b/src/main/scala/io/github/stivens/CaseComplete/CaseComplete.scala index 79a3cd9..909f51d 100644 --- a/src/main/scala/io/github/stivens/CaseComplete/CaseComplete.scala +++ b/src/main/scala/io/github/stivens/CaseComplete/CaseComplete.scala @@ -11,17 +11,14 @@ object CaseComplete { CaseCompleteBuilder.apply[SOURCE_TYPE, TARGET_TYPE] } -/** - * Implementation of CaseComplete that stores handlers in a Map and evaluates them in sorted order. - * - * This class is used internally by the CaseCompleteBuilder to create the final CaseComplete instance - * after all field handlers have been registered. - */ private[casecomplete] class CaseCompleteImpl[SOURCE_TYPE <: Product, TARGET_TYPE]( handlers: Map[String, SOURCE_TYPE => TARGET_TYPE] ) extends CaseComplete[SOURCE_TYPE, TARGET_TYPE] { - def eval(source: SOURCE_TYPE): List[TARGET_TYPE] = + private val sortedHandlers: List[SOURCE_TYPE => TARGET_TYPE] = handlers.toList .sortBy { case (fieldName, _) => fieldName } - .map { case (_, handler) => handler(source) } + .map { case (_, handler) => handler } + + def eval(source: SOURCE_TYPE): List[TARGET_TYPE] = + sortedHandlers.map(handler => handler(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 59c0ff3..1bf114d 100644 --- a/src/main/scala/io/github/stivens/CaseComplete/macros/CaseCompleteBuilder.scala +++ b/src/main/scala/io/github/stivens/CaseComplete/macros/CaseCompleteBuilder.scala @@ -36,12 +36,13 @@ import scala.quoted.* * @tparam TARGET_TYPE The target type that each field handler produces * @tparam Handled A tuple type representing the field names that have been handled so far */ -class CaseCompleteBuilder[SOURCE_TYPE <: Product, TARGET_TYPE, Handled <: Tuple]( - val handlers: Map[String, SOURCE_TYPE => TARGET_TYPE] +class CaseCompleteBuilder[SOURCE_TYPE <: Product, TARGET_TYPE, Handled <: Tuple] private[casecomplete] ( + private[casecomplete] val handlers: Map[String, SOURCE_TYPE => TARGET_TYPE] ) { - // Package-private so users cannot forge a Handled claim for a field that has no handler. Quoted - // calls resolve at macro-definition site, so the generated code can still reach these. + // Package-private, together with the constructor, so users cannot forge a Handled claim for a field + // that has no handler. Quoted calls resolve at macro-definition site, so generated code still + // reaches these -- see ExternalAccessSpec. private[casecomplete] def addHandler[NewHandled <: Tuple]( name: String, handler: SOURCE_TYPE => TARGET_TYPE @@ -75,15 +76,14 @@ class CaseCompleteBuilder[SOURCE_TYPE <: Product, TARGET_TYPE, Handled <: Tuple] ): CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, ?] = // The '?' hides the complex result type from the user ${ CaseCompleteBuilder.usingImpl('this, 'field, 'handler) } + // Keep this and its sibling 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. /** * Registers a handler for an optional field, automatically handling the None case. * - * Only available when the target type is an `Option`; the handler produces the `Option`'s payload - * and `None` fields map to `None`. Equivalent to `using(_.field)(_.map(handler))`. - * - * This must stay a method on the class. As a `transparent inline` extension method it 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. + * Equivalent to `using(_.field)(_.map(handler))`, and only available when the target type is an + * `Option`. * * @example * {{{ @@ -140,12 +140,6 @@ class CaseCompleteBuilder[SOURCE_TYPE <: Product, TARGET_TYPE, Handled <: Tuple] ${ CaseCompleteBuilder.compileImpl[SOURCE_TYPE, TARGET_TYPE, Handled]('this) } } -/** - * Companion object providing factory methods and extensions for CaseCompleteBuilder. - * - * This object contains the main entry point for creating CaseCompleteBuilder instances - * and provides extension methods for handling optional fields. - */ object CaseCompleteBuilder { /** @@ -168,8 +162,6 @@ object CaseCompleteBuilder { new CaseCompleteBuilder(Map.empty[String, SOURCE_TYPE => TARGET_TYPE]) /** - * The payload of an `Option` target type, used to type `usingNonEmpty`'s handler. - * * The `Any` fallback keeps this reducible for non-`Option` targets so that `usingNonEmptyImpl` * reports the mismatch; without it the user gets a raw "match type reduction failed" instead. */ @@ -241,13 +233,8 @@ object CaseCompleteBuilder { } } - /** - * Emits `builder.addHandler[fieldName *: Handled](fieldName, handler)`. - * - * `builder` is the whole preceding chain, so it must be spliced exactly once -- a second splice - * copies that tree. `t & Tuple` supplies the bound that a quoted type pattern cannot express - * before Scala 3.4. - */ + // `builder` is the whole preceding chain, so it must be spliced exactly once -- a second splice + // copies that tree. private def addHandlerCall[ SOURCE_TYPE <: Product: Type, TARGET_TYPE: Type, @@ -293,6 +280,8 @@ object CaseCompleteBuilder { } } + // Returns an unbounded `Type[?]` because a quoted type pattern cannot express `<: Tuple` before + // Scala 3.4; call sites recover the bound with `t & Tuple`. private def newHandledType[Handled <: Tuple: Type](fieldName: String)(using q: Quotes): Type[?] = { import q.reflect.* ConstantType(StringConstant(fieldName)).asType match { @@ -349,23 +338,23 @@ object CaseCompleteBuilder { } /** - * Recursively unpacks the tuple type to get a Set of handled field names. - * - * This function traverses the `Handled` type parameter, which is a tuple of - * singleton string types representing the field names that have been handled. - * - * @param t The tuple type to unpack - * @return A Set containing all the field names that have been handled + * Unpacks `Handled` -- a tuple of singleton string types -- into the set of field names it records. + * + * Decoded structurally rather than with quoted type patterns (`'[head *: tail]`): every chain step + * walks the whole accumulated tuple, so this is quadratic over a chain, and the type comparer those + * patterns invoke made it ~10% of typer time at 96 fields. */ private def getHandledFields(t: Type[?])(using q: Quotes): Set[String] = { import q.reflect.* - t match { - case '[EmptyTuple] => Set.empty - case '[(head *: tail)] => - val headStr = Type.valueOfConstant[head].get.asInstanceOf[String] - getHandledFields(Type.of[tail]) + headStr - case _ => - report.errorAndAbort(s"Internal error: HandledFields type was not a tuple.") + + def loop(repr: TypeRepr, acc: Set[String]): Set[String] = repr.dealias match { + case AndType(left, _) => loop(left, acc) // the `t & Tuple` bound recovered at the call sites + case AppliedType(tycon, List(ConstantType(StringConstant(name)), tail)) if tycon.typeSymbol.name == "*:" => + loop(tail, acc + name) + case empty if empty =:= TypeRepr.of[EmptyTuple] => acc + case other => report.errorAndAbort(s"Internal error: HandledFields type was not a tuple: ${other.show}") } + + loop(TypeRepr.of(using t), Set.empty) } } diff --git a/src/test/scala/externaluser/ExternalAccessSpec.scala b/src/test/scala/externaluser/ExternalAccessSpec.scala new file mode 100644 index 0000000..e6c95d9 --- /dev/null +++ b/src/test/scala/externaluser/ExternalAccessSpec.scala @@ -0,0 +1,49 @@ +package externaluser + +import io.github.stivens.casecomplete.CaseComplete +import org.scalatest.funspec.AnyFunSpec + +import scala.compiletime.testing.typeCheckErrors + +case class Filter(a: Option[String], b: Option[String]) + +/** + * The rest of the suite lives inside `io.github.stivens.casecomplete`, where `private[casecomplete]` + * is indistinguishable from public. This spec sits outside that package, so it is the only place + * that pins both halves of the access story: generated code still reaches the package-private + * members, and users cannot. + */ +class ExternalAccessSpec extends AnyFunSpec { + + describe("a builder used from outside the library's package") { + + it("should compile and evaluate a chain") { + val handler = CaseComplete + .build[Filter, Option[String]] + .usingNonEmpty(_.a)(value => f"a = $value") + .ignoring(_.b) + .compile + + assert(handler.eval(Filter(a = Some("x"), b = None)).flatten == List("a = x")) + } + + it("should not let a field be marked handled without a handler") { + val errors = typeCheckErrors(""" + CaseComplete.build[Filter, Option[String]] + .using(_.a)(identity) + .markHandled[("b", "a")] + .compile + """) + + assert(errors.exists(_.message.contains("markHandled"))) + } + + it("should not let a builder be constructed directly") { + val errors = typeCheckErrors(""" + new io.github.stivens.casecomplete.macros.CaseCompleteBuilder[Filter, Option[String], ("a", "b")](Map.empty) + """) + + assert(errors.exists(_.message.contains("CaseCompleteBuilder"))) + } + } +} diff --git a/src/test/scala/io/github/stivens/CaseComplete/CaseCompleteSpec.scala b/src/test/scala/io/github/stivens/CaseComplete/CaseCompleteSpec.scala index 27f8400..615be46 100644 --- a/src/test/scala/io/github/stivens/CaseComplete/CaseCompleteSpec.scala +++ b/src/test/scala/io/github/stivens/CaseComplete/CaseCompleteSpec.scala @@ -3,6 +3,7 @@ package io.github.stivens.casecomplete import org.scalatest.funspec.AnyFunSpec import java.time.Year +import scala.compiletime.testing.typeCheckErrors class CaseCompleteSpec extends AnyFunSpec { describe("CaseCompleteBuilder") { @@ -19,36 +20,43 @@ class CaseCompleteSpec extends AnyFunSpec { releaseYear_eq = Some(Year.of(1999)), rating_gte = Some(7.0) ) - val expectedResult = Set("releaseYear = 1999", "rating >= 7.0") + val expectedOrder = List("rating >= 7.0", "releaseYear = 1999") + val expectedResult = expectedOrder.toSet val buildMovieFilterHandler = CaseComplete.build[MovieFilter, Option[String]] - it("should properly use all the fields of the source type and compile") { - val movieFilterHandler = buildMovieFilterHandler - .using(_.title_like)(_.map(title => f"title ILIKE $title")) - .using(_.director_eq)(_.map(director => f"director = $director")) - .using(_.releaseYear_eq)(_.map(releaseYear => f"releaseYear = $releaseYear")) - .using(_.rating_gte)(_.map(rating => f"rating >= $rating")) - .compile + val movieFilterHandler = buildMovieFilterHandler + .using(_.title_like)(_.map(title => f"title ILIKE $title")) + .using(_.director_eq)(_.map(director => f"director = $director")) + .using(_.releaseYear_eq)(_.map(releaseYear => f"releaseYear = $releaseYear")) + .using(_.rating_gte)(_.map(rating => f"rating >= $rating")) + .compile + it("should properly use all the fields of the source type and compile") { val evaulated = movieFilterHandler.eval(filter).toSet.flatten assert(evaulated == expectedResult) } it("should properly use all the non-empty optional fields of the source type and compile") { - val movieFilterHandler = buildMovieFilterHandler + val nonEmptyHandler = buildMovieFilterHandler .usingNonEmpty(_.title_like)(title => f"title ILIKE $title") .usingNonEmpty(_.director_eq)(director => f"director = $director") .usingNonEmpty(_.releaseYear_eq)(releaseYear => f"releaseYear = $releaseYear") .usingNonEmpty(_.rating_gte)(rating => f"rating >= $rating") .compile - val evaulated = movieFilterHandler.eval(filter).toSet.flatten + val evaulated = nonEmptyHandler.eval(filter).toSet.flatten assert(evaulated == expectedResult) } + it("should evaluate handlers in alphabetical order of field name, 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) + } + it("should allow to explicitly ignore a field") { val movieFilterHandler = buildMovieFilterHandler .ignoring(_.title_like) @@ -104,38 +112,49 @@ class CaseCompleteSpec extends AnyFunSpec { """) } - it("should fail to compile when a field has no handler") { - assertDoesNotCompile(""" + // Asserted on the message text, not just on failure: each of these messages exists only to be + // read, so a test that accepts any compile error would not notice it degrading into the raw + // compiler diagnostic it was written to replace. + it("should report the unhandled field when one has no handler") { + val errors = typeCheckErrors(""" CaseComplete.build[TwoFieldFilter, Option[String]] .using(_.a)(identity) .compile """) + + assert(errors.exists(_.message.contains("Missing handlers for fields: b"))) } - it("should fail to compile when the same field is handled twice") { - assertDoesNotCompile(""" + it("should report the field name when the same field is handled twice") { + val errors = typeCheckErrors(""" CaseComplete.build[TwoFieldFilter, Option[String]] .using(_.a)(identity) .using(_.a)(identity) """) + + assert(errors.exists(_.message.contains("Field 'a' has already been handled"))) } - it("should fail to compile when the selector is not a plain field access") { - assertDoesNotCompile(""" + it("should report the offending expression when the selector is not a plain field access") { + val errors = typeCheckErrors(""" CaseComplete.build[TwoFieldFilter, Option[String]] .using(filter => filter.a.map(_.trim))(identity) """) + + assert(errors.exists(_.message.contains("expected a field selector"))) } - it("should fail to compile usingNonEmpty when the target type is not an Option") { - assertDoesNotCompile(""" + it("should point at `using` when usingNonEmpty is applied to a non-Option target") { + val errors = typeCheckErrors(""" CaseComplete.build[TwoFieldFilter, String] .usingNonEmpty(_.a)(value => value) """) + + assert(errors.exists(_.message.contains("usingNonEmpty requires the target type to be an Option"))) } } } } -// Top-level so the assertDoesNotCompile snippets below can name it; never instantiated. +// Top-level so the type-checking snippets above can name it. case class TwoFieldFilter(a: Option[String], b: Option[String]) diff --git a/src/test/scala/io/github/stivens/CaseComplete/LongChainSpec.scala b/src/test/scala/io/github/stivens/CaseComplete/LongChainSpec.scala index 7c91127..af76928 100644 --- a/src/test/scala/io/github/stivens/CaseComplete/LongChainSpec.scala +++ b/src/test/scala/io/github/stivens/CaseComplete/LongChainSpec.scala @@ -9,8 +9,8 @@ import org.scalatest.funspec.AnyFunSpec * in posttyper and 20 steps spent 54 s, so the 32 steps below would take hours. Post-fix the whole * file costs ~0.25 s. All three chaining methods are interleaved because each is equally at risk. * - * Note this fails by hanging rather than by a fast assertion, so it is only a usable CI signal once - * the workflow sets a job `timeout-minutes`. + * This fails by hanging rather than by a fast assertion, which is why the CI job sets + * `timeout-minutes`. */ class LongChainSpec extends AnyFunSpec { From 5116ca60db33ef5ca62d215b5faa40439dd67df0 Mon Sep 17 00:00:00 2001 From: stivens Date: Thu, 6 Aug 2026 16:57:51 +0200 Subject: [PATCH 3/9] revert the README --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6bfc8ab..9e9db1a 100644 --- a/README.md +++ b/README.md @@ -19,19 +19,19 @@ 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" % "0.2.2" ``` `scala-cli`: ```scala -//> using lib "io.github.stivens::casecomplete:0.3.0" +//> using lib "io.github.stivens::casecomplete:0.2.2" ``` `scala-cli REPL`: ```bash -scala-cli repl --dep io.github.stivens::casecomplete:0.3.0 +scala-cli repl --dep io.github.stivens::casecomplete:0.2.2 ``` ## Quick Start From 662d3cd92f807fc2e1d8b84df6fcfb0968b354aa Mon Sep 17 00:00:00 2001 From: stivens Date: Thu, 6 Aug 2026 19:02:09 +0200 Subject: [PATCH 4/9] Address review: reject strict Option subtypes in usingNonEmpty, tighten comments - usingNonEmptyImpl now requires TARGET_TYPE =:= Option[payload]; a strict subtype like Some[String] previously escaped the quoted pattern's conformance check and crashed the expansion with an ExprCastException. Covered by a new compile-time test. - getHandledFields identifies *: by symbol instead of by name string. - ExternalAccessSpec asserts the "cannot be accessed" diagnostic, so the tests cannot pass on an unrelated error mentioning the member name. - Comment pass: dropped the addHandlerCall splice-once note (contradicted by the PR's own analysis), stale narration in compileImpl, and the doc blocks that restated signatures; trimmed the survivors. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 4 +- .../macros/CaseCompleteBuilder.scala | 150 ++++-------------- .../externaluser/ExternalAccessSpec.scala | 11 +- .../CaseComplete/CaseCompleteSpec.scala | 14 +- .../stivens/CaseComplete/LongChainSpec.scala | 12 +- 5 files changed, 54 insertions(+), 137 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d672a6..7422b5a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,8 +11,8 @@ jobs: ci: name: Compile and test CaseComplete runs-on: ubuntu-latest - # LongChainSpec guards against a compile-time blowup, so it fails by hanging rather than by - # an assertion. Without this it would hang for GitHub's 360-minute default. + # A LongChainSpec regression fails by hanging, so cap the job instead of burning + # GitHub's 360-minute default. timeout-minutes: 15 steps: - uses: actions/checkout@v4 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 1bf114d..593b091 100644 --- a/src/main/scala/io/github/stivens/CaseComplete/macros/CaseCompleteBuilder.scala +++ b/src/main/scala/io/github/stivens/CaseComplete/macros/CaseCompleteBuilder.scala @@ -5,37 +5,17 @@ import io.github.stivens.casecomplete.* import scala.quoted.* /** - * Builder class for creating CaseComplete instances with compile-time field completeness checking. - * - * The builder tracks which fields have been handled through the type parameter `Handled`, which is - * a tuple of field names. This enables compile-time verification that all case class fields have - * corresponding handlers. - * - * Usage examples: - * {{{ - * case class MovieFilter( - * title_like: Option[String] = None, - * director_eq: Option[String] = None, - * releaseYear: Option[Year] = None, - * rating_gte: Option[Double] = None - * ) - * - * val movieFilterHandler = CaseCompleteBuilder[MovieFilter, Option[String]] - * .usingNonEmpty(_.title_like)(title => s"title ILIKE $title") - * .usingNonEmpty(_.director_eq)(director => s"director = $director") - * .usingNonEmpty(_.releaseYear)(year => s"releaseYear = $year") - * .usingNonEmpty(_.rating_gte)(rating => s"rating >= $rating") - * .compile - * - * val filter = MovieFilter(releaseYear = Some(Year.of(1999)), rating_gte = Some(7.0)) - * val result = movieFilterHandler.eval(filter).toSet.flatten - * // Returns: Set("releaseYear = 1999", "rating >= 7.0") - * }}} - * - * @tparam SOURCE_TYPE The source case class type that must be a Product - * @tparam TARGET_TYPE The target type that each field handler produces - * @tparam Handled A tuple type representing the field names that have been handled so far - */ + * Builds a [[CaseComplete]] by registering one handler per field. `Handled` accumulates the handled + * field names as a tuple of singleton string types, so `compile` can verify completeness. + * + * {{{ + * val movieFilterHandler = CaseCompleteBuilder[MovieFilter, Option[String]] + * .usingNonEmpty(_.title_like)(title => s"title ILIKE $title") + * .usingNonEmpty(_.releaseYear)(year => s"releaseYear = $year") + * .ignoring(_.internalId) + * .compile + * }}} + */ class CaseCompleteBuilder[SOURCE_TYPE <: Product, TARGET_TYPE, Handled <: Tuple] private[casecomplete] ( private[casecomplete] val handlers: Map[String, SOURCE_TYPE => TARGET_TYPE] ) { @@ -53,17 +33,8 @@ class CaseCompleteBuilder[SOURCE_TYPE <: Product, TARGET_TYPE, Handled <: Tuple] new CaseCompleteBuilder(handlers) /** - * Registers a handler for a specific field of the source case class. - * - * This method extracts the field name at compile time and adds it to the `Handled` type parameter - * to track which fields have been processed. The field selector must be a simple field access - * expression like `_.fieldName`. - * - * @param field A field selector function that extracts a field from the source type - * @param handler A function that transforms the field value to the target type - * @tparam FIELD The type of the field being handled - * @return A new CaseCompleteBuilder with the updated handlers and type tracking - * + * Registers a handler for one field. The selector must be a plain field access, e.g. `_.title_like`. + * * @example * {{{ * builder.using(_.title_like)(_.map(title => s"title ILIKE $title")) @@ -98,16 +69,8 @@ class CaseCompleteBuilder[SOURCE_TYPE <: Product, TARGET_TYPE, Handled <: Tuple] ${ CaseCompleteBuilder.usingNonEmptyImpl('this, 'field, 'handler) } /** - * Explicitly ignores a specific field of the source case class. - * - * This method marks a field as handled without creating a handler for it. This is useful - * when you want to explicitly indicate that a field should be ignored during processing. - * The field selector must be a simple field access expression like `_.fieldName`. - * - * @param field A field selector function that extracts a field from the source type - * @tparam FIELD The type of the field being ignored - * @return A new CaseCompleteBuilder with the updated type tracking (no handler added) - * + * Marks a field as handled without registering a handler for it. + * * @example * {{{ * builder.ignoring(_.deprecatedField) @@ -119,22 +82,8 @@ class CaseCompleteBuilder[SOURCE_TYPE <: Product, TARGET_TYPE, Handled <: Tuple] ${ CaseCompleteBuilder.ignoringImpl('this, 'field) } /** - * Compiles the handler, verifying at compile time that all fields have been handled. - * - * This method performs compile-time validation to ensure that every field in the source - * case class has a corresponding handler. If any fields are missing, compilation will - * fail with a detailed error message listing the unhandled fields. - * - * @return A CaseComplete instance that can process source objects - * @throws Compilation error if any case class fields are missing handlers - * - * @example - * {{{ - * val handler = CaseCompleteBuilder[MovieFilter, Option[String]] - * .usingNonEmpty(_.title_like)(title => s"title ILIKE $title") - * .usingNonEmpty(_.director_eq)(director => s"director = $director") - * .compile // Will fail if releaseYear or rating_gte fields are not handled - * }}} + * Produces the final [[CaseComplete]], failing compilation with the list of unhandled fields if + * any field of SOURCE_TYPE has neither a handler nor an `ignoring` mark. */ inline def compile: CaseComplete[SOURCE_TYPE, TARGET_TYPE] = ${ CaseCompleteBuilder.compileImpl[SOURCE_TYPE, TARGET_TYPE, Handled]('this) } @@ -142,28 +91,12 @@ class CaseCompleteBuilder[SOURCE_TYPE <: Product, TARGET_TYPE, Handled <: Tuple] object CaseCompleteBuilder { - /** - * Creates a new CaseCompleteBuilder instance for the specified source and target types. - * - * This is the main entry point for creating CaseCompleteBuilder instances. The returned - * builder starts with no handlers and an empty tuple for the `Handled` type parameter. - * - * @tparam SOURCE_TYPE The source case class type that must be a Product - * @tparam TARGET_TYPE The target type that each field handler produces - * @return A new CaseCompleteBuilder instance ready for field handler registration - * - * @example - * {{{ - * val builder = CaseCompleteBuilder[MovieFilter, Option[String]] - * // builder is ready to accept field handlers via .using() calls - * }}} - */ def apply[SOURCE_TYPE <: Product, TARGET_TYPE]: CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, EmptyTuple] = new CaseCompleteBuilder(Map.empty[String, SOURCE_TYPE => TARGET_TYPE]) /** - * The `Any` fallback keeps this reducible for non-`Option` targets so that `usingNonEmptyImpl` - * reports the mismatch; without it the user gets a raw "match type reduction failed" instead. + * The `Any` fallback keeps this reducible for non-`Option` targets, so `usingNonEmptyImpl` gets to + * report the mismatch instead of the compiler's raw "match type reduction failed". */ type OptionPayload[T] = T match { case Option[payload] => payload @@ -202,9 +135,11 @@ object CaseCompleteBuilder { checkNotAlreadyHandled[Handled](fieldName) Type.of[TARGET_TYPE] match { - case '[Option[payload]] => - // OptionPayload[TARGET_TYPE] reduces to `payload` exactly here, but only after TARGET_TYPE - // has been matched, which the compiler cannot see through in the quote below. + // The pattern alone also admits strict subtypes like `Some[String]`, for which the asExprOf + // below would crash the expansion; the =:= guard sends them to the readable error instead. + case '[Option[payload]] if TypeRepr.of[TARGET_TYPE] =:= TypeRepr.of[Option[payload]] => + // Inside this case OptionPayload[TARGET_TYPE] is known to reduce to `payload`, but the + // quote below cannot see that -- hence the two casts. val fullHandler = '{ (s: SOURCE_TYPE) => $field(s).map(${ handler.asExprOf[FIELD => payload] }) } .asExprOf[SOURCE_TYPE => TARGET_TYPE] @@ -233,8 +168,6 @@ object CaseCompleteBuilder { } } - // `builder` is the whole preceding chain, so it must be spliced exactly once -- a second splice - // copies that tree. private def addHandlerCall[ SOURCE_TYPE <: Product: Type, TARGET_TYPE: Type, @@ -289,20 +222,6 @@ object CaseCompleteBuilder { } } - /** - * Macro implementation for the `compile` method. - * - * This macro performs compile-time validation to ensure all case class fields have - * corresponding handlers. It compares the set of handled fields (from the `Handled` - * type parameter) with the actual case class fields and reports any missing handlers. - * - * @param builder The current builder expression - * @tparam SOURCE_TYPE The source case class type - * @tparam TARGET_TYPE The target type - * @tparam Handled The handled fields tuple type - * @return An expression for the final CaseComplete instance - * @throws Compilation error if any case class fields are missing handlers - */ def compileImpl[ SOURCE_TYPE <: Product: Type, TARGET_TYPE: Type, @@ -312,15 +231,11 @@ object CaseCompleteBuilder { )(using q: Quotes): Expr[CaseComplete[SOURCE_TYPE, TARGET_TYPE]] = { import q.reflect.* - // Get the set of fields handled so far from the `Handled` type parameter. - val handledFields = getHandledFields(Type.of[Handled]) - // Get the set of all fields defined on the case class `A`. + val handledFields = getHandledFields(Type.of[Handled]) val caseClassFields = TypeRepr.of[SOURCE_TYPE].typeSymbol.caseFields.map(_.name).toSet - // Find the difference. val missingFields = caseClassFields -- handledFields - // If there are any missing fields, abort compilation with an error. if missingFields.nonEmpty then report.errorAndAbort(s""" |CaseComplete compilation failed: Missing handlers for ${missingFields.size} field(s) in class ${Type.show[SOURCE_TYPE]}. | @@ -333,23 +248,20 @@ object CaseCompleteBuilder { | // ... other handlers | .compile""") - // If all checks pass, generate the code for the final HandleAllFieldsImpl instance. '{ new CaseCompleteImpl($builder.handlers) } } - /** - * Unpacks `Handled` -- a tuple of singleton string types -- into the set of field names it records. - * - * Decoded structurally rather than with quoted type patterns (`'[head *: tail]`): every chain step - * walks the whole accumulated tuple, so this is quadratic over a chain, and the type comparer those - * patterns invoke made it ~10% of typer time at 96 fields. - */ + // 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. private def getHandledFields(t: Type[?])(using q: Quotes): Set[String] = { import q.reflect.* + val consSymbol = TypeRepr.of[Any *: Tuple].typeSymbol + def loop(repr: TypeRepr, acc: Set[String]): Set[String] = repr.dealias match { case AndType(left, _) => loop(left, acc) // the `t & Tuple` bound recovered at the call sites - case AppliedType(tycon, List(ConstantType(StringConstant(name)), tail)) if tycon.typeSymbol.name == "*:" => + case AppliedType(tycon, List(ConstantType(StringConstant(name)), tail)) if tycon.typeSymbol == consSymbol => loop(tail, acc + name) case empty if empty =:= TypeRepr.of[EmptyTuple] => acc case other => report.errorAndAbort(s"Internal error: HandledFields type was not a tuple: ${other.show}") diff --git a/src/test/scala/externaluser/ExternalAccessSpec.scala b/src/test/scala/externaluser/ExternalAccessSpec.scala index e6c95d9..1dade08 100644 --- a/src/test/scala/externaluser/ExternalAccessSpec.scala +++ b/src/test/scala/externaluser/ExternalAccessSpec.scala @@ -8,10 +8,9 @@ import scala.compiletime.testing.typeCheckErrors case class Filter(a: Option[String], b: Option[String]) /** - * The rest of the suite lives inside `io.github.stivens.casecomplete`, where `private[casecomplete]` - * is indistinguishable from public. This spec sits outside that package, so it is the only place - * that pins both halves of the access story: generated code still reaches the package-private - * members, and users cannot. + * Deliberately outside `io.github.stivens.casecomplete` -- the only vantage point where + * `private[casecomplete]` differs from public. Pins both directions: generated code reaches the + * package-private members, users cannot. */ class ExternalAccessSpec extends AnyFunSpec { @@ -35,7 +34,7 @@ class ExternalAccessSpec extends AnyFunSpec { .compile """) - assert(errors.exists(_.message.contains("markHandled"))) + assert(errors.exists(e => e.message.contains("markHandled") && e.message.contains("cannot be accessed"))) } it("should not let a builder be constructed directly") { @@ -43,7 +42,7 @@ class ExternalAccessSpec extends AnyFunSpec { new io.github.stivens.casecomplete.macros.CaseCompleteBuilder[Filter, Option[String], ("a", "b")](Map.empty) """) - assert(errors.exists(_.message.contains("CaseCompleteBuilder"))) + assert(errors.exists(_.message.contains("cannot be accessed"))) } } } diff --git a/src/test/scala/io/github/stivens/CaseComplete/CaseCompleteSpec.scala b/src/test/scala/io/github/stivens/CaseComplete/CaseCompleteSpec.scala index 615be46..43d8272 100644 --- a/src/test/scala/io/github/stivens/CaseComplete/CaseCompleteSpec.scala +++ b/src/test/scala/io/github/stivens/CaseComplete/CaseCompleteSpec.scala @@ -112,9 +112,8 @@ class CaseCompleteSpec extends AnyFunSpec { """) } - // Asserted on the message text, not just on failure: each of these messages exists only to be - // read, so a test that accepts any compile error would not notice it degrading into the raw - // compiler diagnostic it was written to replace. + // These assert the message text, not just failure: the messages exist to be read, and a + // failure-only test would not notice them degrading into raw compiler diagnostics. it("should report the unhandled field when one has no handler") { val errors = typeCheckErrors(""" CaseComplete.build[TwoFieldFilter, Option[String]] @@ -152,6 +151,15 @@ class CaseCompleteSpec extends AnyFunSpec { assert(errors.exists(_.message.contains("usingNonEmpty requires the target type to be an Option"))) } + + it("should reject a target type that is a strict subtype of Option") { + val errors = typeCheckErrors(""" + CaseComplete.build[TwoFieldFilter, Some[String]] + .usingNonEmpty(_.a)(identity) + """) + + assert(errors.exists(_.message.contains("usingNonEmpty requires the target type to be an Option"))) + } } } } diff --git a/src/test/scala/io/github/stivens/CaseComplete/LongChainSpec.scala b/src/test/scala/io/github/stivens/CaseComplete/LongChainSpec.scala index af76928..c0d08bc 100644 --- a/src/test/scala/io/github/stivens/CaseComplete/LongChainSpec.scala +++ b/src/test/scala/io/github/stivens/CaseComplete/LongChainSpec.scala @@ -3,14 +3,12 @@ package io.github.stivens.casecomplete import org.scalatest.funspec.AnyFunSpec /** - * Compile-time regression guard for the blowup described on `CaseCompleteBuilder.usingNonEmpty`. + * Regression guard for the compile-time blowup described on `CaseCompleteBuilder.usingNonEmpty`: + * pre-fix the cost was ~1.9x per chain step (16 steps: 4.0 s of posttyper; 20 steps: 54 s), so the + * 32 steps below would take hours; post-fix the file costs ~0.25 s. All three chaining methods are + * interleaved because each is equally at risk. * - * Chaining `transparent inline` extension methods costs ~1.9x per step: pre-fix, 16 steps spent 4.0 s - * in posttyper and 20 steps spent 54 s, so the 32 steps below would take hours. Post-fix the whole - * file costs ~0.25 s. All three chaining methods are interleaved because each is equally at risk. - * - * This fails by hanging rather than by a fast assertion, which is why the CI job sets - * `timeout-minutes`. + * A regression fails by hanging, not by assertion -- hence `timeout-minutes` on the CI job. */ class LongChainSpec extends AnyFunSpec { From ae3d03264edb3cc84207bfdf21344c25f53e6464 Mon Sep 17 00:00:00 2001 From: stivens Date: Thu, 6 Aug 2026 19:14:29 +0200 Subject: [PATCH 5/9] Consolidate the macro registration pipeline into registerField The three impls each hand-assembled extract -> duplicate-check -> emit, and the pre-3.4 `t & Tuple` bound recovery had two emit sites. registerField now owns the pipeline (handler absent = ignoring), absorbing newHandledType and addHandlerCall. getHandledFields takes Handled directly instead of a Type[?] round-trip, and its terminal EmptyTuple check compares symbols instead of invoking the type comparer. Co-Authored-By: Claude Fable 5 --- .../macros/CaseCompleteBuilder.scala | 76 +++++++++---------- 1 file changed, 36 insertions(+), 40 deletions(-) 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 593b091..fa9fb39 100644 --- a/src/main/scala/io/github/stivens/CaseComplete/macros/CaseCompleteBuilder.scala +++ b/src/main/scala/io/github/stivens/CaseComplete/macros/CaseCompleteBuilder.scala @@ -112,12 +112,8 @@ object CaseCompleteBuilder { builder: Expr[CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, Handled]], field: Expr[SOURCE_TYPE => FIELD], handler: Expr[FIELD => TARGET_TYPE] - )(using Quotes): Expr[CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, ?]] = { - val fieldName = extractFieldNameOrAbort(field) - checkNotAlreadyHandled[Handled](fieldName) - - addHandlerCall(builder, fieldName, '{ (s: SOURCE_TYPE) => $handler($field(s)) }) - } + )(using Quotes): Expr[CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, ?]] = + registerField(builder, field, Some('{ (s: SOURCE_TYPE) => $handler($field(s)) })) def usingNonEmptyImpl[ SOURCE_TYPE <: Product: Type, @@ -131,9 +127,6 @@ object CaseCompleteBuilder { )(using q: Quotes): Expr[CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, ?]] = { import q.reflect.* - val fieldName = extractFieldNameOrAbort(field) - checkNotAlreadyHandled[Handled](fieldName) - Type.of[TARGET_TYPE] match { // The pattern alone also admits strict subtypes like `Some[String]`, for which the asExprOf // below would crash the expansion; the =:= guard sends them to the readable error instead. @@ -144,7 +137,7 @@ object CaseCompleteBuilder { '{ (s: SOURCE_TYPE) => $field(s).map(${ handler.asExprOf[FIELD => payload] }) } .asExprOf[SOURCE_TYPE => TARGET_TYPE] - addHandlerCall(builder, fieldName, fullHandler) + registerField(builder, field, Some(fullHandler)) case _ => report.errorAndAbort( s"usingNonEmpty requires the target type to be an Option, but it is ${Type.show[TARGET_TYPE]}. Use `using` instead." @@ -159,27 +152,38 @@ object CaseCompleteBuilder { ]( builder: Expr[CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, Handled]], field: Expr[SOURCE_TYPE => ?] - )(using Quotes): Expr[CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, ?]] = { - val fieldName = extractFieldNameOrAbort(field) - checkNotAlreadyHandled[Handled](fieldName) - - newHandledType[Handled](fieldName) match { - case '[t] => '{ $builder.markHandled[t & Tuple] } - } - } + )(using Quotes): Expr[CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, ?]] = + registerField(builder, field, None) - private def addHandlerCall[ + // Owns the shared pipeline -- selector extraction, duplicate check, emit under the extended + // `Handled` type -- so a new validation or a change to the type encoding lands in one place. + // A quoted type pattern cannot express `<: Tuple` before Scala 3.4, hence the unbounded `'[t]` + // with the bound recovered as `t & Tuple`; getHandledFields strips that intersection back off. + private def registerField[ SOURCE_TYPE <: Product: Type, TARGET_TYPE: Type, Handled <: Tuple: Type ]( builder: Expr[CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, Handled]], - fieldName: String, - handler: Expr[SOURCE_TYPE => TARGET_TYPE] - )(using Quotes): Expr[CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, ?]] = - newHandledType[Handled](fieldName) match { - case '[t] => '{ $builder.addHandler[t & Tuple](${ Expr(fieldName) }, $handler) } + field: Expr[SOURCE_TYPE => ?], + handler: Option[Expr[SOURCE_TYPE => TARGET_TYPE]] + )(using q: Quotes): Expr[CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, ?]] = { + import q.reflect.* + + val fieldName = extractFieldNameOrAbort(field) + checkNotAlreadyHandled[Handled](fieldName) + + ConstantType(StringConstant(fieldName)).asType match { + case '[name] => + Type.of[name *: Handled] match { + case '[t] => + handler match { + case Some(h) => '{ $builder.addHandler[t & Tuple](${ Expr(fieldName) }, $h) } + case None => '{ $builder.markHandled[t & Tuple] } + } + } } + } private def extractFieldNameOrAbort(field: Expr[?])(using q: Quotes): String = { import q.reflect.* @@ -208,20 +212,11 @@ object CaseCompleteBuilder { private def checkNotAlreadyHandled[Handled <: Tuple: Type](fieldName: String)(using q: Quotes): Unit = { import q.reflect.* - if getHandledFields(Type.of[Handled]).contains(fieldName) then { + if getHandledFields[Handled].contains(fieldName) then { report.errorAndAbort(s"Field '$fieldName' has already been handled. Each field can only be handled once.") } } - // Returns an unbounded `Type[?]` because a quoted type pattern cannot express `<: Tuple` before - // Scala 3.4; call sites recover the bound with `t & Tuple`. - private def newHandledType[Handled <: Tuple: Type](fieldName: String)(using q: Quotes): Type[?] = { - import q.reflect.* - ConstantType(StringConstant(fieldName)).asType match { - case '[name] => Type.of[name *: Handled] - } - } - def compileImpl[ SOURCE_TYPE <: Product: Type, TARGET_TYPE: Type, @@ -231,7 +226,7 @@ object CaseCompleteBuilder { )(using q: Quotes): Expr[CaseComplete[SOURCE_TYPE, TARGET_TYPE]] = { import q.reflect.* - val handledFields = getHandledFields(Type.of[Handled]) + val handledFields = getHandledFields[Handled] val caseClassFields = TypeRepr.of[SOURCE_TYPE].typeSymbol.caseFields.map(_.name).toSet val missingFields = caseClassFields -- handledFields @@ -254,19 +249,20 @@ object CaseCompleteBuilder { // 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. - private def getHandledFields(t: Type[?])(using q: Quotes): Set[String] = { + private def getHandledFields[Handled <: Tuple: Type](using q: Quotes): Set[String] = { import q.reflect.* - val consSymbol = TypeRepr.of[Any *: Tuple].typeSymbol + 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 { - case AndType(left, _) => loop(left, acc) // the `t & Tuple` bound recovered at the call sites + case AndType(left, _) => loop(left, acc) // strips the `t & Tuple` emitted by registerField case AppliedType(tycon, List(ConstantType(StringConstant(name)), tail)) if tycon.typeSymbol == consSymbol => loop(tail, acc + name) - case empty if empty =:= TypeRepr.of[EmptyTuple] => acc + case empty if empty.typeSymbol == emptyTupleSymbol => acc case other => report.errorAndAbort(s"Internal error: HandledFields type was not a tuple: ${other.show}") } - loop(TypeRepr.of(using t), Set.empty) + loop(TypeRepr.of[Handled], Set.empty) } } From 817ed53923d1ce452b6d8cba04b07ab32e24859b Mon Sep 17 00:00:00 2001 From: stivens Date: Thu, 6 Aug 2026 19:32:55 +0200 Subject: [PATCH 6/9] Reject selectors that are not direct field accesses on the lambda parameter extractFieldName accepted any Select, so `using(_.a.b)` registered the source type's field "b" while the handler read a.b -- marking b handled with no real handler and defeating the completeness check. The receiver must now be the lambda's own parameter. Also: usingNonEmpty's non-Option error prints short type names (String, not scala.Predef.String), its scaladoc no longer claims the method is unavailable for non-Option targets, and the duplicate check gains an ignoring-then-using test. Co-Authored-By: Claude Fable 5 --- .../macros/CaseCompleteBuilder.scala | 32 +++++++++++-------- .../CaseComplete/CaseCompleteSpec.scala | 23 ++++++++++++- 2 files changed, 40 insertions(+), 15 deletions(-) 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 fa9fb39..3da21fd 100644 --- a/src/main/scala/io/github/stivens/CaseComplete/macros/CaseCompleteBuilder.scala +++ b/src/main/scala/io/github/stivens/CaseComplete/macros/CaseCompleteBuilder.scala @@ -53,8 +53,8 @@ class CaseCompleteBuilder[SOURCE_TYPE <: Product, TARGET_TYPE, Handled <: Tuple] /** * Registers a handler for an optional field, automatically handling the None case. * - * Equivalent to `using(_.field)(_.map(handler))`, and only available when the target type is an - * `Option`. + * Equivalent to `using(_.field)(_.map(handler))`. Fails compilation with a dedicated error when + * the target type is not an `Option`. * * @example * {{{ @@ -140,7 +140,7 @@ object CaseCompleteBuilder { registerField(builder, field, Some(fullHandler)) case _ => report.errorAndAbort( - s"usingNonEmpty requires the target type to be an Option, but it is ${Type.show[TARGET_TYPE]}. Use `using` instead." + s"usingNonEmpty requires the target type to be an Option, but it is ${TypeRepr.of[TARGET_TYPE].show(using Printer.TypeReprShortCode)}. Use `using` instead." ) } } @@ -188,17 +188,21 @@ object CaseCompleteBuilder { private def extractFieldNameOrAbort(field: Expr[?])(using q: Quotes): String = { import q.reflect.* - def extractFieldName(term: Term): Option[String] = term match { - case Select(_, name) => Some(name) - case Inlined(_, _, block) => extractFieldName(block) - case Block(ls, _) => - ls match { - case (defdef: DefDef) :: _ => - defdef match { - case DefDef(_, _, _, Some(body)) => extractFieldName(body) - case _ => None - } - case _ => None + def strip(term: Term): Term = term match { + case Inlined(_, _, inner) => strip(inner) + case Typed(inner, _) => strip(inner) + case Block(Nil, inner) => strip(inner) + case _ => term + } + + // The receiver must be the lambda's own parameter: accepting any Select would let `_.a.b` + // register the *source type's* field "b" and silently defeat the completeness check. + def extractFieldName(term: Term): Option[String] = strip(term) match { + case Block(List(defdef @ DefDef(_, _, _, Some(body))), _) => + val params = defdef.termParamss.flatMap(_.params).map(_.symbol) + strip(body) match { + case Select(receiver: Ident, name) if params.contains(receiver.symbol) => Some(name) + case _ => None } case _ => None } diff --git a/src/test/scala/io/github/stivens/CaseComplete/CaseCompleteSpec.scala b/src/test/scala/io/github/stivens/CaseComplete/CaseCompleteSpec.scala index 43d8272..37ac0cd 100644 --- a/src/test/scala/io/github/stivens/CaseComplete/CaseCompleteSpec.scala +++ b/src/test/scala/io/github/stivens/CaseComplete/CaseCompleteSpec.scala @@ -143,6 +143,25 @@ class CaseCompleteSpec extends AnyFunSpec { assert(errors.exists(_.message.contains("expected a field selector"))) } + it("should reject a nested selector, which would register the inner field's name against the source type") { + val errors = typeCheckErrors(""" + CaseComplete.build[NestedFilter, Option[String]] + .using(_.a.b)(identity) + """) + + assert(errors.exists(_.message.contains("expected a field selector"))) + } + + it("should report the field name when a field is ignored and then handled") { + val errors = typeCheckErrors(""" + CaseComplete.build[TwoFieldFilter, Option[String]] + .ignoring(_.a) + .using(_.a)(identity) + """) + + assert(errors.exists(_.message.contains("Field 'a' has already been handled"))) + } + it("should point at `using` when usingNonEmpty is applied to a non-Option target") { val errors = typeCheckErrors(""" CaseComplete.build[TwoFieldFilter, String] @@ -164,5 +183,7 @@ class CaseCompleteSpec extends AnyFunSpec { } } -// Top-level so the type-checking snippets above can name it. +// Top-level so the type-checking snippets above can name them. case class TwoFieldFilter(a: Option[String], b: Option[String]) +case class NestedInner(b: Option[String]) +case class NestedFilter(a: NestedInner, b: Option[String]) From e88acf71a189c178e8ba875ce915c3f08de2964e Mon Sep 17 00:00:00 2001 From: stivens Date: Thu, 6 Aug 2026 19:39:30 +0200 Subject: [PATCH 7/9] Simplify the macro internals - registerField splices `name *: Handled` directly; the `'[t]` re-match with the `t & Tuple` bound recovery was never needed since `Handled`'s Tuple bound is statically known, and getHandledFields loses its AndType-stripping case with it. - extractFieldNameOrAbort uses `underlyingArgument` and the `Lambda` extractor instead of hand-rolled Inlined/Typed/Block stripping and DefDef dissection. - checkNotAlreadyHandled inlined into registerField (single caller). - CaseCompleteImpl drops pair-destructuring ceremony. Co-Authored-By: Claude Fable 5 --- .../stivens/CaseComplete/CaseComplete.scala | 6 +-- .../macros/CaseCompleteBuilder.scala | 49 ++++++------------- 2 files changed, 16 insertions(+), 39 deletions(-) diff --git a/src/main/scala/io/github/stivens/CaseComplete/CaseComplete.scala b/src/main/scala/io/github/stivens/CaseComplete/CaseComplete.scala index 909f51d..17b361c 100644 --- a/src/main/scala/io/github/stivens/CaseComplete/CaseComplete.scala +++ b/src/main/scala/io/github/stivens/CaseComplete/CaseComplete.scala @@ -15,10 +15,8 @@ private[casecomplete] class CaseCompleteImpl[SOURCE_TYPE <: Product, TARGET_TYPE handlers: Map[String, SOURCE_TYPE => TARGET_TYPE] ) extends CaseComplete[SOURCE_TYPE, TARGET_TYPE] { private val sortedHandlers: List[SOURCE_TYPE => TARGET_TYPE] = - handlers.toList - .sortBy { case (fieldName, _) => fieldName } - .map { case (_, handler) => handler } + handlers.toList.sortBy(_._1).map(_._2) def eval(source: SOURCE_TYPE): List[TARGET_TYPE] = - sortedHandlers.map(handler => handler(source)) + sortedHandlers.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 3da21fd..0040439 100644 --- a/src/main/scala/io/github/stivens/CaseComplete/macros/CaseCompleteBuilder.scala +++ b/src/main/scala/io/github/stivens/CaseComplete/macros/CaseCompleteBuilder.scala @@ -157,8 +157,6 @@ object CaseCompleteBuilder { // Owns the shared pipeline -- selector extraction, duplicate check, emit under the extended // `Handled` type -- so a new validation or a change to the type encoding lands in one place. - // A quoted type pattern cannot express `<: Tuple` before Scala 3.4, hence the unbounded `'[t]` - // with the bound recovered as `t & Tuple`; getHandledFields strips that intersection back off. private def registerField[ SOURCE_TYPE <: Product: Type, TARGET_TYPE: Type, @@ -171,16 +169,15 @@ object CaseCompleteBuilder { import q.reflect.* val fieldName = extractFieldNameOrAbort(field) - checkNotAlreadyHandled[Handled](fieldName) + if getHandledFields[Handled].contains(fieldName) then { + report.errorAndAbort(s"Field '$fieldName' has already been handled. Each field can only be handled once.") + } ConstantType(StringConstant(fieldName)).asType match { case '[name] => - Type.of[name *: Handled] match { - case '[t] => - handler match { - case Some(h) => '{ $builder.addHandler[t & Tuple](${ Expr(fieldName) }, $h) } - case None => '{ $builder.markHandled[t & Tuple] } - } + handler match { + case Some(h) => '{ $builder.addHandler[name *: Handled](${ Expr(fieldName) }, $h) } + case None => '{ $builder.markHandled[name *: Handled] } } } } @@ -188,37 +185,20 @@ object CaseCompleteBuilder { private def extractFieldNameOrAbort(field: Expr[?])(using q: Quotes): String = { import q.reflect.* - def strip(term: Term): Term = term match { - case Inlined(_, _, inner) => strip(inner) - case Typed(inner, _) => strip(inner) - case Block(Nil, inner) => strip(inner) - case _ => term - } - // The receiver must be the lambda's own parameter: accepting any Select would let `_.a.b` // register the *source type's* field "b" and silently defeat the completeness check. - def extractFieldName(term: Term): Option[String] = strip(term) match { - case Block(List(defdef @ DefDef(_, _, _, Some(body))), _) => - val params = defdef.termParamss.flatMap(_.params).map(_.symbol) - strip(body) match { - case Select(receiver: Ident, name) if params.contains(receiver.symbol) => Some(name) - case _ => None + val fieldName = field.asTerm.underlyingArgument match { + case Lambda(List(param), body) => + body.underlyingArgument match { + case Select(receiver: Ident, name) if receiver.symbol == param.symbol => Some(name) + case _ => None } case _ => None } - val fieldAsTerm = field.asTerm - extractFieldName(fieldAsTerm) match { - case Some(name) => name - case None => report.errorAndAbort(s"Illegal expression: ${fieldAsTerm.show}, expected a field selector, e.g. `_.foo`") - } - } - - private def checkNotAlreadyHandled[Handled <: Tuple: Type](fieldName: String)(using q: Quotes): Unit = { - import q.reflect.* - if getHandledFields[Handled].contains(fieldName) then { - report.errorAndAbort(s"Field '$fieldName' has already been handled. Each field can only be handled once.") - } + fieldName.getOrElse( + report.errorAndAbort(s"Illegal expression: ${field.asTerm.show}, expected a field selector, e.g. `_.foo`") + ) } def compileImpl[ @@ -260,7 +240,6 @@ object CaseCompleteBuilder { val emptyTupleSymbol = TypeRepr.of[EmptyTuple].dealias.typeSymbol def loop(repr: TypeRepr, acc: Set[String]): Set[String] = repr.dealias match { - case AndType(left, _) => loop(left, acc) // strips the `t & Tuple` emitted by registerField case AppliedType(tycon, List(ConstantType(StringConstant(name)), tail)) if tycon.typeSymbol == consSymbol => loop(tail, acc + name) case empty if empty.typeSymbol == emptyTupleSymbol => acc From d9ef046baec055a2fb3f356d62fc10b69eb4eeb1 Mon Sep 17 00:00:00 2001 From: stivens Date: Fri, 7 Aug 2026 13:54:03 +0200 Subject: [PATCH 8/9] Harden selector validation and unify the registration pipeline's diagnostics Review fixes: - Selectors must name a case field: `_.productArity` or a body val is a Select on the lambda parameter too, and previously registered a phantom handler that ran on every eval outside the completeness check's universe. - usingNonEmpty's error no longer reads as self-contradicting for Some[String] ("must be exactly Option[...]" instead of "an Option"). - getHandledFields states the invariant its structural decoding relies on, and the abort distinguishes a user-widened builder type (with actionable advice) from a genuine internal error. The widened case is reachable by ascribing a chain to `CaseCompleteBuilder[..., ?]` and compiling. - ExternalAccessSpec now pins all four package-private members (addHandler and handlers joined markHandled and the constructor). Cleanups: - caseFieldNames is the single definition of the handleable-field universe, consulted by both registerField's gate and compileImpl's completeness check. - registerField takes the handler by name, so usingNonEmpty's target-type check runs after the shared selector/case-field/duplicate checks and all three entry points report selector errors with the same precedence. - The Option-target check matches the type constructor's symbol structurally, replacing the quoted pattern + `=:=` guard pair. - The 14 copies of the typeCheckErrors assertion scaffold collapsed into inline helpers whose failures print the actual compiler messages. - Two new tests pin that ignoring and usingNonEmpty route through the shared pipeline rather than only using. Tests 17 -> 24. Co-Authored-By: Claude Fable 5 --- .../macros/CaseCompleteBuilder.scala | 83 ++++++++---- .../externaluser/ExternalAccessSpec.scala | 43 +++++- .../CaseComplete/CaseCompleteSpec.scala | 126 ++++++++++++++---- .../stivens/CaseComplete/LongChainSpec.scala | 3 +- 4 files changed, 192 insertions(+), 63 deletions(-) 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 0040439..e703f5e 100644 --- a/src/main/scala/io/github/stivens/CaseComplete/macros/CaseCompleteBuilder.scala +++ b/src/main/scala/io/github/stivens/CaseComplete/macros/CaseCompleteBuilder.scala @@ -27,11 +27,14 @@ class CaseCompleteBuilder[SOURCE_TYPE <: Product, TARGET_TYPE, Handled <: Tuple] name: String, handler: SOURCE_TYPE => TARGET_TYPE ): CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, NewHandled] = - new CaseCompleteBuilder(handlers + (name -> handler)) + new CaseCompleteBuilder(handlers.updated(name, handler)) private[casecomplete] def markHandled[NewHandled <: Tuple]: CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, NewHandled] = new CaseCompleteBuilder(handlers) + // `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. /** * Registers a handler for one field. The selector must be a plain field access, e.g. `_.title_like`. * @@ -47,9 +50,6 @@ class CaseCompleteBuilder[SOURCE_TYPE <: Product, TARGET_TYPE, Handled <: Tuple] ): CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, ?] = // The '?' hides the complex result type from the user ${ CaseCompleteBuilder.usingImpl('this, 'field, 'handler) } - // Keep this and its sibling 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. /** * Registers a handler for an optional field, automatically handling the None case. * @@ -127,22 +127,24 @@ object CaseCompleteBuilder { )(using q: Quotes): Expr[CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, ?]] = { import q.reflect.* - Type.of[TARGET_TYPE] match { - // The pattern alone also admits strict subtypes like `Some[String]`, for which the asExprOf - // below would crash the expansion; the =:= guard sends them to the readable error instead. - case '[Option[payload]] if TypeRepr.of[TARGET_TYPE] =:= TypeRepr.of[Option[payload]] => - // Inside this case OptionPayload[TARGET_TYPE] is known to reduce to `payload`, but the - // quote below cannot see that -- hence the two casts. - val fullHandler = - '{ (s: SOURCE_TYPE) => $field(s).map(${ handler.asExprOf[FIELD => payload] }) } - .asExprOf[SOURCE_TYPE => TARGET_TYPE] - - registerField(builder, field, Some(fullHandler)) + // Matched on the type constructor's symbol: a quoted pattern ('[Option[payload]]) also admits + // strict subtypes like `Some[String]`, for which the asExprOf below would crash the expansion. + def fullHandler = TypeRepr.of[TARGET_TYPE].dealias match { + case AppliedType(tycon, List(payloadRepr)) if tycon.typeSymbol == TypeRepr.of[Option[Any]].typeSymbol => + payloadRepr.asType match { + case '[payload] => + // Here OptionPayload[TARGET_TYPE] is known to reduce to `payload`, but the quote + // cannot see that -- hence the two casts. + '{ (s: SOURCE_TYPE) => $field(s).map(${ handler.asExprOf[FIELD => payload] }) } + .asExprOf[SOURCE_TYPE => TARGET_TYPE] + } case _ => report.errorAndAbort( - s"usingNonEmpty requires the target type to be an Option, but it is ${TypeRepr.of[TARGET_TYPE].show(using Printer.TypeReprShortCode)}. Use `using` instead." + s"usingNonEmpty requires the target type to be exactly Option[...], but it is ${TypeRepr.of[TARGET_TYPE].show(using Printer.TypeReprShortCode)}. Use `using` instead." ) } + + registerField(builder, field, Some(fullHandler)) } def ignoringImpl[ @@ -164,11 +166,23 @@ object CaseCompleteBuilder { ]( builder: Expr[CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, Handled]], field: Expr[SOURCE_TYPE => ?], - handler: Option[Expr[SOURCE_TYPE => TARGET_TYPE]] + // By-name so an entry point's own validation (usingNonEmpty's Option-target check) runs after + // the shared checks below -- every entry point reports selector errors with the same precedence. + handler: => Option[Expr[SOURCE_TYPE => TARGET_TYPE]] )(using q: Quotes): Expr[CaseCompleteBuilder[SOURCE_TYPE, TARGET_TYPE, ?]] = { import q.reflect.* val fieldName = extractFieldNameOrAbort(field) + + // A parameterless method (`_.productArity`) or a body val is a Select on the parameter too; + // registering one would add a phantom handler outside the completeness check's universe. + val caseFields = caseFieldNames[SOURCE_TYPE] + if !caseFields.contains(fieldName) then { + report.errorAndAbort( + s"'$fieldName' is not a case field of ${Type.show[SOURCE_TYPE]}. Only constructor fields can be handled: ${caseFields.mkString(", ")}." + ) + } + if getHandledFields[Handled].contains(fieldName) then { report.errorAndAbort(s"Field '$fieldName' has already been handled. Each field can only be handled once.") } @@ -185,20 +199,20 @@ object CaseCompleteBuilder { private def extractFieldNameOrAbort(field: Expr[?])(using q: Quotes): String = { import q.reflect.* + val selector = field.asTerm.underlyingArgument + def abort: Nothing = + report.errorAndAbort(s"Illegal expression: ${selector.show}, expected a field selector, e.g. `_.foo`") + // The receiver must be the lambda's own parameter: accepting any Select would let `_.a.b` // register the *source type's* field "b" and silently defeat the completeness check. - val fieldName = field.asTerm.underlyingArgument match { + selector match { case Lambda(List(param), body) => body.underlyingArgument match { - case Select(receiver: Ident, name) if receiver.symbol == param.symbol => Some(name) - case _ => None + case Select(receiver: Ident, name) if receiver.symbol == param.symbol => name + case _ => abort } - case _ => None + case _ => abort } - - fieldName.getOrElse( - report.errorAndAbort(s"Illegal expression: ${field.asTerm.show}, expected a field selector, e.g. `_.foo`") - ) } def compileImpl[ @@ -211,7 +225,7 @@ object CaseCompleteBuilder { import q.reflect.* val handledFields = getHandledFields[Handled] - val caseClassFields = TypeRepr.of[SOURCE_TYPE].typeSymbol.caseFields.map(_.name).toSet + val caseClassFields = caseFieldNames[SOURCE_TYPE].toSet val missingFields = caseClassFields -- handledFields @@ -230,9 +244,18 @@ object CaseCompleteBuilder { '{ new CaseCompleteImpl($builder.handlers) } } + // The single definition of the handleable-field universe: registerField's gate and compileImpl's + // completeness check must agree on it, or a field could be rejected yet demanded. + private def caseFieldNames[SOURCE_TYPE: Type](using q: Quotes): List[String] = { + import q.reflect.* + TypeRepr.of[SOURCE_TYPE].typeSymbol.caseFields.map(_.name) + } + // 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. + // 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] = { import q.reflect.* @@ -243,7 +266,11 @@ object CaseCompleteBuilder { case AppliedType(tycon, List(ConstantType(StringConstant(name)), tail)) if tycon.typeSymbol == consSymbol => loop(tail, acc + name) case empty if empty.typeSymbol == emptyTupleSymbol => acc - case other => report.errorAndAbort(s"Internal error: HandledFields type was not a tuple: ${other.show}") + case other if other.typeSymbol.isAbstractType => + report.errorAndAbort( + s"Cannot read the handled fields from type ${other.show}: the builder was ascribed a widened type (e.g. `CaseCompleteBuilder[..., ?]`). Keep the chain's inferred type instead." + ) + case other => report.errorAndAbort(s"Internal error: unexpected Handled type: ${other.show}") } loop(TypeRepr.of[Handled], Set.empty) diff --git a/src/test/scala/externaluser/ExternalAccessSpec.scala b/src/test/scala/externaluser/ExternalAccessSpec.scala index 1dade08..fd6215f 100644 --- a/src/test/scala/externaluser/ExternalAccessSpec.scala +++ b/src/test/scala/externaluser/ExternalAccessSpec.scala @@ -27,22 +27,51 @@ class ExternalAccessSpec extends AnyFunSpec { } it("should not let a field be marked handled without a handler") { - val errors = typeCheckErrors(""" + assertInaccessible( + """ CaseComplete.build[Filter, Option[String]] .using(_.a)(identity) .markHandled[("b", "a")] .compile - """) - - assert(errors.exists(e => e.message.contains("markHandled") && e.message.contains("cannot be accessed"))) + """, + "markHandled" + ) } it("should not let a builder be constructed directly") { - val errors = typeCheckErrors(""" + assertInaccessible( + """ new io.github.stivens.casecomplete.macros.CaseCompleteBuilder[Filter, Option[String], ("a", "b")](Map.empty) - """) + """, + "CaseCompleteBuilder" + ) + } - assert(errors.exists(_.message.contains("cannot be accessed"))) + it("should not let a handler be registered under a forged field name") { + assertInaccessible( + """ + CaseComplete.build[Filter, Option[String]] + .using(_.a)(identity) + .addHandler[("b", "a")]("b", _ => None) + .compile + """, + "addHandler" + ) } + + it("should not expose the handler map") { + assertInaccessible( + """CaseComplete.build[Filter, Option[String]].handlers""", + "handlers" + ) + } + } + + private inline def assertInaccessible(inline code: String, member: String): Unit = { + val errors = typeCheckErrors(code) + assert( + errors.exists(e => e.message.contains(member) && e.message.contains("cannot be accessed")), + s"no error said '$member' cannot be accessed; got: ${errors.map(_.message)}" + ) } } diff --git a/src/test/scala/io/github/stivens/CaseComplete/CaseCompleteSpec.scala b/src/test/scala/io/github/stivens/CaseComplete/CaseCompleteSpec.scala index 37ac0cd..e8b742e 100644 --- a/src/test/scala/io/github/stivens/CaseComplete/CaseCompleteSpec.scala +++ b/src/test/scala/io/github/stivens/CaseComplete/CaseCompleteSpec.scala @@ -115,75 +115,147 @@ class CaseCompleteSpec extends AnyFunSpec { // These assert the message text, not just failure: the messages exist to be read, and a // failure-only test would not notice them degrading into raw compiler diagnostics. it("should report the unhandled field when one has no handler") { - val errors = typeCheckErrors(""" + assertErrorContains( + """ CaseComplete.build[TwoFieldFilter, Option[String]] .using(_.a)(identity) .compile - """) - - assert(errors.exists(_.message.contains("Missing handlers for fields: b"))) + """, + "Missing handlers for fields: b" + ) } it("should report the field name when the same field is handled twice") { - val errors = typeCheckErrors(""" + assertErrorContains( + """ CaseComplete.build[TwoFieldFilter, Option[String]] .using(_.a)(identity) .using(_.a)(identity) - """) - - assert(errors.exists(_.message.contains("Field 'a' has already been handled"))) + """, + "Field 'a' has already been handled" + ) } it("should report the offending expression when the selector is not a plain field access") { - val errors = typeCheckErrors(""" + assertErrorContains( + """ CaseComplete.build[TwoFieldFilter, Option[String]] .using(filter => filter.a.map(_.trim))(identity) - """) - - assert(errors.exists(_.message.contains("expected a field selector"))) + """, + "expected a field selector" + ) } it("should reject a nested selector, which would register the inner field's name against the source type") { - val errors = typeCheckErrors(""" + assertErrorContains( + """ CaseComplete.build[NestedFilter, Option[String]] .using(_.a.b)(identity) - """) - - assert(errors.exists(_.message.contains("expected a field selector"))) + """, + "expected a field selector" + ) } it("should report the field name when a field is ignored and then handled") { - val errors = typeCheckErrors(""" + assertErrorContains( + """ CaseComplete.build[TwoFieldFilter, Option[String]] .ignoring(_.a) .using(_.a)(identity) - """) - - assert(errors.exists(_.message.contains("Field 'a' has already been handled"))) + """, + "Field 'a' has already been handled" + ) } it("should point at `using` when usingNonEmpty is applied to a non-Option target") { - val errors = typeCheckErrors(""" + assertErrorContains( + """ CaseComplete.build[TwoFieldFilter, String] .usingNonEmpty(_.a)(value => value) - """) - - assert(errors.exists(_.message.contains("usingNonEmpty requires the target type to be an Option"))) + """, + "usingNonEmpty requires the target type to be exactly Option" + ) } it("should reject a target type that is a strict subtype of Option") { - val errors = typeCheckErrors(""" + assertErrorContains( + """ CaseComplete.build[TwoFieldFilter, Some[String]] .usingNonEmpty(_.a)(identity) - """) + """, + "usingNonEmpty requires the target type to be exactly Option" + ) + } + + it("should reject a selector that is not a case field, such as an inherited method") { + assertErrorContains( + """ + CaseComplete.build[TwoFieldFilter, Option[String]] + .using(_.productArity)(_ => None) + """, + "'productArity' is not a case field" + ) + } + + it("should reject a selector that names a body val rather than a constructor field") { + assertErrorContains( + """ + CaseComplete.build[BodyValFilter, Option[String]] + .using(_.derived)(_ => None) + """, + "'derived' is not a case field" + ) + } - assert(errors.exists(_.message.contains("usingNonEmpty requires the target type to be an Option"))) + it("should explain the fix when compile is called on a builder ascribed a widened type") { + assertErrorContains( + """ + val b: macros.CaseCompleteBuilder[TwoFieldFilter, Option[String], ?] = + CaseComplete.build[TwoFieldFilter, Option[String]] + .using(_.a)(identity) + .using(_.b)(identity) + b.compile + """, + "the chain's inferred type" + ) + } + + // The three validations live in registerField, shared by all entry points; these pin that + // `ignoring` and `usingNonEmpty` route through it rather than just `using`. + it("should run the shared selector checks for ignoring too") { + assertErrorContains( + """CaseComplete.build[NestedFilter, Option[String]].ignoring(_.a.b)""", + "expected a field selector" + ) + assertErrorContains( + """CaseComplete.build[TwoFieldFilter, Option[String]].ignoring(_.productArity)""", + "'productArity' is not a case field" + ) + } + + it("should report a bad selector before usingNonEmpty's target-type check") { + assertErrorContains( + """ + CaseComplete.build[NestedFilter, String] + .usingNonEmpty(_.a.b)(identity) + """, + "expected a field selector" + ) } } } + + private inline def assertErrorContains(inline code: String, expected: String): Unit = { + val errors = typeCheckErrors(code) + assert( + errors.exists(_.message.contains(expected)), + s"no compile error contained '$expected'; got: ${errors.map(_.message)}" + ) + } } // Top-level so the type-checking snippets above can name them. case class TwoFieldFilter(a: Option[String], b: Option[String]) case class NestedInner(b: Option[String]) case class NestedFilter(a: NestedInner, b: Option[String]) +case class BodyValFilter(a: Option[String]) { val derived: Option[String] = a } diff --git a/src/test/scala/io/github/stivens/CaseComplete/LongChainSpec.scala b/src/test/scala/io/github/stivens/CaseComplete/LongChainSpec.scala index c0d08bc..1b46416 100644 --- a/src/test/scala/io/github/stivens/CaseComplete/LongChainSpec.scala +++ b/src/test/scala/io/github/stivens/CaseComplete/LongChainSpec.scala @@ -3,7 +3,8 @@ package io.github.stivens.casecomplete import org.scalatest.funspec.AnyFunSpec /** - * Regression guard for the compile-time blowup described on `CaseCompleteBuilder.usingNonEmpty`: + * Regression guard for the compile-time blowup described in `CaseCompleteBuilder` (the note above + * `using`): * pre-fix the cost was ~1.9x per chain step (16 steps: 4.0 s of posttyper; 20 steps: 54 s), so the * 32 steps below would take hours; post-fix the file costs ~0.25 s. All three chaining methods are * interleaved because each is equally at risk. From b8f2e950d4fd170549ca71c29e0b5dcd8884683f Mon Sep 17 00:00:00 2001 From: stivens Date: Fri, 7 Aug 2026 14:21:55 +0200 Subject: [PATCH 9/9] Allow members outside the primary constructor to be handled Handling a body val or parameterless method is harmless: it cannot share a name with a constructor field, so it can never satisfy another field's completeness obligation, and compileImpl only demands the constructor fields. Drop registerField's case-field gate and pin the new contract: extra members can be handled and evaluated, but do not count toward completeness. Nested selectors stay rejected. Also, from review: - Fix the self-recursive MovieFilter test fixture (its object initializer called its own companion apply, blowing the stack on any runtime instantiation). - Share one compile-error assertion helper between the specs. - Hoist the duplicated four-step handler chain in the extra-fields tests. Co-Authored-By: Claude Fable 5 --- .../macros/CaseCompleteBuilder.scala | 18 +----- .../externaluser/ExternalAccessSpec.scala | 14 ++--- .../CaseComplete/CaseCompleteSpec.scala | 61 ++++++++----------- .../testsupport/CompileErrorAssertions.scala | 17 ++++++ 4 files changed, 47 insertions(+), 63 deletions(-) create mode 100644 src/test/scala/testsupport/CompileErrorAssertions.scala 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 e703f5e..7c1cd1a 100644 --- a/src/main/scala/io/github/stivens/CaseComplete/macros/CaseCompleteBuilder.scala +++ b/src/main/scala/io/github/stivens/CaseComplete/macros/CaseCompleteBuilder.scala @@ -174,15 +174,6 @@ object CaseCompleteBuilder { val fieldName = extractFieldNameOrAbort(field) - // A parameterless method (`_.productArity`) or a body val is a Select on the parameter too; - // registering one would add a phantom handler outside the completeness check's universe. - val caseFields = caseFieldNames[SOURCE_TYPE] - if !caseFields.contains(fieldName) then { - report.errorAndAbort( - s"'$fieldName' is not a case field of ${Type.show[SOURCE_TYPE]}. Only constructor fields can be handled: ${caseFields.mkString(", ")}." - ) - } - if getHandledFields[Handled].contains(fieldName) then { report.errorAndAbort(s"Field '$fieldName' has already been handled. Each field can only be handled once.") } @@ -225,7 +216,7 @@ object CaseCompleteBuilder { import q.reflect.* val handledFields = getHandledFields[Handled] - val caseClassFields = caseFieldNames[SOURCE_TYPE].toSet + val caseClassFields = TypeRepr.of[SOURCE_TYPE].typeSymbol.caseFields.map(_.name).toSet val missingFields = caseClassFields -- handledFields @@ -244,13 +235,6 @@ object CaseCompleteBuilder { '{ new CaseCompleteImpl($builder.handlers) } } - // The single definition of the handleable-field universe: registerField's gate and compileImpl's - // completeness check must agree on it, or a field could be rejected yet demanded. - private def caseFieldNames[SOURCE_TYPE: Type](using q: Quotes): List[String] = { - import q.reflect.* - TypeRepr.of[SOURCE_TYPE].typeSymbol.caseFields.map(_.name) - } - // 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 diff --git a/src/test/scala/externaluser/ExternalAccessSpec.scala b/src/test/scala/externaluser/ExternalAccessSpec.scala index fd6215f..652c203 100644 --- a/src/test/scala/externaluser/ExternalAccessSpec.scala +++ b/src/test/scala/externaluser/ExternalAccessSpec.scala @@ -2,8 +2,7 @@ package externaluser import io.github.stivens.casecomplete.CaseComplete import org.scalatest.funspec.AnyFunSpec - -import scala.compiletime.testing.typeCheckErrors +import testsupport.CompileErrorAssertions case class Filter(a: Option[String], b: Option[String]) @@ -12,7 +11,7 @@ case class Filter(a: Option[String], b: Option[String]) * `private[casecomplete]` differs from public. Pins both directions: generated code reaches the * package-private members, users cannot. */ -class ExternalAccessSpec extends AnyFunSpec { +class ExternalAccessSpec extends AnyFunSpec with CompileErrorAssertions { describe("a builder used from outside the library's package") { @@ -67,11 +66,6 @@ class ExternalAccessSpec extends AnyFunSpec { } } - private inline def assertInaccessible(inline code: String, member: String): Unit = { - val errors = typeCheckErrors(code) - assert( - errors.exists(e => e.message.contains(member) && e.message.contains("cannot be accessed")), - s"no error said '$member' cannot be accessed; got: ${errors.map(_.message)}" - ) - } + private inline def assertInaccessible(inline code: String, member: String): Unit = + assertErrorContains(code, member, "cannot be accessed") } diff --git a/src/test/scala/io/github/stivens/CaseComplete/CaseCompleteSpec.scala b/src/test/scala/io/github/stivens/CaseComplete/CaseCompleteSpec.scala index e8b742e..5ff3e1a 100644 --- a/src/test/scala/io/github/stivens/CaseComplete/CaseCompleteSpec.scala +++ b/src/test/scala/io/github/stivens/CaseComplete/CaseCompleteSpec.scala @@ -1,11 +1,11 @@ package io.github.stivens.casecomplete import org.scalatest.funspec.AnyFunSpec +import testsupport.CompileErrorAssertions import java.time.Year -import scala.compiletime.testing.typeCheckErrors -class CaseCompleteSpec extends AnyFunSpec { +class CaseCompleteSpec extends AnyFunSpec with CompileErrorAssertions { describe("CaseCompleteBuilder") { describe("when given a source type and a target type") { @@ -84,25 +84,35 @@ class CaseCompleteSpec extends AnyFunSpec { } object MovieFilter { - val empty = MovieFilter() + // Explicit `new`: `MovieFilter()` would re-enter this initializer through the companion's apply. + val empty = new MovieFilter(None, None, None, None) } - val buildMovieFilterHandler = CaseComplete.build[MovieFilter, Option[String]] + val allConstructorFieldsHandled = CaseComplete + .build[MovieFilter, Option[String]] + .using(_.title_like)(_ => None) + .using(_.director_eq)(_ => None) + .using(_.releaseYear_eq)(_ => None) + .using(_.rating_gte)(_ => None) it("should not require the extra fields to be handled") { - val movieFilterHandler = buildMovieFilterHandler - .using(_.title_like)(_ => None) - .using(_.director_eq)(_ => None) - .using(_.releaseYear_eq)(_ => None) - .using(_.rating_gte)(_ => None) - .compile + allConstructorFieldsHandled.compile assert(true) // code compiles } + + it("should allow the extra fields to be handled") { + val movieFilterHandler = allConstructorFieldsHandled + .using(_.foo)(Some(_)) + .compile + + assert(movieFilterHandler.eval(MovieFilter()).flatten == List("bar")) + } } describe("when validating the chain at compile time") { + // Positive control for the negative snippet tests below. it("should compile a chain that handles every field") { assertCompiles(""" CaseComplete.build[TwoFieldFilter, Option[String]] @@ -187,23 +197,14 @@ class CaseCompleteSpec extends AnyFunSpec { ) } - it("should reject a selector that is not a case field, such as an inherited method") { - assertErrorContains( - """ - CaseComplete.build[TwoFieldFilter, Option[String]] - .using(_.productArity)(_ => None) - """, - "'productArity' is not a case field" - ) - } - - it("should reject a selector that names a body val rather than a constructor field") { + it("should not count a handled body val toward the completeness of constructor fields") { assertErrorContains( """ CaseComplete.build[BodyValFilter, Option[String]] - .using(_.derived)(_ => None) + .using(_.derived)(identity) + .compile """, - "'derived' is not a case field" + "Missing handlers for fields: a" ) } @@ -220,17 +221,13 @@ class CaseCompleteSpec extends AnyFunSpec { ) } - // The three validations live in registerField, shared by all entry points; these pin that + // The validations live in registerField, shared by all entry points; these pin that // `ignoring` and `usingNonEmpty` route through it rather than just `using`. it("should run the shared selector checks for ignoring too") { assertErrorContains( """CaseComplete.build[NestedFilter, Option[String]].ignoring(_.a.b)""", "expected a field selector" ) - assertErrorContains( - """CaseComplete.build[TwoFieldFilter, Option[String]].ignoring(_.productArity)""", - "'productArity' is not a case field" - ) } it("should report a bad selector before usingNonEmpty's target-type check") { @@ -244,14 +241,6 @@ class CaseCompleteSpec extends AnyFunSpec { } } } - - private inline def assertErrorContains(inline code: String, expected: String): Unit = { - val errors = typeCheckErrors(code) - assert( - errors.exists(_.message.contains(expected)), - s"no compile error contained '$expected'; got: ${errors.map(_.message)}" - ) - } } // Top-level so the type-checking snippets above can name them. diff --git a/src/test/scala/testsupport/CompileErrorAssertions.scala b/src/test/scala/testsupport/CompileErrorAssertions.scala new file mode 100644 index 0000000..8f8b4c5 --- /dev/null +++ b/src/test/scala/testsupport/CompileErrorAssertions.scala @@ -0,0 +1,17 @@ +package testsupport + +import org.scalatest.Assertions + +import scala.compiletime.testing.typeCheckErrors + +trait CompileErrorAssertions extends Assertions { + + /** Asserts that `code` fails to compile with a single error containing every expected substring. */ + inline def assertErrorContains(inline code: String, expected: String*): Unit = { + val errors = typeCheckErrors(code) + assert( + errors.exists(e => expected.forall(e.message.contains)), + s"no compile error contained ${expected.map(e => s"'$e'").mkString(" and ")}; got: ${errors.map(_.message)}" + ) + } +}