From 00c2c0bbf4abcc554ece0c917a7a8b7ae2f73a6a Mon Sep 17 00:00:00 2001 From: Hakim Jonas Ghoula Date: Fri, 21 Nov 2025 16:47:46 +0100 Subject: [PATCH 01/19] Upgrade to Scala 3.7.4 and add modernization audit - Upgrade Scala version from 3.7.1 to 3.7.4 - Upgrade Scala Native from 0.5.8 to 0.5.9 - Add comprehensive modernization audit document - All tests passing (JVM + Native) The audit analyzes opportunities to leverage Scala 3.7.x improvements in inline metaprogramming, type class derivation, and macro capabilities. Key finding: Current architecture is already modern and well-designed. Includes phased modernization roadmap: - Phase 1 (v0.5.1): Quick wins - transparent inline, better errors - Phase 2 (v0.6.0): Type-level enhancements - Phase 3 (v0.7.0+): Future Scala 3.8+ features --- MODERNIZATION_AUDIT.md | 525 +++++++++++++++++++++++++++++++++++++++++ build.sbt | 2 +- project/plugins.sbt | 2 +- 3 files changed, 527 insertions(+), 2 deletions(-) create mode 100644 MODERNIZATION_AUDIT.md diff --git a/MODERNIZATION_AUDIT.md b/MODERNIZATION_AUDIT.md new file mode 100644 index 0000000..d84e5c7 --- /dev/null +++ b/MODERNIZATION_AUDIT.md @@ -0,0 +1,525 @@ +# Valar Modernization Audit - Scala 3.7.4 + +**Date:** November 21, 2025 +**Scala Version:** 3.7.1 โ†’ 3.7.4 +**Scala Native:** 0.5.8 โ†’ 0.5.9 +**Status:** โœ… Upgrade Complete, All Tests Passing + +--- + +## Executive Summary + +This audit evaluates opportunities to modernize Valar's codebase following the upgrade to Scala 3.7.4. The focus is on leveraging recent Scala 3 improvements in inline metaprogramming, type class derivation, and macro capabilities to make the code more elegant, performant, and maintainable. + +**Key Finding:** Valar's current metaprogramming approach is already quite modern and well-architected. The opportunities for improvement are evolutionary rather than revolutionary. + +--- + +## Current State Analysis + +### Metaprogramming Architecture + +#### โœ… Strengths + +1. **Clean Separation of Concerns** + - `Derivation.scala`: Core macro logic (342 lines) + - `MacroHelper.scala`: Type casting utilities (26 lines) + - Well-documented internal APIs + +2. **Modern Quotes Reflection API** + - Already using Scala 3's quotes reflection (`scala.quoted.*`) + - Type-safe pattern matching on `TypeRepr` + - Proper use of `Expr.summon` for implicit resolution + +3. **Strategic Use of Inline** + - `inline given` for zero-cost abstractions (`ValidationObserver.noOpObserver`) + - `inline def` for compile-time optimizations (`observe()`, `upcastTo()`) + - `inline given` for default configurations (`ValidationConfig.default`) + +4. **Compile-Time Introspection** + - Mirror-based derivation for product types + - Field label extraction from tuple types + - Option type detection via subtyping checks + +#### ๐Ÿ” Current Implementation Patterns + +**Macro Derivation (`Derivation.scala:217-342`)** +```scala +def deriveValidatorImpl[T: Type, Elems <: Tuple: Type, Labels <: Tuple: Type]( + m: Expr[Mirror.ProductOf[T]], + isAsync: Boolean +)(using q: Quotes): Expr[Any] +``` + +**Inline Optimization (`ValidationObserver.scala:109-111`)** +```scala +inline given noOpObserver: ValidationObserver with { + def onResult[A](result: ValidationResult[A]): Unit = () +} +``` + +**Type Casting (`MacroHelper.scala:23`)** +```scala +@SuppressWarnings(Array("scalafix:DisableSyntax.asInstanceOf")) +inline def upcastTo[T](x: Any): T = x.asInstanceOf[T] +``` + +--- + +## Scala 3.7.x Improvements Analysis + +### 1. Enhanced Inline Method Handling + +**What Changed:** +- "Fail not inlined inline method calls early" - better compile-time error messages +- Improved inline export forwarders +- More robust symbol remapping + +**Impact on Valar:** โš ๏ธ Minor +- Current inline usage is straightforward and shouldn't benefit significantly +- Better error messages will improve developer experience +- No changes needed to existing code + +### 2. Metaprogramming and Macro Improvements + +**What Changed:** +- Enhanced quotes functionality +- Better symbol remapping +- Improved reflection API stability + +**Impact on Valar:** โš ๏ธ Minor +- Already using stable quotes APIs +- Complex macro in `Derivation.deriveValidatorImpl` works well +- Potential for minor optimizations in type introspection + +### 3. Type Class Derivation Enhancements + +**What Changed:** +- More robust derivation capabilities +- Better type inference for derived instances +- Improved Mirror.ProductOf handling + +**Impact on Valar:** โœ… Relevant +- Current mirror-based approach could potentially be simplified +- Better type inference may reduce need for explicit type annotations +- Worth investigating for version 0.6.0 + +### 4. Experimental Features + +**Available but Not Yet Stable:** +- Capture checking +- Explicit nulls +- Separation checking +- Global initialization checking + +**Impact on Valar:** ๐Ÿ”ฎ Future +- Explicit nulls could enhance null safety in validation +- Not recommended for adoption yet (experimental) +- Monitor for stable release in Scala 3.8+ + +--- + +## Modernization Opportunities + +### Priority 1: High Value, Low Risk + +#### 1.1 Transparent Inline for Better Type Inference + +**Current:** +```scala +inline def upcastTo[T](x: Any): T = x.asInstanceOf[T] +``` + +**Potential Enhancement:** +```scala +transparent inline def upcastTo[T](x: Any): T = x.asInstanceOf[T] +``` + +**Benefits:** +- Better type inference at call sites +- More precise return types in complex scenarios +- Zero breaking changes + +**Location:** `valar-core/src/main/scala/net/ghoula/valar/internal/MacroHelper.scala:23` + +**Estimated Effort:** 30 minutes +**Risk:** Very Low +**Recommendation:** โœ… Adopt + +--- + +#### 1.2 Inline Match Types for Field Type Analysis + +**Current Pattern (`Derivation.scala:177-184`):** +```scala +private def getIsOptionFlags[Elems <: Tuple: Type](using q: Quotes): List[Boolean] = { + import q.reflect.* + Type.of[Elems] match { + case '[EmptyTuple] => Nil + case '[h *: t] => + (TypeRepr.of[h] <:< TypeRepr.of[Option[Any]]) :: getIsOptionFlags[t] + } +} +``` + +**Potential Enhancement:** +```scala +// Use match types at type level for compile-time guarantees +private inline def getIsOptionFlags[Elems <: Tuple: Type](using q: Quotes): List[Boolean] = { + import q.reflect.* + // Could potentially leverage match types for more elegant type-level computation + inline erasedValue[Elems] match { + case _: EmptyTuple => Nil + case _: (h *: t) => + inline if TypeTest[h, Option[?]] then + true :: getIsOptionFlags[t] + else + false :: getIsOptionFlags[t] + } +} +``` + +**Benefits:** +- More idiomatic Scala 3 style +- Potentially better compile-time optimization +- Clearer intent at the type level + +**Estimated Effort:** 2-3 hours (requires testing) +**Risk:** Medium (needs thorough validation) +**Recommendation:** ๐Ÿ”ฌ Experimental - Test in branch + +--- + +#### 1.3 Enhanced Error Messages with Source Positions + +**Current (`Derivation.scala:149-152`):** +```scala +report.errorAndAbort( + s"Invalid field label type: expected string literal, found ${head.show}. " + + "This typically indicates a structural issue with the case class definition." +) +``` + +**Enhancement:** +```scala +report.errorAndAbort( + s"Invalid field label type: expected string literal, found ${head.show}. " + + "This typically indicates a structural issue with the case class definition.", + head.pos // Add source position for better IDE integration +) +``` + +**Benefits:** +- Better error messages with precise source locations +- Improved IDE integration +- Easier debugging for users + +**Location:** Multiple locations in `Derivation.scala` +**Estimated Effort:** 1 hour +**Risk:** Very Low +**Recommendation:** โœ… Adopt + +--- + +### Priority 2: Medium Value, Medium Risk + +#### 2.1 Compile-Time Validator Validation + +**Opportunity:** +Use Scala 3.7's improved compile-time capabilities to validate validator consistency at compile time. + +**Current Challenge:** +Validators are resolved at macro expansion time, but their behavior isn't validated. + +**Potential Enhancement:** +```scala +// Add compile-time checks that validators are consistent +// E.g., ensure Option[T] has both T validator and Option validator +inline def validateValidatorConsistency[T: Type](using Quotes): Unit = { + import quotes.reflect.* + // Check for common validation pitfalls at compile time + // - Missing required validators + // - Circular validator dependencies + // - Type incompatibilities +} +``` + +**Benefits:** +- Catch configuration errors at compile time +- Better user experience +- Reduced runtime surprises + +**Estimated Effort:** 8-12 hours +**Risk:** Medium +**Recommendation:** ๐Ÿ”ฌ Research for v0.6.0 + +--- + +#### 2.2 Refined Field Path Tracking + +**Current (`Derivation.scala:69-80`):** +The field annotation is done at runtime with string manipulation. + +**Potential Enhancement:** +Build field paths at compile time using type-level programming: + +```scala +// Type-level field path representation +type FieldPath = List[String] + +// Compile-time path construction +inline def buildFieldPath[T: Type, FieldName <: String: Type]: String = + constValue[FieldName] // Available in Scala 3.7+ +``` + +**Benefits:** +- Zero runtime overhead for path construction +- Type-safe field paths +- Better integration with IDE tooling + +**Estimated Effort:** 16-20 hours +**Risk:** Medium-High +**Recommendation:** ๐Ÿ“‹ Consider for v0.6.0 major feature + +--- + +### Priority 3: Future Exploration + +#### 3.1 Adoption of Experimental Features + +**Explicit Nulls:** +- Currently handling null at runtime +- Could leverage `-Yexplicit-nulls` for compile-time null safety +- **Status:** Experimental - wait for Scala 3.8+ stabilization + +**Capture Checking:** +- Could enhance async validation safety +- Prevent escaping references in validation callbacks +- **Status:** Experimental - monitor progress + +**Recommendation:** ๐Ÿ”ฎ Monitor, don't adopt yet + +--- + +## Performance Optimization Opportunities + +### 1. Inline Budget Analysis + +Scala 3.7 has improved inline heuristics. Review inline usage for: +- Methods that are too large to inline effectively +- Recursive inline methods that hit inline depth limits +- Methods that would benefit from `@inline` instead of `inline` + +**Current Inline Usage:** +``` +ValidationObserver.scala: + - inline given noOpObserver (โœ… Perfect use case) + - inline def observe() (โœ… Perfect use case) + +ValidationConfig.scala: + - inline given default (โœ… Perfect use case) + +MacroHelper.scala: + - inline def upcastTo[T] (โœ… Perfect use case) +``` + +**Finding:** โœ… All current inline usages are appropriate and optimal. + +--- + +### 2. Macro Expansion Complexity + +**Current Complexity:** +The `deriveValidatorImpl` method is the largest macro (125 lines of implementation). + +**Analysis:** +- Handles both sync and async validator derivation +- Complex but well-structured +- Good use of helper methods + +**Potential Split:** +```scala +// Split into smaller, focused macros +def deriveSyncValidatorImpl[...]: Expr[Validator[T]] = ... +def deriveAsyncValidatorImpl[...]: Expr[AsyncValidator[T]] = ... + +// Keep current unified entry point that dispatches +def deriveValidatorImpl[...](isAsync: Boolean): Expr[Any] = + if isAsync then deriveAsyncValidatorImpl + else deriveSyncValidatorImpl +``` + +**Benefits:** +- Easier to understand and maintain +- Better compile-time performance (smaller expansion units) +- More testable + +**Estimated Effort:** 6-8 hours +**Risk:** Low +**Recommendation:** โœ… Good refactoring for v0.5.1 + +--- + +## Code Quality Improvements + +### 1. Documentation with Scala 3.7 Features + +Scala 3.7 has improved Scaladoc capabilities. Consider: + +```scala +/** Enhanced documentation with better code examples + * + * @example {{{ + * // Scala 3.7 supports better syntax highlighting in docs + * given Validator[User] = Validator.deriveValidatorMacro + * + * val result: ValidationResult[User] = user.validate + * }}} + * + * @see [[ValidationResult]] for result handling + * @note This macro requires validators for all field types + */ +inline def deriveValidatorMacro[T]: Validator[T] = ... +``` + +**Status:** โœ… Current documentation is already excellent. Minor enhancements possible. + +--- + +### 2. Type Signature Clarity + +With Scala 3.7's improved type inference, some explicit type annotations may be redundant: + +**Review Areas:** +- `Derivation.scala:42-53` - Return types could potentially be inferred +- Pattern matching on types could use newer match type syntax + +**Recommendation:** โš ๏ธ Low priority - explicit types aid readability + +--- + +## Migration Safety + +### Breaking Changes Assessment + +**Scala 3.7.x Breaking Changes:** +- None that affect Valar's codebase +- All tests pass without modification +- Binary compatibility maintained + +**Upgrade Safety:** โœ… 100% safe + +--- + +## Recommendations Summary + +### Immediate Actions (v0.5.1) + +1. โœ… **Add `transparent inline` to `MacroHelper.upcastTo`** + - Effort: 30 minutes + - Risk: Very Low + - Value: Better type inference + +2. โœ… **Enhance macro error messages with source positions** + - Effort: 1 hour + - Risk: Very Low + - Value: Better developer experience + +3. โœ… **Split `deriveValidatorImpl` into separate sync/async macros** + - Effort: 6-8 hours + - Risk: Low + - Value: Better maintainability + +**Total Estimated Effort:** 8-10 hours + +--- + +### Near-Term Exploration (v0.6.0) + +1. ๐Ÿ”ฌ **Experiment with inline match types for field analysis** + - Research branch to validate approach + - Benchmark against current implementation + - If successful, cleaner and more idiomatic code + +2. ๐Ÿ”ฌ **Compile-time validator validation** + - Catch validation configuration errors earlier + - Enhance user experience + - Requires design work + +3. ๐Ÿ“‹ **Type-level field path construction** + - Zero runtime overhead + - Better IDE integration + - Major feature for v0.6.0 + +--- + +### Long-Term Monitoring (v0.7.0+) + +1. ๐Ÿ”ฎ **Explicit nulls support** + - Wait for Scala 3.8 stabilization + - Could eliminate runtime null checks + - Major safety improvement + +2. ๐Ÿ”ฎ **Capture checking for async validation** + - Prevent reference escape bugs + - Enhanced type safety + - Experimental feature to monitor + +--- + +## Modernization Roadmap + +### Phase 1: Quick Wins (v0.5.1) - 2 weeks +- Transparent inline adoption +- Enhanced error messages +- Macro refactoring for maintainability +- **Goal:** Better DX, no functional changes + +### Phase 2: Type-Level Enhancements (v0.6.0) - 1-2 months +- Inline match types exploration +- Compile-time validator validation +- Type-level field paths +- **Goal:** More elegant, performant code + +### Phase 3: Future-Proofing (v0.7.0+) - 6-12 months +- Adopt stabilized Scala 3.8+ features +- Explicit nulls integration +- Capture checking support +- **Goal:** Leverage cutting-edge Scala features + +--- + +## Conclusion + +**Current Assessment:** Valar's metaprogramming is already modern and well-designed. The codebase effectively uses Scala 3's capabilities and follows best practices. + +**Modernization Value:** Evolutionary improvements rather than revolutionary changes. Focus should be on: +1. Small, safe enhancements (transparent inline, better errors) +2. Incremental refactoring for maintainability +3. Monitoring Scala ecosystem for stabilized features + +**Key Insight:** The upgrade to Scala 3.7.4 validates that Valar's architecture is forward-compatible and robust. Rather than major rewrites, the focus should be on: +- Refining inline usage for optimal performance +- Enhancing developer experience with better errors +- Preparing for future Scala features through careful experimentation + +**Recommendation:** Proceed with Phase 1 quick wins for v0.5.1, and allocate time for experimental branches to validate Phase 2 enhancements. + +--- + +## Appendix: Testing Strategy + +All modernization changes must: +1. โœ… Pass full test suite (JVM + Native) +2. โœ… Maintain binary compatibility (MiMa checks) +3. โœ… Pass TASTy compatibility (for inline changes) +4. โœ… Show no performance regression +5. โœ… Pass mdoc documentation checks + +**Experimental Features:** Require dedicated feature branches with benchmarking before merge. + +--- + +**Document Version:** 1.0 +**Last Updated:** November 21, 2025 +**Next Review:** After Scala 3.8.0 release diff --git a/build.sbt b/build.sbt index 55daeb1..7740e88 100644 --- a/build.sbt +++ b/build.sbt @@ -9,7 +9,7 @@ import _root_.mdoc.MdocPlugin // ===== Buildโ€‘wide Settings ===== ThisBuild / organization := "net.ghoula" ThisBuild / versionScheme := Some("early-semver") -ThisBuild / scalaVersion := "3.7.1" +ThisBuild / scalaVersion := "3.7.4" ThisBuild / semanticdbEnabled := true ThisBuild / semanticdbVersion := scalafixSemanticdb.revision diff --git a/project/plugins.sbt b/project/plugins.sbt index 7e3b1a5..5540fd9 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -15,7 +15,7 @@ addSbtPlugin("org.portable-scala" % "sbt-crossproject" % "1.3.2") addSbtPlugin("org.portable-scala" % "sbt-scala-native-crossproject" % "1.3.2") // Scala Native -addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.5.8") +addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.5.9") // --- Compatibility Tools --- // For binary compatibility checking From 2ba2f6eca4cf9a43ff7bf6cf2614ae77fe532c14 Mon Sep 17 00:00:00 2001 From: Hakim Jonas Ghoula Date: Fri, 21 Nov 2025 21:45:13 +0100 Subject: [PATCH 02/19] Implement Phase 1 modernization: macro refactoring and improved type safety - Add `transparent inline` to MacroHelper.upcastTo for better type inference - Split deriveValidatorImpl into typed deriveSyncValidatorImpl/deriveAsyncValidatorImpl - Eliminate unnecessary .asExprOf casts by returning properly typed Expr - Add Position.ofMacroExpansion to all error messages for better IDE integration These changes follow the modernization roadmap from MODERNIZATION_AUDIT.md, improving code maintainability and developer experience with no functional changes. --- .../net/ghoula/valar/AsyncValidator.scala | 5 +- .../scala/net/ghoula/valar/Validator.scala | 5 +- .../ghoula/valar/internal/Derivation.scala | 251 ++++++++++-------- .../ghoula/valar/internal/MacroHelper.scala | 2 +- 4 files changed, 148 insertions(+), 115 deletions(-) diff --git a/valar-core/src/main/scala/net/ghoula/valar/AsyncValidator.scala b/valar-core/src/main/scala/net/ghoula/valar/AsyncValidator.scala index db83191..9d59a6f 100644 --- a/valar-core/src/main/scala/net/ghoula/valar/AsyncValidator.scala +++ b/valar-core/src/main/scala/net/ghoula/valar/AsyncValidator.scala @@ -454,7 +454,6 @@ object AsyncValidator { */ private def deriveImpl[T: Type, Elems <: Tuple: Type, Labels <: Tuple: Type]( m: Expr[Mirror.ProductOf[T]] - )(using q: Quotes): Expr[AsyncValidator[T]] = { - Derivation.deriveValidatorImpl[T, Elems, Labels](m, isAsync = true).asExprOf[AsyncValidator[T]] - } + )(using q: Quotes): Expr[AsyncValidator[T]] = + Derivation.deriveAsyncValidatorImpl[T, Elems, Labels](m) } diff --git a/valar-core/src/main/scala/net/ghoula/valar/Validator.scala b/valar-core/src/main/scala/net/ghoula/valar/Validator.scala index 1edf6cb..5b12fe1 100644 --- a/valar-core/src/main/scala/net/ghoula/valar/Validator.scala +++ b/valar-core/src/main/scala/net/ghoula/valar/Validator.scala @@ -294,7 +294,6 @@ object Validator { /** Macro implementation for deriving a `Validator`. */ private def deriveImpl[T: Type, Elems <: Tuple: Type, Labels <: Tuple: Type]( m: Expr[Mirror.ProductOf[T]] - )(using q: Quotes): Expr[Validator[T]] = { - Derivation.deriveValidatorImpl[T, Elems, Labels](m, isAsync = false).asExprOf[Validator[T]] - } + )(using q: Quotes): Expr[Validator[T]] = + Derivation.deriveSyncValidatorImpl[T, Elems, Labels](m) } diff --git a/valar-core/src/main/scala/net/ghoula/valar/internal/Derivation.scala b/valar-core/src/main/scala/net/ghoula/valar/internal/Derivation.scala index 3941d64..cb13c5b 100644 --- a/valar-core/src/main/scala/net/ghoula/valar/internal/Derivation.scala +++ b/valar-core/src/main/scala/net/ghoula/valar/internal/Derivation.scala @@ -148,14 +148,16 @@ object Derivation { case _ => report.errorAndAbort( s"Invalid field label type: expected string literal, found ${head.show}. " + - "This typically indicates a structural issue with the case class definition." + "This typically indicates a structural issue with the case class definition.", + Position.ofMacroExpansion ) } case t if t =:= TypeRepr.of[EmptyTuple] => Nil case _ => report.errorAndAbort( s"Invalid label tuple structure: ${tpe.show}. " + - "This may indicate an incompatible case class or tuple definition." + "This may indicate an incompatible case class or tuple definition.", + Position.ofMacroExpansion ) } loop(TypeRepr.of[Labels]) @@ -183,24 +185,19 @@ object Derivation { } } - /** Generates validator instances for product types using compile-time reflection. + /** Generates a synchronous validator for product types using compile-time reflection. * - * This is the core derivation method that generates either synchronous or asynchronous - * validators based on the `isAsync` parameter. It performs compile-time introspection of the - * product type, extracts field information, summons appropriate validators for each field, and - * generates optimized validation logic. + * This method performs compile-time introspection of the product type, extracts field + * information, summons appropriate validators for each field, and generates optimized + * validation logic. * - * The generated validators handle: + * The generated validator handles: * - Field-by-field validation using appropriate validator instances * - Error accumulation and proper error context annotation * - Null-safety for optional vs required fields - * - Automatic lifting of synchronous validators in async contexts - * - Exception handling for asynchronous operations * * @param m * The mirror instance for the product type being validated. - * @param isAsync - * Flag indicating whether to generate an `AsyncValidator` (true) or `Validator` (false). * @param q * The quotes context for macro operations. * @tparam T @@ -210,14 +207,13 @@ object Derivation { * @tparam Labels * The tuple type containing all field names as string literals. * @return - * An expression representing the generated validator instance. + * An expression representing the generated `Validator[T]` instance. * @throws Compilation * error if required validator instances cannot be found for any field type. */ - def deriveValidatorImpl[T: Type, Elems <: Tuple: Type, Labels <: Tuple: Type]( - m: Expr[Mirror.ProductOf[T]], - isAsync: Boolean - )(using q: Quotes): Expr[Any] = { + def deriveSyncValidatorImpl[T: Type, Elems <: Tuple: Type, Labels <: Tuple: Type]( + m: Expr[Mirror.ProductOf[T]] + )(using q: Quotes): Expr[Validator[T]] = { import q.reflect.* val fieldLabels: List[String] = getLabels[Labels] @@ -225,104 +221,134 @@ object Derivation { val fieldLabelsExpr: Expr[List[String]] = Expr(fieldLabels) val isOptionListExpr: Expr[List[Boolean]] = Expr(isOptionList) - if (isAsync) { - def summonAsyncOrSync[E <: Tuple: Type]: List[Expr[AsyncValidator[Any]]] = - Type.of[E] match { - case '[EmptyTuple] => Nil - case '[h *: t] => - val validatorExpr = Expr.summon[AsyncValidator[h]].orElse(Expr.summon[Validator[h]]).getOrElse { - report.errorAndAbort( - s"Cannot derive AsyncValidator for ${Type.show[T]}: missing validator for field type ${Type.show[h]}. " + - "Please provide a given instance of either Validator[${Type.show[h]}] or AsyncValidator[${Type.show[h]}]." - ) - } + def summonValidators[E <: Tuple: Type]: List[Expr[Validator[Any]]] = + Type.of[E] match { + case '[EmptyTuple] => Nil + case '[h *: t] => + val validatorExpr = Expr.summon[Validator[h]].getOrElse { + report.errorAndAbort( + s"Cannot derive Validator for ${Type.show[T]}: missing validator for field type ${Type.show[h]}. " + + "Please provide a given instance of Validator[${Type.show[h]}].", + Position.ofMacroExpansion + ) + } + '{ MacroHelper.upcastTo[Validator[Any]](${ validatorExpr }) } :: summonValidators[t] + } - val finalExpr = validatorExpr.asTerm.tpe.asType match { - case '[AsyncValidator[h]] => validatorExpr - case '[Validator[h]] => '{ AsyncValidator.fromSync(${ validatorExpr.asExprOf[Validator[h]] }) } - } + val fieldValidators: List[Expr[Validator[Any]]] = summonValidators[Elems] + val validatorsExpr: Expr[Seq[Validator[Any]]] = Expr.ofSeq(fieldValidators) - '{ MacroHelper.upcastTo[AsyncValidator[Any]](${ finalExpr }) } :: summonAsyncOrSync[t] - } + '{ + new Validator[T] { + def validate(a: T): ValidationResult[T] = { + a match { + case product: Product => + val validators = ${ validatorsExpr } + val labels = ${ fieldLabelsExpr } + val isOptionFlags = ${ isOptionListExpr } - val fieldValidators: List[Expr[AsyncValidator[Any]]] = summonAsyncOrSync[Elems] - val validatorsExpr: Expr[Seq[AsyncValidator[Any]]] = Expr.ofSeq(fieldValidators) + val results = validateProduct( + product, + validators, + labels, + isOptionFlags, + validateAndAnnotate = (v, fv, l) => annotateErrors(v.validate(fv), l, fv), + handleNull = l => + ValidationResult.invalid( + ValidationError( + s"Field '$l' must not be null.", + List(l), + expected = Some("non-null value"), + actual = Some("null") + ) + ) + ) - '{ - new AsyncValidator[T] { - def validateAsync(a: T)(using ec: ExecutionContext): Future[ValidationResult[T]] = { - a match { - case product: Product => - val validators = ${ validatorsExpr } - val labels = ${ fieldLabelsExpr } - val isOptionFlags = ${ isOptionListExpr } + processResults(results, ${ m }) + } + } + } + } + } - val fieldResultsF = validateProduct( - product, - validators, - labels, - isOptionFlags, - validateAndAnnotate = (v, fv, l) => v.validateAsync(fv).map(annotateErrors(_, l, fv)), - handleNull = l => - Future.successful( - ValidationResult.invalid( - ValidationError( - s"Field '$l' must not be null.", - List(l), - expected = Some("non-null value"), - actual = Some("null") - ) - ) - ) - ) + /** Generates an asynchronous validator for product types using compile-time reflection. + * + * This method performs compile-time introspection of the product type, extracts field + * information, summons appropriate validators for each field, and generates optimized + * async validation logic. + * + * The generated validator handles: + * - Field-by-field validation using appropriate validator instances + * - Error accumulation and proper error context annotation + * - Null-safety for optional vs required fields + * - Automatic lifting of synchronous validators in async contexts + * - Exception handling for asynchronous operations + * + * @param m + * The mirror instance for the product type being validated. + * @param q + * The quotes context for macro operations. + * @tparam T + * The product type for which to generate a validator. + * @tparam Elems + * The tuple type containing all field types. + * @tparam Labels + * The tuple type containing all field names as string literals. + * @return + * An expression representing the generated `AsyncValidator[T]` instance. + * @throws Compilation + * error if required validator instances cannot be found for any field type. + */ + def deriveAsyncValidatorImpl[T: Type, Elems <: Tuple: Type, Labels <: Tuple: Type]( + m: Expr[Mirror.ProductOf[T]] + )(using q: Quotes): Expr[AsyncValidator[T]] = { + import q.reflect.* - val allResultsF: Future[List[ValidationResult[Any]]] = - Future.sequence(fieldResultsF.map { f => - f.recover { case scala.util.control.NonFatal(ex) => - ValidationResult.invalid( - ValidationError(s"Asynchronous validation failed unexpectedly: ${ex.getMessage}") - ) - } - }) + val fieldLabels: List[String] = getLabels[Labels] + val isOptionList: List[Boolean] = getIsOptionFlags[Elems] + val fieldLabelsExpr: Expr[List[String]] = Expr(fieldLabels) + val isOptionListExpr: Expr[List[Boolean]] = Expr(isOptionList) - allResultsF.map(processResults(_, ${ m })) - } + def summonAsyncOrSync[E <: Tuple: Type]: List[Expr[AsyncValidator[Any]]] = + Type.of[E] match { + case '[EmptyTuple] => Nil + case '[h *: t] => + val validatorExpr = Expr.summon[AsyncValidator[h]].orElse(Expr.summon[Validator[h]]).getOrElse { + report.errorAndAbort( + s"Cannot derive AsyncValidator for ${Type.show[T]}: missing validator for field type ${Type.show[h]}. " + + "Please provide a given instance of either Validator[${Type.show[h]}] or AsyncValidator[${Type.show[h]}].", + Position.ofMacroExpansion + ) } - } - }.asExprOf[Any] - } else { - def summonValidators[E <: Tuple: Type]: List[Expr[Validator[Any]]] = - Type.of[E] match { - case '[EmptyTuple] => Nil - case '[h *: t] => - val validatorExpr = Expr.summon[Validator[h]].getOrElse { - report.errorAndAbort( - s"Cannot derive Validator for ${Type.show[T]}: missing validator for field type ${Type.show[h]}. " + - "Please provide a given instance of Validator[${Type.show[h]}]." - ) - } - '{ MacroHelper.upcastTo[Validator[Any]](${ validatorExpr }) } :: summonValidators[t] - } - val fieldValidators: List[Expr[Validator[Any]]] = summonValidators[Elems] - val validatorsExpr: Expr[Seq[Validator[Any]]] = Expr.ofSeq(fieldValidators) + val finalExpr = validatorExpr.asTerm.tpe.asType match { + case '[AsyncValidator[h]] => validatorExpr + case '[Validator[h]] => '{ AsyncValidator.fromSync(${ validatorExpr.asExprOf[Validator[h]] }) } + } - '{ - new Validator[T] { - def validate(a: T): ValidationResult[T] = { - a match { - case product: Product => - val validators = ${ validatorsExpr } - val labels = ${ fieldLabelsExpr } - val isOptionFlags = ${ isOptionListExpr } + '{ MacroHelper.upcastTo[AsyncValidator[Any]](${ finalExpr }) } :: summonAsyncOrSync[t] + } - val results = validateProduct( - product, - validators, - labels, - isOptionFlags, - validateAndAnnotate = (v, fv, l) => annotateErrors(v.validate(fv), l, fv), - handleNull = l => + val fieldValidators: List[Expr[AsyncValidator[Any]]] = summonAsyncOrSync[Elems] + val validatorsExpr: Expr[Seq[AsyncValidator[Any]]] = Expr.ofSeq(fieldValidators) + + '{ + new AsyncValidator[T] { + def validateAsync(a: T)(using ec: ExecutionContext): Future[ValidationResult[T]] = { + a match { + case product: Product => + val validators = ${ validatorsExpr } + val labels = ${ fieldLabelsExpr } + val isOptionFlags = ${ isOptionListExpr } + + val fieldResultsF = validateProduct( + product, + validators, + labels, + isOptionFlags, + validateAndAnnotate = (v, fv, l) => v.validateAsync(fv).map(annotateErrors(_, l, fv)), + handleNull = l => + Future.successful( ValidationResult.invalid( ValidationError( s"Field '$l' must not be null.", @@ -331,13 +357,22 @@ object Derivation { actual = Some("null") ) ) - ) + ) + ) - processResults(results, ${ m }) - } + val allResultsF: Future[List[ValidationResult[Any]]] = + Future.sequence(fieldResultsF.map { f => + f.recover { case scala.util.control.NonFatal(ex) => + ValidationResult.invalid( + ValidationError(s"Asynchronous validation failed unexpectedly: ${ex.getMessage}") + ) + } + }) + + allResultsF.map(processResults(_, ${ m })) } } - }.asExprOf[Any] + } } } } diff --git a/valar-core/src/main/scala/net/ghoula/valar/internal/MacroHelper.scala b/valar-core/src/main/scala/net/ghoula/valar/internal/MacroHelper.scala index 01c1b8f..67a070b 100644 --- a/valar-core/src/main/scala/net/ghoula/valar/internal/MacroHelper.scala +++ b/valar-core/src/main/scala/net/ghoula/valar/internal/MacroHelper.scala @@ -20,6 +20,6 @@ object MacroHelper { * The value `x` cast to type `T`. */ @SuppressWarnings(Array("scalafix:DisableSyntax.asInstanceOf")) - inline def upcastTo[T](x: Any): T = x.asInstanceOf[T] + transparent inline def upcastTo[T](x: Any): T = x.asInstanceOf[T] } From 16b6b9c34c9249aa112c56c4b2b5467277154c84 Mon Sep 17 00:00:00 2001 From: Hakim Jonas Ghoula Date: Fri, 21 Nov 2025 23:20:42 +0100 Subject: [PATCH 03/19] Phase 1 & 2: Macro modernization with Scala 3.7.4 features Phase 1 - Zero-cast field access: - Case classes: Select.unique(a, "fieldName") - zero cast - Regular tuples: Select.unique(a, "_1") - zero cast - Named tuples: productElement with cast (matches stdlib pattern) - Remove MacroHelper.scala (no longer needed) Phase 2 - Type-level enhancements: - Inline Option detection during field processing (single pass) - Compile-time validator validation with comprehensive error messages - Type-level label extraction using pattern matching - Better error messages showing ALL missing validators at once Key changes: - Derivation.scala refactored with cleaner Scala 3 idioms - Added Scala 3.7.4 stdlib sources for reference (Tuple.scala, NamedTuple.scala) --- docs/scala-3.7.4/NamedTuple.scala | 214 ++++++++ docs/scala-3.7.4/Tuple.scala | 341 +++++++++++++ .../ghoula/valar/internal/Derivation.scala | 478 +++++++++++------- .../ghoula/valar/internal/MacroHelper.scala | 25 - 4 files changed, 860 insertions(+), 198 deletions(-) create mode 100644 docs/scala-3.7.4/NamedTuple.scala create mode 100644 docs/scala-3.7.4/Tuple.scala delete mode 100644 valar-core/src/main/scala/net/ghoula/valar/internal/MacroHelper.scala diff --git a/docs/scala-3.7.4/NamedTuple.scala b/docs/scala-3.7.4/NamedTuple.scala new file mode 100644 index 0000000..1e4d3c0 --- /dev/null +++ b/docs/scala-3.7.4/NamedTuple.scala @@ -0,0 +1,214 @@ +package scala +import compiletime.ops.boolean.* + +object NamedTuple: + + /** The type to which named tuples get mapped to. For instance, + * (name: String, age: Int) + * gets mapped to + * NamedTuple[("name", "age"), (String, Int)] + */ + opaque type NamedTuple[N <: Tuple, +V <: Tuple] >: V <: AnyNamedTuple = V + + /** A type which is a supertype of all named tuples */ + opaque type AnyNamedTuple = Any + + def apply[N <: Tuple, V <: Tuple](x: V): NamedTuple[N, V] = x + + def unapply[N <: Tuple, V <: Tuple](x: NamedTuple[N, V]): Some[V] = Some(x) + + /** A named tuple expression will desugar to a call to `build`. For instance, + * `(name = "Lyra", age = 23)` will desugar to `build[("name", "age")]()(("Lyra", 23))`. + */ + inline def build[N <: Tuple]()[V <: Tuple](x: V): NamedTuple[N, V] = x + + extension [V <: Tuple](x: V) + inline def withNames[N <: Tuple]: NamedTuple[N, V] = x + + import NamedTupleDecomposition.{Names, DropNames} + export NamedTupleDecomposition.{ + Names, DropNames, + apply, size, init, head, last, tail, take, drop, splitAt, ++, map, reverse, zip, toList, toArray, toIArray + } + + extension [N <: Tuple, V <: Tuple](x: NamedTuple[N, V]) + + // ALL METHODS DEPENDING ON `toTuple` MUST BE EXPORTED FROM `NamedTupleDecomposition` + /** The underlying tuple without the names */ + inline def toTuple: V = x + + // This intentionally works for empty named tuples as well. I think NonEmptyTuple is a dead end + // and should be reverted, just like NonEmptyList is also appealing at first, but a bad idea + // in the end. + + // inline def :* [L] (x: L): NamedTuple[Append[N, ???], Append[V, L] = ??? + // inline def *: [H] (x: H): NamedTuple[??? *: N], H *: V] = ??? + + end extension + + /** The size of a named tuple, represented as a literal constant subtype of Int */ + type Size[X <: AnyNamedTuple] = Tuple.Size[DropNames[X]] + + /** The type of the element value at position N in the named tuple X */ + type Elem[X <: AnyNamedTuple, N <: Int] = Tuple.Elem[DropNames[X], N] + + /** The type of the first element value of a named tuple */ + type Head[X <: AnyNamedTuple] = Elem[X, 0] + + /** The type of the last element value of a named tuple */ + type Last[X <: AnyNamedTuple] = Tuple.Last[DropNames[X]] + + /** The type of a named tuple consisting of all elements of named tuple X except the first one */ + type Tail[X <: AnyNamedTuple] = Drop[X, 1] + + /** The type of the initial part of a named tuple without its last element */ + type Init[X <: AnyNamedTuple] = + NamedTuple[Tuple.Init[Names[X]], Tuple.Init[DropNames[X]]] + + /** The type of the named tuple consisting of the first `N` elements of `X`, + * or all elements if `N` exceeds `Size[X]`. + */ + type Take[X <: AnyNamedTuple, N <: Int] = + NamedTuple[Tuple.Take[Names[X], N], Tuple.Take[DropNames[X], N]] + + /** The type of the named tuple consisting of all elements of `X` except the first `N` ones, + * or no elements if `N` exceeds `Size[X]`. + */ + type Drop[X <: AnyNamedTuple, N <: Int] = + NamedTuple[Tuple.Drop[Names[X], N], Tuple.Drop[DropNames[X], N]] + + /** The pair type `(Take(X, N), Drop[X, N]). */ + type Split[X <: AnyNamedTuple, N <: Int] = (Take[X, N], Drop[X, N]) + + /** Type of the concatenation of two tuples `X` and `Y` */ + type Concat[X <: AnyNamedTuple, Y <: AnyNamedTuple] = + NamedTuple[Tuple.Concat[Names[X], Names[Y]], Tuple.Concat[DropNames[X], DropNames[Y]]] + + /** The type of the named tuple `X` mapped with the type-level function `F`. + * If `X = (n1 : T1, ..., ni : Ti)` then `Map[X, F] = `(n1 : F[T1], ..., ni : F[Ti])`. + */ + type Map[X <: AnyNamedTuple, F[_ <: Tuple.Union[DropNames[X]]]] = + NamedTuple[Names[X], Tuple.Map[DropNames[X], F]] + + /** A named tuple with the elements of tuple `X` in reversed order */ + type Reverse[X <: AnyNamedTuple] = + NamedTuple[Tuple.Reverse[Names[X]], Tuple.Reverse[DropNames[X]]] + + /** The type of the named tuple consisting of all element values of + * named tuple `X` zipped with corresponding element values of + * named tuple `Y`. If the two tuples have different sizes, + * the extra elements of the larger tuple will be disregarded. + * The names of `X` and `Y` at the same index must be the same. + * The result tuple keeps the same names as the operand tuples. + * For example, if + * ``` + * X = (n1 : S1, ..., ni : Si) + * Y = (n1 : T1, ..., nj : Tj) where j >= i + * ``` + * then + * ``` + * Zip[X, Y] = (n1 : (S1, T1), ..., ni: (Si, Ti)) + * ``` + * @syntax markdown + */ + type Zip[X <: AnyNamedTuple, Y <: AnyNamedTuple] = + Names[X] match + case Names[Y] => + NamedTuple[Names[X], Tuple.Zip[DropNames[X], DropNames[Y]]] + + /** A type specially treated by the compiler to represent all fields of a + * class argument `T` as a named tuple. Or, if `T` is already a named tuple, + * `From[T]` is the same as `T`. + */ + type From[T] <: AnyNamedTuple + + /** The type of the empty named tuple */ + type Empty = NamedTuple[EmptyTuple, EmptyTuple] + + /** The empty named tuple */ + val Empty: Empty = EmptyTuple + +end NamedTuple + +/** Separate from NamedTuple object so that we can match on the opaque type NamedTuple. */ +object NamedTupleDecomposition: + import NamedTuple.* + extension [N <: Tuple, V <: Tuple](x: NamedTuple[N, V]) + /** The value (without the name) at index `n` of this tuple */ + inline def apply(n: Int): Elem[NamedTuple[N, V], n.type] = + x.toTuple.apply(n).asInstanceOf[Elem[NamedTuple[N, V], n.type]] + + /** The number of elements in this tuple */ + inline def size: Size[NamedTuple[N, V]] = x.toTuple.size + + /** The first element value of this tuple */ + inline def head: Head[NamedTuple[N, V]] = apply(0) + + /** The last element value of this tuple */ + inline def last: Last[NamedTuple[N, V]] = apply(size - 1).asInstanceOf[Last[NamedTuple[N, V]]] + + /** The tuple consisting of all elements of this tuple except the last one */ + inline def init: Init[NamedTuple[N, V]] = + x.take(size - 1).asInstanceOf[Init[NamedTuple[N, V]]] + + /** The tuple consisting of all elements of this tuple except the first one */ + inline def tail: Tail[NamedTuple[N, V]] = x.toTuple.drop(1) + + /** The tuple consisting of the first `n` elements of this tuple, or all + * elements if `n` exceeds `size`. + */ + inline def take(n: Int): Take[NamedTuple[N, V], n.type] = x.toTuple.take(n) + + /** The tuple consisting of all elements of this tuple except the first `n` ones, + * or no elements if `n` exceeds `size`. + */ + inline def drop(n: Int): Drop[NamedTuple[N, V], n.type] = x.toTuple.drop(n) + + /** The tuple `(x.take(n), x.drop(n))` */ + inline def splitAt(n: Int): Split[NamedTuple[N, V], n.type] = x.toTuple.splitAt(n) + + /** The tuple consisting of all elements of this tuple followed by all elements + * of tuple `that`. The names of the two tuples must be disjoint. + */ + inline def ++ [N2 <: Tuple, V2 <: Tuple](that: NamedTuple[N2, V2])(using Tuple.Disjoint[N, N2] =:= true) + : Concat[NamedTuple[N, V], NamedTuple[N2, V2]] + = x.toTuple ++ that.toTuple + + /** The named tuple consisting of all element values of this tuple mapped by + * the polymorphic mapping function `f`. The names of elements are preserved. + * If `x = (n1 = v1, ..., ni = vi)` then `x.map(f) = `(n1 = f(v1), ..., ni = f(vi))`. + */ + inline def map[F[_]](f: [t] => t => F[t]): Map[NamedTuple[N, V], F] = + x.toTuple.map[F](f) + + /** The named tuple consisting of all elements of this tuple in reverse */ + inline def reverse: Reverse[NamedTuple[N, V]] = x.toTuple.reverse + + /** The named tuple consisting of all element values of this tuple zipped + * with corresponding element values in named tuple `that`. + * If the two tuples have different sizes, + * the extra elements of the larger tuple will be disregarded. + * The names of `x` and `that` at the same index must be the same. + * The result tuple keeps the same names as the operand tuples. + */ + inline def zip[V2 <: Tuple](that: NamedTuple[N, V2]): Zip[NamedTuple[N, V], NamedTuple[N, V2]] = + x.toTuple.zip(that.toTuple) + + /** A list consisting of all element values */ + inline def toList: List[Tuple.Union[V]] = x.toTuple.toList + + /** An array consisting of all element values */ + inline def toArray: Array[Object] = x.toTuple.toArray + + /** An immutable array consisting of all element values */ + inline def toIArray: IArray[Object] = x.toTuple.toIArray + + end extension + + /** The names of a named tuple, represented as a tuple of literal string values. */ + type Names[X <: AnyNamedTuple] <: Tuple = X match + case NamedTuple[n, _] => n + + /** The value types of a named tuple represented as a regular tuple. */ + type DropNames[NT <: AnyNamedTuple] <: Tuple = NT match + case NamedTuple[_, x] => x diff --git a/docs/scala-3.7.4/Tuple.scala b/docs/scala-3.7.4/Tuple.scala new file mode 100644 index 0000000..57d1572 --- /dev/null +++ b/docs/scala-3.7.4/Tuple.scala @@ -0,0 +1,341 @@ +package scala + +import annotation.showAsInfix +import compiletime.* +import compiletime.ops.int.* + +/** Tuple of arbitrary arity */ +sealed trait Tuple extends Product { + import Tuple.* + + /** Create a copy of this tuple as an Array */ + inline def toArray: Array[Object] = + runtime.Tuples.toArray(this) + + /** Create a copy of this tuple as a List */ + inline def toList: List[Union[this.type]] = + this.productIterator.toList + .asInstanceOf[List[Union[this.type]]] + + /** Create a copy of this tuple as an IArray */ + inline def toIArray: IArray[Object] = + runtime.Tuples.toIArray(this) + + /** Return a copy of `this` tuple with an element appended */ + inline def :* [This >: this.type <: Tuple, L] (x: L): This :* L = + runtime.Tuples.append(x, this).asInstanceOf[This :* L] + + /** Return a new tuple by prepending the element to `this` tuple. + * This operation is O(this.size) + */ + inline def *: [H, This >: this.type <: Tuple] (x: H): H *: This = + runtime.Tuples.cons(x, this).asInstanceOf[H *: This] + + /** Get the i-th element of this tuple. + * Equivalent to productElement but with a precise return type. + */ + inline def apply[This >: this.type <: Tuple](n: Int): Elem[This, n.type] = + runtime.Tuples.apply(this, n).asInstanceOf[Elem[This, n.type]] + + /** Get the head of this tuple */ + inline def head[This >: this.type <: Tuple]: Head[This] = + runtime.Tuples.apply(this, 0).asInstanceOf[Head[This]] + + /** Get the initial part of the tuple without its last element */ + inline def init[This >: this.type <: Tuple]: Init[This] = + runtime.Tuples.init(this).asInstanceOf[Init[This]] + + /** Get the last of this tuple */ + inline def last[This >: this.type <: Tuple]: Last[This] = + runtime.Tuples.last(this).asInstanceOf[Last[This]] + + /** Get the tail of this tuple. + * This operation is O(this.size) + */ + inline def tail[This >: this.type <: Tuple]: Tail[This] = + runtime.Tuples.tail(this).asInstanceOf[Tail[This]] + + /** Return a new tuple by concatenating `this` tuple with `that` tuple. + * This operation is O(this.size + that.size) + */ + inline def ++ [This >: this.type <: Tuple](that: Tuple): This ++ that.type = + runtime.Tuples.concat(this, that).asInstanceOf[This ++ that.type] + + /** Return the size (or arity) of the tuple */ + inline def size[This >: this.type <: Tuple]: Size[This] = + runtime.Tuples.size(this).asInstanceOf[Size[This]] + + /** Given two tuples, `(a1, ..., an)` and `(b1, ..., bn)`, returns a tuple + * `((a1, b1), ..., (an, bn))`. If the two tuples have different sizes, + * the extra elements of the larger tuple will be disregarded. + * The result is typed as `((A1, B1), ..., (An, Bn))` if at least one of the + * tuple types has a `EmptyTuple` tail. Otherwise the result type is + * `(A1, B1) *: ... *: (Ai, Bi) *: Tuple` + */ + inline def zip[This >: this.type <: Tuple, T2 <: Tuple](t2: T2): Zip[This, T2] = + runtime.Tuples.zip(this, t2).asInstanceOf[Zip[This, T2]] + + /** Called on a tuple `(a1, ..., an)`, returns a new tuple `(f(a1), ..., f(an))`. + * The result is typed as `(F[A1], ..., F[An])` if the tuple type is fully known. + * If the tuple is of the form `a1 *: ... *: Tuple` (that is, the tail is not known + * to be the cons type. + */ + inline def map[F[_]](f: [t] => t => F[t]): Map[this.type, F] = + runtime.Tuples.map(this, f).asInstanceOf[Map[this.type, F]] + + /** Given a tuple `(a1, ..., am)`, returns the tuple `(a1, ..., an)` consisting + * of its first n elements. + */ + inline def take[This >: this.type <: Tuple](n: Int): Take[This, n.type] = + runtime.Tuples.take(this, n).asInstanceOf[Take[This, n.type]] + + + /** Given a tuple `(a1, ..., am)`, returns the tuple `(an+1, ..., am)` consisting + * all its elements except the first n ones. + */ + inline def drop[This >: this.type <: Tuple](n: Int): Drop[This, n.type] = + runtime.Tuples.drop(this, n).asInstanceOf[Drop[This, n.type]] + + /** Given a tuple `(a1, ..., am)`, returns a pair of the tuple `(a1, ..., an)` + * consisting of the first n elements, and the tuple `(an+1, ..., am)` consisting + * of the remaining elements. + */ + inline def splitAt[This >: this.type <: Tuple](n: Int): Split[This, n.type] = + runtime.Tuples.splitAt(this, n).asInstanceOf[Split[This, n.type]] + + /** Given a tuple `(a1, ..., am)`, returns the reversed tuple `(am, ..., a1)` + * consisting all its elements. + */ + inline def reverse[This >: this.type <: Tuple]: Reverse[This] = + runtime.Tuples.reverse(this).asInstanceOf[Reverse[This]] +} + +object Tuple { + + /** Type of a tuple with an element appended */ + type Append[X <: Tuple, Y] <: NonEmptyTuple = X match { + case EmptyTuple => Y *: EmptyTuple + case x *: xs => x *: Append[xs, Y] + } + + /** An infix shorthand for `Append[X, Y]` */ + infix type :*[X <: Tuple, Y] = Append[X, Y] + + /** Type of the head of a tuple */ + type Head[X <: Tuple] = X match { + case x *: _ => x + } + + /** Type of the initial part of the tuple without its last element */ + type Init[X <: Tuple] <: Tuple = X match { + case _ *: EmptyTuple => EmptyTuple + case x *: xs => + x *: Init[xs] + } + + /** Type of the tail of a tuple */ + type Tail[X <: Tuple] <: Tuple = X match { + case _ *: xs => xs + } + + /** Type of the last element of a tuple */ + type Last[X <: Tuple] = X match { + case x *: EmptyTuple => x + case _ *: xs => Last[xs] + } + + /** Type of the concatenation of two tuples */ + type Concat[X <: Tuple, +Y <: Tuple] <: Tuple = X match { + case EmptyTuple => Y + case x1 *: xs1 => x1 *: Concat[xs1, Y] + } + + /** An infix shorthand for `Concat[X, Y]` */ + infix type ++[X <: Tuple, +Y <: Tuple] = Concat[X, Y] + + /** Type of the element at position N in the tuple X */ + type Elem[X <: Tuple, N <: Int] = X match { + case x *: xs => + N match { + case 0 => x + case S[n1] => Elem[xs, n1] + } + } + + /** Literal constant Int size of a tuple */ + type Size[X <: Tuple] <: Int = X match { + case EmptyTuple => 0 + case x *: xs => S[Size[xs]] + } + + /** Fold a tuple `(T1, ..., Tn)` into `F[T1, F[... F[Tn, Z]...]]]` */ + type Fold[Tup <: Tuple, Z, F[_, _]] = Tup match + case EmptyTuple => Z + case h *: t => F[h, Fold[t, Z, F]] + + /** Converts a tuple `(T1, ..., Tn)` to `(F[T1], ..., F[Tn])` */ + type Map[Tup <: Tuple, F[_ <: Union[Tup]]] <: Tuple = Tup match { + case EmptyTuple => EmptyTuple + case h *: t => F[h] *: Map[t, F] + } + + /** Converts a tuple `(T1, ..., Tn)` to a flattened `(..F[T1], ..., ..F[Tn])` */ + type FlatMap[Tup <: Tuple, F[_ <: Union[Tup]] <: Tuple] <: Tuple = Tup match { + case EmptyTuple => EmptyTuple + case h *: t => Concat[F[h], FlatMap[t, F]] + } + + /** Filters out those members of the tuple for which the predicate `P` returns `false`. + * A predicate `P[X]` is a type that can be either `true` or `false`. For example: + * ```scala + * type IsString[x] <: Boolean = x match { + * case String => true + * case _ => false + * } + * summon[Tuple.Filter[(1, "foo", 2, "bar"), IsString] =:= ("foo", "bar")] + * ``` + * @syntax markdown + */ + type Filter[Tup <: Tuple, P[_ <: Union[Tup]] <: Boolean] <: Tuple = Tup match { + case EmptyTuple => EmptyTuple + case h *: t => P[h] match { + case true => h *: Filter[t, P] + case false => Filter[t, P] + } + } + + /** Given two tuples, `A1 *: ... *: An * At` and `B1 *: ... *: Bn *: Bt` + * where at least one of `At` or `Bt` is `EmptyTuple`, + * returns the tuple type `(A1, B1) *: ... *: (An, Bn) *: EmptyTuple`. + */ + type Zip[T1 <: Tuple, T2 <: Tuple] <: Tuple = (T1, T2) match { + case (h1 *: t1, h2 *: t2) => (h1, h2) *: Zip[t1, t2] + case _ => EmptyTuple + } + + /** Converts a tuple `(F[T1], ..., F[Tn])` to `(T1, ... Tn)` */ + type InverseMap[X <: Tuple, F[_]] <: Tuple = X match { + case F[x] *: t => x *: InverseMap[t, F] + case EmptyTuple => EmptyTuple + } + + /** Implicit evidence. IsMappedBy[F][X] is present in the implicit scope iff + * X is a tuple for which each element's type is constructed via `F`. E.g. + * (F[A1], ..., F[An]), but not `(F[A1], B2, ..., F[An])` where B2 does not + * have the shape of `F[A]`. + */ + type IsMappedBy[F[_]] = [X <: Tuple] =>> X =:= Map[InverseMap[X, F], F] + + /** Type of the reversed tuple */ + type Reverse[X <: Tuple] = ReverseOnto[X, EmptyTuple] + + /** Prepends all elements of a tuple in reverse order onto the other tuple */ + type ReverseOnto[From <: Tuple, +To <: Tuple] <: Tuple = From match + case x *: xs => ReverseOnto[xs, x *: To] + case EmptyTuple => To + + /** Transforms a tuple `(T1, ..., Tn)` into `(T1, ..., Ti)`. */ + type Take[T <: Tuple, N <: Int] <: Tuple = N match { + case 0 => EmptyTuple + case S[n1] => T match { + case EmptyTuple => EmptyTuple + case x *: xs => x *: Take[xs, n1] + } + } + + /** Transforms a tuple `(T1, ..., Tn)` into `(Ti+1, ..., Tn)`. */ + type Drop[T <: Tuple, N <: Int] <: Tuple = N match { + case 0 => T + case S[n1] => T match { + case EmptyTuple => EmptyTuple + case x *: xs => Drop[xs, n1] + } + } + + /** Splits a tuple (T1, ..., Tn) into a pair of two tuples `(T1, ..., Ti)` and + * `(Ti+1, ..., Tn)`. + */ + type Split[T <: Tuple, N <: Int] = (Take[T, N], Drop[T, N]) + + /** Given a tuple `(T1, ..., Tn)`, returns a union of its + * member types: `T1 | ... | Tn`. Returns `Nothing` if the tuple is empty. + */ + type Union[T <: Tuple] = Fold[T, Nothing, [x, y] =>> x | y] + + /** A type level Boolean indicating whether the tuple `X` has an element + * that matches `Y`. + * @pre The elements of `X` are assumed to be singleton types + */ + type Contains[X <: Tuple, Y] <: Boolean = X match + case Y *: _ => true + case _ *: xs => Contains[xs, Y] + case EmptyTuple => false + + /** A type level Boolean indicating whether the type `Y` contains + * none of the elements of `X`. + * @pre The elements of `X` and `Y` are assumed to be singleton types + */ + type Disjoint[X <: Tuple, Y <: Tuple] <: Boolean = X match + case x *: xs => Contains[Y, x] match + case true => false + case false => Disjoint[xs, Y] + case EmptyTuple => true + + /** Empty tuple */ + def apply(): EmptyTuple = EmptyTuple + + /** Tuple with one element */ + def apply[T](x: T): T *: EmptyTuple = Tuple1(x) + + /** Matches an empty tuple. */ + def unapply(x: EmptyTuple): true = true + + /** Convert an array into a tuple of unknown arity and types */ + def fromArray[T](xs: Array[T]): Tuple = { + val xs2 = xs match { + case xs: Array[Object] => xs + case xs => xs.map(_.asInstanceOf[Object]) + } + runtime.Tuples.fromArray(xs2) + } + + /** Convert an immutable array into a tuple of unknown arity and types */ + def fromIArray[T](xs: IArray[T]): Tuple = { + val xs2: IArray[Object] = xs match { + case xs: IArray[Object] @unchecked => xs + case _ => + xs.map(_.asInstanceOf[Object]) + } + runtime.Tuples.fromIArray(xs2) + } + + /** Convert a Product into a tuple of unknown arity and types */ + def fromProduct(product: Product): Tuple = + runtime.Tuples.fromProduct(product) + + def fromProductTyped[P <: Product](p: P)(using m: scala.deriving.Mirror.ProductOf[P]): m.MirroredElemTypes = + runtime.Tuples.fromProduct(p).asInstanceOf[m.MirroredElemTypes] + + given canEqualEmptyTuple: CanEqual[EmptyTuple, EmptyTuple] = CanEqual.derived + given canEqualTuple[H1, T1 <: Tuple, H2, T2 <: Tuple]( + using eqHead: CanEqual[H1, H2], eqTail: CanEqual[T1, T2] + ): CanEqual[H1 *: T1, H2 *: T2] = CanEqual.derived +} + +/** A tuple of 0 elements */ +type EmptyTuple = EmptyTuple.type + +/** A tuple of 0 elements. */ +case object EmptyTuple extends Tuple { + override def toString(): String = "()" +} + +/** Tuple of arbitrary non-zero arity */ +sealed trait NonEmptyTuple extends Tuple + +@showAsInfix +sealed abstract class *:[+H, +T <: Tuple] extends NonEmptyTuple + +object *: { + def unapply[H, T <: Tuple](x: H *: T): (H, T) = (x.head, x.tail) +} diff --git a/valar-core/src/main/scala/net/ghoula/valar/internal/Derivation.scala b/valar-core/src/main/scala/net/ghoula/valar/internal/Derivation.scala index cb13c5b..617f63f 100644 --- a/valar-core/src/main/scala/net/ghoula/valar/internal/Derivation.scala +++ b/valar-core/src/main/scala/net/ghoula/valar/internal/Derivation.scala @@ -7,6 +7,21 @@ import scala.quoted.{Expr, Quotes, Type} import net.ghoula.valar.ValidationErrors.ValidationError import net.ghoula.valar.{AsyncValidator, ValidationResult, Validator} +/** Represents a missing validator discovered during compile-time validation. + * + * @param fieldName + * The name of the field missing a validator. + * @param fieldType + * A human-readable representation of the field's type. + * @param suggestion + * A helpful suggestion for how to fix the issue. + */ +private[internal] case class MissingValidator( + fieldName: String, + fieldType: String, + suggestion: String +) + /** Internal derivation engine for automatically generating validator instances. * * This object provides the core macro infrastructure for deriving both synchronous and @@ -79,56 +94,11 @@ object Derivation { } } - /** Applies validation logic to each field of a product type with null-safety handling. - * - * This method iterates through the fields of a product type, applying the appropriate validation - * logic to each field. It handles null values appropriately based on whether the field is - * optional, and provides consistent error handling for both synchronous and asynchronous - * validation scenarios. - * - * @param product - * The product instance whose fields are being validated. - * @param validators - * The sequence of validators corresponding to each field. - * @param labels - * The field names for error reporting. - * @param isOptionFlags - * Flags indicating which fields are optional (Option types). - * @param validateAndAnnotate - * Function to apply validation and annotation to a field. - * @param handleNull - * Function to handle null values in non-optional fields. - * @tparam V - * The validator type (either `Validator` or `AsyncValidator`). - * @tparam R - * The result type (either `ValidationResult` or `Future[ValidationResult]`). - * @return - * A list of validation results for each field. - */ - private def validateProduct[V, R]( - product: Product, - validators: Seq[V], - labels: List[String], - isOptionFlags: List[Boolean], - validateAndAnnotate: (V, Any, String) => R, - handleNull: String => R - ): List[R] = { - product.productIterator.zipWithIndex.map { case (fieldValue, i) => - val label = labels(i) - if (Option(fieldValue).isEmpty && !isOptionFlags(i)) { - handleNull(label) - } else { - val validator = validators(i) - validateAndAnnotate(validator, fieldValue, label) - } - }.toList - } - /** Extracts field names from a compile-time tuple of string literal types. * - * This method recursively processes a tuple type containing string literals (typically from - * `Mirror.MirroredElemLabels`) to extract the actual field names as a runtime `List[String]`. It - * performs compile-time validation to ensure all labels are string literals. + * Uses Scala 3.7.4's type-level pattern matching to extract string literals from the + * `Mirror.MirroredElemLabels` tuple type. Each element is a singleton string type that gets + * extracted to a runtime value. * * @param q * The quotes context for macro operations. @@ -141,55 +111,106 @@ object Derivation { */ private def getLabels[Labels <: Tuple: Type](using q: Quotes): List[String] = { import q.reflect.* - def loop(tpe: TypeRepr): List[String] = tpe.dealias match { - case AppliedType(_, List(head, tail)) => - head match { - case ConstantType(StringConstant(label)) => label :: loop(tail) - case _ => - report.errorAndAbort( - s"Invalid field label type: expected string literal, found ${head.show}. " + - "This typically indicates a structural issue with the case class definition.", - Position.ofMacroExpansion - ) + + // Type-level extraction using pattern matching on quoted types + // More idiomatic Scala 3 approach than manual TypeRepr traversal + def extract[L <: Tuple: Type]: List[String] = Type.of[L] match { + case '[EmptyTuple] => Nil + case '[label *: rest] => + // Extract the singleton string type's value using constValue pattern + Type.of[label] match { + case '[l] => + TypeRepr.of[l] match { + case ConstantType(StringConstant(s)) => s :: extract[rest] + case other => + report.errorAndAbort( + s"Invalid field label type: expected string literal, found ${other.show}. " + + "This typically indicates a structural issue with the case class definition.", + Position.ofMacroExpansion + ) + } } - case t if t =:= TypeRepr.of[EmptyTuple] => Nil - case _ => - report.errorAndAbort( - s"Invalid label tuple structure: ${tpe.show}. " + - "This may indicate an incompatible case class or tuple definition.", - Position.ofMacroExpansion - ) } - loop(TypeRepr.of[Labels]) + + extract[Labels] } - /** Analyzes field types to identify which fields are optional (`Option[T]`). + /** Validates that all required validators are available at compile time. * - * This method examines each field type in a product type to determine if it's an `Option` type. - * This information is used during validation to handle null values appropriately - null values - * are acceptable for optional fields but trigger validation errors for required fields. + * This method performs upfront validation of all field types before code generation, collecting + * ALL missing validators rather than failing on the first one. This provides better developer + * experience by showing all issues at once. * * @param q * The quotes context for macro operations. + * @tparam T + * The product type being validated. * @tparam Elems * The tuple type containing all field types. - * @return - * A list of boolean flags indicating which fields are optional. + * @param labels + * The list of field names. + * @param isAsync + * Whether this is for async validation (affects error message). */ - private def getIsOptionFlags[Elems <: Tuple: Type](using q: Quotes): List[Boolean] = { + private def validateAllFieldsHaveValidators[T: Type, Elems <: Tuple: Type]( + labels: List[String], + isAsync: Boolean + )(using q: Quotes): Unit = { import q.reflect.* - Type.of[Elems] match { - case '[EmptyTuple] => Nil - case '[h *: t] => - (TypeRepr.of[h] <:< TypeRepr.of[Option[Any]]) :: getIsOptionFlags[t] + + def collectMissing[E <: Tuple: Type]( + remainingLabels: List[String], + acc: List[MissingValidator] + ): List[MissingValidator] = + Type.of[E] match { + case '[EmptyTuple] => acc.reverse + case '[h *: t] => + val label = remainingLabels.head + val fieldTypeStr = Type.show[h] + + // For async, we accept either AsyncValidator or Validator + val hasValidator = if (isAsync) { + Expr.summon[AsyncValidator[h]].isDefined || Expr.summon[Validator[h]].isDefined + } else { + Expr.summon[Validator[h]].isDefined + } + + val newAcc = if (hasValidator) acc + else { + val suggestion = if (isAsync) { + s"given Validator[$fieldTypeStr] = ... or given AsyncValidator[$fieldTypeStr] = ..." + } else { + s"given Validator[$fieldTypeStr] = ..." + } + MissingValidator(label, fieldTypeStr, suggestion) :: acc + } + + collectMissing[t](remainingLabels.tail, newAcc) + } + + val missing = collectMissing[Elems](labels, Nil) + + if (missing.nonEmpty) { + val validatorType = if (isAsync) "AsyncValidator" else "Validator" + val header = s"Cannot derive $validatorType for ${Type.show[T]}: missing validators for ${missing.length} field(s).\n" + + val details = missing.zipWithIndex.map { case (m, i) => + s" ${i + 1}. Field '${m.fieldName}' of type ${m.fieldType}\n" + + s" Add: ${m.suggestion}" + }.mkString("\n\n") + + val footer = "\n\nHint: Valar provides built-in validators for common types (Int, String, Option, etc.).\n" + + "For custom types, either derive them with `Validator.derived` or provide explicit instances." + + report.errorAndAbort(header + "\n" + details + footer, Position.ofMacroExpansion) } } /** Generates a synchronous validator for product types using compile-time reflection. * * This method performs compile-time introspection of the product type, extracts field - * information, summons appropriate validators for each field, and generates optimized - * validation logic. + * information, summons appropriate validators for each field, and generates optimized validation + * logic. * * The generated validator handles: * - Field-by-field validation using appropriate validator instances @@ -217,55 +238,111 @@ object Derivation { import q.reflect.* val fieldLabels: List[String] = getLabels[Labels] - val isOptionList: List[Boolean] = getIsOptionFlags[Elems] - val fieldLabelsExpr: Expr[List[String]] = Expr(fieldLabels) - val isOptionListExpr: Expr[List[Boolean]] = Expr(isOptionList) - def summonValidators[E <: Tuple: Type]: List[Expr[Validator[Any]]] = - Type.of[E] match { - case '[EmptyTuple] => Nil - case '[h *: t] => - val validatorExpr = Expr.summon[Validator[h]].getOrElse { - report.errorAndAbort( - s"Cannot derive Validator for ${Type.show[T]}: missing validator for field type ${Type.show[h]}. " + - "Please provide a given instance of Validator[${Type.show[h]}].", - Position.ofMacroExpansion + // Compile-time validation: check ALL fields have validators before generating code + // This provides better error messages by reporting all missing validators at once + validateAllFieldsHaveValidators[T, Elems](fieldLabels, isAsync = false) + + // Type detection strategy: + // 1. Regular tuples (Tuple2, etc.): T <:< Tuple, use _1, _2 accessors - zero cast + // 2. Case classes: have actual field members, use field name accessor - zero cast + // 3. Named tuples: NOT <:< Tuple, no field members, use productElement - requires cast + // (This matches Scala 3.7.4 stdlib pattern: NamedTuple.apply uses asInstanceOf) + val isRegularTuple = TypeRepr.of[T] <:< TypeRepr.of[Tuple] + + // Generate validation expression for a single field + // Field access strategy depends on type: + // - Regular tuples: Select.unique(a, "_1") - zero cast + // - Case classes: Select.unique(a, "fieldName") - zero cast + // - Named tuples: productElement(index).asInstanceOf[H] - matches stdlib + def generateFieldValidation[H: Type]( + aExpr: Expr[T], + label: String, + index: Int, + isOption: Boolean, + validatorExpr: Expr[Validator[H]] + ): Expr[ValidationResult[Any]] = { + val labelExpr = Expr(label) + + // Determine field access method based on type structure + val fieldAccess: Expr[H] = if (isRegularTuple) { + // Regular tuple: use _1, _2, etc. - typed accessors, zero cast + Select.unique(aExpr.asTerm, s"_${index + 1}").asExprOf[H] + } else { + // Check if type has actual field member (case classes do, named tuples don't) + val typeSymbol = TypeRepr.of[T].typeSymbol + val hasFieldMember = typeSymbol.fieldMember(label) != Symbol.noSymbol + + if (hasFieldMember) { + // Case class: direct field access - zero cast + Select.unique(aExpr.asTerm, label).asExprOf[H] + } else { + // Named tuple: use productElement (matches Scala 3.7.4 stdlib pattern) + // See: scala.NamedTuple.apply uses asInstanceOf for element access + val indexExpr = Expr(index) + '{ $aExpr.asInstanceOf[Product].productElement($indexExpr).asInstanceOf[H] } + } + } + + if (isOption) { + // Option fields: null is valid (will be None), just validate + '{ + val fieldValue: H = $fieldAccess + val result = $validatorExpr.validate(fieldValue) + annotateErrors(result, $labelExpr, fieldValue) + } + } else { + // Required fields: null triggers error + '{ + val fieldValue: H = $fieldAccess + if (fieldValue == null) { + ValidationResult.invalid( + ValidationError( + s"Field '${$labelExpr}' must not be null.", + List($labelExpr), + expected = Some("non-null value"), + actual = Some("null") + ) ) + } else { + val result = $validatorExpr.validate(fieldValue) + annotateErrors(result, $labelExpr, fieldValue) } - '{ MacroHelper.upcastTo[Validator[Any]](${ validatorExpr }) } :: summonValidators[t] + } } + } - val fieldValidators: List[Expr[Validator[Any]]] = summonValidators[Elems] - val validatorsExpr: Expr[Seq[Validator[Any]]] = Expr.ofSeq(fieldValidators) + // Generate all field validations at compile time + // Uses type-level IsOption[H] match type for compile-time Option detection + def generateAllValidations[E <: Tuple: Type]( + aExpr: Expr[T], + index: Int, + labels: List[String] + ): List[Expr[ValidationResult[Any]]] = + Type.of[E] match { + case '[EmptyTuple] => Nil + case '[h *: t] => + val label = labels.head + + // Type-level Option detection using quotes reflection + // Checks if h <:< Option[?] at macro expansion time + val isOption: Boolean = TypeRepr.of[h] <:< TypeRepr.of[Option[?]] + + // Safe to use .get - upfront validation guarantees validators exist + val validatorExpr = Expr.summon[Validator[h]].get + + val fieldValidation = generateFieldValidation[h](aExpr, label, index, isOption, validatorExpr) + fieldValidation :: generateAllValidations[t](aExpr, index + 1, labels.tail) + } '{ new Validator[T] { def validate(a: T): ValidationResult[T] = { - a match { - case product: Product => - val validators = ${ validatorsExpr } - val labels = ${ fieldLabelsExpr } - val isOptionFlags = ${ isOptionListExpr } - - val results = validateProduct( - product, - validators, - labels, - isOptionFlags, - validateAndAnnotate = (v, fv, l) => annotateErrors(v.validate(fv), l, fv), - handleNull = l => - ValidationResult.invalid( - ValidationError( - s"Field '$l' must not be null.", - List(l), - expected = Some("non-null value"), - actual = Some("null") - ) - ) - ) - - processResults(results, ${ m }) + val results: List[ValidationResult[Any]] = ${ + val aExpr = 'a + Expr.ofList(generateAllValidations[Elems](aExpr, 0, fieldLabels)) } + processResults(results, $m) } } } @@ -274,8 +351,8 @@ object Derivation { /** Generates an asynchronous validator for product types using compile-time reflection. * * This method performs compile-time introspection of the product type, extracts field - * information, summons appropriate validators for each field, and generates optimized - * async validation logic. + * information, summons appropriate validators for each field, and generates optimized async + * validation logic. * * The generated validator handles: * - Field-by-field validation using appropriate validator instances @@ -305,72 +382,127 @@ object Derivation { import q.reflect.* val fieldLabels: List[String] = getLabels[Labels] - val isOptionList: List[Boolean] = getIsOptionFlags[Elems] - val fieldLabelsExpr: Expr[List[String]] = Expr(fieldLabels) - val isOptionListExpr: Expr[List[Boolean]] = Expr(isOptionList) - def summonAsyncOrSync[E <: Tuple: Type]: List[Expr[AsyncValidator[Any]]] = + // Compile-time validation: check ALL fields have validators before generating code + validateAllFieldsHaveValidators[T, Elems](fieldLabels, isAsync = true) + + // Type detection strategy (same as sync version): + // 1. Regular tuples: T <:< Tuple, use _1, _2 accessors - zero cast + // 2. Case classes: have actual field members, use field name accessor - zero cast + // 3. Named tuples: NOT <:< Tuple, no field members, use productElement - requires cast + val isRegularTuple = TypeRepr.of[T] <:< TypeRepr.of[Tuple] + + // Generate async validation expression for a single field + // Field access strategy depends on type structure + def generateAsyncFieldValidation[H: Type]( + aExpr: Expr[T], + label: String, + index: Int, + isOption: Boolean, + asyncValidatorExpr: Expr[AsyncValidator[H]] + ): Expr[ExecutionContext => Future[ValidationResult[Any]]] = { + val labelExpr = Expr(label) + + // Determine field access method based on type structure + val fieldAccess: Expr[H] = if (isRegularTuple) { + // Regular tuple: use _1, _2, etc. - typed accessors, zero cast + Select.unique(aExpr.asTerm, s"_${index + 1}").asExprOf[H] + } else { + // Check if type has actual field member (case classes do, named tuples don't) + val typeSymbol = TypeRepr.of[T].typeSymbol + val hasFieldMember = typeSymbol.fieldMember(label) != Symbol.noSymbol + + if (hasFieldMember) { + // Case class: direct field access - zero cast + Select.unique(aExpr.asTerm, label).asExprOf[H] + } else { + // Named tuple: use productElement (matches Scala 3.7.4 stdlib pattern) + val indexExpr = Expr(index) + '{ $aExpr.asInstanceOf[Product].productElement($indexExpr).asInstanceOf[H] } + } + } + + if (isOption) { + // Option fields: null is valid (will be None), just validate + '{ (ec: ExecutionContext) => + given ExecutionContext = ec + val fieldValue: H = $fieldAccess + $asyncValidatorExpr.validateAsync(fieldValue) + .map(result => annotateErrors(result, $labelExpr, fieldValue)) + .recover { case scala.util.control.NonFatal(ex) => + ValidationResult.invalid( + ValidationError(s"Asynchronous validation failed unexpectedly: ${ex.getMessage}") + ) + } + } + } else { + // Required fields: null triggers error + '{ (ec: ExecutionContext) => + given ExecutionContext = ec + val fieldValue: H = $fieldAccess + if (fieldValue == null) { + Future.successful( + ValidationResult.invalid( + ValidationError( + s"Field '${$labelExpr}' must not be null.", + List($labelExpr), + expected = Some("non-null value"), + actual = Some("null") + ) + ) + ) + } else { + $asyncValidatorExpr.validateAsync(fieldValue) + .map(result => annotateErrors(result, $labelExpr, fieldValue)) + .recover { case scala.util.control.NonFatal(ex) => + ValidationResult.invalid( + ValidationError(s"Asynchronous validation failed unexpectedly: ${ex.getMessage}") + ) + } + } + } + } + } + + // Generate all async field validations at compile time + // Uses type-level IsOption[H] match type for compile-time Option detection + def generateAllAsyncValidations[E <: Tuple: Type]( + aExpr: Expr[T], + index: Int, + labels: List[String] + ): List[Expr[ExecutionContext => Future[ValidationResult[Any]]]] = Type.of[E] match { case '[EmptyTuple] => Nil case '[h *: t] => - val validatorExpr = Expr.summon[AsyncValidator[h]].orElse(Expr.summon[Validator[h]]).getOrElse { - report.errorAndAbort( - s"Cannot derive AsyncValidator for ${Type.show[T]}: missing validator for field type ${Type.show[h]}. " + - "Please provide a given instance of either Validator[${Type.show[h]}] or AsyncValidator[${Type.show[h]}].", - Position.ofMacroExpansion - ) - } + val label = labels.head + + // Type-level Option detection using quotes reflection + // Checks if h <:< Option[?] at macro expansion time + val isOption: Boolean = TypeRepr.of[h] <:< TypeRepr.of[Option[?]] - val finalExpr = validatorExpr.asTerm.tpe.asType match { - case '[AsyncValidator[h]] => validatorExpr - case '[Validator[h]] => '{ AsyncValidator.fromSync(${ validatorExpr.asExprOf[Validator[h]] }) } + // Safe to use .get - upfront validation guarantees validators exist + // Try AsyncValidator first, fall back to Validator + val validatorExpr = Expr.summon[AsyncValidator[h]].orElse(Expr.summon[Validator[h]]).get + + // Convert sync validator to async if needed (compile-time type witness, no runtime cast) + val asyncValidatorExpr: Expr[AsyncValidator[h]] = validatorExpr.asTerm.tpe.asType match { + case '[AsyncValidator[`h`]] => validatorExpr.asExprOf[AsyncValidator[h]] + case '[Validator[`h`]] => '{ AsyncValidator.fromSync(${ validatorExpr.asExprOf[Validator[h]] }) } } - '{ MacroHelper.upcastTo[AsyncValidator[Any]](${ finalExpr }) } :: summonAsyncOrSync[t] + val fieldValidation = generateAsyncFieldValidation[h](aExpr, label, index, isOption, asyncValidatorExpr) + fieldValidation :: generateAllAsyncValidations[t](aExpr, index + 1, labels.tail) } - val fieldValidators: List[Expr[AsyncValidator[Any]]] = summonAsyncOrSync[Elems] - val validatorsExpr: Expr[Seq[AsyncValidator[Any]]] = Expr.ofSeq(fieldValidators) - '{ new AsyncValidator[T] { def validateAsync(a: T)(using ec: ExecutionContext): Future[ValidationResult[T]] = { - a match { - case product: Product => - val validators = ${ validatorsExpr } - val labels = ${ fieldLabelsExpr } - val isOptionFlags = ${ isOptionListExpr } - - val fieldResultsF = validateProduct( - product, - validators, - labels, - isOptionFlags, - validateAndAnnotate = (v, fv, l) => v.validateAsync(fv).map(annotateErrors(_, l, fv)), - handleNull = l => - Future.successful( - ValidationResult.invalid( - ValidationError( - s"Field '$l' must not be null.", - List(l), - expected = Some("non-null value"), - actual = Some("null") - ) - ) - ) - ) - - val allResultsF: Future[List[ValidationResult[Any]]] = - Future.sequence(fieldResultsF.map { f => - f.recover { case scala.util.control.NonFatal(ex) => - ValidationResult.invalid( - ValidationError(s"Asynchronous validation failed unexpectedly: ${ex.getMessage}") - ) - } - }) - - allResultsF.map(processResults(_, ${ m })) + val validations: List[ExecutionContext => Future[ValidationResult[Any]]] = ${ + val aExpr = 'a + Expr.ofList(generateAllAsyncValidations[Elems](aExpr, 0, fieldLabels)) } + val futureResults = validations.map(_(ec)) + Future.sequence(futureResults).map(processResults(_, $m)) } } } diff --git a/valar-core/src/main/scala/net/ghoula/valar/internal/MacroHelper.scala b/valar-core/src/main/scala/net/ghoula/valar/internal/MacroHelper.scala deleted file mode 100644 index 67a070b..0000000 --- a/valar-core/src/main/scala/net/ghoula/valar/internal/MacroHelper.scala +++ /dev/null @@ -1,25 +0,0 @@ -package net.ghoula.valar.internal - -/** Internal utility for type casting operations required by Valar's macro system. - * - * This function exists to handle specific, unavoidable casting scenarios in macros while - * suppressing linter warnings for `asInstanceOf`. - * - * '''Safety Contract''': This function should only be used when macro logic guarantees type - * compatibility. Incorrect usage will result in a `ClassCastException` at runtime. - */ -object MacroHelper { - - /** Casts a value to a specific type `T` when the type is guaranteed by macro logic. - * - * @param x - * The value to cast. - * @tparam T - * The target type. - * @return - * The value `x` cast to type `T`. - */ - @SuppressWarnings(Array("scalafix:DisableSyntax.asInstanceOf")) - transparent inline def upcastTo[T](x: Any): T = x.asInstanceOf[T] - -} From 383044f20c9d6d65ed611c20c91e491aa59351a0 Mon Sep 17 00:00:00 2001 From: Hakim Jonas Ghoula Date: Fri, 21 Nov 2025 23:34:00 +0100 Subject: [PATCH 04/19] Remove unused reflectiveCalls import from Validator.scala --- valar-core/src/main/scala/net/ghoula/valar/Validator.scala | 1 - 1 file changed, 1 deletion(-) diff --git a/valar-core/src/main/scala/net/ghoula/valar/Validator.scala b/valar-core/src/main/scala/net/ghoula/valar/Validator.scala index 5b12fe1..a6da495 100644 --- a/valar-core/src/main/scala/net/ghoula/valar/Validator.scala +++ b/valar-core/src/main/scala/net/ghoula/valar/Validator.scala @@ -4,7 +4,6 @@ import java.time.{Duration, Instant, LocalDate, LocalDateTime, LocalTime, ZonedD import java.util.UUID import scala.collection.immutable.ArraySeq import scala.deriving.Mirror -import scala.language.reflectiveCalls import scala.quoted.{Expr, Quotes, Type} import scala.reflect.ClassTag From 4271d4f1e7c96a33b7b01c0a89900d50b8b49ea9 Mon Sep 17 00:00:00 2001 From: Hakim Jonas Ghoula Date: Fri, 21 Nov 2025 23:44:10 +0100 Subject: [PATCH 05/19] Update documentation for Scala 3.7.4 modernization - Replace "macros" terminology with "inline metaprogramming" - Update Scala version references to 3.7.4 - Update MODERNIZATION_AUDIT.md to reflect completed Phase 1 & 2 work - Remove MacroHelper.scala references (file was deleted) - Mark all implemented items as complete - Update roadmap and conclusions --- MODERNIZATION_AUDIT.md | 385 +++++++++++++++++-------------------- README.md | 13 +- valar-benchmarks/README.md | 2 +- 3 files changed, 188 insertions(+), 212 deletions(-) diff --git a/MODERNIZATION_AUDIT.md b/MODERNIZATION_AUDIT.md index d84e5c7..e8c4918 100644 --- a/MODERNIZATION_AUDIT.md +++ b/MODERNIZATION_AUDIT.md @@ -1,30 +1,30 @@ # Valar Modernization Audit - Scala 3.7.4 **Date:** November 21, 2025 -**Scala Version:** 3.7.1 โ†’ 3.7.4 -**Scala Native:** 0.5.8 โ†’ 0.5.9 -**Status:** โœ… Upgrade Complete, All Tests Passing +**Scala Version:** 3.7.4 +**Scala Native:** 0.5.9 +**Status:** โœ… Phase 1 & 2 Complete, All Tests Passing --- ## Executive Summary -This audit evaluates opportunities to modernize Valar's codebase following the upgrade to Scala 3.7.4. The focus is on leveraging recent Scala 3 improvements in inline metaprogramming, type class derivation, and macro capabilities to make the code more elegant, performant, and maintainable. +This audit evaluates opportunities to modernize Valar's codebase following the upgrade to Scala 3.7.4. The focus is on leveraging recent Scala 3 improvements in inline metaprogramming, type class derivation, and compile-time capabilities to make the code more elegant, performant, and maintainable. -**Key Finding:** Valar's current metaprogramming approach is already quite modern and well-architected. The opportunities for improvement are evolutionary rather than revolutionary. +**Key Finding:** Valar's inline metaprogramming is now fully modernized for Scala 3.7.4. Phase 1 and Phase 2 improvements have been implemented, including zero-cast field access, compile-time validator validation, and cleaner type-level patterns. --- ## Current State Analysis -### Metaprogramming Architecture +### Inline Metaprogramming Architecture #### โœ… Strengths 1. **Clean Separation of Concerns** - - `Derivation.scala`: Core macro logic (342 lines) - - `MacroHelper.scala`: Type casting utilities (26 lines) + - `Derivation.scala`: Core derivation logic with compile-time validation - Well-documented internal APIs + - Zero-cast field access for case classes and tuples 2. **Modern Quotes Reflection API** - Already using Scala 3's quotes reflection (`scala.quoted.*`) @@ -38,30 +38,37 @@ This audit evaluates opportunities to modernize Valar's codebase following the u 4. **Compile-Time Introspection** - Mirror-based derivation for product types - - Field label extraction from tuple types - - Option type detection via subtyping checks + - Type-level label extraction using pattern matching + - Inline Option detection via `TypeRepr <:< Option[?]` + - Upfront validator validation with comprehensive error messages #### ๐Ÿ” Current Implementation Patterns -**Macro Derivation (`Derivation.scala:217-342`)** +**Sync/Async Derivation (Derivation.scala)** ```scala -def deriveValidatorImpl[T: Type, Elems <: Tuple: Type, Labels <: Tuple: Type]( - m: Expr[Mirror.ProductOf[T]], - isAsync: Boolean -)(using q: Quotes): Expr[Any] +def deriveSyncValidatorImpl[T: Type, Elems <: Tuple: Type, Labels <: Tuple: Type]( + m: Expr[Mirror.ProductOf[T]] +)(using q: Quotes): Expr[Validator[T]] + +def deriveAsyncValidatorImpl[T: Type, Elems <: Tuple: Type, Labels <: Tuple: Type]( + m: Expr[Mirror.ProductOf[T]] +)(using q: Quotes): Expr[AsyncValidator[T]] ``` -**Inline Optimization (`ValidationObserver.scala:109-111`)** +**Zero-Cast Field Access** ```scala -inline given noOpObserver: ValidationObserver with { - def onResult[A](result: ValidationResult[A]): Unit = () -} +// Case classes: direct field access via Select.unique +val fieldAccess = Select.unique(aExpr.asTerm, label).asExprOf[H] + +// Regular tuples: _1, _2, etc. +val fieldAccess = Select.unique(aExpr.asTerm, s"_${index + 1}").asExprOf[H] ``` -**Type Casting (`MacroHelper.scala:23`)** +**Inline Optimization (`ValidationObserver.scala`)** ```scala -@SuppressWarnings(Array("scalafix:DisableSyntax.asInstanceOf")) -inline def upcastTo[T](x: Any): T = x.asInstanceOf[T] +inline given noOpObserver: ValidationObserver with { + def onResult[A](result: ValidationResult[A]): Unit = () +} ``` --- @@ -121,39 +128,37 @@ inline def upcastTo[T](x: Any): T = x.asInstanceOf[T] ## Modernization Opportunities -### Priority 1: High Value, Low Risk +### Priority 1: High Value, Low Risk โœ… COMPLETED -#### 1.1 Transparent Inline for Better Type Inference +#### 1.1 Zero-Cast Field Access โœ… DONE -**Current:** +**Previous Approach:** ```scala +// Used asInstanceOf via MacroHelper.upcastTo inline def upcastTo[T](x: Any): T = x.asInstanceOf[T] ``` -**Potential Enhancement:** +**Implemented Solution:** ```scala -transparent inline def upcastTo[T](x: Any): T = x.asInstanceOf[T] +// Direct field access via Select.unique - zero runtime cast! +val fieldAccess = Select.unique(aExpr.asTerm, label).asExprOf[H] ``` -**Benefits:** -- Better type inference at call sites -- More precise return types in complex scenarios -- Zero breaking changes - -**Location:** `valar-core/src/main/scala/net/ghoula/valar/internal/MacroHelper.scala:23` +**Benefits Achieved:** +- Zero runtime casts for case classes and regular tuples +- Named tuples use stdlib pattern (productElement with cast - matches Scala's own stdlib) +- MacroHelper.scala removed entirely -**Estimated Effort:** 30 minutes -**Risk:** Very Low -**Recommendation:** โœ… Adopt +**Status:** โœ… Implemented and tested --- -#### 1.2 Inline Match Types for Field Type Analysis +#### 1.2 Inline Option Detection โœ… DONE -**Current Pattern (`Derivation.scala:177-184`):** +**Previous Pattern:** ```scala +// Separate pass to compute isOptionFlags list private def getIsOptionFlags[Elems <: Tuple: Type](using q: Quotes): List[Boolean] = { - import q.reflect.* Type.of[Elems] match { case '[EmptyTuple] => Nil case '[h *: t] => @@ -162,124 +167,104 @@ private def getIsOptionFlags[Elems <: Tuple: Type](using q: Quotes): List[Boolea } ``` -**Potential Enhancement:** +**Implemented Solution:** ```scala -// Use match types at type level for compile-time guarantees -private inline def getIsOptionFlags[Elems <: Tuple: Type](using q: Quotes): List[Boolean] = { - import q.reflect.* - // Could potentially leverage match types for more elegant type-level computation - inline erasedValue[Elems] match { - case _: EmptyTuple => Nil - case _: (h *: t) => - inline if TypeTest[h, Option[?]] then - true :: getIsOptionFlags[t] - else - false :: getIsOptionFlags[t] - } -} +// Inline check during field processing - single pass! +val isOption: Boolean = TypeRepr.of[h] <:< TypeRepr.of[Option[?]] ``` -**Benefits:** -- More idiomatic Scala 3 style -- Potentially better compile-time optimization -- Clearer intent at the type level +**Benefits Achieved:** +- Eliminated separate tuple traversal +- Single-pass field processing +- Cleaner, more direct code -**Estimated Effort:** 2-3 hours (requires testing) -**Risk:** Medium (needs thorough validation) -**Recommendation:** ๐Ÿ”ฌ Experimental - Test in branch +**Note:** Type-level match types (`IsOption[T]`) don't reduce inside macro contexts. +Quotes reflection (`TypeRepr <:<`) is the correct approach for macros. ---- +**Status:** โœ… Implemented and tested -#### 1.3 Enhanced Error Messages with Source Positions +--- -**Current (`Derivation.scala:149-152`):** -```scala -report.errorAndAbort( - s"Invalid field label type: expected string literal, found ${head.show}. " + - "This typically indicates a structural issue with the case class definition." -) -``` +#### 1.3 Enhanced Error Messages with Source Positions โœ… DONE -**Enhancement:** +**Implemented:** ```scala report.errorAndAbort( - s"Invalid field label type: expected string literal, found ${head.show}. " + - "This typically indicates a structural issue with the case class definition.", - head.pos // Add source position for better IDE integration + s"Cannot derive Validator for ${Type.show[T]}: missing validators for ${missing.length} field(s).\n" + + details + footer, + Position.ofMacroExpansion ) ``` -**Benefits:** -- Better error messages with precise source locations -- Improved IDE integration -- Easier debugging for users +**Benefits Achieved:** +- Source positions added via `Position.ofMacroExpansion` +- Comprehensive error messages showing ALL missing validators at once +- Helpful suggestions for how to fix issues -**Location:** Multiple locations in `Derivation.scala` -**Estimated Effort:** 1 hour -**Risk:** Very Low -**Recommendation:** โœ… Adopt +**Status:** โœ… Implemented and tested --- -### Priority 2: Medium Value, Medium Risk - -#### 2.1 Compile-Time Validator Validation +### Priority 2: Medium Value, Medium Risk โœ… COMPLETED -**Opportunity:** -Use Scala 3.7's improved compile-time capabilities to validate validator consistency at compile time. +#### 2.1 Compile-Time Validator Validation โœ… DONE -**Current Challenge:** -Validators are resolved at macro expansion time, but their behavior isn't validated. - -**Potential Enhancement:** +**Implemented Solution:** ```scala -// Add compile-time checks that validators are consistent -// E.g., ensure Option[T] has both T validator and Option validator -inline def validateValidatorConsistency[T: Type](using Quotes): Unit = { - import quotes.reflect.* - // Check for common validation pitfalls at compile time - // - Missing required validators - // - Circular validator dependencies - // - Type incompatibilities +private def validateAllFieldsHaveValidators[T: Type, Elems <: Tuple: Type]( + labels: List[String], + isAsync: Boolean +)(using q: Quotes): Unit = { + // Collects ALL missing validators before generating code + // Reports all issues at once with helpful suggestions } ``` -**Benefits:** -- Catch configuration errors at compile time -- Better user experience -- Reduced runtime surprises +**Benefits Achieved:** +- Catches ALL missing validators at compile time (not just the first one) +- Provides helpful suggestions: `Add: given Validator[FieldType] = ...` +- Better developer experience with comprehensive error messages -**Estimated Effort:** 8-12 hours -**Risk:** Medium -**Recommendation:** ๐Ÿ”ฌ Research for v0.6.0 +**Status:** โœ… Implemented and tested --- -#### 2.2 Refined Field Path Tracking - -**Current (`Derivation.scala:69-80`):** -The field annotation is done at runtime with string manipulation. - -**Potential Enhancement:** -Build field paths at compile time using type-level programming: +#### 2.2 Type-Level Label Extraction โœ… DONE +**Previous Pattern:** ```scala -// Type-level field path representation -type FieldPath = List[String] +// Manual TypeRepr traversal for label extraction +def loop(tpe: TypeRepr): List[String] = tpe.dealias match { + case AppliedType(_, List(head, tail)) => + head match { + case ConstantType(StringConstant(label)) => label :: loop(tail) + // ... + } +} +``` -// Compile-time path construction -inline def buildFieldPath[T: Type, FieldName <: String: Type]: String = - constValue[FieldName] // Available in Scala 3.7+ +**Implemented Solution:** +```scala +// Type-level pattern matching - more idiomatic Scala 3 +def extract[L <: Tuple: Type]: List[String] = Type.of[L] match { + case '[EmptyTuple] => Nil + case '[label *: rest] => + Type.of[label] match { + case '[l] => + TypeRepr.of[l] match { + case ConstantType(StringConstant(s)) => s :: extract[rest] + // ... + } + } +} ``` -**Benefits:** -- Zero runtime overhead for path construction -- Type-safe field paths -- Better integration with IDE tooling +**Benefits Achieved:** +- Cleaner, more idiomatic Scala 3 type-level pattern matching +- More readable code using `'[label *: rest]` syntax +- Same compile-time performance, better maintainability -**Estimated Effort:** 16-20 hours -**Risk:** Medium-High -**Recommendation:** ๐Ÿ“‹ Consider for v0.6.0 major feature +**Status:** โœ… Implemented and tested --- @@ -318,45 +303,36 @@ ValidationObserver.scala: ValidationConfig.scala: - inline given default (โœ… Perfect use case) - -MacroHelper.scala: - - inline def upcastTo[T] (โœ… Perfect use case) ``` -**Finding:** โœ… All current inline usages are appropriate and optimal. +**Finding:** โœ… All current inline usages are appropriate and optimal. MacroHelper was removed +as direct field access via `Select.unique` eliminated the need for type casting helpers. --- -### 2. Macro Expansion Complexity - -**Current Complexity:** -The `deriveValidatorImpl` method is the largest macro (125 lines of implementation). +### 2. Derivation Structure โœ… REFACTORED -**Analysis:** -- Handles both sync and async validator derivation -- Complex but well-structured -- Good use of helper methods +**Previous:** +Single `deriveValidatorImpl` method handling both sync and async with boolean flag. -**Potential Split:** +**Current (Refactored):** ```scala -// Split into smaller, focused macros -def deriveSyncValidatorImpl[...]: Expr[Validator[T]] = ... -def deriveAsyncValidatorImpl[...]: Expr[AsyncValidator[T]] = ... - -// Keep current unified entry point that dispatches -def deriveValidatorImpl[...](isAsync: Boolean): Expr[Any] = - if isAsync then deriveAsyncValidatorImpl - else deriveSyncValidatorImpl +// Separate, focused implementations +def deriveSyncValidatorImpl[T: Type, Elems <: Tuple: Type, Labels <: Tuple: Type]( + m: Expr[Mirror.ProductOf[T]] +)(using q: Quotes): Expr[Validator[T]] + +def deriveAsyncValidatorImpl[T: Type, Elems <: Tuple: Type, Labels <: Tuple: Type]( + m: Expr[Mirror.ProductOf[T]] +)(using q: Quotes): Expr[AsyncValidator[T]] ``` -**Benefits:** -- Easier to understand and maintain -- Better compile-time performance (smaller expansion units) -- More testable +**Benefits Achieved:** +- Clearer separation of sync vs async logic +- Better type safety (typed return types instead of `Expr[Any]`) +- Easier to maintain and extend -**Estimated Effort:** 6-8 hours -**Risk:** Low -**Recommendation:** โœ… Good refactoring for v0.5.1 +**Status:** โœ… Implemented --- @@ -413,43 +389,43 @@ With Scala 3.7's improved type inference, some explicit type annotations may be ## Recommendations Summary -### Immediate Actions (v0.5.1) +### โœ… Completed Actions (v0.5.1) -1. โœ… **Add `transparent inline` to `MacroHelper.upcastTo`** - - Effort: 30 minutes - - Risk: Very Low - - Value: Better type inference +1. โœ… **Zero-cast field access via Select.unique** + - MacroHelper removed entirely + - Direct field access for case classes and tuples -2. โœ… **Enhance macro error messages with source positions** - - Effort: 1 hour - - Risk: Very Low - - Value: Better developer experience +2. โœ… **Enhanced error messages with source positions** + - Comprehensive error reporting showing ALL missing validators + - Helpful suggestions for fixes -3. โœ… **Split `deriveValidatorImpl` into separate sync/async macros** - - Effort: 6-8 hours - - Risk: Low - - Value: Better maintainability +3. โœ… **Split derivation into separate sync/async implementations** + - Better type safety and maintainability + - Cleaner code organization -**Total Estimated Effort:** 8-10 hours +4. โœ… **Inline Option detection** + - Single-pass field processing + - Eliminated separate isOptionFlags traversal + +5. โœ… **Type-level label extraction** + - More idiomatic Scala 3 pattern matching + - Cleaner code + +**Status:** All Phase 1 and Phase 2 items implemented and tested. --- ### Near-Term Exploration (v0.6.0) -1. ๐Ÿ”ฌ **Experiment with inline match types for field analysis** - - Research branch to validate approach - - Benchmark against current implementation - - If successful, cleaner and more idiomatic code +1. ๐Ÿ“‹ **Named tuple field name access** + - Currently named tuples use productElement (matches stdlib) + - Could potentially use `.toTuple` conversion for cleaner access + - Low priority - current implementation is correct and efficient -2. ๐Ÿ”ฌ **Compile-time validator validation** - - Catch validation configuration errors earlier - - Enhance user experience - - Requires design work - -3. ๐Ÿ“‹ **Type-level field path construction** - - Zero runtime overhead - - Better IDE integration - - Major feature for v0.6.0 +2. ๐Ÿ“‹ **Additional compile-time validations** + - Detect circular validator dependencies + - Warn about potentially inefficient validation patterns + - Nice-to-have, not critical --- @@ -469,41 +445,42 @@ With Scala 3.7's improved type inference, some explicit type annotations may be ## Modernization Roadmap -### Phase 1: Quick Wins (v0.5.1) - 2 weeks -- Transparent inline adoption -- Enhanced error messages -- Macro refactoring for maintainability -- **Goal:** Better DX, no functional changes +### Phase 1: Quick Wins (v0.5.1) โœ… COMPLETE +- โœ… Zero-cast field access via Select.unique +- โœ… Enhanced error messages with source positions +- โœ… Derivation refactoring (sync/async split) +- **Result:** Better DX, cleaner code, no breaking changes -### Phase 2: Type-Level Enhancements (v0.6.0) - 1-2 months -- Inline match types exploration -- Compile-time validator validation -- Type-level field paths -- **Goal:** More elegant, performant code +### Phase 2: Type-Level Enhancements (v0.5.1) โœ… COMPLETE +- โœ… Inline Option detection (single-pass) +- โœ… Compile-time validator validation (all-at-once error reporting) +- โœ… Type-level label extraction (idiomatic pattern matching) +- **Result:** More elegant, maintainable code -### Phase 3: Future-Proofing (v0.7.0+) - 6-12 months -- Adopt stabilized Scala 3.8+ features -- Explicit nulls integration -- Capture checking support -- **Goal:** Leverage cutting-edge Scala features +### Phase 3: Future-Proofing (v0.7.0+) - Monitor +- ๐Ÿ”ฎ Adopt stabilized Scala 3.8+ features when available +- ๐Ÿ”ฎ Explicit nulls integration (still experimental) +- ๐Ÿ”ฎ Capture checking support (still experimental) +- **Goal:** Leverage cutting-edge Scala features when stable --- ## Conclusion -**Current Assessment:** Valar's metaprogramming is already modern and well-designed. The codebase effectively uses Scala 3's capabilities and follows best practices. +**Current Assessment:** Valar's inline metaprogramming is now fully modernized for Scala 3.7.4. All Phase 1 and Phase 2 improvements have been implemented and tested. + +**Modernization Results:** +1. โœ… Zero-cast field access for case classes and regular tuples +2. โœ… Comprehensive compile-time validator validation +3. โœ… Cleaner, more idiomatic Scala 3 code patterns +4. โœ… Better developer experience with improved error messages +5. โœ… Removed unnecessary helper code (MacroHelper.scala) -**Modernization Value:** Evolutionary improvements rather than revolutionary changes. Focus should be on: -1. Small, safe enhancements (transparent inline, better errors) -2. Incremental refactoring for maintainability -3. Monitoring Scala ecosystem for stabilized features +**Key Insight:** The Scala 3.7.4 documentation research revealed that even the Scala stdlib uses `asInstanceOf` for named tuple element access. This validated our approach: zero-cast for case classes and regular tuples, stdlib-compatible pattern for named tuples. -**Key Insight:** The upgrade to Scala 3.7.4 validates that Valar's architecture is forward-compatible and robust. Rather than major rewrites, the focus should be on: -- Refining inline usage for optimal performance -- Enhancing developer experience with better errors -- Preparing for future Scala features through careful experimentation +**Terminology Note:** Throughout this codebase, we now use "inline metaprogramming" instead of "macros" to reflect modern Scala 3 terminology. The quotes/reflect API is the standard approach for compile-time code generation in Scala 3. -**Recommendation:** Proceed with Phase 1 quick wins for v0.5.1, and allocate time for experimental branches to validate Phase 2 enhancements. +**Next Steps:** Monitor Scala 3.8+ for stabilization of experimental features (explicit nulls, capture checking). --- diff --git a/README.md b/README.md index 3bfd6d3..1424b93 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT) Valar is a validation library for Scala 3 designed for clarity and ease of use. It leverages Scala 3's type system and -metaprogramming (macros) to help you define complex validation rules with less boilerplate, while providing structured, +inline metaprogramming to help you define complex validation rules with less boilerplate, while providing structured, detailed error messages useful for debugging or user feedback. ## **โœจ What's New in 0.5.X** @@ -16,8 +16,7 @@ detailed error messages useful for debugging or user feedback. `Translator` typeclass. * **๐Ÿงช Enhanced ValarSuite**: Updated testing utilities in `valar-munit` now used in `valar-translator` for more robust validation testing. -* **โšก Reworked Macros**: Simpler, more performant, and more modern macro implementations for better compile-time - validation. +* **โšก Reworked Derivation**: Simpler, more performant inline metaprogramming for better compile-time validation. * **๐Ÿ›ก๏ธ MiMa Checks**: Added binary compatibility verification to ensure smooth upgrades between versions. * **๐Ÿ“š Improved Documentation**: Comprehensive updates to scaladoc and module-level README files for a better developer experience. @@ -26,7 +25,7 @@ detailed error messages useful for debugging or user feedback. * **Type Safety:** Clearly distinguish between valid results and accumulated errors at compile time using ValidationResult[A]. Eliminate runtime errors caused by unexpected validation states. -* **Minimal Boilerplate:** Derive Validator instances automatically for case classes using compile-time macros, +* **Minimal Boilerplate:** Derive Validator instances automatically for case classes using compile-time derivation, significantly reducing repetitive validation logic. Focus on your rules, not the wiring. * **Flexible Error Handling:** Choose the strategy that fits your use case: * **Error Accumulation** (default): Collect all validation failures, ideal for reporting multiple issues (e.g., in @@ -37,7 +36,7 @@ detailed error messages useful for debugging or user feedback. * **Named Tuple Support:** Field-aware error messages for Scala 3.7's named tuples, with preserved backward compatibility. * **Scala 3 Idiomatic:** Built specifically for Scala 3, embracing features like extension methods, given instances, - opaque types, and macros for a modern, expressive API. + opaque types, and inline metaprogramming for a modern, expressive API. ## **Extensibility Pattern** @@ -404,7 +403,7 @@ Version 0.5.0 introduces several new features while maintaining backward compati 1. **New ValidationObserver trait** for observing validation outcomes 2. **New valar-translator module** for internationalization support 3. **Enhanced ValarSuite** with improved testing utilities -4. **Reworked macros** for better performance and modern Scala 3 features +4. **Reworked inline metaprogramming** for better performance and modern Scala 3 features 5. **MiMa checks** to ensure binary compatibility To upgrade to v0.5.0, update your build.sbt: @@ -493,7 +492,7 @@ When a collection exceeds the configured limit, validation fails immediately ''' ## **Compatibility** -* **Scala:** 3.7+ +* **Scala:** 3.7.4+ * **Platforms:** JVM, Scala Native * **Dependencies:** valar-core has a Compile dependency on `io.github.cquiroz:scala-java-time` to provide robust, cross-platform support for the `java.time` API. diff --git a/valar-benchmarks/README.md b/valar-benchmarks/README.md index f70ce8a..d8007b2 100644 --- a/valar-benchmarks/README.md +++ b/valar-benchmarks/README.md @@ -129,7 +129,7 @@ To add new benchmarks: ## Dependencies - JMH 1.37 -- Scala 3.7.1 +- Scala 3.7.4 - OpenJDK 21+ ## Notes From baecb992ff9ae3671d3acd5184278d1394b67c05 Mon Sep 17 00:00:00 2001 From: Hakim Jonas Ghoula Date: Fri, 21 Nov 2025 23:59:35 +0100 Subject: [PATCH 06/19] Clean up repo root: remove internal documents, fix terminology MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove IMPLEMENTATION_SUMMARY.md (internal dev log) - Remove MODERNIZATION_AUDIT.md (internal architecture audit) - Fix "macros" โ†’ "inline metaprogramming" in MIGRATION.md --- IMPLEMENTATION_SUMMARY.md | 290 ---------------------- MIGRATION.md | 2 +- MODERNIZATION_AUDIT.md | 502 -------------------------------------- 3 files changed, 1 insertion(+), 793 deletions(-) delete mode 100644 IMPLEMENTATION_SUMMARY.md delete mode 100644 MODERNIZATION_AUDIT.md diff --git a/IMPLEMENTATION_SUMMARY.md b/IMPLEMENTATION_SUMMARY.md deleted file mode 100644 index fecde22..0000000 --- a/IMPLEMENTATION_SUMMARY.md +++ /dev/null @@ -1,290 +0,0 @@ -# Implementation Summary: Code Review Improvements - -This document summarizes all improvements implemented based on the comprehensive code review. - -## Branch: `code-review-improvements` - ---- - -## Changes Implemented - -### โœ… Priority 1A: Code Coverage Reporting - -**Status:** Completed - -**Changes:** -- Added `sbt-scoverage` 2.2.2 plugin to `project/plugins.sbt` -- Configured coverage thresholds in `build.sbt`: - - Minimum statement coverage: 80% - - Coverage highlighting enabled - - Fail on minimum: disabled (warning only) - -**Files Modified:** -- `project/plugins.sbt` -- `build.sbt` - -**Commit:** `71473fa` - Add scoverage plugin for code coverage reporting - ---- - -### โœ… Priority 1B.1: ReDoS Security Documentation - -**Status:** Completed - -**Changes:** -- Added comprehensive security warnings to `regexMatch` methods in `ValidationHelpers.scala` -- Documented safe vs. unsafe usage patterns with examples -- Added new "Security Considerations" section to `README.md` covering: - - Regular Expression Denial of Service (ReDoS) - - Input size limits - - Error information disclosure - -**Files Modified:** -- `valar-core/src/main/scala/net/ghoula/valar/ValidationHelpers.scala` -- `README.md` - -**Commit:** `995a9a5` - Add comprehensive ReDoS security warnings - -**Impact:** -- Users are now clearly warned about ReDoS vulnerabilities -- Documentation provides actionable guidance for secure usage - ---- - -### โœ… Priority 1B.2: Input Size Limits (ValidationConfig) - -**Status:** Completed - -**Changes:** -- Created new `ValidationConfig.scala` with configurable limits: - - `maxCollectionSize`: Limits elements in collections - - `maxNestingDepth`: Reserved for future use -- Provided three presets: - - `ValidationConfig.default`: No limits (trusted data) - - `ValidationConfig.strict`: 10,000 element limit (untrusted data) - - `ValidationConfig.permissive`: 1,000,000 element limit (internal data) -- Updated all collection validators to check size limits: - - `List`, `Seq`, `Vector`, `Set`, `Map`, `Array`, `ArraySeq` -- Size checks fail fast before processing elements for performance - -**Files Modified:** -- `valar-core/src/main/scala/net/ghoula/valar/ValidationConfig.scala` (new file) -- `valar-core/src/main/scala/net/ghoula/valar/Validator.scala` - -**Commit:** `618a4a6` - Implement ValidationConfig for input size limits - -**Impact:** -- Protection against memory exhaustion attacks -- Protection against CPU exhaustion from large collections -- Configurable security boundaries for different trust levels - ---- - -### โœ… Priority 3.2: Convenience Methods - -**Status:** Completed - -**Changes:** -- Added `ValidationResult.sequence[A]` method: - - Combines `List[ValidationResult[A]]` into `ValidationResult[List[A]]` - - Accumulates all errors from failed validations -- Added `ValidationResult.traverse[A, B]` method: - - Maps and sequences in one operation - - More efficient than separate map + sequence -- Both methods include comprehensive documentation and examples - -**Files Modified:** -- `valar-core/src/main/scala/net/ghoula/valar/ValidationResult.scala` - -**Commit:** `50490c8` - Add sequence and traverse convenience methods - -**Impact:** -- Improved developer experience for working with collections -- Common functional programming patterns now available -- Reduces boilerplate code - ---- - -### โœ… Priority 4.1: Troubleshooting Guide - -**Status:** Completed - -**Changes:** -- Created comprehensive `TROUBLESHOOTING.md` with: - - Compilation errors section (missing validators, ambiguous implicits, etc.) - - Runtime issues section (empty error vectors, size limits, etc.) - - Performance problems section (slow validation, stack overflow) - - Security concerns section (ReDoS, error disclosure) - - Best practices section (fail-fast vs accumulation, optional fields, composition) - - Quick reference table - -**Files Created:** -- `TROUBLESHOOTING.md` - -**Files Modified:** -- `README.md` (added link to troubleshooting guide) - -**Commit:** `ad36cb9` - Add comprehensive troubleshooting guide - -**Impact:** -- Reduced support burden with self-service troubleshooting -- Faster problem resolution for users -- Better understanding of common patterns and anti-patterns - ---- - -### โœ… Priority 4.2: Performance Documentation - -**Status:** Completed - -**Changes:** -- Added "Performance" section to `README.md` with: - - Complexity characteristics table (time/space complexity) - - Performance best practices - - Link to detailed benchmarks - - Key findings from JMH benchmarks -- Enhanced "Input Size Limits" section with ValidationConfig usage examples -- Added troubleshooting guide to Additional Resources - -**Files Modified:** -- `README.md` - -**Commit:** `7fa1727` - Add performance documentation and link to troubleshooting - -**Impact:** -- Users understand performance characteristics before using the library -- Clear guidance on optimizing validation performance -- Better security posture through documented ValidationConfig usage - ---- - -### โœ… Priority 5.1: CI/CD Enhancements - -**Status:** Completed - -**Changes:** -- Added code coverage reporting: - - Tests run with `sbt coverage ... coverageReport` - - Coverage uploaded to Codecov - - Tracks coverage across all modules (core, munit, translator) -- Added caching for Scala Native builds: - - Caches coursier, ivy2, and sbt directories - - Significantly speeds up CI builds -- Enhanced test coverage to include all modules - -**Files Modified:** -- `.github/workflows/scala.yml` - -**Commit:** `344908f` - Enhance CI/CD pipeline with coverage and caching - -**Impact:** -- Visibility into code coverage trends over time -- Faster CI builds with caching -- More comprehensive test execution - ---- - -## Summary Statistics - -**Total Commits:** 8 -**Files Modified:** 10 -**New Files Created:** 3 -**Lines Added:** ~800 -**Lines Modified:** ~100 - -### Files Changed: -1. `project/plugins.sbt` - Added scoverage plugin -2. `build.sbt` - Added coverage configuration -3. `valar-core/src/main/scala/net/ghoula/valar/ValidationHelpers.scala` - Security warnings -4. `valar-core/src/main/scala/net/ghoula/valar/ValidationConfig.scala` - **NEW** -5. `valar-core/src/main/scala/net/ghoula/valar/Validator.scala` - Size limit checks -6. `valar-core/src/main/scala/net/ghoula/valar/ValidationResult.scala` - Convenience methods -7. `README.md` - Performance docs, security section, troubleshooting link -8. `TROUBLESHOOTING.md` - **NEW** -9. `IMPLEMENTATION_SUMMARY.md` - **NEW** (this file) -10. `.github/workflows/scala.yml` - Coverage and caching - ---- - -## Items NOT Implemented (Lower Priority) - -The following items from the original plan were not implemented in this iteration: - -### Priority 2.1: Add Edge-Case Tests -- **Reason:** Requires running coverage report first to identify gaps -- **Recommendation:** Run `sbt coverage test coverageReport` and review gaps, then add targeted tests - -### Priority 3.1: Standardize Error Codes -- **Reason:** This is a breaking change that should be bundled into a 0.6.0 release -- **Recommendation:** Plan for 0.6.0 release with comprehensive migration guide - -### Priority 3.3: Improve Error Message Consistency -- **Reason:** Should be done together with error code standardization (3.1) -- **Recommendation:** Include in 0.6.0 release - ---- - -## Breaking Changes Analysis - -**Current Implementation:** No breaking changes introduced - -All changes are backward compatible: -- `ValidationConfig` is optional (default provides unlimited validation like before) -- New methods (`sequence`, `traverse`) are additions, not modifications -- Documentation changes have no API impact -- CI/CD changes are infrastructure only - ---- - -## Recommendations for Next Steps - -1. **Immediate (Before Merge):** - - Review all commits - - Run full test suite locally - - Generate and review coverage report - - Test ValidationConfig with various limits - -2. **Short Term (After Merge):** - - Monitor Codecov reports for coverage trends - - Add edge-case tests based on coverage gaps - - Gather user feedback on troubleshooting guide - -3. **Medium Term (0.6.0 Planning):** - - Standardize error codes across all validators - - Improve error message consistency - - Consider deprecating String-based `regexMatch` - - Add ScalaCheck property-based tests - -4. **Long Term:** - - Implement `valar-cats-effect` module - - Implement `valar-zio` module - - Add Scalafix migration rules for breaking changes - ---- - -## Testing Checklist - -Before merging, verify: - -- [ ] All tests pass: `sbt test` -- [ ] Code formatting: `sbt scalafmtCheckAll` -- [ ] Linting: `sbt scalafixAll --check` -- [ ] Coverage report generates: `sbt coverage test coverageReport` -- [ ] Documentation builds: `sbt mdoc` -- [ ] Native builds work: `sbt valarCoreNative/test` -- [ ] ValidationConfig works as expected with strict/permissive/custom limits -- [ ] Security warnings are visible in Scaladoc - ---- - -## Acknowledgments - -This implementation addresses the findings from the comprehensive code review conducted on 2025-01-17, which gave the project an A- (90/100) rating and identified these improvements as high-priority items for production readiness. - ---- - -**Implementation Date:** 2025-01-17 -**Branch:** `code-review-improvements` -**Implemented By:** Claude (Anthropic) -**Review Grade Before:** A- (90/100) -**Expected Grade After:** A (95/100) diff --git a/MIGRATION.md b/MIGRATION.md index 43faf88..eb2ccc1 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -7,7 +7,7 @@ Version 0.5.0 introduces several new features while maintaining backward compati 1. **New ValidationObserver trait** for observing validation outcomes without altering the flow 2. **New valar-translator module** for internationalization support of validation error messages 3. **Enhanced ValarSuite** with improved testing utilities -4. **Reworked macros** for better performance and modern Scala 3 features +4. **Reworked derivation** using modern Scala 3 inline metaprogramming 5. **MiMa checks** to ensure binary compatibility between versions ### Update build.sbt: diff --git a/MODERNIZATION_AUDIT.md b/MODERNIZATION_AUDIT.md deleted file mode 100644 index e8c4918..0000000 --- a/MODERNIZATION_AUDIT.md +++ /dev/null @@ -1,502 +0,0 @@ -# Valar Modernization Audit - Scala 3.7.4 - -**Date:** November 21, 2025 -**Scala Version:** 3.7.4 -**Scala Native:** 0.5.9 -**Status:** โœ… Phase 1 & 2 Complete, All Tests Passing - ---- - -## Executive Summary - -This audit evaluates opportunities to modernize Valar's codebase following the upgrade to Scala 3.7.4. The focus is on leveraging recent Scala 3 improvements in inline metaprogramming, type class derivation, and compile-time capabilities to make the code more elegant, performant, and maintainable. - -**Key Finding:** Valar's inline metaprogramming is now fully modernized for Scala 3.7.4. Phase 1 and Phase 2 improvements have been implemented, including zero-cast field access, compile-time validator validation, and cleaner type-level patterns. - ---- - -## Current State Analysis - -### Inline Metaprogramming Architecture - -#### โœ… Strengths - -1. **Clean Separation of Concerns** - - `Derivation.scala`: Core derivation logic with compile-time validation - - Well-documented internal APIs - - Zero-cast field access for case classes and tuples - -2. **Modern Quotes Reflection API** - - Already using Scala 3's quotes reflection (`scala.quoted.*`) - - Type-safe pattern matching on `TypeRepr` - - Proper use of `Expr.summon` for implicit resolution - -3. **Strategic Use of Inline** - - `inline given` for zero-cost abstractions (`ValidationObserver.noOpObserver`) - - `inline def` for compile-time optimizations (`observe()`, `upcastTo()`) - - `inline given` for default configurations (`ValidationConfig.default`) - -4. **Compile-Time Introspection** - - Mirror-based derivation for product types - - Type-level label extraction using pattern matching - - Inline Option detection via `TypeRepr <:< Option[?]` - - Upfront validator validation with comprehensive error messages - -#### ๐Ÿ” Current Implementation Patterns - -**Sync/Async Derivation (Derivation.scala)** -```scala -def deriveSyncValidatorImpl[T: Type, Elems <: Tuple: Type, Labels <: Tuple: Type]( - m: Expr[Mirror.ProductOf[T]] -)(using q: Quotes): Expr[Validator[T]] - -def deriveAsyncValidatorImpl[T: Type, Elems <: Tuple: Type, Labels <: Tuple: Type]( - m: Expr[Mirror.ProductOf[T]] -)(using q: Quotes): Expr[AsyncValidator[T]] -``` - -**Zero-Cast Field Access** -```scala -// Case classes: direct field access via Select.unique -val fieldAccess = Select.unique(aExpr.asTerm, label).asExprOf[H] - -// Regular tuples: _1, _2, etc. -val fieldAccess = Select.unique(aExpr.asTerm, s"_${index + 1}").asExprOf[H] -``` - -**Inline Optimization (`ValidationObserver.scala`)** -```scala -inline given noOpObserver: ValidationObserver with { - def onResult[A](result: ValidationResult[A]): Unit = () -} -``` - ---- - -## Scala 3.7.x Improvements Analysis - -### 1. Enhanced Inline Method Handling - -**What Changed:** -- "Fail not inlined inline method calls early" - better compile-time error messages -- Improved inline export forwarders -- More robust symbol remapping - -**Impact on Valar:** โš ๏ธ Minor -- Current inline usage is straightforward and shouldn't benefit significantly -- Better error messages will improve developer experience -- No changes needed to existing code - -### 2. Metaprogramming and Macro Improvements - -**What Changed:** -- Enhanced quotes functionality -- Better symbol remapping -- Improved reflection API stability - -**Impact on Valar:** โš ๏ธ Minor -- Already using stable quotes APIs -- Complex macro in `Derivation.deriveValidatorImpl` works well -- Potential for minor optimizations in type introspection - -### 3. Type Class Derivation Enhancements - -**What Changed:** -- More robust derivation capabilities -- Better type inference for derived instances -- Improved Mirror.ProductOf handling - -**Impact on Valar:** โœ… Relevant -- Current mirror-based approach could potentially be simplified -- Better type inference may reduce need for explicit type annotations -- Worth investigating for version 0.6.0 - -### 4. Experimental Features - -**Available but Not Yet Stable:** -- Capture checking -- Explicit nulls -- Separation checking -- Global initialization checking - -**Impact on Valar:** ๐Ÿ”ฎ Future -- Explicit nulls could enhance null safety in validation -- Not recommended for adoption yet (experimental) -- Monitor for stable release in Scala 3.8+ - ---- - -## Modernization Opportunities - -### Priority 1: High Value, Low Risk โœ… COMPLETED - -#### 1.1 Zero-Cast Field Access โœ… DONE - -**Previous Approach:** -```scala -// Used asInstanceOf via MacroHelper.upcastTo -inline def upcastTo[T](x: Any): T = x.asInstanceOf[T] -``` - -**Implemented Solution:** -```scala -// Direct field access via Select.unique - zero runtime cast! -val fieldAccess = Select.unique(aExpr.asTerm, label).asExprOf[H] -``` - -**Benefits Achieved:** -- Zero runtime casts for case classes and regular tuples -- Named tuples use stdlib pattern (productElement with cast - matches Scala's own stdlib) -- MacroHelper.scala removed entirely - -**Status:** โœ… Implemented and tested - ---- - -#### 1.2 Inline Option Detection โœ… DONE - -**Previous Pattern:** -```scala -// Separate pass to compute isOptionFlags list -private def getIsOptionFlags[Elems <: Tuple: Type](using q: Quotes): List[Boolean] = { - Type.of[Elems] match { - case '[EmptyTuple] => Nil - case '[h *: t] => - (TypeRepr.of[h] <:< TypeRepr.of[Option[Any]]) :: getIsOptionFlags[t] - } -} -``` - -**Implemented Solution:** -```scala -// Inline check during field processing - single pass! -val isOption: Boolean = TypeRepr.of[h] <:< TypeRepr.of[Option[?]] -``` - -**Benefits Achieved:** -- Eliminated separate tuple traversal -- Single-pass field processing -- Cleaner, more direct code - -**Note:** Type-level match types (`IsOption[T]`) don't reduce inside macro contexts. -Quotes reflection (`TypeRepr <:<`) is the correct approach for macros. - -**Status:** โœ… Implemented and tested - ---- - -#### 1.3 Enhanced Error Messages with Source Positions โœ… DONE - -**Implemented:** -```scala -report.errorAndAbort( - s"Cannot derive Validator for ${Type.show[T]}: missing validators for ${missing.length} field(s).\n" + - details + footer, - Position.ofMacroExpansion -) -``` - -**Benefits Achieved:** -- Source positions added via `Position.ofMacroExpansion` -- Comprehensive error messages showing ALL missing validators at once -- Helpful suggestions for how to fix issues - -**Status:** โœ… Implemented and tested - ---- - -### Priority 2: Medium Value, Medium Risk โœ… COMPLETED - -#### 2.1 Compile-Time Validator Validation โœ… DONE - -**Implemented Solution:** -```scala -private def validateAllFieldsHaveValidators[T: Type, Elems <: Tuple: Type]( - labels: List[String], - isAsync: Boolean -)(using q: Quotes): Unit = { - // Collects ALL missing validators before generating code - // Reports all issues at once with helpful suggestions -} -``` - -**Benefits Achieved:** -- Catches ALL missing validators at compile time (not just the first one) -- Provides helpful suggestions: `Add: given Validator[FieldType] = ...` -- Better developer experience with comprehensive error messages - -**Status:** โœ… Implemented and tested - ---- - -#### 2.2 Type-Level Label Extraction โœ… DONE - -**Previous Pattern:** -```scala -// Manual TypeRepr traversal for label extraction -def loop(tpe: TypeRepr): List[String] = tpe.dealias match { - case AppliedType(_, List(head, tail)) => - head match { - case ConstantType(StringConstant(label)) => label :: loop(tail) - // ... - } -} -``` - -**Implemented Solution:** -```scala -// Type-level pattern matching - more idiomatic Scala 3 -def extract[L <: Tuple: Type]: List[String] = Type.of[L] match { - case '[EmptyTuple] => Nil - case '[label *: rest] => - Type.of[label] match { - case '[l] => - TypeRepr.of[l] match { - case ConstantType(StringConstant(s)) => s :: extract[rest] - // ... - } - } -} -``` - -**Benefits Achieved:** -- Cleaner, more idiomatic Scala 3 type-level pattern matching -- More readable code using `'[label *: rest]` syntax -- Same compile-time performance, better maintainability - -**Status:** โœ… Implemented and tested - ---- - -### Priority 3: Future Exploration - -#### 3.1 Adoption of Experimental Features - -**Explicit Nulls:** -- Currently handling null at runtime -- Could leverage `-Yexplicit-nulls` for compile-time null safety -- **Status:** Experimental - wait for Scala 3.8+ stabilization - -**Capture Checking:** -- Could enhance async validation safety -- Prevent escaping references in validation callbacks -- **Status:** Experimental - monitor progress - -**Recommendation:** ๐Ÿ”ฎ Monitor, don't adopt yet - ---- - -## Performance Optimization Opportunities - -### 1. Inline Budget Analysis - -Scala 3.7 has improved inline heuristics. Review inline usage for: -- Methods that are too large to inline effectively -- Recursive inline methods that hit inline depth limits -- Methods that would benefit from `@inline` instead of `inline` - -**Current Inline Usage:** -``` -ValidationObserver.scala: - - inline given noOpObserver (โœ… Perfect use case) - - inline def observe() (โœ… Perfect use case) - -ValidationConfig.scala: - - inline given default (โœ… Perfect use case) -``` - -**Finding:** โœ… All current inline usages are appropriate and optimal. MacroHelper was removed -as direct field access via `Select.unique` eliminated the need for type casting helpers. - ---- - -### 2. Derivation Structure โœ… REFACTORED - -**Previous:** -Single `deriveValidatorImpl` method handling both sync and async with boolean flag. - -**Current (Refactored):** -```scala -// Separate, focused implementations -def deriveSyncValidatorImpl[T: Type, Elems <: Tuple: Type, Labels <: Tuple: Type]( - m: Expr[Mirror.ProductOf[T]] -)(using q: Quotes): Expr[Validator[T]] - -def deriveAsyncValidatorImpl[T: Type, Elems <: Tuple: Type, Labels <: Tuple: Type]( - m: Expr[Mirror.ProductOf[T]] -)(using q: Quotes): Expr[AsyncValidator[T]] -``` - -**Benefits Achieved:** -- Clearer separation of sync vs async logic -- Better type safety (typed return types instead of `Expr[Any]`) -- Easier to maintain and extend - -**Status:** โœ… Implemented - ---- - -## Code Quality Improvements - -### 1. Documentation with Scala 3.7 Features - -Scala 3.7 has improved Scaladoc capabilities. Consider: - -```scala -/** Enhanced documentation with better code examples - * - * @example {{{ - * // Scala 3.7 supports better syntax highlighting in docs - * given Validator[User] = Validator.deriveValidatorMacro - * - * val result: ValidationResult[User] = user.validate - * }}} - * - * @see [[ValidationResult]] for result handling - * @note This macro requires validators for all field types - */ -inline def deriveValidatorMacro[T]: Validator[T] = ... -``` - -**Status:** โœ… Current documentation is already excellent. Minor enhancements possible. - ---- - -### 2. Type Signature Clarity - -With Scala 3.7's improved type inference, some explicit type annotations may be redundant: - -**Review Areas:** -- `Derivation.scala:42-53` - Return types could potentially be inferred -- Pattern matching on types could use newer match type syntax - -**Recommendation:** โš ๏ธ Low priority - explicit types aid readability - ---- - -## Migration Safety - -### Breaking Changes Assessment - -**Scala 3.7.x Breaking Changes:** -- None that affect Valar's codebase -- All tests pass without modification -- Binary compatibility maintained - -**Upgrade Safety:** โœ… 100% safe - ---- - -## Recommendations Summary - -### โœ… Completed Actions (v0.5.1) - -1. โœ… **Zero-cast field access via Select.unique** - - MacroHelper removed entirely - - Direct field access for case classes and tuples - -2. โœ… **Enhanced error messages with source positions** - - Comprehensive error reporting showing ALL missing validators - - Helpful suggestions for fixes - -3. โœ… **Split derivation into separate sync/async implementations** - - Better type safety and maintainability - - Cleaner code organization - -4. โœ… **Inline Option detection** - - Single-pass field processing - - Eliminated separate isOptionFlags traversal - -5. โœ… **Type-level label extraction** - - More idiomatic Scala 3 pattern matching - - Cleaner code - -**Status:** All Phase 1 and Phase 2 items implemented and tested. - ---- - -### Near-Term Exploration (v0.6.0) - -1. ๐Ÿ“‹ **Named tuple field name access** - - Currently named tuples use productElement (matches stdlib) - - Could potentially use `.toTuple` conversion for cleaner access - - Low priority - current implementation is correct and efficient - -2. ๐Ÿ“‹ **Additional compile-time validations** - - Detect circular validator dependencies - - Warn about potentially inefficient validation patterns - - Nice-to-have, not critical - ---- - -### Long-Term Monitoring (v0.7.0+) - -1. ๐Ÿ”ฎ **Explicit nulls support** - - Wait for Scala 3.8 stabilization - - Could eliminate runtime null checks - - Major safety improvement - -2. ๐Ÿ”ฎ **Capture checking for async validation** - - Prevent reference escape bugs - - Enhanced type safety - - Experimental feature to monitor - ---- - -## Modernization Roadmap - -### Phase 1: Quick Wins (v0.5.1) โœ… COMPLETE -- โœ… Zero-cast field access via Select.unique -- โœ… Enhanced error messages with source positions -- โœ… Derivation refactoring (sync/async split) -- **Result:** Better DX, cleaner code, no breaking changes - -### Phase 2: Type-Level Enhancements (v0.5.1) โœ… COMPLETE -- โœ… Inline Option detection (single-pass) -- โœ… Compile-time validator validation (all-at-once error reporting) -- โœ… Type-level label extraction (idiomatic pattern matching) -- **Result:** More elegant, maintainable code - -### Phase 3: Future-Proofing (v0.7.0+) - Monitor -- ๐Ÿ”ฎ Adopt stabilized Scala 3.8+ features when available -- ๐Ÿ”ฎ Explicit nulls integration (still experimental) -- ๐Ÿ”ฎ Capture checking support (still experimental) -- **Goal:** Leverage cutting-edge Scala features when stable - ---- - -## Conclusion - -**Current Assessment:** Valar's inline metaprogramming is now fully modernized for Scala 3.7.4. All Phase 1 and Phase 2 improvements have been implemented and tested. - -**Modernization Results:** -1. โœ… Zero-cast field access for case classes and regular tuples -2. โœ… Comprehensive compile-time validator validation -3. โœ… Cleaner, more idiomatic Scala 3 code patterns -4. โœ… Better developer experience with improved error messages -5. โœ… Removed unnecessary helper code (MacroHelper.scala) - -**Key Insight:** The Scala 3.7.4 documentation research revealed that even the Scala stdlib uses `asInstanceOf` for named tuple element access. This validated our approach: zero-cast for case classes and regular tuples, stdlib-compatible pattern for named tuples. - -**Terminology Note:** Throughout this codebase, we now use "inline metaprogramming" instead of "macros" to reflect modern Scala 3 terminology. The quotes/reflect API is the standard approach for compile-time code generation in Scala 3. - -**Next Steps:** Monitor Scala 3.8+ for stabilization of experimental features (explicit nulls, capture checking). - ---- - -## Appendix: Testing Strategy - -All modernization changes must: -1. โœ… Pass full test suite (JVM + Native) -2. โœ… Maintain binary compatibility (MiMa checks) -3. โœ… Pass TASTy compatibility (for inline changes) -4. โœ… Show no performance regression -5. โœ… Pass mdoc documentation checks - -**Experimental Features:** Require dedicated feature branches with benchmarking before merge. - ---- - -**Document Version:** 1.0 -**Last Updated:** November 21, 2025 -**Next Review:** After Scala 3.8.0 release From 8989576017ad62253d0d30aa1cacbcf014050581 Mon Sep 17 00:00:00 2001 From: Hakim Jonas Ghoula Date: Sat, 22 Nov 2025 00:06:21 +0100 Subject: [PATCH 07/19] Remove Scala stdlib reference files from docs/ --- docs/scala-3.7.4/NamedTuple.scala | 214 ------------------- docs/scala-3.7.4/Tuple.scala | 341 ------------------------------ 2 files changed, 555 deletions(-) delete mode 100644 docs/scala-3.7.4/NamedTuple.scala delete mode 100644 docs/scala-3.7.4/Tuple.scala diff --git a/docs/scala-3.7.4/NamedTuple.scala b/docs/scala-3.7.4/NamedTuple.scala deleted file mode 100644 index 1e4d3c0..0000000 --- a/docs/scala-3.7.4/NamedTuple.scala +++ /dev/null @@ -1,214 +0,0 @@ -package scala -import compiletime.ops.boolean.* - -object NamedTuple: - - /** The type to which named tuples get mapped to. For instance, - * (name: String, age: Int) - * gets mapped to - * NamedTuple[("name", "age"), (String, Int)] - */ - opaque type NamedTuple[N <: Tuple, +V <: Tuple] >: V <: AnyNamedTuple = V - - /** A type which is a supertype of all named tuples */ - opaque type AnyNamedTuple = Any - - def apply[N <: Tuple, V <: Tuple](x: V): NamedTuple[N, V] = x - - def unapply[N <: Tuple, V <: Tuple](x: NamedTuple[N, V]): Some[V] = Some(x) - - /** A named tuple expression will desugar to a call to `build`. For instance, - * `(name = "Lyra", age = 23)` will desugar to `build[("name", "age")]()(("Lyra", 23))`. - */ - inline def build[N <: Tuple]()[V <: Tuple](x: V): NamedTuple[N, V] = x - - extension [V <: Tuple](x: V) - inline def withNames[N <: Tuple]: NamedTuple[N, V] = x - - import NamedTupleDecomposition.{Names, DropNames} - export NamedTupleDecomposition.{ - Names, DropNames, - apply, size, init, head, last, tail, take, drop, splitAt, ++, map, reverse, zip, toList, toArray, toIArray - } - - extension [N <: Tuple, V <: Tuple](x: NamedTuple[N, V]) - - // ALL METHODS DEPENDING ON `toTuple` MUST BE EXPORTED FROM `NamedTupleDecomposition` - /** The underlying tuple without the names */ - inline def toTuple: V = x - - // This intentionally works for empty named tuples as well. I think NonEmptyTuple is a dead end - // and should be reverted, just like NonEmptyList is also appealing at first, but a bad idea - // in the end. - - // inline def :* [L] (x: L): NamedTuple[Append[N, ???], Append[V, L] = ??? - // inline def *: [H] (x: H): NamedTuple[??? *: N], H *: V] = ??? - - end extension - - /** The size of a named tuple, represented as a literal constant subtype of Int */ - type Size[X <: AnyNamedTuple] = Tuple.Size[DropNames[X]] - - /** The type of the element value at position N in the named tuple X */ - type Elem[X <: AnyNamedTuple, N <: Int] = Tuple.Elem[DropNames[X], N] - - /** The type of the first element value of a named tuple */ - type Head[X <: AnyNamedTuple] = Elem[X, 0] - - /** The type of the last element value of a named tuple */ - type Last[X <: AnyNamedTuple] = Tuple.Last[DropNames[X]] - - /** The type of a named tuple consisting of all elements of named tuple X except the first one */ - type Tail[X <: AnyNamedTuple] = Drop[X, 1] - - /** The type of the initial part of a named tuple without its last element */ - type Init[X <: AnyNamedTuple] = - NamedTuple[Tuple.Init[Names[X]], Tuple.Init[DropNames[X]]] - - /** The type of the named tuple consisting of the first `N` elements of `X`, - * or all elements if `N` exceeds `Size[X]`. - */ - type Take[X <: AnyNamedTuple, N <: Int] = - NamedTuple[Tuple.Take[Names[X], N], Tuple.Take[DropNames[X], N]] - - /** The type of the named tuple consisting of all elements of `X` except the first `N` ones, - * or no elements if `N` exceeds `Size[X]`. - */ - type Drop[X <: AnyNamedTuple, N <: Int] = - NamedTuple[Tuple.Drop[Names[X], N], Tuple.Drop[DropNames[X], N]] - - /** The pair type `(Take(X, N), Drop[X, N]). */ - type Split[X <: AnyNamedTuple, N <: Int] = (Take[X, N], Drop[X, N]) - - /** Type of the concatenation of two tuples `X` and `Y` */ - type Concat[X <: AnyNamedTuple, Y <: AnyNamedTuple] = - NamedTuple[Tuple.Concat[Names[X], Names[Y]], Tuple.Concat[DropNames[X], DropNames[Y]]] - - /** The type of the named tuple `X` mapped with the type-level function `F`. - * If `X = (n1 : T1, ..., ni : Ti)` then `Map[X, F] = `(n1 : F[T1], ..., ni : F[Ti])`. - */ - type Map[X <: AnyNamedTuple, F[_ <: Tuple.Union[DropNames[X]]]] = - NamedTuple[Names[X], Tuple.Map[DropNames[X], F]] - - /** A named tuple with the elements of tuple `X` in reversed order */ - type Reverse[X <: AnyNamedTuple] = - NamedTuple[Tuple.Reverse[Names[X]], Tuple.Reverse[DropNames[X]]] - - /** The type of the named tuple consisting of all element values of - * named tuple `X` zipped with corresponding element values of - * named tuple `Y`. If the two tuples have different sizes, - * the extra elements of the larger tuple will be disregarded. - * The names of `X` and `Y` at the same index must be the same. - * The result tuple keeps the same names as the operand tuples. - * For example, if - * ``` - * X = (n1 : S1, ..., ni : Si) - * Y = (n1 : T1, ..., nj : Tj) where j >= i - * ``` - * then - * ``` - * Zip[X, Y] = (n1 : (S1, T1), ..., ni: (Si, Ti)) - * ``` - * @syntax markdown - */ - type Zip[X <: AnyNamedTuple, Y <: AnyNamedTuple] = - Names[X] match - case Names[Y] => - NamedTuple[Names[X], Tuple.Zip[DropNames[X], DropNames[Y]]] - - /** A type specially treated by the compiler to represent all fields of a - * class argument `T` as a named tuple. Or, if `T` is already a named tuple, - * `From[T]` is the same as `T`. - */ - type From[T] <: AnyNamedTuple - - /** The type of the empty named tuple */ - type Empty = NamedTuple[EmptyTuple, EmptyTuple] - - /** The empty named tuple */ - val Empty: Empty = EmptyTuple - -end NamedTuple - -/** Separate from NamedTuple object so that we can match on the opaque type NamedTuple. */ -object NamedTupleDecomposition: - import NamedTuple.* - extension [N <: Tuple, V <: Tuple](x: NamedTuple[N, V]) - /** The value (without the name) at index `n` of this tuple */ - inline def apply(n: Int): Elem[NamedTuple[N, V], n.type] = - x.toTuple.apply(n).asInstanceOf[Elem[NamedTuple[N, V], n.type]] - - /** The number of elements in this tuple */ - inline def size: Size[NamedTuple[N, V]] = x.toTuple.size - - /** The first element value of this tuple */ - inline def head: Head[NamedTuple[N, V]] = apply(0) - - /** The last element value of this tuple */ - inline def last: Last[NamedTuple[N, V]] = apply(size - 1).asInstanceOf[Last[NamedTuple[N, V]]] - - /** The tuple consisting of all elements of this tuple except the last one */ - inline def init: Init[NamedTuple[N, V]] = - x.take(size - 1).asInstanceOf[Init[NamedTuple[N, V]]] - - /** The tuple consisting of all elements of this tuple except the first one */ - inline def tail: Tail[NamedTuple[N, V]] = x.toTuple.drop(1) - - /** The tuple consisting of the first `n` elements of this tuple, or all - * elements if `n` exceeds `size`. - */ - inline def take(n: Int): Take[NamedTuple[N, V], n.type] = x.toTuple.take(n) - - /** The tuple consisting of all elements of this tuple except the first `n` ones, - * or no elements if `n` exceeds `size`. - */ - inline def drop(n: Int): Drop[NamedTuple[N, V], n.type] = x.toTuple.drop(n) - - /** The tuple `(x.take(n), x.drop(n))` */ - inline def splitAt(n: Int): Split[NamedTuple[N, V], n.type] = x.toTuple.splitAt(n) - - /** The tuple consisting of all elements of this tuple followed by all elements - * of tuple `that`. The names of the two tuples must be disjoint. - */ - inline def ++ [N2 <: Tuple, V2 <: Tuple](that: NamedTuple[N2, V2])(using Tuple.Disjoint[N, N2] =:= true) - : Concat[NamedTuple[N, V], NamedTuple[N2, V2]] - = x.toTuple ++ that.toTuple - - /** The named tuple consisting of all element values of this tuple mapped by - * the polymorphic mapping function `f`. The names of elements are preserved. - * If `x = (n1 = v1, ..., ni = vi)` then `x.map(f) = `(n1 = f(v1), ..., ni = f(vi))`. - */ - inline def map[F[_]](f: [t] => t => F[t]): Map[NamedTuple[N, V], F] = - x.toTuple.map[F](f) - - /** The named tuple consisting of all elements of this tuple in reverse */ - inline def reverse: Reverse[NamedTuple[N, V]] = x.toTuple.reverse - - /** The named tuple consisting of all element values of this tuple zipped - * with corresponding element values in named tuple `that`. - * If the two tuples have different sizes, - * the extra elements of the larger tuple will be disregarded. - * The names of `x` and `that` at the same index must be the same. - * The result tuple keeps the same names as the operand tuples. - */ - inline def zip[V2 <: Tuple](that: NamedTuple[N, V2]): Zip[NamedTuple[N, V], NamedTuple[N, V2]] = - x.toTuple.zip(that.toTuple) - - /** A list consisting of all element values */ - inline def toList: List[Tuple.Union[V]] = x.toTuple.toList - - /** An array consisting of all element values */ - inline def toArray: Array[Object] = x.toTuple.toArray - - /** An immutable array consisting of all element values */ - inline def toIArray: IArray[Object] = x.toTuple.toIArray - - end extension - - /** The names of a named tuple, represented as a tuple of literal string values. */ - type Names[X <: AnyNamedTuple] <: Tuple = X match - case NamedTuple[n, _] => n - - /** The value types of a named tuple represented as a regular tuple. */ - type DropNames[NT <: AnyNamedTuple] <: Tuple = NT match - case NamedTuple[_, x] => x diff --git a/docs/scala-3.7.4/Tuple.scala b/docs/scala-3.7.4/Tuple.scala deleted file mode 100644 index 57d1572..0000000 --- a/docs/scala-3.7.4/Tuple.scala +++ /dev/null @@ -1,341 +0,0 @@ -package scala - -import annotation.showAsInfix -import compiletime.* -import compiletime.ops.int.* - -/** Tuple of arbitrary arity */ -sealed trait Tuple extends Product { - import Tuple.* - - /** Create a copy of this tuple as an Array */ - inline def toArray: Array[Object] = - runtime.Tuples.toArray(this) - - /** Create a copy of this tuple as a List */ - inline def toList: List[Union[this.type]] = - this.productIterator.toList - .asInstanceOf[List[Union[this.type]]] - - /** Create a copy of this tuple as an IArray */ - inline def toIArray: IArray[Object] = - runtime.Tuples.toIArray(this) - - /** Return a copy of `this` tuple with an element appended */ - inline def :* [This >: this.type <: Tuple, L] (x: L): This :* L = - runtime.Tuples.append(x, this).asInstanceOf[This :* L] - - /** Return a new tuple by prepending the element to `this` tuple. - * This operation is O(this.size) - */ - inline def *: [H, This >: this.type <: Tuple] (x: H): H *: This = - runtime.Tuples.cons(x, this).asInstanceOf[H *: This] - - /** Get the i-th element of this tuple. - * Equivalent to productElement but with a precise return type. - */ - inline def apply[This >: this.type <: Tuple](n: Int): Elem[This, n.type] = - runtime.Tuples.apply(this, n).asInstanceOf[Elem[This, n.type]] - - /** Get the head of this tuple */ - inline def head[This >: this.type <: Tuple]: Head[This] = - runtime.Tuples.apply(this, 0).asInstanceOf[Head[This]] - - /** Get the initial part of the tuple without its last element */ - inline def init[This >: this.type <: Tuple]: Init[This] = - runtime.Tuples.init(this).asInstanceOf[Init[This]] - - /** Get the last of this tuple */ - inline def last[This >: this.type <: Tuple]: Last[This] = - runtime.Tuples.last(this).asInstanceOf[Last[This]] - - /** Get the tail of this tuple. - * This operation is O(this.size) - */ - inline def tail[This >: this.type <: Tuple]: Tail[This] = - runtime.Tuples.tail(this).asInstanceOf[Tail[This]] - - /** Return a new tuple by concatenating `this` tuple with `that` tuple. - * This operation is O(this.size + that.size) - */ - inline def ++ [This >: this.type <: Tuple](that: Tuple): This ++ that.type = - runtime.Tuples.concat(this, that).asInstanceOf[This ++ that.type] - - /** Return the size (or arity) of the tuple */ - inline def size[This >: this.type <: Tuple]: Size[This] = - runtime.Tuples.size(this).asInstanceOf[Size[This]] - - /** Given two tuples, `(a1, ..., an)` and `(b1, ..., bn)`, returns a tuple - * `((a1, b1), ..., (an, bn))`. If the two tuples have different sizes, - * the extra elements of the larger tuple will be disregarded. - * The result is typed as `((A1, B1), ..., (An, Bn))` if at least one of the - * tuple types has a `EmptyTuple` tail. Otherwise the result type is - * `(A1, B1) *: ... *: (Ai, Bi) *: Tuple` - */ - inline def zip[This >: this.type <: Tuple, T2 <: Tuple](t2: T2): Zip[This, T2] = - runtime.Tuples.zip(this, t2).asInstanceOf[Zip[This, T2]] - - /** Called on a tuple `(a1, ..., an)`, returns a new tuple `(f(a1), ..., f(an))`. - * The result is typed as `(F[A1], ..., F[An])` if the tuple type is fully known. - * If the tuple is of the form `a1 *: ... *: Tuple` (that is, the tail is not known - * to be the cons type. - */ - inline def map[F[_]](f: [t] => t => F[t]): Map[this.type, F] = - runtime.Tuples.map(this, f).asInstanceOf[Map[this.type, F]] - - /** Given a tuple `(a1, ..., am)`, returns the tuple `(a1, ..., an)` consisting - * of its first n elements. - */ - inline def take[This >: this.type <: Tuple](n: Int): Take[This, n.type] = - runtime.Tuples.take(this, n).asInstanceOf[Take[This, n.type]] - - - /** Given a tuple `(a1, ..., am)`, returns the tuple `(an+1, ..., am)` consisting - * all its elements except the first n ones. - */ - inline def drop[This >: this.type <: Tuple](n: Int): Drop[This, n.type] = - runtime.Tuples.drop(this, n).asInstanceOf[Drop[This, n.type]] - - /** Given a tuple `(a1, ..., am)`, returns a pair of the tuple `(a1, ..., an)` - * consisting of the first n elements, and the tuple `(an+1, ..., am)` consisting - * of the remaining elements. - */ - inline def splitAt[This >: this.type <: Tuple](n: Int): Split[This, n.type] = - runtime.Tuples.splitAt(this, n).asInstanceOf[Split[This, n.type]] - - /** Given a tuple `(a1, ..., am)`, returns the reversed tuple `(am, ..., a1)` - * consisting all its elements. - */ - inline def reverse[This >: this.type <: Tuple]: Reverse[This] = - runtime.Tuples.reverse(this).asInstanceOf[Reverse[This]] -} - -object Tuple { - - /** Type of a tuple with an element appended */ - type Append[X <: Tuple, Y] <: NonEmptyTuple = X match { - case EmptyTuple => Y *: EmptyTuple - case x *: xs => x *: Append[xs, Y] - } - - /** An infix shorthand for `Append[X, Y]` */ - infix type :*[X <: Tuple, Y] = Append[X, Y] - - /** Type of the head of a tuple */ - type Head[X <: Tuple] = X match { - case x *: _ => x - } - - /** Type of the initial part of the tuple without its last element */ - type Init[X <: Tuple] <: Tuple = X match { - case _ *: EmptyTuple => EmptyTuple - case x *: xs => - x *: Init[xs] - } - - /** Type of the tail of a tuple */ - type Tail[X <: Tuple] <: Tuple = X match { - case _ *: xs => xs - } - - /** Type of the last element of a tuple */ - type Last[X <: Tuple] = X match { - case x *: EmptyTuple => x - case _ *: xs => Last[xs] - } - - /** Type of the concatenation of two tuples */ - type Concat[X <: Tuple, +Y <: Tuple] <: Tuple = X match { - case EmptyTuple => Y - case x1 *: xs1 => x1 *: Concat[xs1, Y] - } - - /** An infix shorthand for `Concat[X, Y]` */ - infix type ++[X <: Tuple, +Y <: Tuple] = Concat[X, Y] - - /** Type of the element at position N in the tuple X */ - type Elem[X <: Tuple, N <: Int] = X match { - case x *: xs => - N match { - case 0 => x - case S[n1] => Elem[xs, n1] - } - } - - /** Literal constant Int size of a tuple */ - type Size[X <: Tuple] <: Int = X match { - case EmptyTuple => 0 - case x *: xs => S[Size[xs]] - } - - /** Fold a tuple `(T1, ..., Tn)` into `F[T1, F[... F[Tn, Z]...]]]` */ - type Fold[Tup <: Tuple, Z, F[_, _]] = Tup match - case EmptyTuple => Z - case h *: t => F[h, Fold[t, Z, F]] - - /** Converts a tuple `(T1, ..., Tn)` to `(F[T1], ..., F[Tn])` */ - type Map[Tup <: Tuple, F[_ <: Union[Tup]]] <: Tuple = Tup match { - case EmptyTuple => EmptyTuple - case h *: t => F[h] *: Map[t, F] - } - - /** Converts a tuple `(T1, ..., Tn)` to a flattened `(..F[T1], ..., ..F[Tn])` */ - type FlatMap[Tup <: Tuple, F[_ <: Union[Tup]] <: Tuple] <: Tuple = Tup match { - case EmptyTuple => EmptyTuple - case h *: t => Concat[F[h], FlatMap[t, F]] - } - - /** Filters out those members of the tuple for which the predicate `P` returns `false`. - * A predicate `P[X]` is a type that can be either `true` or `false`. For example: - * ```scala - * type IsString[x] <: Boolean = x match { - * case String => true - * case _ => false - * } - * summon[Tuple.Filter[(1, "foo", 2, "bar"), IsString] =:= ("foo", "bar")] - * ``` - * @syntax markdown - */ - type Filter[Tup <: Tuple, P[_ <: Union[Tup]] <: Boolean] <: Tuple = Tup match { - case EmptyTuple => EmptyTuple - case h *: t => P[h] match { - case true => h *: Filter[t, P] - case false => Filter[t, P] - } - } - - /** Given two tuples, `A1 *: ... *: An * At` and `B1 *: ... *: Bn *: Bt` - * where at least one of `At` or `Bt` is `EmptyTuple`, - * returns the tuple type `(A1, B1) *: ... *: (An, Bn) *: EmptyTuple`. - */ - type Zip[T1 <: Tuple, T2 <: Tuple] <: Tuple = (T1, T2) match { - case (h1 *: t1, h2 *: t2) => (h1, h2) *: Zip[t1, t2] - case _ => EmptyTuple - } - - /** Converts a tuple `(F[T1], ..., F[Tn])` to `(T1, ... Tn)` */ - type InverseMap[X <: Tuple, F[_]] <: Tuple = X match { - case F[x] *: t => x *: InverseMap[t, F] - case EmptyTuple => EmptyTuple - } - - /** Implicit evidence. IsMappedBy[F][X] is present in the implicit scope iff - * X is a tuple for which each element's type is constructed via `F`. E.g. - * (F[A1], ..., F[An]), but not `(F[A1], B2, ..., F[An])` where B2 does not - * have the shape of `F[A]`. - */ - type IsMappedBy[F[_]] = [X <: Tuple] =>> X =:= Map[InverseMap[X, F], F] - - /** Type of the reversed tuple */ - type Reverse[X <: Tuple] = ReverseOnto[X, EmptyTuple] - - /** Prepends all elements of a tuple in reverse order onto the other tuple */ - type ReverseOnto[From <: Tuple, +To <: Tuple] <: Tuple = From match - case x *: xs => ReverseOnto[xs, x *: To] - case EmptyTuple => To - - /** Transforms a tuple `(T1, ..., Tn)` into `(T1, ..., Ti)`. */ - type Take[T <: Tuple, N <: Int] <: Tuple = N match { - case 0 => EmptyTuple - case S[n1] => T match { - case EmptyTuple => EmptyTuple - case x *: xs => x *: Take[xs, n1] - } - } - - /** Transforms a tuple `(T1, ..., Tn)` into `(Ti+1, ..., Tn)`. */ - type Drop[T <: Tuple, N <: Int] <: Tuple = N match { - case 0 => T - case S[n1] => T match { - case EmptyTuple => EmptyTuple - case x *: xs => Drop[xs, n1] - } - } - - /** Splits a tuple (T1, ..., Tn) into a pair of two tuples `(T1, ..., Ti)` and - * `(Ti+1, ..., Tn)`. - */ - type Split[T <: Tuple, N <: Int] = (Take[T, N], Drop[T, N]) - - /** Given a tuple `(T1, ..., Tn)`, returns a union of its - * member types: `T1 | ... | Tn`. Returns `Nothing` if the tuple is empty. - */ - type Union[T <: Tuple] = Fold[T, Nothing, [x, y] =>> x | y] - - /** A type level Boolean indicating whether the tuple `X` has an element - * that matches `Y`. - * @pre The elements of `X` are assumed to be singleton types - */ - type Contains[X <: Tuple, Y] <: Boolean = X match - case Y *: _ => true - case _ *: xs => Contains[xs, Y] - case EmptyTuple => false - - /** A type level Boolean indicating whether the type `Y` contains - * none of the elements of `X`. - * @pre The elements of `X` and `Y` are assumed to be singleton types - */ - type Disjoint[X <: Tuple, Y <: Tuple] <: Boolean = X match - case x *: xs => Contains[Y, x] match - case true => false - case false => Disjoint[xs, Y] - case EmptyTuple => true - - /** Empty tuple */ - def apply(): EmptyTuple = EmptyTuple - - /** Tuple with one element */ - def apply[T](x: T): T *: EmptyTuple = Tuple1(x) - - /** Matches an empty tuple. */ - def unapply(x: EmptyTuple): true = true - - /** Convert an array into a tuple of unknown arity and types */ - def fromArray[T](xs: Array[T]): Tuple = { - val xs2 = xs match { - case xs: Array[Object] => xs - case xs => xs.map(_.asInstanceOf[Object]) - } - runtime.Tuples.fromArray(xs2) - } - - /** Convert an immutable array into a tuple of unknown arity and types */ - def fromIArray[T](xs: IArray[T]): Tuple = { - val xs2: IArray[Object] = xs match { - case xs: IArray[Object] @unchecked => xs - case _ => - xs.map(_.asInstanceOf[Object]) - } - runtime.Tuples.fromIArray(xs2) - } - - /** Convert a Product into a tuple of unknown arity and types */ - def fromProduct(product: Product): Tuple = - runtime.Tuples.fromProduct(product) - - def fromProductTyped[P <: Product](p: P)(using m: scala.deriving.Mirror.ProductOf[P]): m.MirroredElemTypes = - runtime.Tuples.fromProduct(p).asInstanceOf[m.MirroredElemTypes] - - given canEqualEmptyTuple: CanEqual[EmptyTuple, EmptyTuple] = CanEqual.derived - given canEqualTuple[H1, T1 <: Tuple, H2, T2 <: Tuple]( - using eqHead: CanEqual[H1, H2], eqTail: CanEqual[T1, T2] - ): CanEqual[H1 *: T1, H2 *: T2] = CanEqual.derived -} - -/** A tuple of 0 elements */ -type EmptyTuple = EmptyTuple.type - -/** A tuple of 0 elements. */ -case object EmptyTuple extends Tuple { - override def toString(): String = "()" -} - -/** Tuple of arbitrary non-zero arity */ -sealed trait NonEmptyTuple extends Tuple - -@showAsInfix -sealed abstract class *:[+H, +T <: Tuple] extends NonEmptyTuple - -object *: { - def unapply[H, T <: Tuple](x: H *: T): (H, T) = (x.head, x.tail) -} From 3eb591b29f6cbd2836e6f54b55d6986e2ee4f688 Mon Sep 17 00:00:00 2001 From: Hakim Jonas Ghoula Date: Sat, 22 Nov 2025 00:10:19 +0100 Subject: [PATCH 08/19] Clean up README, add CONTRIBUTING.md README.md: - Remove emojis from headings and lists - Simplify language, reduce marketing tone - Consolidate redundant sections CONTRIBUTING.md: - Add basic contribution guidelines - Include development setup instructions - Document project structure --- CONTRIBUTING.md | 72 ++++++++++++++++++++ README.md | 172 +++++++++++++++++++----------------------------- 2 files changed, 139 insertions(+), 105 deletions(-) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e8b0efb --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,72 @@ +# Contributing to Valar + +Thanks for your interest in contributing to Valar. + +## Getting Started + +1. Fork the repository +2. Clone your fork: `git clone https://github.com/YOUR_USERNAME/valar.git` +3. Create a branch: `git checkout -b your-feature-branch` + +## Development Setup + +Requirements: +- JDK 21+ +- sbt 1.10+ + +Build and test: +```bash +sbt compile +sbt test +``` + +Run tests for a specific module: +```bash +sbt valarCore/test +sbt valarMunit/test +sbt valarTranslator/test +``` + +Format code: +```bash +sbt scalafmtAll +``` + +Check formatting: +```bash +sbt scalafmtCheckAll +``` + +## Project Structure + +``` +valar/ + valar-core/ # Core validation library + valar-munit/ # MUnit testing utilities + valar-translator/ # i18n support + valar-benchmarks/ # JMH benchmarks +``` + +## Submitting Changes + +1. Ensure all tests pass: `sbt test` +2. Check formatting: `sbt scalafmtCheckAll` +3. Write clear commit messages +4. Open a pull request against `main` + +## Guidelines + +- Keep changes focused. One feature or fix per PR. +- Add tests for new functionality. +- Update documentation if needed. +- Follow existing code style. + +## Running Benchmarks + +```bash +sbt "valarBenchmarks / Jmh / run" +``` + +## Questions + +Open an issue for questions or discussion. diff --git a/README.md b/README.md index 1424b93..bfcbcb1 100644 --- a/README.md +++ b/README.md @@ -8,44 +8,28 @@ Valar is a validation library for Scala 3 designed for clarity and ease of use. inline metaprogramming to help you define complex validation rules with less boilerplate, while providing structured, detailed error messages useful for debugging or user feedback. -## **โœจ What's New in 0.5.X** - -* **๐Ÿ” ValidationObserver**: A new trait in `valar-core` for observing validation outcomes without altering the flow, - perfect for logging, metrics collection, or auditing with zero overhead when not used. -* **๐ŸŒ valar-translator Module**: New internationalization (i18n) support for validation error messages through the - `Translator` typeclass. -* **๐Ÿงช Enhanced ValarSuite**: Updated testing utilities in `valar-munit` now used in `valar-translator` for more robust - validation testing. -* **โšก Reworked Derivation**: Simpler, more performant inline metaprogramming for better compile-time validation. -* **๐Ÿ›ก๏ธ MiMa Checks**: Added binary compatibility verification to ensure smooth upgrades between versions. -* **๐Ÿ“š Improved Documentation**: Comprehensive updates to scaladoc and module-level README files for a better developer - experience. - -## **Key Features** - -* **Type Safety:** Clearly distinguish between valid results and accumulated errors at compile time using - ValidationResult[A]. Eliminate runtime errors caused by unexpected validation states. -* **Minimal Boilerplate:** Derive Validator instances automatically for case classes using compile-time derivation, - significantly reducing repetitive validation logic. Focus on your rules, not the wiring. -* **Flexible Error Handling:** Choose the strategy that fits your use case: - * **Error Accumulation** (default): Collect all validation failures, ideal for reporting multiple issues (e.g., in - UIs or API responses). - * **Fail-Fast**: Stop validation immediately on the first failure, suitable for performance-sensitive pipelines. -* **Actionable Error Reports:** Generate detailed ValidationError objects containing precise field paths, validation - rule specifics (like expected vs. actual values), and optional codes/severity. -* **Named Tuple Support:** Field-aware error messages for Scala 3.7's named tuples, with preserved backward - compatibility. -* **Scala 3 Idiomatic:** Built specifically for Scala 3, embracing features like extension methods, given instances, - opaque types, and inline metaprogramming for a modern, expressive API. - -## **Extensibility Pattern** - -Valar is designed to be extensible through the **ValidationObserver pattern**, which provides a clean, type-safe way to -integrate with external systems without modifying the core validation logic. - -### The ValidationObserver Pattern - -The `ValidationObserver` trait serves as the foundational pattern for extending Valar with cross-cutting concerns: +## What's New in 0.5.X + +* **ValidationObserver**: A trait for observing validation outcomes without altering the flow. Useful for logging, metrics, or auditing. Zero overhead when not used. +* **valar-translator**: Internationalization support for validation error messages via the `Translator` typeclass. +* **Enhanced ValarSuite**: Improved testing utilities in `valar-munit`. +* **Reworked Derivation**: Cleaner inline metaprogramming for compile-time validation. +* **MiMa Checks**: Binary compatibility verification between versions. + +## Key Features + +* **Type-safe results**: `ValidationResult[A]` distinguishes valid results from accumulated errors at compile time. +* **Automatic derivation**: Derive `Validator` instances for case classes at compile time, reducing boilerplate. +* **Flexible error handling**: + * *Error accumulation* (default): Collect all validation failures for comprehensive reporting. + * *Fail-fast*: Stop on first failure for performance-sensitive paths. +* **Detailed error reports**: `ValidationError` includes field paths, expected vs. actual values, optional codes, and severity. +* **Named tuple support**: Field-aware error messages for Scala 3.7's named tuples. +* **Scala 3 native**: Built for Scala 3 using extension methods, given instances, opaque types, and inline metaprogramming. + +## Extensibility + +Valar is extensible through the `ValidationObserver` pattern, which allows integrating with external systems without modifying core validation logic. ```scala trait ValidationObserver { @@ -53,28 +37,16 @@ trait ValidationObserver { } ``` -This pattern offers several advantages: - -* **Zero Overhead**: When using the default no-op observer, the compiler eliminates all observer-related code -* **Non-Intrusive**: Observes validation results without altering the validation flow -* **Composable**: Works seamlessly with other Valar features and can be chained -* **Type-Safe**: Leverages Scala's type system for compile-time safety - -### Examples of Extensions Using This Pattern +Properties: +* Zero overhead when using the default no-op observer (compiler eliminates the code) +* Non-intrusive: observes results without altering validation flow +* Composable with other Valar features -Current implementations are following this pattern: +Common uses: logging, metrics collection, auditing. -- **Logging**: Log validation outcomes for debugging and monitoring -- **Metrics**: Collect validation statistics for performance analysis -- **Auditing**: Track validation events for compliance and security +Planned extensions: `valar-cats-effect`, `valar-zio`. -Future extensions planned: - -- **valar-cats-effect**: Async validation with IO-based observers -- **valar-zio**: ZIO-based validation with resource management -- **Context-aware validation**: Observers that can access request-scoped data - -## **Available Artifacts** +## Available Artifacts Valar provides artifacts for both JVM and Scala Native platforms: @@ -89,11 +61,9 @@ Valar provides artifacts for both JVM and Scala Native platforms: > **Note:** When using the `%%%` operator in sbt, the correct platform-specific artifact will be selected automatically. -## **Performance** - -Valar is designed for high performance with minimal overhead: +## Performance -### **Complexity Characteristics** +### Complexity Characteristics | Operation | Time Complexity | Space Complexity | Notes | |-----------|----------------|------------------|-------| @@ -103,7 +73,7 @@ Valar is designed for high performance with minimal overhead: | Nested case class | O(fields) | O(errors) | Accumulates errors across all fields | | Union type validation | O(types) | O(errors) | Tries each type in the union | -### **Performance Best Practices** +### Best Practices 1. **Use ValidationConfig limits** for untrusted input to prevent DoS: ```scala @@ -126,23 +96,23 @@ Valar is designed for high performance with minimal overhead: if (collection.size > 10000) return BadRequest("Too large") ``` -### **Benchmark Results** +### Benchmark Results -Detailed performance benchmarks with JMH are available in the [valar-benchmarks module](https://github.com/hakimjonas/valar/blob/main/valar-benchmarks/README.md). +Detailed JMH benchmarks are available in the [valar-benchmarks module](https://github.com/hakimjonas/valar/blob/main/valar-benchmarks/README.md). -**Key findings:** +Summary: - Simple validations: ~10-50 nanoseconds - Case class derivation: Zero runtime overhead (compile-time only) - Collection validation: Linear with collection size -- Zero-cost abstractions: `ValidationObserver` with no-op has no runtime impact -## **Additional Resources** +## Additional Resources -- ๐Ÿ“Š **[Performance Benchmarks](https://github.com/hakimjonas/valar/blob/main/valar-benchmarks/README.md)**: Detailed JMH benchmark results and analysis -- ๐Ÿงช **[Testing Guide](https://github.com/hakimjonas/valar/blob/main/valar-munit/README.md)**: Enhanced testing utilities with ValarSuite -- ๐ŸŒ **[Internationalization](https://github.com/hakimjonas/valar/blob/main/valar-translator/README.md)**: i18n support for validation error messages -- ๐Ÿ”ง **[Troubleshooting Guide](https://github.com/hakimjonas/valar/blob/main/TROUBLESHOOTING.md)**: Common issues and solutions -## **Installation** +- [Performance Benchmarks](https://github.com/hakimjonas/valar/blob/main/valar-benchmarks/README.md) - JMH benchmark results +- [Testing Guide](https://github.com/hakimjonas/valar/blob/main/valar-munit/README.md) - ValarSuite testing utilities +- [Internationalization](https://github.com/hakimjonas/valar/blob/main/valar-translator/README.md) - i18n support +- [Troubleshooting](https://github.com/hakimjonas/valar/blob/main/TROUBLESHOOTING.md) - Common issues and solutions + +## Installation Add the following to your build.sbt: @@ -157,7 +127,7 @@ libraryDependencies += "net.ghoula" %%% "valar-translator" % "0.5.0" libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.5.0" % Test ``` -## **Basic Usage Example** +## Basic Usage Example Here's a basic example of validating a case class. Valar provides default validators for String (non-empty) and Int ( non-negative). @@ -196,7 +166,7 @@ result match { } ``` -## **Testing with valar-munit** +## Testing with valar-munit The optional valar-munit module provides ValarSuite, a trait that offers powerful, validation-specific assertions to make your tests clean and expressive. @@ -236,9 +206,9 @@ class UserValidationSuite extends ValarSuite { } ``` -## **Core Components** +## Core Components -### **ValidationResult** +### ValidationResult Represents the outcome of validation as either Valid(value) or Invalid(errors): @@ -251,7 +221,7 @@ enum ValidationResult[+A] { } ``` -### **ValidationError** +### ValidationError Opaque type providing rich context for validation errors, including: @@ -262,7 +232,7 @@ Opaque type providing rich context for validation errors, including: * **expected/actual**: Information about expected and actual values. * **children**: Nested errors for structured reporting. -### **Validator[A]** +### Validator[A] A typeclass defining validation logic for a given type: @@ -281,7 +251,7 @@ to be available in scope for **all** field types within the case class. If a val *compilation will fail**. This strictness ensures that all fields are explicitly considered during validation. See the " Built-in Validators" section for types supported out-of-the-box. -## **Built-in Validators** +## Built-in Validators Valar provides given Validator instances out-of-the-box for many common types to ease setup and support derivation. This includes: @@ -299,15 +269,14 @@ includes: Most built-in validators for scalar types (excluding those with obvious constraints like Int, String, Float, Double) are **pass-through** validators. You should define custom validators if you need specific constraints for these types. -## **ValidationObserver, The Core Extensibility Pattern** +## ValidationObserver -The `ValidationObserver` trait is more than just a logging mechanismโ€”it's the **foundational pattern** for extending -Valar with custom functionality. This pattern allows you to: +The `ValidationObserver` trait provides a way to extend Valar with custom functionality: -- **Integrate with external systems** (logging, metrics, monitoring) -- **Add side effects** without modifying validation logic -- **Build composable extensions** that work together seamlessly -- **Maintain zero overhead** when extensions aren't needed +- Integrate with external systems (logging, metrics, monitoring) +- Add side effects without modifying validation logic +- Build composable extensions +- Zero overhead when not used ```scala import net.ghoula.valar.* @@ -349,16 +318,9 @@ val result = Validator[User].validate(user) .map(processUser) ``` -Key features of ValidationObserver: - -* **Zero Overhead**: When using the default no-op observer, the compiler eliminates all observer-related code -* **Non-Intrusive**: Observes validation results without altering the validation flow -* **Chainable**: Works seamlessly with other operations in the validation pipeline -* **Flexible**: Can be used for logging, metrics, alerting, or any other side effect - -## **Internationalization with valar-translator** +## Internationalization with valar-translator -The `valar-translator` module provides internationalization (i18n) support for validation error messages: +The `valar-translator` module provides internationalization support for validation error messages: ```scala import net.ghoula.valar.* @@ -396,7 +358,7 @@ The `valar-translator` module is designed to: * Compose cleanly with other Valar features like ValidationObserver * Provide a clear separation between validation logic and presentation concerns -## **Migration Guide from v0.4.8 to v0.5.0** +## Migration Guide from v0.4.8 to v0.5.0 Version 0.5.0 introduces several new features while maintaining backward compatibility with v0.4.8: @@ -421,7 +383,7 @@ libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.5.0" % Test Your existing validation code will continue to work without any changes. -## **Migration Guide from v0.3.0 to v0.4.8** +## Migration Guide from v0.3.0 to v0.4.8 The main breaking change in v0.4.0 was the **artifact name change** from valar to valar-core to support the modular architecture. @@ -436,13 +398,13 @@ libraryDependencies += "net.ghoula" %% "valar" % "0.3.0" libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8-bundle" ``` -## **Security Considerations** +## Security Considerations -When using Valar with untrusted user input, please be aware of the following security considerations: +When using Valar with untrusted user input, be aware of the following: -### **Regular Expression Denial of Service (ReDoS)** +### Regular Expression Denial of Service (ReDoS) -โš ๏ธ **Warning:** The `regexMatch` methods that accept `String` patterns are vulnerable to ReDoS attacks when used with untrusted input. +The `regexMatch` methods that accept `String` patterns are vulnerable to ReDoS attacks when used with untrusted input. **Safe Practice:** ```scala @@ -458,7 +420,7 @@ val userPattern = request.getParameter("pattern") regexMatch(value, userPattern)(_ => "Invalid") // ReDoS vulnerability! ``` -### **Input Size Limits** +### Input Size Limits Valar provides built-in protection against resource exhaustion through `ValidationConfig`: @@ -483,21 +445,21 @@ When a collection exceeds the configured limit, validation fails immediately ''' **Important:** Always use `ValidationConfig.strict` or custom limits when validating untrusted user input. -### **Error Information Disclosure** +### Error Information Disclosure `ValidationError` objects include detailed information about what was expected vs. what was received. When exposing validation errors to end users: - Review error messages for sensitive information - Consider using the `valar-translator` module to provide user-friendly, sanitized messages - Be cautious about exposing internal field names or structure -## **Compatibility** +## Compatibility * **Scala:** 3.7.4+ * **Platforms:** JVM, Scala Native * **Dependencies:** valar-core has a Compile dependency on `io.github.cquiroz:scala-java-time` to provide robust, cross-platform support for the `java.time` API. -## **License** +## License Valar is licensed under the **MIT License**. See the [LICENSE](https://github.com/hakimjonas/valar/blob/main/LICENSE) file for details. From 421d5fef03b0896f71483235c62e2a21a2e0502f Mon Sep 17 00:00:00 2001 From: Hakim Jonas Ghoula Date: Sat, 22 Nov 2025 00:16:35 +0100 Subject: [PATCH 09/19] Add docs/design.md and docs/examples.md design.md: - Scala 2 vs Scala 3 typeclass derivation comparison - Architecture overview - Field access strategy explanation - Why inline metaprogramming examples.md: - Async validation with AsyncValidator - Validator composition (sequential and parallel) - Union and intersection type validation - Nested case classes - Collection validation --- docs/design.md | 164 ++++++++++++++++++++++ docs/examples.md | 357 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 521 insertions(+) create mode 100644 docs/design.md create mode 100644 docs/examples.md diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..ae6ba25 --- /dev/null +++ b/docs/design.md @@ -0,0 +1,164 @@ +# Valar Design + +This document explains the technical design choices in Valar and how Scala 3's type system enables its implementation. + +## Typeclass Derivation: Scala 2 vs Scala 3 + +### Scala 2 Approach + +In Scala 2, automatic typeclass derivation required external libraries: + +**Shapeless** - Generic programming via HLists: +```scala +import shapeless._ + +trait Validator[A] { + def validate(a: A): ValidationResult[A] +} + +object Validator { + // Derive using Generic and HList machinery + implicit def deriveHNil: Validator[HNil] = ... + implicit def deriveHCons[H, T <: HList]( + implicit hv: Validator[H], tv: Validator[T] + ): Validator[H :: T] = ... + implicit def deriveGeneric[A, R]( + implicit gen: Generic.Aux[A, R], rv: Validator[R] + ): Validator[A] = ... +} +``` + +Problems: +- Complex implicit resolution chains +- Slow compile times for large case classes +- Cryptic error messages ("could not find implicit value for...") +- Runtime overhead from HList conversions + +**Magnolia** - Macro-based derivation: +```scala +import magnolia1._ + +object Validator extends AutoDerivation[Validator] { + def join[A](ctx: CaseClass[Validator, A]): Validator[A] = new Validator[A] { + def validate(a: A) = { + ctx.parameters.foldLeft(ValidationResult.Valid(a)) { (acc, param) => + // validate each parameter + } + } + } +} +``` + +Better than Shapeless but still: +- Requires macro annotation or explicit derivation calls +- Limited compile-time introspection +- Error messages still opaque + +### Scala 3 Approach + +Scala 3 provides first-class support for typeclass derivation through: + +1. **Mirrors** - Compile-time type information +2. **Inline/Quotes** - Type-safe metaprogramming +3. **Match types** - Type-level computation + +Valar's implementation: + +```scala +import scala.deriving.Mirror +import scala.quoted.* + +object Validator { + // User-facing API + inline def derive[T](using m: Mirror.ProductOf[T]): Validator[T] = + ${ deriveSyncValidatorImpl[T, m.MirroredElemTypes, m.MirroredElemLabels]('m) } + + // Compile-time implementation + private def deriveSyncValidatorImpl[T: Type, Elems <: Tuple: Type, Labels <: Tuple: Type]( + m: Expr[Mirror.ProductOf[T]] + )(using q: Quotes): Expr[Validator[T]] = { + // Extract field names and types at compile time + // Generate validation code for each field + // Return fully inlined validator + } +} +``` + +Advantages: +- **Zero runtime overhead** - all derivation happens at compile time +- **Clear error messages** - Valar reports exactly which field validators are missing +- **Direct field access** - no HList conversion, uses `Select.unique` for zero-cast access +- **Type-safe** - the quotes API ensures generated code is well-typed + +### Compile-Time Validation + +Valar validates that all required validators exist before generating code: + +```scala +// If you write: +case class User(name: String, age: Int, data: MyCustomType) +given Validator[User] = Validator.derive + +// And MyCustomType has no Validator, you get: +// error: Cannot derive Validator for User: missing validators for 1 field(s). +// - Field 'data' of type MyCustomType +// Add: given Validator[MyCustomType] = ... +``` + +This is implemented by checking `Expr.summon[Validator[FieldType]]` for each field at compile time and collecting all failures before reporting. + +## Architecture + +### Core Types + +``` +ValidationResult[+A] +โ”œโ”€โ”€ Valid(value: A) +โ””โ”€โ”€ Invalid(errors: Vector[ValidationError]) + +Validator[A] - Synchronous validation +AsyncValidator[A] - Asynchronous validation (Future-based) +ValidationObserver - Side-effect hook for logging/metrics +ValidationConfig - Runtime configuration (collection limits) +``` + +### Field Access Strategy + +Valar uses different strategies depending on the type being validated: + +| Type | Strategy | Cast Required | +|------|----------|---------------| +| Case class | `Select.unique(term, fieldName)` | No | +| Regular tuple | `Select.unique(term, "_N")` | No | +| Named tuple | `productElement(index)` | Yes (matches stdlib) | + +This was determined by examining how the Scala 3.7.4 standard library handles named tuples - they also use `productElement` with a cast. + +### Error Accumulation + +By default, Valar accumulates all errors rather than failing fast: + +```scala +// zip combines results, accumulating errors +val result = validateName(name).zip(validateAge(age)).zip(validateEmail(email)) + +// flatMap is fail-fast (stops on first error) +val result = for { + n <- validateName(name) + a <- validateAge(age) + e <- validateEmail(email) +} yield User(n, a, e) +``` + +The `Invalid` case uses `Vector[ValidationError]` for efficient concatenation during accumulation. + +## Why Inline Metaprogramming? + +Valar uses Scala 3's inline metaprogramming (quotes/reflect API) rather than runtime reflection because: + +1. **Performance** - No runtime type inspection or reflection calls +2. **Type safety** - Generated code is checked by the compiler +3. **Early errors** - Missing validators are caught at compile time +4. **Optimization** - The compiler can inline and optimize the generated code + +The term "inline metaprogramming" reflects modern Scala 3 terminology. The quotes/reflect API is the standard approach for compile-time code generation, replacing Scala 2's `scala.reflect` macros. diff --git a/docs/examples.md b/docs/examples.md new file mode 100644 index 0000000..3ca62c0 --- /dev/null +++ b/docs/examples.md @@ -0,0 +1,357 @@ +# Valar Examples + +Advanced usage patterns and examples. + +## Async Validation + +Use `AsyncValidator` when validation requires I/O operations like database lookups or API calls. + +```scala +import net.ghoula.valar.* +import scala.concurrent.{Future, ExecutionContext} + +case class User(email: String, username: String) + +// Simulate async checks +def emailExists(email: String)(using ec: ExecutionContext): Future[Boolean] = + Future { /* database lookup */ true } + +def usernameAvailable(username: String)(using ec: ExecutionContext): Future[Boolean] = + Future { /* database lookup */ true } + +// Define async validators +given ec: ExecutionContext = ExecutionContext.global + +given AsyncValidator[String] with { + def validateAsync(value: String): Future[ValidationResult[String]] = + Future.successful( + if (value.nonEmpty) ValidationResult.Valid(value) + else ValidationResult.invalid(ValidationError("Value must not be empty")) + ) +} + +// Custom async validator for User with database checks +given AsyncValidator[User] with { + def validateAsync(user: User): Future[ValidationResult[User]] = { + for { + emailCheck <- emailExists(user.email) + usernameCheck <- usernameAvailable(user.username) + } yield { + val errors = Vector.newBuilder[ValidationError] + + if (emailCheck) + errors += ValidationError("Email already registered") + .withFieldPath("email") + + if (!usernameCheck) + errors += ValidationError("Username not available") + .withFieldPath("username") + + val errs = errors.result() + if (errs.isEmpty) ValidationResult.Valid(user) + else ValidationResult.Invalid(errs) + } + } +} + +// Usage +val user = User("test@example.com", "newuser") +val resultFuture: Future[ValidationResult[User]] = + AsyncValidator[User].validateAsync(user) +``` + +### Mixing Sync and Async + +You can derive an `AsyncValidator` that uses synchronous validators for fields: + +```scala +case class Registration(user: User, acceptedTerms: Boolean) + +// Sync validator for Boolean +given Validator[Boolean] with { + def validate(value: Boolean): ValidationResult[Boolean] = + if (value) ValidationResult.Valid(value) + else ValidationResult.invalid(ValidationError("Must accept terms")) +} + +// Derive async validator - uses sync validators wrapped in Future.successful +given AsyncValidator[Registration] = AsyncValidator.derive +``` + +## Validator Composition + +### Sequential Composition (Fail-Fast) + +Use `flatMap` or for-comprehensions when later validations depend on earlier ones: + +```scala +import net.ghoula.valar.* +import net.ghoula.valar.ValidationHelpers.* + +def validatePassword(password: String): ValidationResult[String] = { + for { + p <- nonEmpty(password, _ => "Password required") + p <- minLength(p, 8, _ => "Password must be at least 8 characters") + p <- if (p.exists(_.isUpper)) ValidationResult.Valid(p) + else ValidationResult.invalid(ValidationError("Password must contain uppercase")) + p <- if (p.exists(_.isDigit)) ValidationResult.Valid(p) + else ValidationResult.invalid(ValidationError("Password must contain a digit")) + } yield p +} + +// Stops at first failure +validatePassword("short") // Invalid: "Password must be at least 8 characters" +validatePassword("longenoughbutnoupperodigit") // Invalid: "Password must contain uppercase" +``` + +### Parallel Composition (Error Accumulation) + +Use `zip` to validate independent fields and collect all errors: + +```scala +case class RegistrationForm( + username: String, + email: String, + password: String, + confirmPassword: String +) + +def validateForm(form: RegistrationForm): ValidationResult[RegistrationForm] = { + val usernameResult = nonEmpty(form.username, _ => "Username required") + .map(_ => form.username) + + val emailResult = nonEmpty(form.email, _ => "Email required") + .flatMap(e => regexMatch(e, "^[^@]+@[^@]+$".r)(_ => "Invalid email format")) + .map(_ => form.email) + + val passwordResult = validatePassword(form.password) + + val confirmResult = + if (form.password == form.confirmPassword) ValidationResult.Valid(form.confirmPassword) + else ValidationResult.invalid(ValidationError("Passwords do not match")) + + // Combine all validations - accumulates errors + usernameResult + .zip(emailResult) + .zip(passwordResult) + .zip(confirmResult) + .map { case (((_, _), _), _) => form } +} +``` + +### Reusable Validator Combinators + +Build validators from smaller pieces: + +```scala +object Validators { + def nonEmptyString: Validator[String] = new Validator[String] { + def validate(s: String) = + if (s.trim.nonEmpty) ValidationResult.Valid(s.trim) + else ValidationResult.invalid(ValidationError("Must not be empty")) + } + + def inRange(min: Int, max: Int): Validator[Int] = new Validator[Int] { + def validate(n: Int) = + if (n >= min && n <= max) ValidationResult.Valid(n) + else ValidationResult.invalid( + ValidationError(s"Must be between $min and $max") + .withExpected(s"$min-$max") + .withActual(n.toString) + ) + } + + def matchesRegex(pattern: scala.util.matching.Regex, message: String): Validator[String] = + new Validator[String] { + def validate(s: String) = + if (pattern.matches(s)) ValidationResult.Valid(s) + else ValidationResult.invalid(ValidationError(message)) + } +} + +// Compose into domain validators +object UserValidators { + val username: Validator[String] = new Validator[String] { + def validate(s: String) = + Validators.nonEmptyString.validate(s).flatMap { trimmed => + Validators.matchesRegex("^[a-zA-Z0-9_]+$".r, "Username can only contain letters, numbers, and underscores") + .validate(trimmed) + } + } + + val age: Validator[Int] = Validators.inRange(0, 150) +} +``` + +## Union Types + +Valar can validate union types when validators exist for each member: + +```scala +// Define validators for each type in the union +given Validator[String] with { + def validate(s: String) = + if (s.nonEmpty) ValidationResult.Valid(s) + else ValidationResult.invalid(ValidationError("String must not be empty")) +} + +given Validator[Int] with { + def validate(n: Int) = + if (n >= 0) ValidationResult.Valid(n) + else ValidationResult.invalid(ValidationError("Int must be non-negative")) +} + +// Union type validator is derived automatically +val unionValidator = summon[Validator[String | Int]] + +unionValidator.validate("hello") // Valid("hello") +unionValidator.validate(42) // Valid(42) +unionValidator.validate("") // Invalid: String must not be empty +unionValidator.validate(-1) // Invalid: Int must be non-negative +``` + +### Discriminated Unions with Enums + +```scala +enum PaymentMethod { + case CreditCard(number: String, expiry: String, cvv: String) + case BankTransfer(iban: String, bic: String) + case Crypto(walletAddress: String) +} + +// Validators for each case +given Validator[PaymentMethod.CreditCard] with { + def validate(cc: PaymentMethod.CreditCard) = { + val numberValid = cc.number.length == 16 && cc.number.forall(_.isDigit) + val cvvValid = cc.cvv.length == 3 && cc.cvv.forall(_.isDigit) + + if (numberValid && cvvValid) ValidationResult.Valid(cc) + else ValidationResult.invalid(ValidationError("Invalid credit card details")) + } +} + +given Validator[PaymentMethod.BankTransfer] with { + def validate(bt: PaymentMethod.BankTransfer) = { + if (bt.iban.nonEmpty && bt.bic.nonEmpty) ValidationResult.Valid(bt) + else ValidationResult.invalid(ValidationError("IBAN and BIC required")) + } +} + +given Validator[PaymentMethod.Crypto] with { + def validate(c: PaymentMethod.Crypto) = { + if (c.walletAddress.startsWith("0x")) ValidationResult.Valid(c) + else ValidationResult.invalid(ValidationError("Invalid wallet address")) + } +} + +// Validate any payment method +def validatePayment(pm: PaymentMethod): ValidationResult[PaymentMethod] = pm match { + case cc: PaymentMethod.CreditCard => Validator[PaymentMethod.CreditCard].validate(cc) + case bt: PaymentMethod.BankTransfer => Validator[PaymentMethod.BankTransfer].validate(bt) + case c: PaymentMethod.Crypto => Validator[PaymentMethod.Crypto].validate(c) +} +``` + +## Intersection Types + +Intersection types validate when the value satisfies all component validators: + +```scala +trait Named { def name: String } +trait Aged { def age: Int } + +case class Person(name: String, age: Int) extends Named with Aged + +given Validator[Named] with { + def validate(n: Named) = + if (n.name.nonEmpty) ValidationResult.Valid(n) + else ValidationResult.invalid(ValidationError("Name required")) +} + +given Validator[Aged] with { + def validate(a: Aged) = + if (a.age >= 0) ValidationResult.Valid(a) + else ValidationResult.invalid(ValidationError("Age must be non-negative")) +} + +// For intersection types, both validators must pass +val person = Person("Alice", 30) + +// Validate against both traits +val namedResult = Validator[Named].validate(person) +val agedResult = Validator[Aged].validate(person) + +// Combine results +val combined = namedResult.zip(agedResult).map { case (_, _) => person } +``` + +## Nested Case Classes + +Valar automatically validates nested structures: + +```scala +case class Address(street: String, city: String, postalCode: String) +case class Company(name: String, address: Address) +case class Employee(name: String, company: Company) + +// Define validators for leaf types +given Validator[String] with { + def validate(s: String) = + if (s.nonEmpty) ValidationResult.Valid(s) + else ValidationResult.invalid(ValidationError("Must not be empty")) +} + +// Derive validators bottom-up +given Validator[Address] = Validator.derive +given Validator[Company] = Validator.derive +given Validator[Employee] = Validator.derive + +// Validation errors include full field paths +val invalid = Employee("", Company("", Address("", "", ""))) +val result = Validator[Employee].validate(invalid) + +// Errors will have paths like: +// - "name" +// - "company.name" +// - "company.address.street" +// - "company.address.city" +// - "company.address.postalCode" +``` + +## Collection Validation + +Collections are validated element by element: + +```scala +case class Order(items: List[OrderItem]) +case class OrderItem(productId: String, quantity: Int) + +given Validator[OrderItem] with { + def validate(item: OrderItem) = { + val productValid = item.productId.nonEmpty + val quantityValid = item.quantity > 0 + + if (productValid && quantityValid) ValidationResult.Valid(item) + else { + val errors = Vector.newBuilder[ValidationError] + if (!productValid) errors += ValidationError("Product ID required") + if (!quantityValid) errors += ValidationError("Quantity must be positive") + ValidationResult.Invalid(errors.result()) + } + } +} + +given Validator[Order] = Validator.derive + +// With ValidationConfig to limit collection size +given ValidationConfig = ValidationConfig.strict // Max 10,000 items + +val order = Order(List( + OrderItem("", 1), // Invalid: no product ID + OrderItem("abc", 0), // Invalid: quantity not positive + OrderItem("xyz", 5) // Valid +)) + +val result = Validator[Order].validate(order) +// Accumulates errors from all invalid items +``` From 976b11ecb68ccbb6adb6fbdcc904dd7842daeb67 Mon Sep 17 00:00:00 2001 From: Hakim Jonas Ghoula Date: Sat, 22 Nov 2025 00:20:44 +0100 Subject: [PATCH 10/19] Add scalafix suppression for intentional asInstanceOf and null checks Named tuple field access requires asInstanceOf (matches Scala stdlib pattern). Null checks are defensive validation for potentially null field values. --- .../main/scala/net/ghoula/valar/internal/Derivation.scala | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/valar-core/src/main/scala/net/ghoula/valar/internal/Derivation.scala b/valar-core/src/main/scala/net/ghoula/valar/internal/Derivation.scala index 617f63f..2230aa6 100644 --- a/valar-core/src/main/scala/net/ghoula/valar/internal/Derivation.scala +++ b/valar-core/src/main/scala/net/ghoula/valar/internal/Derivation.scala @@ -280,7 +280,7 @@ object Derivation { // Named tuple: use productElement (matches Scala 3.7.4 stdlib pattern) // See: scala.NamedTuple.apply uses asInstanceOf for element access val indexExpr = Expr(index) - '{ $aExpr.asInstanceOf[Product].productElement($indexExpr).asInstanceOf[H] } + '{ $aExpr.asInstanceOf[Product].productElement($indexExpr).asInstanceOf[H] } // scalafix:ok DisableSyntax.asInstanceOf } } @@ -295,7 +295,7 @@ object Derivation { // Required fields: null triggers error '{ val fieldValue: H = $fieldAccess - if (fieldValue == null) { + if (fieldValue == null) { // scalafix:ok DisableSyntax.null ValidationResult.invalid( ValidationError( s"Field '${$labelExpr}' must not be null.", @@ -418,7 +418,7 @@ object Derivation { } else { // Named tuple: use productElement (matches Scala 3.7.4 stdlib pattern) val indexExpr = Expr(index) - '{ $aExpr.asInstanceOf[Product].productElement($indexExpr).asInstanceOf[H] } + '{ $aExpr.asInstanceOf[Product].productElement($indexExpr).asInstanceOf[H] } // scalafix:ok DisableSyntax.asInstanceOf } } @@ -440,7 +440,7 @@ object Derivation { '{ (ec: ExecutionContext) => given ExecutionContext = ec val fieldValue: H = $fieldAccess - if (fieldValue == null) { + if (fieldValue == null) { // scalafix:ok DisableSyntax.null Future.successful( ValidationResult.invalid( ValidationError( From 85f35c31181b928f2c5918058373e08ea2ee741a Mon Sep 17 00:00:00 2001 From: Hakim Jonas Ghoula Date: Sat, 22 Nov 2025 00:28:39 +0100 Subject: [PATCH 11/19] Remove built-in null checks, delegate to user-defined validators Derivation no longer checks for null field values. This keeps the core library simple and fast for idiomatic Scala 3 code. For Java interop or Spark, users can define null-aware validators: given Validator[String] with { def validate(s: String) = if (s == null) ValidationResult.invalid(...) else ... } Changes: - Remove null checks from sync and async field validation - Remove unused isOption parameter from field generation - Remove null-related tests (NullFieldTest, Team) - Add documentation in TROUBLESHOOTING.md --- TROUBLESHOOTING.md | 30 ++++++ .../ghoula/valar/internal/Derivation.scala | 97 ++++--------------- .../net/ghoula/valar/AsyncValidatorSpec.scala | 25 +---- .../net/ghoula/valar/ValidationSpec.scala | 7 -- 4 files changed, 49 insertions(+), 110 deletions(-) diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md index 6c081f3..c77f75d 100644 --- a/TROUBLESHOOTING.md +++ b/TROUBLESHOOTING.md @@ -185,6 +185,35 @@ val result = Validator[List[Data]].validate(userList) --- +### Issue: NullPointerException during validation (Java interop / Spark) + +**Problem:** Validating objects with null fields throws NPE instead of returning Invalid. + +**Cause:** Valar is designed for idiomatic Scala 3 code where nulls are discouraged. By default, derived validators pass field values directly to the field's validator without null checks. + +**Solution:** Define null-aware validators for types that may contain null: + +```scala +// For Java interop or Spark DataFrames +given Validator[String] with { + def validate(s: String) = + if (s == null) ValidationResult.invalid(ValidationError("Value is null")) + else if (s.isEmpty) ValidationResult.invalid(ValidationError("Value is empty")) + else ValidationResult.Valid(s) +} + +// Now derivation will handle nulls gracefully +case class JavaUser(name: String, email: String) +given Validator[JavaUser] = Validator.derive +``` + +This approach: +- Keeps the core library simple and fast for pure Scala code +- Lets users opt-in to null handling where needed +- Allows custom null handling behavior (fail vs. treat as empty, etc.) + +--- + ## Performance Problems ### Issue: Validation of large collections is slow @@ -476,3 +505,4 @@ def validateUser(user: User): ValidationResult[User] = for { | Stack overflow | Limit nesting depth or use iterative validation | | ReDoS vulnerability | Never accept user regex patterns | | Sensitive data in errors | Use `valar-translator` to sanitize messages | +| NPE with null fields (Java/Spark) | Define null-aware validators for affected types | diff --git a/valar-core/src/main/scala/net/ghoula/valar/internal/Derivation.scala b/valar-core/src/main/scala/net/ghoula/valar/internal/Derivation.scala index 2230aa6..a7e415f 100644 --- a/valar-core/src/main/scala/net/ghoula/valar/internal/Derivation.scala +++ b/valar-core/src/main/scala/net/ghoula/valar/internal/Derivation.scala @@ -259,7 +259,6 @@ object Derivation { aExpr: Expr[T], label: String, index: Int, - isOption: Boolean, validatorExpr: Expr[Validator[H]] ): Expr[ValidationResult[Any]] = { val labelExpr = Expr(label) @@ -284,36 +283,16 @@ object Derivation { } } - if (isOption) { - // Option fields: null is valid (will be None), just validate - '{ - val fieldValue: H = $fieldAccess - val result = $validatorExpr.validate(fieldValue) - annotateErrors(result, $labelExpr, fieldValue) - } - } else { - // Required fields: null triggers error - '{ - val fieldValue: H = $fieldAccess - if (fieldValue == null) { // scalafix:ok DisableSyntax.null - ValidationResult.invalid( - ValidationError( - s"Field '${$labelExpr}' must not be null.", - List($labelExpr), - expected = Some("non-null value"), - actual = Some("null") - ) - ) - } else { - val result = $validatorExpr.validate(fieldValue) - annotateErrors(result, $labelExpr, fieldValue) - } - } + // Validate field - null handling is delegated to the field's Validator + // For Java interop or Spark, users can define null-aware validators + '{ + val fieldValue: H = $fieldAccess + val result = $validatorExpr.validate(fieldValue) + annotateErrors(result, $labelExpr, fieldValue) } } // Generate all field validations at compile time - // Uses type-level IsOption[H] match type for compile-time Option detection def generateAllValidations[E <: Tuple: Type]( aExpr: Expr[T], index: Int, @@ -323,15 +302,9 @@ object Derivation { case '[EmptyTuple] => Nil case '[h *: t] => val label = labels.head - - // Type-level Option detection using quotes reflection - // Checks if h <:< Option[?] at macro expansion time - val isOption: Boolean = TypeRepr.of[h] <:< TypeRepr.of[Option[?]] - // Safe to use .get - upfront validation guarantees validators exist val validatorExpr = Expr.summon[Validator[h]].get - - val fieldValidation = generateFieldValidation[h](aExpr, label, index, isOption, validatorExpr) + val fieldValidation = generateFieldValidation[h](aExpr, label, index, validatorExpr) fieldValidation :: generateAllValidations[t](aExpr, index + 1, labels.tail) } @@ -398,7 +371,6 @@ object Derivation { aExpr: Expr[T], label: String, index: Int, - isOption: Boolean, asyncValidatorExpr: Expr[AsyncValidator[H]] ): Expr[ExecutionContext => Future[ValidationResult[Any]]] = { val labelExpr = Expr(label) @@ -422,50 +394,22 @@ object Derivation { } } - if (isOption) { - // Option fields: null is valid (will be None), just validate - '{ (ec: ExecutionContext) => - given ExecutionContext = ec - val fieldValue: H = $fieldAccess - $asyncValidatorExpr.validateAsync(fieldValue) - .map(result => annotateErrors(result, $labelExpr, fieldValue)) - .recover { case scala.util.control.NonFatal(ex) => - ValidationResult.invalid( - ValidationError(s"Asynchronous validation failed unexpectedly: ${ex.getMessage}") - ) - } - } - } else { - // Required fields: null triggers error - '{ (ec: ExecutionContext) => - given ExecutionContext = ec - val fieldValue: H = $fieldAccess - if (fieldValue == null) { // scalafix:ok DisableSyntax.null - Future.successful( - ValidationResult.invalid( - ValidationError( - s"Field '${$labelExpr}' must not be null.", - List($labelExpr), - expected = Some("non-null value"), - actual = Some("null") - ) - ) + // Validate field - null handling is delegated to the field's AsyncValidator + // For Java interop or Spark, users can define null-aware validators + '{ (ec: ExecutionContext) => + given ExecutionContext = ec + val fieldValue: H = $fieldAccess + $asyncValidatorExpr.validateAsync(fieldValue) + .map(result => annotateErrors(result, $labelExpr, fieldValue)) + .recover { case scala.util.control.NonFatal(ex) => + ValidationResult.invalid( + ValidationError(s"Asynchronous validation failed unexpectedly: ${ex.getMessage}") ) - } else { - $asyncValidatorExpr.validateAsync(fieldValue) - .map(result => annotateErrors(result, $labelExpr, fieldValue)) - .recover { case scala.util.control.NonFatal(ex) => - ValidationResult.invalid( - ValidationError(s"Asynchronous validation failed unexpectedly: ${ex.getMessage}") - ) - } } - } } } // Generate all async field validations at compile time - // Uses type-level IsOption[H] match type for compile-time Option detection def generateAllAsyncValidations[E <: Tuple: Type]( aExpr: Expr[T], index: Int, @@ -475,11 +419,6 @@ object Derivation { case '[EmptyTuple] => Nil case '[h *: t] => val label = labels.head - - // Type-level Option detection using quotes reflection - // Checks if h <:< Option[?] at macro expansion time - val isOption: Boolean = TypeRepr.of[h] <:< TypeRepr.of[Option[?]] - // Safe to use .get - upfront validation guarantees validators exist // Try AsyncValidator first, fall back to Validator val validatorExpr = Expr.summon[AsyncValidator[h]].orElse(Expr.summon[Validator[h]]).get @@ -490,7 +429,7 @@ object Derivation { case '[Validator[`h`]] => '{ AsyncValidator.fromSync(${ validatorExpr.asExprOf[Validator[h]] }) } } - val fieldValidation = generateAsyncFieldValidation[h](aExpr, label, index, isOption, asyncValidatorExpr) + val fieldValidation = generateAsyncFieldValidation[h](aExpr, label, index, asyncValidatorExpr) fieldValidation :: generateAllAsyncValidations[t](aExpr, index + 1, labels.tail) } diff --git a/valar-core/src/test/scala/net/ghoula/valar/AsyncValidatorSpec.scala b/valar-core/src/test/scala/net/ghoula/valar/AsyncValidatorSpec.scala index c9515ac..9f15012 100644 --- a/valar-core/src/test/scala/net/ghoula/valar/AsyncValidatorSpec.scala +++ b/valar-core/src/test/scala/net/ghoula/valar/AsyncValidatorSpec.scala @@ -3,8 +3,7 @@ package net.ghoula.valar import munit.FunSuite import scala.concurrent.ExecutionContext.Implicits.global -import scala.concurrent.duration.* -import scala.concurrent.{Await, Future} +import scala.concurrent.Future import net.ghoula.valar.ValidationErrors.ValidationError @@ -26,9 +25,6 @@ class AsyncValidatorSpec extends FunSuite { /** A nested case class for testing recursive derivation. */ private case class Company(name: String, owner: User) - /** A case class to test null handling. */ - private case class Team(lead: User, name: String) - /** A case class for testing collection validation. */ private case class Post(title: String, comments: List[Comment]) @@ -119,13 +115,6 @@ class AsyncValidatorSpec extends FunSuite { */ private given companyAsyncValidator: AsyncValidator[Company] = AsyncValidator.derive - /** Team validator that reuses the user validation logic. - * - * This validator demonstrates automatic derivation where the existing user validator is used for - * the nested User field, and the string validator is used for the team name. - */ - private given teamAsyncValidator: AsyncValidator[Team] = AsyncValidator.derive - /** A derived validator for Comment that uses the async profanity filter for the text field. * * This validator demonstrates how to use a specific validator for text content that requires @@ -230,18 +219,6 @@ class AsyncValidatorSpec extends FunSuite { } } - test("validateAsync should fail if a non-optional field is null") { - @SuppressWarnings(Array("scalafix:DisableSyntax.null")) - val invalidTeam = Team(null, "The A-Team") - val result = Await.result(teamAsyncValidator.validateAsync(invalidTeam), 1.second) - result match { - case ValidationResult.Invalid(errors) => - assertEquals(errors.size, 1) - assert(errors.head.message.contains("Field 'lead' must not be null.")) - case _ => fail("Expected Invalid result for null field") - } - } - test("validateAsync should recover from a failed Future in a validator") { val failingValidator: AsyncValidator[String] = new AsyncValidator[String] { def validateAsync(a: String)(using ec: concurrent.ExecutionContext): Future[ValidationResult[String]] = diff --git a/valar-core/src/test/scala/net/ghoula/valar/ValidationSpec.scala b/valar-core/src/test/scala/net/ghoula/valar/ValidationSpec.scala index 392fe6b..2700ccf 100644 --- a/valar-core/src/test/scala/net/ghoula/valar/ValidationSpec.scala +++ b/valar-core/src/test/scala/net/ghoula/valar/ValidationSpec.scala @@ -58,9 +58,6 @@ class ValidationSpec extends FunSuite { private case class Company(name: String, address: Address, ceo: Option[User]) private given Validator[Company] = derive - private case class NullFieldTest(name: String, age: Int) - private given Validator[NullFieldTest] = derive - /** Tests for collection type validators. */ test("Collection Validators - listValidator") { @@ -310,10 +307,6 @@ class ValidationSpec extends FunSuite { } } - test("Macro Derivation - non-optional null field") { - assert(summon[Validator[NullFieldTest]].validate(NullFieldTest(Option.empty[String].orNull, 30)).isInvalid) - } - /** Tests for fail-fast validation operations. */ test("Fail-Fast - zipFailFast") { From b67ef13a591448192e078cc5d903aef70de24dd0 Mon Sep 17 00:00:00 2001 From: Hakim Jonas Ghoula Date: Sat, 22 Nov 2025 00:29:44 +0100 Subject: [PATCH 12/19] Add JVM crash logs to .gitignore --- .gitignore | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index e75e032..973175b 100644 --- a/.gitignore +++ b/.gitignore @@ -52,4 +52,8 @@ build/ .DS_Store ### Scala ### -.bsp/ \ No newline at end of file +.bsp/ + +### JVM crash logs ### +hs_err_pid*.log +core.* \ No newline at end of file From f2065cc1daa93af2be225b397e89c39f0f8046d6 Mon Sep 17 00:00:00 2001 From: Hakim Jonas Ghoula Date: Sat, 22 Nov 2025 00:39:48 +0100 Subject: [PATCH 13/19] Audit test suite: remove trivial tests, add ValidationConfig coverage Changes: - Remove 15 pass-through tests from ValidatorSpec (e.g., "Int validator should return Valid(x) for x") - these just verify the identity function - Update AsyncValidatorSpec Scaladoc to remove stale null references - Add ValidationConfigSpec with 5 tests covering: - Default unlimited collection sizes - Strict config 10,000 limit enforcement - Custom collection size limits - Fail-fast behavior (no element validation when size exceeded) - Make ValidationResultTestOps package-visible for test reuse - Minor scalafmt reformatting in Derivation.scala --- .../ghoula/valar/internal/Derivation.scala | 31 +++++---- .../net/ghoula/valar/AsyncValidatorSpec.scala | 15 ++--- .../ghoula/valar/ValidationConfigSpec.scala | 64 +++++++++++++++++++ .../net/ghoula/valar/ValidationSpec.scala | 7 +- .../net/ghoula/valar/ValidatorSpec.scala | 52 +-------------- 5 files changed, 95 insertions(+), 74 deletions(-) create mode 100644 valar-core/src/test/scala/net/ghoula/valar/ValidationConfigSpec.scala diff --git a/valar-core/src/main/scala/net/ghoula/valar/internal/Derivation.scala b/valar-core/src/main/scala/net/ghoula/valar/internal/Derivation.scala index a7e415f..0906abc 100644 --- a/valar-core/src/main/scala/net/ghoula/valar/internal/Derivation.scala +++ b/valar-core/src/main/scala/net/ghoula/valar/internal/Derivation.scala @@ -175,15 +175,16 @@ object Derivation { Expr.summon[Validator[h]].isDefined } - val newAcc = if (hasValidator) acc - else { - val suggestion = if (isAsync) { - s"given Validator[$fieldTypeStr] = ... or given AsyncValidator[$fieldTypeStr] = ..." - } else { - s"given Validator[$fieldTypeStr] = ..." + val newAcc = + if (hasValidator) acc + else { + val suggestion = if (isAsync) { + s"given Validator[$fieldTypeStr] = ... or given AsyncValidator[$fieldTypeStr] = ..." + } else { + s"given Validator[$fieldTypeStr] = ..." + } + MissingValidator(label, fieldTypeStr, suggestion) :: acc } - MissingValidator(label, fieldTypeStr, suggestion) :: acc - } collectMissing[t](remainingLabels.tail, newAcc) } @@ -192,7 +193,8 @@ object Derivation { if (missing.nonEmpty) { val validatorType = if (isAsync) "AsyncValidator" else "Validator" - val header = s"Cannot derive $validatorType for ${Type.show[T]}: missing validators for ${missing.length} field(s).\n" + val header = + s"Cannot derive $validatorType for ${Type.show[T]}: missing validators for ${missing.length} field(s).\n" val details = missing.zipWithIndex.map { case (m, i) => s" ${i + 1}. Field '${m.fieldName}' of type ${m.fieldType}\n" + @@ -279,7 +281,9 @@ object Derivation { // Named tuple: use productElement (matches Scala 3.7.4 stdlib pattern) // See: scala.NamedTuple.apply uses asInstanceOf for element access val indexExpr = Expr(index) - '{ $aExpr.asInstanceOf[Product].productElement($indexExpr).asInstanceOf[H] } // scalafix:ok DisableSyntax.asInstanceOf + '{ + $aExpr.asInstanceOf[Product].productElement($indexExpr).asInstanceOf[H] + } // scalafix:ok DisableSyntax.asInstanceOf } } @@ -390,7 +394,9 @@ object Derivation { } else { // Named tuple: use productElement (matches Scala 3.7.4 stdlib pattern) val indexExpr = Expr(index) - '{ $aExpr.asInstanceOf[Product].productElement($indexExpr).asInstanceOf[H] } // scalafix:ok DisableSyntax.asInstanceOf + '{ + $aExpr.asInstanceOf[Product].productElement($indexExpr).asInstanceOf[H] + } // scalafix:ok DisableSyntax.asInstanceOf } } @@ -399,7 +405,8 @@ object Derivation { '{ (ec: ExecutionContext) => given ExecutionContext = ec val fieldValue: H = $fieldAccess - $asyncValidatorExpr.validateAsync(fieldValue) + $asyncValidatorExpr + .validateAsync(fieldValue) .map(result => annotateErrors(result, $labelExpr, fieldValue)) .recover { case scala.util.control.NonFatal(ex) => ValidationResult.invalid( diff --git a/valar-core/src/test/scala/net/ghoula/valar/AsyncValidatorSpec.scala b/valar-core/src/test/scala/net/ghoula/valar/AsyncValidatorSpec.scala index 9f15012..7673f4c 100644 --- a/valar-core/src/test/scala/net/ghoula/valar/AsyncValidatorSpec.scala +++ b/valar-core/src/test/scala/net/ghoula/valar/AsyncValidatorSpec.scala @@ -7,15 +7,14 @@ import scala.concurrent.Future import net.ghoula.valar.ValidationErrors.ValidationError -/** Provides a comprehensive test suite for the [[AsyncValidator]] typeclass and its derivation. +/** Tests the [[AsyncValidator]] typeclass and its derivation. * - * This spec verifies all core functionalities of the asynchronous validation mechanism: - * - Successful validation of valid objects. - * - Correct handling of failures from synchronous validators within an async context. - * - Correct handling of failures from native asynchronous validators. - * - Proper accumulation of errors from both sync and async sources. - * - Correct validation of nested case classes with proper error path annotation. - * - Robustness against null values, optional fields, collections, and exceptions within Futures. + * Covers: + * - Successful validation of valid objects + * - Handling of failures from sync and async validators + * - Error accumulation from both sources + * - Nested case class validation with proper error paths + * - Optional fields, collections, and Future exception recovery */ class AsyncValidatorSpec extends FunSuite { diff --git a/valar-core/src/test/scala/net/ghoula/valar/ValidationConfigSpec.scala b/valar-core/src/test/scala/net/ghoula/valar/ValidationConfigSpec.scala new file mode 100644 index 0000000..1c1fa1c --- /dev/null +++ b/valar-core/src/test/scala/net/ghoula/valar/ValidationConfigSpec.scala @@ -0,0 +1,64 @@ +package net.ghoula.valar + +import munit.FunSuite + +/** Tests ValidationConfig collection size limits. + */ +class ValidationConfigSpec extends FunSuite { + + private given Validator[Int] with { + def validate(value: Int): ValidationResult[Int] = ValidationResult.Valid(value) + } + + test("default config should allow unlimited collection sizes") { + given ValidationConfig = ValidationConfig.default + val largeList = List.fill(50000)(1) + val validator = summon[Validator[List[Int]]] + assert(validator.validate(largeList).isValid) + } + + test("strict config should reject collections exceeding limit") { + given ValidationConfig = ValidationConfig.strict // 10,000 limit + val oversizedList = List.fill(10001)(1) + val validator = summon[Validator[List[Int]]] + validator.validate(oversizedList) match { + case ValidationResult.Invalid(errors) => + assertEquals(errors.size, 1) + assert(errors.head.message.contains("exceeds maximum allowed size")) + assertEquals(errors.head.code, Some("validation.security.collection_too_large")) + case _ => fail("Expected Invalid for oversized collection") + } + } + + test("strict config should allow collections within limit") { + given ValidationConfig = ValidationConfig.strict + val validList = List.fill(10000)(1) + val validator = summon[Validator[List[Int]]] + assert(validator.validate(validList).isValid) + } + + test("custom config should enforce custom limit") { + given ValidationConfig = ValidationConfig(maxCollectionSize = Some(5)) + val oversized = List(1, 2, 3, 4, 5, 6) + val validator = summon[Validator[List[Int]]] + assert(validator.validate(oversized).isInvalid) + assert(validator.validate(List(1, 2, 3, 4, 5)).isValid) + } + + test("size check should fail fast before validating elements") { + var elementsValidated = 0 + given Validator[String] with { + def validate(s: String): ValidationResult[String] = { + elementsValidated += 1 + ValidationResult.Valid(s) + } + } + given ValidationConfig = ValidationConfig(maxCollectionSize = Some(2)) + + val oversized = List("a", "b", "c", "d") + val validator = summon[Validator[List[String]]] + validator.validate(oversized) + + assertEquals(elementsValidated, 0, "Should not validate any elements when size limit exceeded") + } +} diff --git a/valar-core/src/test/scala/net/ghoula/valar/ValidationSpec.scala b/valar-core/src/test/scala/net/ghoula/valar/ValidationSpec.scala index 2700ccf..54c8240 100644 --- a/valar-core/src/test/scala/net/ghoula/valar/ValidationSpec.scala +++ b/valar-core/src/test/scala/net/ghoula/valar/ValidationSpec.scala @@ -347,9 +347,10 @@ class ValidationSpec extends FunSuite { } /** Extension methods for ValidationResult to support test assertions. */ -private implicit class ValidationResultTestOps[A](vr: ValidationResult[A]) { - def isInvalid: Boolean = vr match { - case _: ValidationResult.Invalid => true +private[valar] implicit class ValidationResultTestOps[A](vr: ValidationResult[A]) { + def isValid: Boolean = vr match { + case _: ValidationResult.Valid[?] => true case _ => false } + def isInvalid: Boolean = !isValid } diff --git a/valar-core/src/test/scala/net/ghoula/valar/ValidatorSpec.scala b/valar-core/src/test/scala/net/ghoula/valar/ValidatorSpec.scala index 188f202..12ec3b8 100644 --- a/valar-core/src/test/scala/net/ghoula/valar/ValidatorSpec.scala +++ b/valar-core/src/test/scala/net/ghoula/valar/ValidatorSpec.scala @@ -2,28 +2,10 @@ package net.ghoula.valar import munit.FunSuite -import java.time.* -import java.util.UUID -import scala.Symbol -import scala.math.{BigDecimal, BigInt} - -/** Tests the built-in validators for standard library and Java types provided in the `Validator` - * companion object. - * - * This spec ensures that Valar provides sensible default instances for common types. It verifies - * both the simple "pass-through" validators (for types like `Long`, `Boolean`, `UUID`, etc.) and - * the more opinionated default validators that enforce constraints (e.g., non-negative `Int`, - * non-empty `String`). +/** Tests the built-in validators that enforce constraints (non-negative Int, non-empty String, etc). */ class ValidatorSpec extends FunSuite { - /** Helper to test simple pass-through validators for a given value. */ - private def checkValidator[T](value: T)(using validator: Validator[T]): Unit = { - assertEquals(validator.validate(value), ValidationResult.Valid(value)) - } - - /** Tests the opinionated standard validators that enforce constraints. */ - test("Provided validator for Int should validate non-negative numbers") { val validator = summon[Validator[Int]] assertEquals(validator.validate(10), ValidationResult.Valid(10)) @@ -78,36 +60,4 @@ class ValidatorSpec extends FunSuite { } } - /** Tests the pass-through validators that accept all values of their type. */ - - test("Pass-through validator for Boolean") { checkValidator(true) } - test("Pass-through validator for Byte") { checkValidator(1.toByte) } - test("Pass-through validator for Short") { checkValidator(1.toShort) } - test("Pass-through validator for Long") { checkValidator(1L) } - test("Pass-through validator for Char") { checkValidator('a') } - test("Pass-through validator for Unit") { checkValidator(()) } - test("Pass-through validator for BigInt") { checkValidator(BigInt(123)) } - test("Pass-through validator for BigDecimal") { checkValidator(BigDecimal(123.45)) } - test("Pass-through validator for Symbol") { checkValidator(Symbol("abc")) } - test("Pass-through validator for UUID") { - checkValidator(UUID.fromString("123e4567-e89b-12d3-a456-426614174000")) - } - test("Pass-through validator for Instant") { - checkValidator(Instant.ofEpochSecond(1672531200)) - } - test("Pass-through validator for LocalDate") { - checkValidator(LocalDate.of(2025, 7, 5)) - } - test("Pass-through validator for LocalTime") { - checkValidator(LocalTime.of(10, 30, 0)) - } - test("Pass-through validator for LocalDateTime") { - checkValidator(LocalDateTime.of(2025, 7, 5, 10, 30, 0)) - } - test("Pass-through validator for ZonedDateTime") { - checkValidator(ZonedDateTime.of(2025, 7, 5, 10, 30, 0, 0, ZoneId.of("UTC"))) - } - test("Pass-through validator for Duration") { - checkValidator(Duration.ofHours(5)) - } } From f64c68a9c68514ec0b5bf1f9512cd47155f71d55 Mon Sep 17 00:00:00 2001 From: Hakim Jonas Ghoula Date: Sat, 22 Nov 2025 00:45:16 +0100 Subject: [PATCH 14/19] Add .claude/ to gitignore --- .gitignore | 5 ++++- .../scala/net/ghoula/valar/ValidationConfigSpec.scala | 8 +++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 973175b..fd7e4a1 100644 --- a/.gitignore +++ b/.gitignore @@ -56,4 +56,7 @@ build/ ### JVM crash logs ### hs_err_pid*.log -core.* \ No newline at end of file +core.* + +### Claude Code ### +.claude/ \ No newline at end of file diff --git a/valar-core/src/test/scala/net/ghoula/valar/ValidationConfigSpec.scala b/valar-core/src/test/scala/net/ghoula/valar/ValidationConfigSpec.scala index 1c1fa1c..77f750d 100644 --- a/valar-core/src/test/scala/net/ghoula/valar/ValidationConfigSpec.scala +++ b/valar-core/src/test/scala/net/ghoula/valar/ValidationConfigSpec.scala @@ -2,6 +2,8 @@ package net.ghoula.valar import munit.FunSuite +import java.util.concurrent.atomic.AtomicInteger + /** Tests ValidationConfig collection size limits. */ class ValidationConfigSpec extends FunSuite { @@ -46,10 +48,10 @@ class ValidationConfigSpec extends FunSuite { } test("size check should fail fast before validating elements") { - var elementsValidated = 0 + val elementsValidated = new AtomicInteger(0) given Validator[String] with { def validate(s: String): ValidationResult[String] = { - elementsValidated += 1 + elementsValidated.incrementAndGet() ValidationResult.Valid(s) } } @@ -59,6 +61,6 @@ class ValidationConfigSpec extends FunSuite { val validator = summon[Validator[List[String]]] validator.validate(oversized) - assertEquals(elementsValidated, 0, "Should not validate any elements when size limit exceeded") + assertEquals(elementsValidated.get(), 0, "Should not validate any elements when size limit exceeded") } } From c5a433b1712593d7aa55fdead94610be96c78e5b Mon Sep 17 00:00:00 2001 From: Hakim Jonas Ghoula Date: Sat, 22 Nov 2025 00:57:04 +0100 Subject: [PATCH 15/19] clean --- valar-core/src/test/scala/net/ghoula/valar/ValidatorSpec.scala | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/valar-core/src/test/scala/net/ghoula/valar/ValidatorSpec.scala b/valar-core/src/test/scala/net/ghoula/valar/ValidatorSpec.scala index 12ec3b8..53259dd 100644 --- a/valar-core/src/test/scala/net/ghoula/valar/ValidatorSpec.scala +++ b/valar-core/src/test/scala/net/ghoula/valar/ValidatorSpec.scala @@ -2,7 +2,8 @@ package net.ghoula.valar import munit.FunSuite -/** Tests the built-in validators that enforce constraints (non-negative Int, non-empty String, etc). +/** Tests the built-in validators that enforce constraints (non-negative Int, non-empty String, + * etc). */ class ValidatorSpec extends FunSuite { From 206e18d835c81fcf73b0b70ea3843f757423a567 Mon Sep 17 00:00:00 2001 From: Hakim Jonas Ghoula Date: Sat, 22 Nov 2025 00:57:04 +0100 Subject: [PATCH 16/19] Add JVM crash logs to .gitignore --- .gitignore | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index fd7e4a1..973175b 100644 --- a/.gitignore +++ b/.gitignore @@ -56,7 +56,4 @@ build/ ### JVM crash logs ### hs_err_pid*.log -core.* - -### Claude Code ### -.claude/ \ No newline at end of file +core.* \ No newline at end of file From 2b8c0cb85e32d974b067d3a22ac6541447ac385d Mon Sep 17 00:00:00 2001 From: Hakim Jonas Ghoula Date: Sat, 22 Nov 2025 01:08:13 +0100 Subject: [PATCH 17/19] Update mdoc source files: remove emojis, use inline metaprogramming terminology --- README.md | 109 ++++++++++++++++----------- docs-src/MIGRATION.md | 2 +- docs-src/README.md | 168 +++++++++++++++++++----------------------- 3 files changed, 145 insertions(+), 134 deletions(-) diff --git a/README.md b/README.md index bfcbcb1..6148260 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,41 @@ -# **Valar โ€“ Type-Safe Validation for Scala 3** +# Valar - Type-Safe Validation for Scala 3 [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_3?label=maven-central&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-core_3) [![Scala CI and GitHub Release](https://github.com/hakimjonas/valar/actions/workflows/scala.yml/badge.svg)](https://github.com/hakimjonas/valar/actions/workflows/scala.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT) -Valar is a validation library for Scala 3 designed for clarity and ease of use. It leverages Scala 3's type system and -inline metaprogramming to help you define complex validation rules with less boilerplate, while providing structured, -detailed error messages useful for debugging or user feedback. +Valar is a validation library for Scala 3. It uses Scala 3's type system and inline metaprogramming to define validation +rules with minimal boilerplate, providing structured error messages for debugging or user feedback. ## What's New in 0.5.X -* **ValidationObserver**: A trait for observing validation outcomes without altering the flow. Useful for logging, metrics, or auditing. Zero overhead when not used. -* **valar-translator**: Internationalization support for validation error messages via the `Translator` typeclass. -* **Enhanced ValarSuite**: Improved testing utilities in `valar-munit`. -* **Reworked Derivation**: Cleaner inline metaprogramming for compile-time validation. +* **ValidationObserver**: A trait for observing validation outcomes without altering the flow. Useful for logging, + metrics, or auditing. Zero overhead when not used. +* **valar-translator Module**: Internationalization (i18n) support for validation error messages via the `Translator` + typeclass. +* **Enhanced ValarSuite**: Updated testing utilities in `valar-munit`. +* **Reworked Derivation**: Uses modern Scala 3 inline metaprogramming for compile-time validation. * **MiMa Checks**: Binary compatibility verification between versions. +* **Improved Documentation**: Updated scaladoc and module-level README files. ## Key Features -* **Type-safe results**: `ValidationResult[A]` distinguishes valid results from accumulated errors at compile time. -* **Automatic derivation**: Derive `Validator` instances for case classes at compile time, reducing boilerplate. -* **Flexible error handling**: - * *Error accumulation* (default): Collect all validation failures for comprehensive reporting. - * *Fail-fast*: Stop on first failure for performance-sensitive paths. -* **Detailed error reports**: `ValidationError` includes field paths, expected vs. actual values, optional codes, and severity. -* **Named tuple support**: Field-aware error messages for Scala 3.7's named tuples. -* **Scala 3 native**: Built for Scala 3 using extension methods, given instances, opaque types, and inline metaprogramming. +* **Type Safety:** Distinguish between valid results and accumulated errors at compile time using `ValidationResult[A]`. +* **Minimal Boilerplate:** Derive `Validator` instances automatically for case classes using compile-time derivation. +* **Flexible Error Handling:** + * **Error Accumulation** (default): Collect all validation failures for reporting multiple issues. + * **Fail-Fast**: Stop on the first failure for performance-sensitive pipelines. +* **Detailed Error Reports:** `ValidationError` objects with field paths, expected vs. actual values, and optional + codes/severity. +* **Named Tuple Support:** Field-aware error messages for Scala 3.7's named tuples. +* **Scala 3 Idiomatic:** Uses extension methods, given instances, opaque types, and inline metaprogramming. -## Extensibility +## Extensibility Pattern -Valar is extensible through the `ValidationObserver` pattern, which allows integrating with external systems without modifying core validation logic. +Valar is extensible through the `ValidationObserver` pattern, which integrates with external systems without modifying +core validation logic. + +### The ValidationObserver Pattern ```scala trait ValidationObserver { @@ -38,13 +44,23 @@ trait ValidationObserver { ``` Properties: -* Zero overhead when using the default no-op observer (compiler eliminates the code) -* Non-intrusive: observes results without altering validation flow -* Composable with other Valar features -Common uses: logging, metrics collection, auditing. +* **Zero Overhead**: Default no-op observer is eliminated by the compiler +* **Non-Intrusive**: Observes results without altering the validation flow +* **Composable**: Works with other Valar features and can be chained + +### Extension Examples + +Current uses: + +- **Logging**: Log validation outcomes +- **Metrics**: Collect validation statistics +- **Auditing**: Track validation events -Planned extensions: `valar-cats-effect`, `valar-zio`. +Planned: + +- **valar-cats-effect**: Async validation with IO-based observers +- **valar-zio**: ZIO-based validation with resource management ## Available Artifacts @@ -98,19 +114,20 @@ Valar provides artifacts for both JVM and Scala Native platforms: ### Benchmark Results -Detailed JMH benchmarks are available in the [valar-benchmarks module](https://github.com/hakimjonas/valar/blob/main/valar-benchmarks/README.md). +Detailed benchmarks available in the [valar-benchmarks module](https://github.com/hakimjonas/valar/blob/main/valar-benchmarks/README.md). -Summary: +Key findings: - Simple validations: ~10-50 nanoseconds - Case class derivation: Zero runtime overhead (compile-time only) - Collection validation: Linear with collection size +- `ValidationObserver` with no-op has no runtime impact ## Additional Resources -- [Performance Benchmarks](https://github.com/hakimjonas/valar/blob/main/valar-benchmarks/README.md) - JMH benchmark results -- [Testing Guide](https://github.com/hakimjonas/valar/blob/main/valar-munit/README.md) - ValarSuite testing utilities -- [Internationalization](https://github.com/hakimjonas/valar/blob/main/valar-translator/README.md) - i18n support -- [Troubleshooting](https://github.com/hakimjonas/valar/blob/main/TROUBLESHOOTING.md) - Common issues and solutions +- [Performance Benchmarks](https://github.com/hakimjonas/valar/blob/main/valar-benchmarks/README.md): JMH benchmark results +- [Testing Guide](https://github.com/hakimjonas/valar/blob/main/valar-munit/README.md): ValarSuite testing utilities +- [Internationalization](https://github.com/hakimjonas/valar/blob/main/valar-translator/README.md): i18n support +- [Troubleshooting Guide](https://github.com/hakimjonas/valar/blob/main/TROUBLESHOOTING.md): Common issues and solutions ## Installation @@ -269,14 +286,15 @@ includes: Most built-in validators for scalar types (excluding those with obvious constraints like Int, String, Float, Double) are **pass-through** validators. You should define custom validators if you need specific constraints for these types. -## ValidationObserver +## ValidationObserver Pattern -The `ValidationObserver` trait provides a way to extend Valar with custom functionality: +The `ValidationObserver` trait is more than just a logging mechanismโ€”it's the **foundational pattern** for extending +Valar with custom functionality. This pattern allows you to: -- Integrate with external systems (logging, metrics, monitoring) -- Add side effects without modifying validation logic -- Build composable extensions -- Zero overhead when not used +- **Integrate with external systems** (logging, metrics, monitoring) +- **Add side effects** without modifying validation logic +- **Build composable extensions** that work together seamlessly +- **Maintain zero overhead** when extensions aren't needed ```scala import net.ghoula.valar.* @@ -318,9 +336,16 @@ val result = Validator[User].validate(user) .map(processUser) ``` +Key features of ValidationObserver: + +* **Zero Overhead**: When using the default no-op observer, the compiler eliminates all observer-related code +* **Non-Intrusive**: Observes validation results without altering the validation flow +* **Chainable**: Works seamlessly with other operations in the validation pipeline +* **Flexible**: Can be used for logging, metrics, alerting, or any other side effect + ## Internationalization with valar-translator -The `valar-translator` module provides internationalization support for validation error messages: +The `valar-translator` module provides internationalization (i18n) support for validation error messages: ```scala import net.ghoula.valar.* @@ -365,7 +390,7 @@ Version 0.5.0 introduces several new features while maintaining backward compati 1. **New ValidationObserver trait** for observing validation outcomes 2. **New valar-translator module** for internationalization support 3. **Enhanced ValarSuite** with improved testing utilities -4. **Reworked inline metaprogramming** for better performance and modern Scala 3 features +4. **Reworked derivation** using modern Scala 3 inline metaprogramming 5. **MiMa checks** to ensure binary compatibility To upgrade to v0.5.0, update your build.sbt: @@ -400,22 +425,22 @@ libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8-bundle" ## Security Considerations -When using Valar with untrusted user input, be aware of the following: +When using Valar with untrusted user input, please be aware of the following security considerations: ### Regular Expression Denial of Service (ReDoS) -The `regexMatch` methods that accept `String` patterns are vulnerable to ReDoS attacks when used with untrusted input. +**Warning:** The `regexMatch` methods that accept `String` patterns are vulnerable to ReDoS attacks when used with untrusted input. **Safe Practice:** ```scala -// โœ… SAFE - Use pre-compiled regex patterns +// SAFE - Use pre-compiled regex patterns val emailPattern = "^[a-zA-Z0-9+_.-]+@[a-zA-Z0-9.-]+$".r regexMatch(userInput, emailPattern)(_ => "Invalid email") ``` **Unsafe Practice:** ```scala -// โŒ UNSAFE - Never pass user-provided patterns! +// UNSAFE - Never pass user-provided patterns! val userPattern = request.getParameter("pattern") regexMatch(value, userPattern)(_ => "Invalid") // ReDoS vulnerability! ``` @@ -454,7 +479,7 @@ When a collection exceeds the configured limit, validation fails immediately ''' ## Compatibility -* **Scala:** 3.7.4+ +* **Scala:** 3.7+ * **Platforms:** JVM, Scala Native * **Dependencies:** valar-core has a Compile dependency on `io.github.cquiroz:scala-java-time` to provide robust, cross-platform support for the `java.time` API. diff --git a/docs-src/MIGRATION.md b/docs-src/MIGRATION.md index 43faf88..eb2ccc1 100644 --- a/docs-src/MIGRATION.md +++ b/docs-src/MIGRATION.md @@ -7,7 +7,7 @@ Version 0.5.0 introduces several new features while maintaining backward compati 1. **New ValidationObserver trait** for observing validation outcomes without altering the flow 2. **New valar-translator module** for internationalization support of validation error messages 3. **Enhanced ValarSuite** with improved testing utilities -4. **Reworked macros** for better performance and modern Scala 3 features +4. **Reworked derivation** using modern Scala 3 inline metaprogramming 5. **MiMa checks** to ensure binary compatibility between versions ### Update build.sbt: diff --git a/docs-src/README.md b/docs-src/README.md index 3bfd6d3..6148260 100644 --- a/docs-src/README.md +++ b/docs-src/README.md @@ -1,52 +1,41 @@ -# **Valar โ€“ Type-Safe Validation for Scala 3** +# Valar - Type-Safe Validation for Scala 3 [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_3?label=maven-central&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-core_3) [![Scala CI and GitHub Release](https://github.com/hakimjonas/valar/actions/workflows/scala.yml/badge.svg)](https://github.com/hakimjonas/valar/actions/workflows/scala.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT) -Valar is a validation library for Scala 3 designed for clarity and ease of use. It leverages Scala 3's type system and -metaprogramming (macros) to help you define complex validation rules with less boilerplate, while providing structured, -detailed error messages useful for debugging or user feedback. - -## **โœจ What's New in 0.5.X** - -* **๐Ÿ” ValidationObserver**: A new trait in `valar-core` for observing validation outcomes without altering the flow, - perfect for logging, metrics collection, or auditing with zero overhead when not used. -* **๐ŸŒ valar-translator Module**: New internationalization (i18n) support for validation error messages through the - `Translator` typeclass. -* **๐Ÿงช Enhanced ValarSuite**: Updated testing utilities in `valar-munit` now used in `valar-translator` for more robust - validation testing. -* **โšก Reworked Macros**: Simpler, more performant, and more modern macro implementations for better compile-time - validation. -* **๐Ÿ›ก๏ธ MiMa Checks**: Added binary compatibility verification to ensure smooth upgrades between versions. -* **๐Ÿ“š Improved Documentation**: Comprehensive updates to scaladoc and module-level README files for a better developer - experience. - -## **Key Features** - -* **Type Safety:** Clearly distinguish between valid results and accumulated errors at compile time using - ValidationResult[A]. Eliminate runtime errors caused by unexpected validation states. -* **Minimal Boilerplate:** Derive Validator instances automatically for case classes using compile-time macros, - significantly reducing repetitive validation logic. Focus on your rules, not the wiring. -* **Flexible Error Handling:** Choose the strategy that fits your use case: - * **Error Accumulation** (default): Collect all validation failures, ideal for reporting multiple issues (e.g., in - UIs or API responses). - * **Fail-Fast**: Stop validation immediately on the first failure, suitable for performance-sensitive pipelines. -* **Actionable Error Reports:** Generate detailed ValidationError objects containing precise field paths, validation - rule specifics (like expected vs. actual values), and optional codes/severity. -* **Named Tuple Support:** Field-aware error messages for Scala 3.7's named tuples, with preserved backward - compatibility. -* **Scala 3 Idiomatic:** Built specifically for Scala 3, embracing features like extension methods, given instances, - opaque types, and macros for a modern, expressive API. - -## **Extensibility Pattern** - -Valar is designed to be extensible through the **ValidationObserver pattern**, which provides a clean, type-safe way to -integrate with external systems without modifying the core validation logic. +Valar is a validation library for Scala 3. It uses Scala 3's type system and inline metaprogramming to define validation +rules with minimal boilerplate, providing structured error messages for debugging or user feedback. -### The ValidationObserver Pattern +## What's New in 0.5.X + +* **ValidationObserver**: A trait for observing validation outcomes without altering the flow. Useful for logging, + metrics, or auditing. Zero overhead when not used. +* **valar-translator Module**: Internationalization (i18n) support for validation error messages via the `Translator` + typeclass. +* **Enhanced ValarSuite**: Updated testing utilities in `valar-munit`. +* **Reworked Derivation**: Uses modern Scala 3 inline metaprogramming for compile-time validation. +* **MiMa Checks**: Binary compatibility verification between versions. +* **Improved Documentation**: Updated scaladoc and module-level README files. + +## Key Features -The `ValidationObserver` trait serves as the foundational pattern for extending Valar with cross-cutting concerns: +* **Type Safety:** Distinguish between valid results and accumulated errors at compile time using `ValidationResult[A]`. +* **Minimal Boilerplate:** Derive `Validator` instances automatically for case classes using compile-time derivation. +* **Flexible Error Handling:** + * **Error Accumulation** (default): Collect all validation failures for reporting multiple issues. + * **Fail-Fast**: Stop on the first failure for performance-sensitive pipelines. +* **Detailed Error Reports:** `ValidationError` objects with field paths, expected vs. actual values, and optional + codes/severity. +* **Named Tuple Support:** Field-aware error messages for Scala 3.7's named tuples. +* **Scala 3 Idiomatic:** Uses extension methods, given instances, opaque types, and inline metaprogramming. + +## Extensibility Pattern + +Valar is extensible through the `ValidationObserver` pattern, which integrates with external systems without modifying +core validation logic. + +### The ValidationObserver Pattern ```scala trait ValidationObserver { @@ -54,28 +43,26 @@ trait ValidationObserver { } ``` -This pattern offers several advantages: +Properties: -* **Zero Overhead**: When using the default no-op observer, the compiler eliminates all observer-related code -* **Non-Intrusive**: Observes validation results without altering the validation flow -* **Composable**: Works seamlessly with other Valar features and can be chained -* **Type-Safe**: Leverages Scala's type system for compile-time safety +* **Zero Overhead**: Default no-op observer is eliminated by the compiler +* **Non-Intrusive**: Observes results without altering the validation flow +* **Composable**: Works with other Valar features and can be chained -### Examples of Extensions Using This Pattern +### Extension Examples -Current implementations are following this pattern: +Current uses: -- **Logging**: Log validation outcomes for debugging and monitoring -- **Metrics**: Collect validation statistics for performance analysis -- **Auditing**: Track validation events for compliance and security +- **Logging**: Log validation outcomes +- **Metrics**: Collect validation statistics +- **Auditing**: Track validation events -Future extensions planned: +Planned: - **valar-cats-effect**: Async validation with IO-based observers - **valar-zio**: ZIO-based validation with resource management -- **Context-aware validation**: Observers that can access request-scoped data -## **Available Artifacts** +## Available Artifacts Valar provides artifacts for both JVM and Scala Native platforms: @@ -90,11 +77,9 @@ Valar provides artifacts for both JVM and Scala Native platforms: > **Note:** When using the `%%%` operator in sbt, the correct platform-specific artifact will be selected automatically. -## **Performance** +## Performance -Valar is designed for high performance with minimal overhead: - -### **Complexity Characteristics** +### Complexity Characteristics | Operation | Time Complexity | Space Complexity | Notes | |-----------|----------------|------------------|-------| @@ -104,7 +89,7 @@ Valar is designed for high performance with minimal overhead: | Nested case class | O(fields) | O(errors) | Accumulates errors across all fields | | Union type validation | O(types) | O(errors) | Tries each type in the union | -### **Performance Best Practices** +### Best Practices 1. **Use ValidationConfig limits** for untrusted input to prevent DoS: ```scala @@ -127,23 +112,24 @@ Valar is designed for high performance with minimal overhead: if (collection.size > 10000) return BadRequest("Too large") ``` -### **Benchmark Results** +### Benchmark Results -Detailed performance benchmarks with JMH are available in the [valar-benchmarks module](https://github.com/hakimjonas/valar/blob/main/valar-benchmarks/README.md). +Detailed benchmarks available in the [valar-benchmarks module](https://github.com/hakimjonas/valar/blob/main/valar-benchmarks/README.md). -**Key findings:** +Key findings: - Simple validations: ~10-50 nanoseconds - Case class derivation: Zero runtime overhead (compile-time only) - Collection validation: Linear with collection size -- Zero-cost abstractions: `ValidationObserver` with no-op has no runtime impact +- `ValidationObserver` with no-op has no runtime impact + +## Additional Resources -## **Additional Resources** +- [Performance Benchmarks](https://github.com/hakimjonas/valar/blob/main/valar-benchmarks/README.md): JMH benchmark results +- [Testing Guide](https://github.com/hakimjonas/valar/blob/main/valar-munit/README.md): ValarSuite testing utilities +- [Internationalization](https://github.com/hakimjonas/valar/blob/main/valar-translator/README.md): i18n support +- [Troubleshooting Guide](https://github.com/hakimjonas/valar/blob/main/TROUBLESHOOTING.md): Common issues and solutions -- ๐Ÿ“Š **[Performance Benchmarks](https://github.com/hakimjonas/valar/blob/main/valar-benchmarks/README.md)**: Detailed JMH benchmark results and analysis -- ๐Ÿงช **[Testing Guide](https://github.com/hakimjonas/valar/blob/main/valar-munit/README.md)**: Enhanced testing utilities with ValarSuite -- ๐ŸŒ **[Internationalization](https://github.com/hakimjonas/valar/blob/main/valar-translator/README.md)**: i18n support for validation error messages -- ๐Ÿ”ง **[Troubleshooting Guide](https://github.com/hakimjonas/valar/blob/main/TROUBLESHOOTING.md)**: Common issues and solutions -## **Installation** +## Installation Add the following to your build.sbt: @@ -158,7 +144,7 @@ libraryDependencies += "net.ghoula" %%% "valar-translator" % "0.5.0" libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.5.0" % Test ``` -## **Basic Usage Example** +## Basic Usage Example Here's a basic example of validating a case class. Valar provides default validators for String (non-empty) and Int ( non-negative). @@ -197,7 +183,7 @@ result match { } ``` -## **Testing with valar-munit** +## Testing with valar-munit The optional valar-munit module provides ValarSuite, a trait that offers powerful, validation-specific assertions to make your tests clean and expressive. @@ -237,9 +223,9 @@ class UserValidationSuite extends ValarSuite { } ``` -## **Core Components** +## Core Components -### **ValidationResult** +### ValidationResult Represents the outcome of validation as either Valid(value) or Invalid(errors): @@ -252,7 +238,7 @@ enum ValidationResult[+A] { } ``` -### **ValidationError** +### ValidationError Opaque type providing rich context for validation errors, including: @@ -263,7 +249,7 @@ Opaque type providing rich context for validation errors, including: * **expected/actual**: Information about expected and actual values. * **children**: Nested errors for structured reporting. -### **Validator[A]** +### Validator[A] A typeclass defining validation logic for a given type: @@ -282,7 +268,7 @@ to be available in scope for **all** field types within the case class. If a val *compilation will fail**. This strictness ensures that all fields are explicitly considered during validation. See the " Built-in Validators" section for types supported out-of-the-box. -## **Built-in Validators** +## Built-in Validators Valar provides given Validator instances out-of-the-box for many common types to ease setup and support derivation. This includes: @@ -300,7 +286,7 @@ includes: Most built-in validators for scalar types (excluding those with obvious constraints like Int, String, Float, Double) are **pass-through** validators. You should define custom validators if you need specific constraints for these types. -## **ValidationObserver, The Core Extensibility Pattern** +## ValidationObserver Pattern The `ValidationObserver` trait is more than just a logging mechanismโ€”it's the **foundational pattern** for extending Valar with custom functionality. This pattern allows you to: @@ -357,7 +343,7 @@ Key features of ValidationObserver: * **Chainable**: Works seamlessly with other operations in the validation pipeline * **Flexible**: Can be used for logging, metrics, alerting, or any other side effect -## **Internationalization with valar-translator** +## Internationalization with valar-translator The `valar-translator` module provides internationalization (i18n) support for validation error messages: @@ -397,14 +383,14 @@ The `valar-translator` module is designed to: * Compose cleanly with other Valar features like ValidationObserver * Provide a clear separation between validation logic and presentation concerns -## **Migration Guide from v0.4.8 to v0.5.0** +## Migration Guide from v0.4.8 to v0.5.0 Version 0.5.0 introduces several new features while maintaining backward compatibility with v0.4.8: 1. **New ValidationObserver trait** for observing validation outcomes 2. **New valar-translator module** for internationalization support 3. **Enhanced ValarSuite** with improved testing utilities -4. **Reworked macros** for better performance and modern Scala 3 features +4. **Reworked derivation** using modern Scala 3 inline metaprogramming 5. **MiMa checks** to ensure binary compatibility To upgrade to v0.5.0, update your build.sbt: @@ -422,7 +408,7 @@ libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.5.0" % Test Your existing validation code will continue to work without any changes. -## **Migration Guide from v0.3.0 to v0.4.8** +## Migration Guide from v0.3.0 to v0.4.8 The main breaking change in v0.4.0 was the **artifact name change** from valar to valar-core to support the modular architecture. @@ -437,29 +423,29 @@ libraryDependencies += "net.ghoula" %% "valar" % "0.3.0" libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8-bundle" ``` -## **Security Considerations** +## Security Considerations When using Valar with untrusted user input, please be aware of the following security considerations: -### **Regular Expression Denial of Service (ReDoS)** +### Regular Expression Denial of Service (ReDoS) -โš ๏ธ **Warning:** The `regexMatch` methods that accept `String` patterns are vulnerable to ReDoS attacks when used with untrusted input. +**Warning:** The `regexMatch` methods that accept `String` patterns are vulnerable to ReDoS attacks when used with untrusted input. **Safe Practice:** ```scala -// โœ… SAFE - Use pre-compiled regex patterns +// SAFE - Use pre-compiled regex patterns val emailPattern = "^[a-zA-Z0-9+_.-]+@[a-zA-Z0-9.-]+$".r regexMatch(userInput, emailPattern)(_ => "Invalid email") ``` **Unsafe Practice:** ```scala -// โŒ UNSAFE - Never pass user-provided patterns! +// UNSAFE - Never pass user-provided patterns! val userPattern = request.getParameter("pattern") regexMatch(value, userPattern)(_ => "Invalid") // ReDoS vulnerability! ``` -### **Input Size Limits** +### Input Size Limits Valar provides built-in protection against resource exhaustion through `ValidationConfig`: @@ -484,21 +470,21 @@ When a collection exceeds the configured limit, validation fails immediately ''' **Important:** Always use `ValidationConfig.strict` or custom limits when validating untrusted user input. -### **Error Information Disclosure** +### Error Information Disclosure `ValidationError` objects include detailed information about what was expected vs. what was received. When exposing validation errors to end users: - Review error messages for sensitive information - Consider using the `valar-translator` module to provide user-friendly, sanitized messages - Be cautious about exposing internal field names or structure -## **Compatibility** +## Compatibility * **Scala:** 3.7+ * **Platforms:** JVM, Scala Native * **Dependencies:** valar-core has a Compile dependency on `io.github.cquiroz:scala-java-time` to provide robust, cross-platform support for the `java.time` API. -## **License** +## License Valar is licensed under the **MIT License**. See the [LICENSE](https://github.com/hakimjonas/valar/blob/main/LICENSE) file for details. From 0baf1ded6400ffbe830a88ee31d7331f5c5a1b41 Mon Sep 17 00:00:00 2001 From: Hakim Jonas Ghoula Date: Sat, 22 Nov 2025 01:53:59 +0100 Subject: [PATCH 18/19] Make built-in validators pass-through (breaking change for v0.6.0) Built-in validators for Int, String, Float, and Double now accept all values. Constraints are opt-in via ValidationHelpers. Why: The opinionated defaults (Int must be non-negative, String must be non-empty) limited Valar's use as a general-purpose foundation. Users validating temperatures, legacy data, or scientific values had to fight the library's defaults. Changes: - Validator[Int]: accepts all values (use nonNegativeInt for constraints) - Validator[String]: accepts all values (use nonEmpty for constraints) - Validator[Float]: accepts all values (use finiteFloat for constraints) - Validator[Double]: accepts all values (use finiteDouble for constraints) - AsyncValidator: updated to match sync validators - Tests: updated to verify pass-through behavior - Docs: updated Built-in Validators section, added migration guide --- MIGRATION.md | 49 +++++++++++++ README.md | 44 ++++++++---- docs-src/MIGRATION.md | 49 +++++++++++++ docs-src/README.md | 44 ++++++++---- .../net/ghoula/valar/AsyncValidator.scala | 35 +++------- .../scala/net/ghoula/valar/Validator.scala | 28 ++++---- .../net/ghoula/valar/ValidatorSpec.scala | 69 +++++++++++-------- 7 files changed, 218 insertions(+), 100 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index eb2ccc1..015c2b3 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,5 +1,54 @@ # Migration Guide +## Migrating from v0.5.0 to v0.6.0 + +Version 0.6.0 includes a **breaking change** to built-in validators. + +### Breaking Change: Pass-Through Validators + +Built-in validators for `Int`, `String`, `Float`, and `Double` are now **pass-through** (accept all values). Previously +they enforced constraints: + +| Type | v0.5.0 (constrained) | v0.6.0 (pass-through) | +|--------|------------------------------|-----------------------| +| Int | Rejects negative values | Accepts all values | +| String | Rejects empty strings | Accepts all values | +| Float | Rejects NaN/Infinity | Accepts all values | +| Double | Rejects NaN/Infinity | Accepts all values | + +**Why this change?** The opinionated defaults limited Valar's use as a general-purpose foundation. Users validating +temperatures (negative values valid), legacy data (empty strings valid), or scientific data (NaN meaningful) had to +fight the library's defaults. + +### Migration Steps + +If you relied on the constrained defaults, define explicit validators using `ValidationHelpers`: + +```scala +import net.ghoula.valar.* +import net.ghoula.valar.ValidationHelpers.* + +// Restore v0.5.0 behavior for Int +given Validator[Int] with { + def validate(i: Int) = nonNegativeInt(i) +} + +// Restore v0.5.0 behavior for String +given Validator[String] with { + def validate(s: String) = nonEmpty(s) +} + +// Restore v0.5.0 behavior for Float +given Validator[Float] with { + def validate(f: Float) = finiteFloat(f) +} + +// Restore v0.5.0 behavior for Double +given Validator[Double] with { + def validate(d: Double) = finiteDouble(d) +} +``` + ## Migrating from v0.4.8 to v0.5.0 Version 0.5.0 introduces several new features while maintaining backward compatibility with v0.4.8: diff --git a/README.md b/README.md index 6148260..05bbd5f 100644 --- a/README.md +++ b/README.md @@ -270,21 +270,35 @@ Built-in Validators" section for types supported out-of-the-box. ## Built-in Validators -Valar provides given Validator instances out-of-the-box for many common types to ease setup and support derivation. This -includes: - -* **Scala Primitives:** Int (non-negative), String (non-empty), Boolean, Long, Double (finite), Float (finite), Byte, - Short, Char, Unit. -* **Other Scala Types:** BigInt, BigDecimal, Symbol. -* **Common Java Types:** java.util.UUID, java.time.Instant, java.time.LocalDate, java.time.LocalDateTime, - java.time.ZonedDateTime, java.time.LocalTime, java.time.Duration. -* **Standard Collections:** Option, List, Vector, Seq, Set, Array, ArraySeq, Map (provided validators exist for their - element/key/value types). -* **Tuple Types:** Named tuples and regular tuples. -* **Intersection (&) and Union (|) Types:** Provided corresponding validators for the constituent types exist. - -Most built-in validators for scalar types (excluding those with obvious constraints like Int, String, Float, Double) are -**pass-through** validators. You should define custom validators if you need specific constraints for these types. +Valar provides pass-through `Validator` instances for common types to enable derivation. All built-in validators accept +any value - constraints are opt-in via `ValidationHelpers`. + +**Supported types:** + +* **Scala Primitives:** Int, String, Boolean, Long, Double, Float, Byte, Short, Char, Unit +* **Other Scala Types:** BigInt, BigDecimal, Symbol +* **Java Types:** UUID, Instant, LocalDate, LocalDateTime, ZonedDateTime, LocalTime, Duration +* **Collections:** Option, List, Vector, Seq, Set, Array, ArraySeq, Map +* **Tuple Types:** Named tuples and regular tuples +* **Composite Types:** Intersection (&) and Union (|) types + +**Opt-in constraints** (from `ValidationHelpers`): + +```scala +import net.ghoula.valar.ValidationHelpers.* + +// Define constrained validators when you need them +given Validator[Int] with { + def validate(i: Int) = nonNegativeInt(i) +} + +given Validator[String] with { + def validate(s: String) = nonEmpty(s) +} +``` + +Available constraint helpers: `nonNegativeInt`, `nonEmpty`, `finiteFloat`, `finiteDouble`, `minLength`, `maxLength`, +`regexMatch`, `inRange`, `oneOf`. ## ValidationObserver Pattern diff --git a/docs-src/MIGRATION.md b/docs-src/MIGRATION.md index eb2ccc1..015c2b3 100644 --- a/docs-src/MIGRATION.md +++ b/docs-src/MIGRATION.md @@ -1,5 +1,54 @@ # Migration Guide +## Migrating from v0.5.0 to v0.6.0 + +Version 0.6.0 includes a **breaking change** to built-in validators. + +### Breaking Change: Pass-Through Validators + +Built-in validators for `Int`, `String`, `Float`, and `Double` are now **pass-through** (accept all values). Previously +they enforced constraints: + +| Type | v0.5.0 (constrained) | v0.6.0 (pass-through) | +|--------|------------------------------|-----------------------| +| Int | Rejects negative values | Accepts all values | +| String | Rejects empty strings | Accepts all values | +| Float | Rejects NaN/Infinity | Accepts all values | +| Double | Rejects NaN/Infinity | Accepts all values | + +**Why this change?** The opinionated defaults limited Valar's use as a general-purpose foundation. Users validating +temperatures (negative values valid), legacy data (empty strings valid), or scientific data (NaN meaningful) had to +fight the library's defaults. + +### Migration Steps + +If you relied on the constrained defaults, define explicit validators using `ValidationHelpers`: + +```scala +import net.ghoula.valar.* +import net.ghoula.valar.ValidationHelpers.* + +// Restore v0.5.0 behavior for Int +given Validator[Int] with { + def validate(i: Int) = nonNegativeInt(i) +} + +// Restore v0.5.0 behavior for String +given Validator[String] with { + def validate(s: String) = nonEmpty(s) +} + +// Restore v0.5.0 behavior for Float +given Validator[Float] with { + def validate(f: Float) = finiteFloat(f) +} + +// Restore v0.5.0 behavior for Double +given Validator[Double] with { + def validate(d: Double) = finiteDouble(d) +} +``` + ## Migrating from v0.4.8 to v0.5.0 Version 0.5.0 introduces several new features while maintaining backward compatibility with v0.4.8: diff --git a/docs-src/README.md b/docs-src/README.md index 6148260..05bbd5f 100644 --- a/docs-src/README.md +++ b/docs-src/README.md @@ -270,21 +270,35 @@ Built-in Validators" section for types supported out-of-the-box. ## Built-in Validators -Valar provides given Validator instances out-of-the-box for many common types to ease setup and support derivation. This -includes: - -* **Scala Primitives:** Int (non-negative), String (non-empty), Boolean, Long, Double (finite), Float (finite), Byte, - Short, Char, Unit. -* **Other Scala Types:** BigInt, BigDecimal, Symbol. -* **Common Java Types:** java.util.UUID, java.time.Instant, java.time.LocalDate, java.time.LocalDateTime, - java.time.ZonedDateTime, java.time.LocalTime, java.time.Duration. -* **Standard Collections:** Option, List, Vector, Seq, Set, Array, ArraySeq, Map (provided validators exist for their - element/key/value types). -* **Tuple Types:** Named tuples and regular tuples. -* **Intersection (&) and Union (|) Types:** Provided corresponding validators for the constituent types exist. - -Most built-in validators for scalar types (excluding those with obvious constraints like Int, String, Float, Double) are -**pass-through** validators. You should define custom validators if you need specific constraints for these types. +Valar provides pass-through `Validator` instances for common types to enable derivation. All built-in validators accept +any value - constraints are opt-in via `ValidationHelpers`. + +**Supported types:** + +* **Scala Primitives:** Int, String, Boolean, Long, Double, Float, Byte, Short, Char, Unit +* **Other Scala Types:** BigInt, BigDecimal, Symbol +* **Java Types:** UUID, Instant, LocalDate, LocalDateTime, ZonedDateTime, LocalTime, Duration +* **Collections:** Option, List, Vector, Seq, Set, Array, ArraySeq, Map +* **Tuple Types:** Named tuples and regular tuples +* **Composite Types:** Intersection (&) and Union (|) types + +**Opt-in constraints** (from `ValidationHelpers`): + +```scala +import net.ghoula.valar.ValidationHelpers.* + +// Define constrained validators when you need them +given Validator[Int] with { + def validate(i: Int) = nonNegativeInt(i) +} + +given Validator[String] with { + def validate(s: String) = nonEmpty(s) +} +``` + +Available constraint helpers: `nonNegativeInt`, `nonEmpty`, `finiteFloat`, `finiteDouble`, `minLength`, `maxLength`, +`regexMatch`, `inRange`, `oneOf`. ## ValidationObserver Pattern diff --git a/valar-core/src/main/scala/net/ghoula/valar/AsyncValidator.scala b/valar-core/src/main/scala/net/ghoula/valar/AsyncValidator.scala index 9d59a6f..b6a12eb 100644 --- a/valar-core/src/main/scala/net/ghoula/valar/AsyncValidator.scala +++ b/valar-core/src/main/scala/net/ghoula/valar/AsyncValidator.scala @@ -284,36 +284,17 @@ object AsyncValidator { } } - /** Asynchronous validator for non-negative integers. - * - * Validates that an integer value is non-negative (>= 0). This validator is lifted from the - * corresponding synchronous validator and is used as a fallback when no custom integer validator - * is provided. - */ - given nonNegativeIntAsyncValidator: AsyncValidator[Int] = fromSync(Validator.nonNegativeIntValidator) + /** Pass-through async validator for Int. For constraints, define a custom AsyncValidator. */ + given intAsyncValidator: AsyncValidator[Int] = fromSync(Validator.intValidator) - /** Asynchronous validator for finite floating-point numbers. - * - * Validates that a float value is finite (not NaN or infinite). This validator is lifted from - * the corresponding synchronous validator and is used as a fallback when no custom float - * validator is provided. - */ - given finiteFloatAsyncValidator: AsyncValidator[Float] = fromSync(Validator.finiteFloatValidator) + /** Pass-through async validator for Float. For constraints, define a custom AsyncValidator. */ + given floatAsyncValidator: AsyncValidator[Float] = fromSync(Validator.floatValidator) - /** Asynchronous validator for finite double-precision numbers. - * - * Validates that a double value is finite (not NaN or infinite). This validator is lifted from - * the corresponding synchronous validator and is used as a fallback when no custom double - * validator is provided. - */ - given finiteDoubleAsyncValidator: AsyncValidator[Double] = fromSync(Validator.finiteDoubleValidator) + /** Pass-through async validator for Double. For constraints, define a custom AsyncValidator. */ + given doubleAsyncValidator: AsyncValidator[Double] = fromSync(Validator.doubleValidator) - /** Asynchronous validator for non-empty strings. - * - * Validates that a string value is not empty. This validator is lifted from the corresponding - * synchronous validator and is used as a fallback when no custom string validator is provided. - */ - given nonEmptyStringAsyncValidator: AsyncValidator[String] = fromSync(Validator.nonEmptyStringValidator) + /** Pass-through async validator for String. For constraints, define a custom AsyncValidator. */ + given stringAsyncValidator: AsyncValidator[String] = fromSync(Validator.stringValidator) /** Asynchronous validator for boolean values. * diff --git a/valar-core/src/main/scala/net/ghoula/valar/Validator.scala b/valar-core/src/main/scala/net/ghoula/valar/Validator.scala index a6da495..08c6579 100644 --- a/valar-core/src/main/scala/net/ghoula/valar/Validator.scala +++ b/valar-core/src/main/scala/net/ghoula/valar/Validator.scala @@ -40,28 +40,24 @@ object Validator { // ... keep all the existing given instances exactly as they are ... - /** Validates that an Int is non-negative (>= 0). Uses [[ValidationHelpers.nonNegativeInt]]. */ - given nonNegativeIntValidator: Validator[Int] with { - def validate(i: Int): ValidationResult[Int] = nonNegativeInt(i) + /** Pass-through validator for Int. For constraints, use [[ValidationHelpers.nonNegativeInt]]. */ + inline given intValidator: Validator[Int] with { + def validate(i: Int): ValidationResult[Int] = ValidationResult.Valid(i) } - /** Validates that a Float is finite (not NaN or infinite). Uses - * [[ValidationHelpers.finiteFloat]]. - */ - given finiteFloatValidator: Validator[Float] with { - def validate(f: Float): ValidationResult[Float] = finiteFloat(f) + /** Pass-through validator for Float. For constraints, use [[ValidationHelpers.finiteFloat]]. */ + inline given floatValidator: Validator[Float] with { + def validate(f: Float): ValidationResult[Float] = ValidationResult.Valid(f) } - /** Validates that a Double is finite (not NaN or infinite). Uses - * [[ValidationHelpers.finiteDouble]]. - */ - given finiteDoubleValidator: Validator[Double] with { - def validate(d: Double): ValidationResult[Double] = finiteDouble(d) + /** Pass-through validator for Double. For constraints, use [[ValidationHelpers.finiteDouble]]. */ + inline given doubleValidator: Validator[Double] with { + def validate(d: Double): ValidationResult[Double] = ValidationResult.Valid(d) } - /** Validates that a String is non-empty. Uses [[ValidationHelpers.nonEmpty]]. */ - given nonEmptyStringValidator: Validator[String] with { - def validate(s: String): ValidationResult[String] = nonEmpty(s) + /** Pass-through validator for String. For constraints, use [[ValidationHelpers.nonEmpty]]. */ + inline given stringValidator: Validator[String] with { + def validate(s: String): ValidationResult[String] = ValidationResult.Valid(s) } /** Default validator for `Option[A]`. */ diff --git a/valar-core/src/test/scala/net/ghoula/valar/ValidatorSpec.scala b/valar-core/src/test/scala/net/ghoula/valar/ValidatorSpec.scala index 53259dd..4636755 100644 --- a/valar-core/src/test/scala/net/ghoula/valar/ValidatorSpec.scala +++ b/valar-core/src/test/scala/net/ghoula/valar/ValidatorSpec.scala @@ -2,63 +2,78 @@ package net.ghoula.valar import munit.FunSuite -/** Tests the built-in validators that enforce constraints (non-negative Int, non-empty String, - * etc). +import net.ghoula.valar.ValidationHelpers.* + +/** Tests the built-in pass-through validators and opt-in constraint validators from + * ValidationHelpers. */ class ValidatorSpec extends FunSuite { - test("Provided validator for Int should validate non-negative numbers") { + test("Built-in Int validator should be pass-through") { val validator = summon[Validator[Int]] assertEquals(validator.validate(10), ValidationResult.Valid(10)) assertEquals(validator.validate(0), ValidationResult.Valid(0)) - validator.validate(-1) match { - case _: ValidationResult.Invalid => - /** Expected Invalid result */ - case v => fail(s"Expected Invalid, but got $v") - } + assertEquals(validator.validate(-1), ValidationResult.Valid(-1)) + assertEquals(validator.validate(Int.MinValue), ValidationResult.Valid(Int.MinValue)) } - test("Provided validator for Float should validate finite numbers") { + test("Built-in Float validator should be pass-through") { val validator = summon[Validator[Float]] assertEquals(validator.validate(3.14f), ValidationResult.Valid(3.14f)) + assertEquals(validator.validate(Float.PositiveInfinity), ValidationResult.Valid(Float.PositiveInfinity)) + // NaN requires special handling since NaN != NaN validator.validate(Float.NaN) match { - case _: ValidationResult.Invalid => - /** Expected Invalid result */ - case v => fail(s"Expected Invalid, but got $v") + case ValidationResult.Valid(v) => assert(v.isNaN) + case _ => fail("Expected Valid result for NaN") } } - test("Provided validator for Double should validate finite numbers") { + test("Built-in Double validator should be pass-through") { val validator = summon[Validator[Double]] assertEquals(validator.validate(3.14d), ValidationResult.Valid(3.14d)) - validator.validate(Double.PositiveInfinity) match { - case _: ValidationResult.Invalid => - /** Expected Invalid result */ - case v => fail(s"Expected Invalid, but got $v") + assertEquals(validator.validate(Double.PositiveInfinity), ValidationResult.Valid(Double.PositiveInfinity)) + // NaN requires special handling since NaN != NaN + validator.validate(Double.NaN) match { + case ValidationResult.Valid(v) => assert(v.isNaN) + case _ => fail("Expected Valid result for NaN") } } - test("Provided validator for String should validate non-empty strings") { + test("Built-in String validator should be pass-through") { val validator = summon[Validator[String]] assertEquals(validator.validate("hello"), ValidationResult.Valid("hello")) - validator.validate("") match { - case _: ValidationResult.Invalid => - /** Expected Invalid result */ - case v => fail(s"Expected Invalid, but got $v") - } + assertEquals(validator.validate(""), ValidationResult.Valid("")) } - test("Provided validator for Option[Int] should validate the inner value") { + test("Built-in Option validator should be pass-through for inner value") { val validator = summon[Validator[Option[Int]]] assertEquals(validator.validate(Some(42)), ValidationResult.Valid(Some(42))) assertEquals(validator.validate(None), ValidationResult.Valid(None)) + assertEquals(validator.validate(Some(-5)), ValidationResult.Valid(Some(-5))) + } - validator.validate(Some(-5)) match { + test("Opt-in nonNegativeInt constraint should reject negative values") { + val result = nonNegativeInt(-5) + result match { case ValidationResult.Invalid(errors) => assertEquals(errors.size, 1) - assert(errors.head.message.contains("Int must be non-negative")) - case _ => fail("Expected Invalid result but got Valid") + assert(errors.head.message.contains("non-negative")) + case _ => fail("Expected Invalid result") } } + test("Opt-in finiteFloat constraint should reject NaN") { + val result = finiteFloat(Float.NaN) + assert(result.isInvalid) + } + + test("Opt-in finiteDouble constraint should reject Infinity") { + val result = finiteDouble(Double.PositiveInfinity) + assert(result.isInvalid) + } + + test("Opt-in nonEmpty constraint should reject empty strings") { + val result = nonEmpty("") + assert(result.isInvalid) + } } From 2faf21b1cd32601df55edb8ebdc7553b9147acc0 Mon Sep 17 00:00:00 2001 From: Hakim Jonas Ghoula Date: Sat, 22 Nov 2025 02:13:12 +0100 Subject: [PATCH 19/19] Eliminate Validator/AsyncValidator duplication with internal F[_] abstraction Addresses reviewer feedback on DRY violation - collection validation logic was duplicated between Validator and AsyncValidator. New internal abstractions (private[valar]): - ValidationEffect[F[_]]: minimal monad-like trait for effect abstraction - SyncEffect: F[X] = X (identity, for sync validators) - FutureEffect: F = Future (for async validators) - ValidationLogic: shared collection/map validation, written once Changes: - Validator: collection validators now call ValidationLogic - AsyncValidator: collection validators now call ValidationLogic - Removed ~70 lines of duplicated traversal/folding logic Benefits: - Single source of truth for collection validation - Security logic (size checks) cannot drift between sync/async - Foundation ready for future F[_] effect support if needed --- .../net/ghoula/valar/AsyncValidator.scala | 202 ++---------------- .../scala/net/ghoula/valar/Validator.scala | 160 +++----------- .../valar/internal/ValidationEffect.scala | 37 ++++ .../valar/internal/ValidationLogic.scala | 139 ++++++++++++ 4 files changed, 221 insertions(+), 317 deletions(-) create mode 100644 valar-core/src/main/scala/net/ghoula/valar/internal/ValidationEffect.scala create mode 100644 valar-core/src/main/scala/net/ghoula/valar/internal/ValidationLogic.scala diff --git a/valar-core/src/main/scala/net/ghoula/valar/AsyncValidator.scala b/valar-core/src/main/scala/net/ghoula/valar/AsyncValidator.scala index b6a12eb..8c92e23 100644 --- a/valar-core/src/main/scala/net/ghoula/valar/AsyncValidator.scala +++ b/valar-core/src/main/scala/net/ghoula/valar/AsyncValidator.scala @@ -6,8 +6,7 @@ import scala.concurrent.{ExecutionContext, Future} import scala.deriving.Mirror import scala.quoted.{Expr, Quotes, Type} -import net.ghoula.valar.ValidationErrors.ValidationError -import net.ghoula.valar.internal.Derivation +import net.ghoula.valar.internal.{Derivation, FutureEffect, ValidationLogic} /** A typeclass for defining custom asynchronous validation logic for type `A`. * @@ -51,76 +50,6 @@ object AsyncValidator { Future.successful(v.validate(a)) } - /** Generic helper method for folding validation results into errors and valid values. - * - * @param results - * the sequence of validation results to fold - * @param emptyAcc - * the empty accumulator for valid values - * @param addToAcc - * function to add a valid value to the accumulator - * @return - * a tuple containing accumulated errors and valid values - */ - private def foldValidationResults[A, B]( - results: Iterable[ValidationResult[A]], - emptyAcc: B, - addToAcc: (B, A) => B - ): (Vector[ValidationError], B) = { - results.foldLeft((Vector.empty[ValidationError], emptyAcc)) { - case ((errs, acc), ValidationResult.Valid(value)) => (errs, addToAcc(acc, value)) - case ((errs, acc), ValidationResult.Invalid(e)) => (errs ++ e, acc) - } - } - - /** Generic helper method for validating collections asynchronously. - * - * This method eliminates code duplication by providing a common validation pattern for different - * collection types. It validates each element in the collection asynchronously and accumulates - * both errors and valid results. - * - * '''Security Note:''' This method enforces size limits from `ValidationConfig` before - * processing elements to prevent memory exhaustion attacks from maliciously large collections. - * - * @param items - * the collection of items to validate - * @param validator - * the validator for individual items - * @param buildResult - * function to construct the final collection from valid items - * @param collectionType - * description of the collection type for error messages - * @param config - * validation configuration for security limits - * @param ec - * execution context for async operations - * @return - * a Future containing the validation result - */ - private def validateCollection[A, C[_]]( - items: Iterable[A], - validator: AsyncValidator[A], - buildResult: Iterable[A] => C[A], - collectionType: String - )(using config: ValidationConfig, ec: ExecutionContext): Future[ValidationResult[C[A]]] = { - config.checkCollectionSize(items.size, collectionType) match { - case ValidationResult.Invalid(errors) => Future.successful(ValidationResult.Invalid(errors)) - case ValidationResult.Valid(_) => - val futureResults = items.map { item => - validator.validateAsync(item).map { - case ValidationResult.Valid(a) => ValidationResult.Valid(a) - case ValidationResult.Invalid(errors) => ValidationResult.Invalid(errors) - } - } - - Future.sequence(futureResults).map { results => - val (errors, validValues) = foldValidationResults(results, Vector.empty[A], _ :+ _) - if (errors.isEmpty) ValidationResult.Valid(buildResult(validValues)) - else ValidationResult.Invalid(errors) - } - } - } - /** Asynchronous validator for optional values. * * Validates an `Option[A]` by delegating to the underlying validator only when the value is @@ -143,145 +72,38 @@ object AsyncValidator { } } - /** Asynchronous validator for lists. - * - * Validates a `List[A]` by applying the element validator to each item in the list - * asynchronously. All validation futures are executed concurrently, and their results are - * collected. Errors from individual elements are accumulated while preserving the order of valid - * elements. - * - * '''Security Note:''' This validator enforces collection size limits from `ValidationConfig` to - * prevent DoS attacks via extremely large lists. - * - * @param v - * the validator for list elements - * @param config - * validation configuration for security limits - * @return - * an AsyncValidator that handles lists - */ + /** Asynchronous validator for lists. */ given listAsyncValidator[A](using v: AsyncValidator[A], config: ValidationConfig): AsyncValidator[List[A]] with { def validateAsync(xs: List[A])(using ec: ExecutionContext): Future[ValidationResult[List[A]]] = - validateCollection(xs, v, _.toList, "List") + ValidationLogic.validateCollection(xs, identity, "List")(v.validateAsync)(using FutureEffect(), config) } - /** Asynchronous validator for sequences. - * - * Validates a `Seq[A]` by applying the element validator to each item in the sequence - * asynchronously. All validation futures are executed concurrently, and their results are - * collected. Errors from individual elements are accumulated while preserving the order of valid - * elements. - * - * '''Security Note:''' This validator enforces collection size limits from `ValidationConfig` to - * prevent DoS attacks via extremely large sequences. - * - * @param v - * the validator for sequence elements - * @param config - * validation configuration for security limits - * @return - * an AsyncValidator that handles sequences - */ + /** Asynchronous validator for sequences. */ given seqAsyncValidator[A](using v: AsyncValidator[A], config: ValidationConfig): AsyncValidator[Seq[A]] with { def validateAsync(xs: Seq[A])(using ec: ExecutionContext): Future[ValidationResult[Seq[A]]] = - validateCollection(xs, v, _.toSeq, "Seq") + ValidationLogic.validateCollection(xs, _.toSeq, "Seq")(v.validateAsync)(using FutureEffect(), config) } - /** Asynchronous validator for vectors. - * - * Validates a `Vector[A]` by applying the element validator to each item in the vector - * asynchronously. All validation futures are executed concurrently, and their results are - * collected. Errors from individual elements are accumulated while preserving the order of valid - * elements. - * - * '''Security Note:''' This validator enforces collection size limits from `ValidationConfig` to - * prevent DoS attacks via extremely large vectors. - * - * @param v - * the validator for vector elements - * @param config - * validation configuration for security limits - * @return - * an AsyncValidator that handles vectors - */ + /** Asynchronous validator for vectors. */ given vectorAsyncValidator[A](using v: AsyncValidator[A], config: ValidationConfig): AsyncValidator[Vector[A]] with { def validateAsync(xs: Vector[A])(using ec: ExecutionContext): Future[ValidationResult[Vector[A]]] = - validateCollection(xs, v, _.toVector, "Vector") + ValidationLogic.validateCollection(xs, _.toVector, "Vector")(v.validateAsync)(using FutureEffect(), config) } - /** Asynchronous validator for sets. - * - * Validates a `Set[A]` by applying the element validator to each item in the set asynchronously. - * All validation futures are executed concurrently, and their results are collected. Errors from - * individual elements are accumulated while preserving the valid elements in the resulting set. - * - * '''Security Note:''' This validator enforces collection size limits from `ValidationConfig` to - * prevent DoS attacks via extremely large sets. - * - * @param v - * the validator for set elements - * @param config - * validation configuration for security limits - * @return - * an AsyncValidator that handles sets - */ + /** Asynchronous validator for sets. */ given setAsyncValidator[A](using v: AsyncValidator[A], config: ValidationConfig): AsyncValidator[Set[A]] with { def validateAsync(xs: Set[A])(using ec: ExecutionContext): Future[ValidationResult[Set[A]]] = - validateCollection(xs, v, _.toSet, "Set") + ValidationLogic.validateCollection(xs, _.toSet, "Set")(v.validateAsync)(using FutureEffect(), config) } - /** Asynchronous validator for maps. - * - * Validates a `Map[K, V]` by applying the key validator to each key and the value validator to - * each value asynchronously. All validation futures are executed concurrently, and their results - * are collected. Errors from individual keys and values are accumulated with proper field path - * annotation, while valid key-value pairs are preserved in the resulting map. - * - * '''Security Note:''' This validator enforces collection size limits from `ValidationConfig` to - * prevent DoS attacks via extremely large maps. - * - * @param vk - * the validator for map keys - * @param vv - * the validator for map values - * @param config - * validation configuration for security limits - * @return - * an AsyncValidator that handles maps - */ + /** Asynchronous validator for maps. */ given mapAsyncValidator[K, V](using vk: AsyncValidator[K], vv: AsyncValidator[V], config: ValidationConfig ): AsyncValidator[Map[K, V]] with { - def validateAsync(m: Map[K, V])(using ec: ExecutionContext): Future[ValidationResult[Map[K, V]]] = { - config.checkCollectionSize(m.size, "Map") match { - case ValidationResult.Invalid(errors) => Future.successful(ValidationResult.Invalid(errors)) - case ValidationResult.Valid(_) => - val futureResults = m.map { case (k, v) => - val futureKey = vk.validateAsync(k).map { - case ValidationResult.Valid(kk) => ValidationResult.Valid(kk) - case ValidationResult.Invalid(es) => - ValidationResult.Invalid(es.map(_.annotateField("key", k.getClass.getSimpleName))) - } - val futureValue = vv.validateAsync(v).map { - case ValidationResult.Valid(vv) => ValidationResult.Valid(vv) - case ValidationResult.Invalid(es) => - ValidationResult.Invalid(es.map(_.annotateField("value", v.getClass.getSimpleName))) - } - - for { - keyResult <- futureKey - valueResult <- futureValue - } yield keyResult.zip(valueResult) - } - - Future.sequence(futureResults).map { results => - val (errors, validPairs) = foldValidationResults(results, Map.empty[K, V], _ + _) - if (errors.isEmpty) ValidationResult.Valid(validPairs) else ValidationResult.Invalid(errors) - } - } - } + def validateAsync(m: Map[K, V])(using ec: ExecutionContext): Future[ValidationResult[Map[K, V]]] = + ValidationLogic.validateMap(m)(vk.validateAsync, vv.validateAsync)(using FutureEffect(), config) } /** Pass-through async validator for Int. For constraints, define a custom AsyncValidator. */ diff --git a/valar-core/src/main/scala/net/ghoula/valar/Validator.scala b/valar-core/src/main/scala/net/ghoula/valar/Validator.scala index 08c6579..a74ea57 100644 --- a/valar-core/src/main/scala/net/ghoula/valar/Validator.scala +++ b/valar-core/src/main/scala/net/ghoula/valar/Validator.scala @@ -7,10 +7,9 @@ import scala.deriving.Mirror import scala.quoted.{Expr, Quotes, Type} import scala.reflect.ClassTag -import net.ghoula.valar.ValidationErrors.ValidationError import net.ghoula.valar.ValidationHelpers.* import net.ghoula.valar.ValidationResult.{validateUnion, given} -import net.ghoula.valar.internal.Derivation +import net.ghoula.valar.internal.{Derivation, SyncEffect, ValidationLogic} /** A typeclass for defining custom validation logic for type `A`. * @@ -66,154 +65,61 @@ object Validator { optional(opt)(using v) } - /** Validates a `List[A]` by validating each element. - * - * If a [[ValidationConfig]] is in scope with `maxCollectionSize` set, this validator will check - * the collection size before processing elements, failing fast if the limit is exceeded. - */ + /** Validates a `List[A]` by validating each element. */ given listValidator[A](using v: Validator[A], config: ValidationConfig): Validator[List[A]] with { - def validate(xs: List[A]): ValidationResult[List[A]] = { - config.checkCollectionSize(xs.size, "List").flatMap { _ => - val results = xs.map(v.validate) - val (errors, validValues) = results.foldLeft((Vector.empty[ValidationError], List.empty[A])) { - case ((errs, vals), ValidationResult.Valid(a)) => (errs, vals :+ a) - case ((errs, vals), ValidationResult.Invalid(e2)) => (errs ++ e2, vals) - } - if errors.isEmpty then ValidationResult.Valid(validValues) else ValidationResult.Invalid(errors) - } - } + def validate(xs: List[A]): ValidationResult[List[A]] = + ValidationLogic.validateCollection[[X] =>> X, A, List[A]](xs, identity, "List")(v.validate)(using + SyncEffect, + config + ) } - /** Validates a `Seq[A]` by validating each element. - * - * If a [[ValidationConfig]] is in scope with `maxCollectionSize` set, this validator will check - * the collection size before processing elements, failing fast if the limit is exceeded. - */ + /** Validates a `Seq[A]` by validating each element. */ given seqValidator[A](using v: Validator[A], config: ValidationConfig): Validator[Seq[A]] with { - def validate(xs: Seq[A]): ValidationResult[Seq[A]] = { - config.checkCollectionSize(xs.size, "Seq").flatMap { _ => - val results = xs.map(v.validate) - val (errors, validValues) = results.foldLeft((Vector.empty[ValidationError], Seq.empty[A])) { - case ((errs, vals), ValidationResult.Valid(a)) => (errs, vals :+ a) - case ((errs, vals), ValidationResult.Invalid(e2)) => (errs ++ e2, vals) - } - if errors.isEmpty then ValidationResult.Valid(validValues) else ValidationResult.Invalid(errors) - } - } + def validate(xs: Seq[A]): ValidationResult[Seq[A]] = + ValidationLogic.validateCollection[[X] =>> X, A, Seq[A]](xs, _.toSeq, "Seq")(v.validate)(using SyncEffect, config) } - /** Validates a `Vector[A]` by validating each element. - * - * If a [[ValidationConfig]] is in scope with `maxCollectionSize` set, this validator will check - * the collection size before processing elements, failing fast if the limit is exceeded. - */ + /** Validates a `Vector[A]` by validating each element. */ given vectorValidator[A](using v: Validator[A], config: ValidationConfig): Validator[Vector[A]] with { - def validate(xs: Vector[A]): ValidationResult[Vector[A]] = { - config.checkCollectionSize(xs.size, "Vector").flatMap { _ => - val results = xs.map(v.validate) - val (errors, validValues) = results.foldLeft((Vector.empty[ValidationError], Vector.empty[A])) { - case ((errs, vals), ValidationResult.Valid(a)) => (errs, vals :+ a) - case ((errs, vals), ValidationResult.Invalid(e2)) => (errs ++ e2, vals) - } - if errors.isEmpty then ValidationResult.Valid(validValues) else ValidationResult.Invalid(errors) - } - } + def validate(xs: Vector[A]): ValidationResult[Vector[A]] = + ValidationLogic.validateCollection[[X] =>> X, A, Vector[A]](xs, _.toVector, "Vector")(v.validate)(using + SyncEffect, + config + ) } - /** Validates a `Set[A]` by validating each element. - * - * If a [[ValidationConfig]] is in scope with `maxCollectionSize` set, this validator will check - * the collection size before processing elements, failing fast if the limit is exceeded. - */ + /** Validates a `Set[A]` by validating each element. */ given setValidator[A](using v: Validator[A], config: ValidationConfig): Validator[Set[A]] with { - def validate(xs: Set[A]): ValidationResult[Set[A]] = { - config.checkCollectionSize(xs.size, "Set").flatMap { _ => - val results = xs.map(v.validate) - val (errors, validValues) = results.foldLeft((Vector.empty[ValidationError], Set.empty[A])) { - case ((errs, vals), ValidationResult.Valid(a)) => (errs, vals + a) - case ((errs, vals), ValidationResult.Invalid(e2)) => (errs ++ e2, vals) - } - if errors.isEmpty then ValidationResult.Valid(validValues) else ValidationResult.Invalid(errors) - } - } + def validate(xs: Set[A]): ValidationResult[Set[A]] = + ValidationLogic.validateCollection[[X] =>> X, A, Set[A]](xs, _.toSet, "Set")(v.validate)(using SyncEffect, config) } - /** Validates a `Map[K, V]` by validating each key and value. - * - * If a [[ValidationConfig]] is in scope with `maxCollectionSize` set, this validator will check - * the map size before processing entries, failing fast if the limit is exceeded. - */ + /** Validates a `Map[K, V]` by validating each key and value. */ given mapValidator[K, V](using vk: Validator[K], vv: Validator[V], config: ValidationConfig): Validator[Map[K, V]] with { - def validate(m: Map[K, V]): ValidationResult[Map[K, V]] = { - config.checkCollectionSize(m.size, "Map").flatMap { _ => - val results = m.map { case (k, v) => - val validatedKey: ValidationResult[K] = vk.validate(k) match { - case ValidationResult.Valid(kk) => ValidationResult.Valid(kk) - case ValidationResult.Invalid(es) => - ValidationResult.Invalid( - es.map(e => e.annotateField("key", k.getClass.getSimpleName)) - ) - } - val validatedValue: ValidationResult[V] = vv.validate(v) match { - case ValidationResult.Valid(vv) => ValidationResult.Valid(vv) - case ValidationResult.Invalid(es) => - ValidationResult.Invalid( - es.map(e => e.annotateField("value", v.getClass.getSimpleName)) - ) - } - validatedKey.zip(validatedValue) - } - val (errors, validPairs) = results.foldLeft((Vector.empty[ValidationError], Map.empty[K, V])) { - case ((errs, acc), ValidationResult.Valid(pair)) => (errs, acc + pair) - case ((errs, acc), ValidationResult.Invalid(e2)) => (errs ++ e2, acc) - } - if errors.isEmpty then ValidationResult.Valid(validPairs) else ValidationResult.Invalid(errors) - } - } - } - - /** Helper for validating iterable collections. */ - private def validateIterable[A, C[_]]( - xs: Iterable[A], - builder: Vector[A] => C[A] - )(using v: Validator[A]): ValidationResult[C[A]] = { - val resultsIterator = xs.iterator.map(v.validate) - val initial = (Vector.empty[ValidationError], Vector.empty[A]) - val (errors, validValues) = resultsIterator.foldLeft(initial) { - case ((currentErrors, currentValidValues), result) => - result match { - case ValidationResult.Valid(a) => (currentErrors, currentValidValues :+ a) - case ValidationResult.Invalid(e2) => (currentErrors ++ e2, currentValidValues) - } - } - if (errors.isEmpty) ValidationResult.Valid(builder(validValues)) - else ValidationResult.Invalid(errors) + def validate(m: Map[K, V]): ValidationResult[Map[K, V]] = + ValidationLogic.validateMap[[X] =>> X, K, V](m)(vk.validate, vv.validate)(using SyncEffect, config) } - /** Validates an `Array[A]`. - * - * If a [[ValidationConfig]] is in scope with `maxCollectionSize` set, this validator will check - * the array size before processing elements, failing fast if the limit is exceeded. - */ + /** Validates an `Array[A]` by validating each element. */ given arrayValidator[A](using v: Validator[A], ct: ClassTag[A], config: ValidationConfig): Validator[Array[A]] with { def validate(xs: Array[A]): ValidationResult[Array[A]] = - config.checkCollectionSize(xs.length, "Array").flatMap { _ => - validateIterable(xs, (validValues: Vector[A]) => validValues.toArray) - } + ValidationLogic.validateCollection[[X] =>> X, A, Array[A]](xs, _.toArray, "Array")(v.validate)(using + SyncEffect, + config + ) } - /** Validates an `ArraySeq[A]`. - * - * If a [[ValidationConfig]] is in scope with `maxCollectionSize` set, this validator will check - * the collection size before processing elements, failing fast if the limit is exceeded. - */ + /** Validates an `ArraySeq[A]` by validating each element. */ given arraySeqValidator[A](using v: Validator[A], ct: ClassTag[A], config: ValidationConfig): Validator[ArraySeq[A]] with { def validate(xs: ArraySeq[A]): ValidationResult[ArraySeq[A]] = - config.checkCollectionSize(xs.size, "ArraySeq").flatMap { _ => - validateIterable(xs, (validValues: Vector[A]) => ArraySeq.unsafeWrapArray(validValues.toArray)) - } + ValidationLogic.validateCollection[[X] =>> X, A, ArraySeq[A]]( + xs, + l => ArraySeq.unsafeWrapArray(l.toArray), + "ArraySeq" + )(v.validate)(using SyncEffect, config) } /** Validates an intersection type `A & B`. */ diff --git a/valar-core/src/main/scala/net/ghoula/valar/internal/ValidationEffect.scala b/valar-core/src/main/scala/net/ghoula/valar/internal/ValidationEffect.scala new file mode 100644 index 0000000..29f7ec8 --- /dev/null +++ b/valar-core/src/main/scala/net/ghoula/valar/internal/ValidationEffect.scala @@ -0,0 +1,37 @@ +package net.ghoula.valar.internal + +import scala.concurrent.{ExecutionContext, Future} + +/** Internal abstraction for effect types used in validation. + * + * This is a minimal monad-like interface that allows collection validation logic to be written + * once and reused for both synchronous (Id) and asynchronous (Future) validation. + * + * This trait is intentionally private to valar - it's an implementation detail, not a public API. + */ +private[valar] trait ValidationEffect[F[_]] { + def pure[A](a: A): F[A] + def map[A, B](fa: F[A])(f: A => B): F[B] + def flatMap[A, B](fa: F[A])(f: A => F[B]): F[B] + def traverse[A, B](as: List[A])(f: A => F[B]): F[List[B]] +} + +/** Synchronous effect (identity). Operations execute immediately. */ +private[valar] object SyncEffect extends ValidationEffect[[X] =>> X] { + def pure[A](a: A): A = a + def map[A, B](fa: A)(f: A => B): B = f(fa) + def flatMap[A, B](fa: A)(f: A => B): B = f(fa) + def traverse[A, B](as: List[A])(f: A => B): List[B] = as.map(f) +} + +/** Asynchronous effect using Future. Requires an ExecutionContext. */ +private[valar] class FutureEffect(using ec: ExecutionContext) extends ValidationEffect[Future] { + def pure[A](a: A): Future[A] = Future.successful(a) + def map[A, B](fa: Future[A])(f: A => B): Future[B] = fa.map(f) + def flatMap[A, B](fa: Future[A])(f: A => Future[B]): Future[B] = fa.flatMap(f) + def traverse[A, B](as: List[A])(f: A => Future[B]): Future[List[B]] = Future.traverse(as)(f) +} + +private[valar] object FutureEffect { + def apply()(using ec: ExecutionContext): FutureEffect = new FutureEffect() +} diff --git a/valar-core/src/main/scala/net/ghoula/valar/internal/ValidationLogic.scala b/valar-core/src/main/scala/net/ghoula/valar/internal/ValidationLogic.scala new file mode 100644 index 0000000..0a7aa8f --- /dev/null +++ b/valar-core/src/main/scala/net/ghoula/valar/internal/ValidationLogic.scala @@ -0,0 +1,139 @@ +package net.ghoula.valar.internal + +import net.ghoula.valar.ValidationErrors.ValidationError +import net.ghoula.valar.{ValidationConfig, ValidationResult} + +/** Shared validation logic for collections. + * + * This eliminates duplication between Validator and AsyncValidator by abstracting over the effect + * type F[_]. The collection validation algorithm is written once here. + */ +private[valar] object ValidationLogic { + + /** Validates a collection of items, checking size limits and accumulating errors. + * + * @tparam F + * The effect type (Id for sync, Future for async) + * @tparam A + * The element type + * @tparam C + * The collection type + * @param items + * The collection to validate + * @param buildResult + * Function to reconstruct the collection from validated elements + * @param collectionType + * Name of collection type for error messages (e.g., "List", "Vector") + * @param validateItem + * Function to validate a single item + * @param F + * The effect instance + * @param config + * Validation configuration with size limits + * @return + * Validation result wrapped in effect F + */ + def validateCollection[F[_], A, C]( + items: Iterable[A], + buildResult: List[A] => C, + collectionType: String + )( + validateItem: A => F[ValidationResult[A]] + )(using + F: ValidationEffect[F], + config: ValidationConfig + ): F[ValidationResult[C]] = { + + // 1. Check size limit (pure/sync operation) + val sizeCheck: ValidationResult[Unit] = config.checkCollectionSize(items.size, collectionType) match { + case ValidationResult.Valid(_) => ValidationResult.Valid(()) + case ValidationResult.Invalid(errors) => ValidationResult.Invalid(errors) + } + + // 2. If size check fails, return early + sizeCheck match { + case ValidationResult.Invalid(errors) => + F.pure(ValidationResult.Invalid(errors)) + + case ValidationResult.Valid(_) => + // 3. Validate all items + val itemResults: F[List[ValidationResult[A]]] = F.traverse(items.toList)(validateItem) + + // 4. Aggregate results + F.map(itemResults) { results => + val (errors, validValues) = results.foldLeft((Vector.empty[ValidationError], List.empty[A])) { + case ((errs, vals), ValidationResult.Valid(a)) => (errs, vals :+ a) + case ((errs, vals), ValidationResult.Invalid(e)) => (errs ++ e, vals) + } + + if (errors.isEmpty) ValidationResult.Valid(buildResult(validValues)) + else ValidationResult.Invalid(errors) + } + } + } + + /** Validates a Map by validating each key and value. + * + * @tparam F + * The effect type + * @tparam K + * The key type + * @tparam V + * The value type + */ + def validateMap[F[_], K, V]( + m: Map[K, V] + )( + validateKey: K => F[ValidationResult[K]], + validateValue: V => F[ValidationResult[V]] + )(using + F: ValidationEffect[F], + config: ValidationConfig + ): F[ValidationResult[Map[K, V]]] = { + + // 1. Check size limit + val sizeCheck = config.checkCollectionSize(m.size, "Map") match { + case ValidationResult.Valid(_) => ValidationResult.Valid(()) + case ValidationResult.Invalid(errors) => ValidationResult.Invalid(errors) + } + + sizeCheck match { + case ValidationResult.Invalid(errors) => + F.pure(ValidationResult.Invalid(errors)) + + case ValidationResult.Valid(_) => + // 2. Validate all entries + val entryResults: F[List[ValidationResult[(K, V)]]] = F.traverse(m.toList) { case (k, v) => + val keyResult: F[ValidationResult[K]] = F.map(validateKey(k)) { + case ValidationResult.Valid(kk) => ValidationResult.Valid(kk) + case ValidationResult.Invalid(es) => + ValidationResult.Invalid(es.map(e => e.annotateField("key", k.getClass.getSimpleName))) + } + + val valueResult: F[ValidationResult[V]] = F.map(validateValue(v)) { + case ValidationResult.Valid(vv) => ValidationResult.Valid(vv) + case ValidationResult.Invalid(es) => + ValidationResult.Invalid(es.map(e => e.annotateField("value", v.getClass.getSimpleName))) + } + + // Combine key and value results + F.flatMap(keyResult) { kr => + F.map(valueResult) { vr => + kr.zip(vr) + } + } + } + + // 3. Aggregate results + F.map(entryResults) { results => + val (errors, validPairs) = results.foldLeft((Vector.empty[ValidationError], Map.empty[K, V])) { + case ((errs, acc), ValidationResult.Valid(pair)) => (errs, acc + pair) + case ((errs, acc), ValidationResult.Invalid(e)) => (errs ++ e, acc) + } + + if (errors.isEmpty) ValidationResult.Valid(validPairs) + else ValidationResult.Invalid(errors) + } + } + } +}