From c8a7a016473fcd4c78c0cc32ac5ce875dea97d13 Mon Sep 17 00:00:00 2001 From: Andriy Onyshchuk Date: Fri, 1 May 2026 10:38:38 -0700 Subject: [PATCH 01/14] PKL Config Scala --- .scalafmt.conf | 12 + .../src/main/kotlin/pklAllProjects.gradle.kts | 15 +- .../main/kotlin/pklScalaLibrary.gradle.kts | 61 ++++ gradle/libs.versions.toml | 10 + pkl-config-scala/pkl-config-scala.gradle.kts | 36 +++ .../mapper/CachedConverterFactories.scala | 161 ++++++++++ .../scala/mapper/CachedSourceTypeInfo.scala | 71 ++++ .../mapper/ConstructorParamResolver.scala | 117 +++++++ .../JavaReflectionSyntaxExtensions.scala | 80 +++++ .../org/pkl/config/scala/mapper/Param.scala | 53 +++ .../scala/mapper/ScalaConversions.scala | 85 +++++ .../mapper/ScalaConverterFactories.scala | 109 +++++++ .../mapper/ScalaPObjectToCaseClass.scala | 153 +++++++++ .../org/pkl/config/scala/syntax/package.scala | 123 +++++++ .../config/scala/mapper/PPairToScalaTuple.pkl | 7 + .../config/scala/ScalaObjectMapperSpec.scala | 303 ++++++++++++++++++ .../scala/mapper/PPairToScalaTupleSpec.scala | 106 ++++++ settings.gradle.kts | 2 + 18 files changed, 1503 insertions(+), 1 deletion(-) create mode 100644 .scalafmt.conf create mode 100644 build-logic/src/main/kotlin/pklScalaLibrary.gradle.kts create mode 100644 pkl-config-scala/pkl-config-scala.gradle.kts create mode 100644 pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/CachedConverterFactories.scala create mode 100644 pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/CachedSourceTypeInfo.scala create mode 100644 pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ConstructorParamResolver.scala create mode 100644 pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/JavaReflectionSyntaxExtensions.scala create mode 100644 pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/Param.scala create mode 100644 pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ScalaConversions.scala create mode 100644 pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ScalaConverterFactories.scala create mode 100644 pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ScalaPObjectToCaseClass.scala create mode 100644 pkl-config-scala/src/main/scala/org/pkl/config/scala/syntax/package.scala create mode 100644 pkl-config-scala/src/test/resources/org/pkl/config/scala/mapper/PPairToScalaTuple.pkl create mode 100644 pkl-config-scala/src/test/scala/org/pkl/config/scala/ScalaObjectMapperSpec.scala create mode 100644 pkl-config-scala/src/test/scala/org/pkl/config/scala/mapper/PPairToScalaTupleSpec.scala diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 000000000..a17be3fcf --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,12 @@ +version = "3.10.0" +runner.dialect = scala3 + +maxColumn = 100 + +docstrings.style = Asterisk +docstrings.blankFirstLine = yes +docstrings.wrap = yes + +rewrite.scala3.removeOptionalBraces = false +rewrite.insertBraces.minLines = 1 // Or 2 +rewrite.insertBraces.allBlocks = true diff --git a/build-logic/src/main/kotlin/pklAllProjects.gradle.kts b/build-logic/src/main/kotlin/pklAllProjects.gradle.kts index e486963d0..48109c56f 100644 --- a/build-logic/src/main/kotlin/pklAllProjects.gradle.kts +++ b/build-logic/src/main/kotlin/pklAllProjects.gradle.kts @@ -22,6 +22,14 @@ val buildInfo = extensions.create("buildInfo", project) configurations { val rejectedVersionSuffix = Regex("-alpha|-beta|-eap|-m|-rc|-snapshot", RegexOption.IGNORE_CASE) + val versionSuffixRejectionExemptions = + setOf( + // I know. + // This looks odd. + // But yes, it's transitively required by one of the release versions of `zinc` + // https://github.com/sbt/zinc/blame/57a2df7104b3ce27b46404bb09a0126bd4013427/project/Dependencies.scala#L85 + "com.eed3si9n:shaded-scalajson_2.13:1.0.0-M4" + ) configureEach { resolutionStrategy { // forbid dependencies whose pom.xml's include version ranges, because this will lead to @@ -30,7 +38,12 @@ configurations { failOnDynamicVersions() componentSelection { all { - if (rejectedVersionSuffix.containsMatchIn(candidate.version)) { + if ( + rejectedVersionSuffix.containsMatchIn(candidate.version) && + !versionSuffixRejectionExemptions.contains( + "${candidate.group}:${candidate.module}:${candidate.version}" + ) + ) { reject( "Rejected dependency $candidate " + "because it has a prelease version suffix matching `$rejectedVersionSuffix`." diff --git a/build-logic/src/main/kotlin/pklScalaLibrary.gradle.kts b/build-logic/src/main/kotlin/pklScalaLibrary.gradle.kts new file mode 100644 index 000000000..a49e16fc4 --- /dev/null +++ b/build-logic/src/main/kotlin/pklScalaLibrary.gradle.kts @@ -0,0 +1,61 @@ +/* + * Copyright © 2024-2026 Apple Inc. and the Pkl project authors. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:Suppress("HttpUrlsUsage", "unused") + +import org.gradle.accessors.dm.LibrariesForLibs +import org.gradle.kotlin.dsl.withType + +plugins { + id("pklJavaLibrary") + scala +} + +// Build configuration. +val buildInfo = project.extensions.getByType() + +// Version Catalog library symbols. +val libs = the() + +dependencies { + testImplementation(libs.scalaTestPlusJunit) + testImplementation(libs.scalaTest) + testImplementation(libs.diffx) +} + +scala { scalaVersion = libs.versions.scala } + +tasks.withType().configureEach { + scalaCompileOptions.additionalParameters = + listOf("-Xsource:3", "-release:${buildInfo.jvmTarget}", "-target:${buildInfo.jvmTarget}") +} + +tasks.test { + useJUnitPlatform { + includeEngines("scalatest") + testLogging { events("passed", "skipped", "failed") } + } +} + +spotless { + scala { + scalafmt(libs.versions.scalafmt.get()).configFile(rootProject.file(".scalafmt.conf")) + target("src/*/scala/**/*.scala") + licenseHeaderFile( + rootProject.file("build-logic/src/main/resources/license-header.star-block.txt"), + "package ", + ) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1cf9d2f17..d0b74f6fb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,6 +4,7 @@ checksumPlugin = "1.4.0" # 5.0.3 is the last version compatible with Kotlin 2.2 clikt = "5.0.3" commonMark = "0.28.0" +diffx = "0.9.0" downloadTaskPlugin = "5.7.0" errorProne = "2.49.0" errorPronePlugin = "5.1.0" @@ -59,6 +60,10 @@ nullaway = "0.13.4" nullawayPlugin = "3.0.0" nuValidator = "26.4.16" paguro = "3.10.3" +scala = "2.13.17" +scalafmt = "3.10.0" +scalaTest = "3.2.19" +scalaTestPlusJunit = "3.2.19.0" shadowPlugin = "9.4.1" slf4j = "2.0.17" snakeYaml = "3.0.1" @@ -71,6 +76,7 @@ clikt = { group = "com.github.ajalt.clikt", name = "clikt", version.ref = "clikt cliktMarkdown = { group = "com.github.ajalt.clikt", name = "clikt-markdown", version.ref = "clikt" } commonMark = { group = "org.commonmark", name = "commonmark", version.ref = "commonMark" } commonMarkTables = { group = "org.commonmark", name = "commonmark-ext-gfm-tables", version.ref = "commonMark" } +diffx = { group = "com.softwaremill.diffx", name = "diffx-scalatest-should_2.13", version.ref = "diffx" } downloadTaskPlugin = { group = "de.undercouch", name = "gradle-download-task", version.ref = "downloadTaskPlugin" } #noinspection UnusedVersionCatalogEntry errorProne = { group = "com.google.errorprone", name = "error_prone_core", version.ref = "errorProne" } @@ -113,6 +119,10 @@ nullawayPlugin = { group = "net.ltgt.gradle", name = "gradle-nullaway-plugin", v # to be replaced with https://github.com/usethesource/capsule or https://github.com/lacuna/bifurcan paguro = { group = "org.organicdesign", name = "Paguro", version.ref = "paguro" } pklConfigJavaAll025 = { group = "org.pkl-lang", name = "pkl-config-java-all", version = "0.25.0" } +scalaLibrary = { group = "org.scala-lang", name = "scala-library", version.ref = "scala" } +scalaReflect = { group = "org.scala-lang", name = "scala-reflect", version.ref = "scala" } +scalaTest = { group = "org.scalatest", name = "scalatest_2.13", version.ref = "scalaTest" } +scalaTestPlusJunit = { group = "org.scalatestplus", name = "junit-5-12_2.13", version.ref = "scalaTestPlusJunit" } shadowPlugin = { group = "com.gradleup.shadow", name = "com.gradleup.shadow.gradle.plugin", version.ref = "shadowPlugin" } slf4jApi = { group = "org.slf4j", name = "slf4j-api", version.ref = "slf4j" } slf4jSimple = { group = "org.slf4j", name = "slf4j-simple", version.ref = "slf4j" } diff --git a/pkl-config-scala/pkl-config-scala.gradle.kts b/pkl-config-scala/pkl-config-scala.gradle.kts new file mode 100644 index 000000000..0a0094ff5 --- /dev/null +++ b/pkl-config-scala/pkl-config-scala.gradle.kts @@ -0,0 +1,36 @@ +/* + * Copyright © 2024-2026 Apple Inc. and the Pkl project authors. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +plugins { + id("pklAllProjects") + id("pklScalaLibrary") + id("pklPublishLibrary") +} + +dependencies { + implementation(projects.pklConfigJava) + api(libs.scalaReflect) +} + +publishing { + publications { + named("library") { + pom { + url.set("https://github.com/apple/pkl/tree/main/pkl-config-scala") + description.set("Scala config library based on the Pkl config language.") + } + } + } +} diff --git a/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/CachedConverterFactories.scala b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/CachedConverterFactories.scala new file mode 100644 index 000000000..48f3ea74c --- /dev/null +++ b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/CachedConverterFactories.scala @@ -0,0 +1,161 @@ +/* + * Copyright © 2025-2026 Apple Inc. and the Pkl project authors. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.pkl.config.scala.mapper + +import org.pkl.config.java.mapper.{Converter, ConverterFactory, Reflection, ValueMapper} +import org.pkl.config.scala.mapper.JavaReflectionSyntaxExtensions.* +import org.pkl.core.PClassInfo + +import java.lang.reflect.Type +import java.util.Optional +import scala.jdk.OptionConverters.RichOption +import scala.reflect.ClassTag + +/** + * Provides infrastructure that helps define custom converter factories in a somewhat concise way at + * the same time utilizing caching. + */ +private[mapper] object CachedConverterFactories { + + /** + * Function used in converters that essentially does a conversion logic. + * + * @tparam S + * source type + * @tparam C + * cache. represented by `CachedSourceTypeInfo` for single-param generic types and + * `(CachedSourceTypeInfo, CachedSourceTypeInfo)` for two-param types. + * @tparam T + * target type + */ + private type ConversionFunction[S, C, T] = (S, C, ValueMapper) => T + + /** + * A converter for single-parameter types, caching conversion functions. + * + * @param conv + * A function that defines the conversion logic using the cached `CachedSourceTypeInfo`. + */ + private final class Converter1[S, T]( + conv: ConversionFunction[S, CachedSourceTypeInfo, T] + ) extends Converter[S, T] { + private val s1 = new CachedSourceTypeInfo() + override def convert(value: S, valueMapper: ValueMapper): T = { + conv.apply(value, s1, valueMapper) + } + } + + /** + * A converter for two-parameter types (e.g., Tuple2 or Map), caching conversion functions. + * + * @param conv + * A function that defines the conversion logic using two instances of `CachedSourceTypeInfo`. + */ + private final class Converter2[S, T]( + conv: ConversionFunction[ + S, + (CachedSourceTypeInfo, CachedSourceTypeInfo), + T + ] + ) extends Converter[S, T] { + private val s1 = new CachedSourceTypeInfo() + private val s2 = new CachedSourceTypeInfo() + override def convert(value: S, valueMapper: ValueMapper): T = { + conv.apply(value, (s1, s2), valueMapper) + } + } + + /** + * A factory for creating converters based on parameterized types, supporting generic conversion. + * + * @param acceptSourceType + * Predicate to determine if the source type is acceptable. + * @param extractTypeParams + * Function to extract type parameters from the `ParameterizedType`. + * @param newConverter + * Function to create a new converter based on extracted type parameters. + */ + private final class ParametrizinglyTypedConverterFactory[T: ClassTag, TT]( + acceptSourceType: PClassInfo[?] => Boolean, + extractTypeParams: Type => Option[TT], + newConverter: TT => Converter[?, ?] + ) extends ConverterFactory { + private val targetClassTag: ClassTag[T] = implicitly + + override def create( + sourceType: PClassInfo[?], + targetType: Type + ): Optional[Converter[?, ?]] = { + if (acceptSourceType(sourceType)) { + val targetClass = Reflection.toRawType(targetType) + if (targetClassTag.runtimeClass.isAssignableFrom(targetClass)) { + val typeParams = extractTypeParams( + Reflection.getExactSupertype(targetType, targetClass) + ) + typeParams.map(newConverter).toJava + } else { + Optional.empty() + } + } else { + Optional.empty() + } + } + } + + /** + * Factory method for single-parameter types such as `List` or `Option`, using cached conversion. + * + * @param acceptSourceType + * Predicate to determine if the source type is acceptable. + * @param conv + * Conversion function applied to the value and cache. + */ + def forParametrizedType1[S, T: ClassTag]( + acceptSourceType: PClassInfo[?] => Boolean, + conv: Type => ConversionFunction[ + S, + CachedSourceTypeInfo, + T + ] + ): ConverterFactory = new ParametrizinglyTypedConverterFactory[T, Type]( + acceptSourceType, + _.params1, + t1 => new Converter1(conv(t1)) + ) + + /** + * Factory method for two-parameter types such as `Map` or `Tuple2`, using cached conversion. + * + * @param acceptSourceType + * Predicate to determine if the source type is acceptable. + * @param conv + * Conversion function applied to the value and cache. + */ + def forParametrizedType2[S, T: ClassTag]( + acceptSourceType: PClassInfo[?] => Boolean, + conv: (Type, Type) => ConversionFunction[ + S, + (CachedSourceTypeInfo, CachedSourceTypeInfo), + T + ] + ): ConverterFactory = { + new ParametrizinglyTypedConverterFactory[T, (Type, Type)]( + acceptSourceType, + _.params2, + { case (t1, t2) => new Converter2(conv(t1, t2)) } + ) + } +} diff --git a/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/CachedSourceTypeInfo.scala b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/CachedSourceTypeInfo.scala new file mode 100644 index 000000000..358da45ea --- /dev/null +++ b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/CachedSourceTypeInfo.scala @@ -0,0 +1,71 @@ +/* + * Copyright © 2025-2026 Apple Inc. and the Pkl project authors. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.pkl.config.scala.mapper + +import org.pkl.config.java.mapper.{Converter, ValueMapper} +import org.pkl.core.PClassInfo + +import java.lang.reflect.Type +import java.util.concurrent.atomic.AtomicReference + +/** + * Manages cached type information and retrieves converters dynamically based on the type of input. + * + * `CachedSourceTypeInfo` encapsulates the source type information (`classInfo`) and a reusable + * converter, optimizing conversions by caching both type details and converters. This caching + * approach is particularly useful in repeated conversions where source type remains consistent. + * + * Thread-safe: `ValueMapper` instances (and therefore this cache) can be shared across threads. The + * paired `classInfo` and `converter` fields are kept coherent via a single `AtomicReference` — they + * always update together or not at all. + */ +private[mapper] class CachedSourceTypeInfo { + import CachedSourceTypeInfo.* + + private val ref: AtomicReference[Entry] = new AtomicReference[Entry]() + + /** + * Updates the cached `classInfo` and retrieves a converter if the type of `v` differs from the + * cached `classInfo`. If the types match, the cached converter is reused. + * + * Under contention, multiple threads may each compute a fresh converter for the same input type — + * this wastes a lookup but never produces an inconsistent cache, because `classInfo` and + * `converter` are stored as a single `Entry` object. + */ + def updateAndGet(v: Any, t: Type, vm: ValueMapper): Any = { + val current = ref.get() + val converter: Converter[Any, Any] = { + if (current != null && current.classInfo.isExactClassOf(v)) { + current.converter + } else { + val newClassInfo = PClassInfo.forValue(v).asInstanceOf[PClassInfo[Any]] + val newConverter = vm + .getConverter(newClassInfo, t) + .asInstanceOf[Converter[Any, Any]] + ref.set(Entry(newClassInfo, newConverter)) + newConverter + } + } + converter.convert(v, vm) + } +} + +private[mapper] object CachedSourceTypeInfo { + private final case class Entry( + classInfo: PClassInfo[Any], + converter: Converter[Any, Any] + ) +} diff --git a/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ConstructorParamResolver.scala b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ConstructorParamResolver.scala new file mode 100644 index 000000000..8ee6ddca2 --- /dev/null +++ b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ConstructorParamResolver.scala @@ -0,0 +1,117 @@ +/* + * Copyright © 2025-2026 Apple Inc. and the Pkl project authors. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.pkl.config.scala.mapper + +import org.pkl.config.java.mapper.Reflection + +import java.lang.reflect.{Constructor, Type as JType} +import scala.collection.concurrent.TrieMap + +/** + * Resolves a case class's primary-constructor parameters into [[Param]]s. + * + * Uses Java reflection for names + generic-aware erased types, and `scala.reflect.runtime.universe` + * to recover path-dependent `Enumeration` information that Java reflection erases. + * + * The expensive part — scala-reflect mirror work — is cached per `Class`. Java-reflection type + * resolution is cheap and varies with `targetType` (generic bindings), so it is recomputed per + * call. + */ +private[mapper] object ConstructorParamResolver { + import scala.reflect.runtime.universe as ru + + private val mirror: ru.Mirror = ru.runtimeMirror(getClass.getClassLoader) + private val enumValueType: ru.Type = ru.typeOf[scala.Enumeration#Value] + + private val enumCache = TrieMap.empty[Class[?], Map[Int, Enumeration]] + + /** + * Resolves the primary-constructor parameters of `ctor`. Returns `None` if the constructor has no + * parameters, or any parameter name is unavailable at runtime. + */ + def resolve(ctor: Constructor[?], targetType: JType): Option[Seq[Param]] = { + val javaParams = ctor.getParameters + if (javaParams.isEmpty || javaParams.exists(!_.isNamePresent)) None + else { + val clazz = ctor.getDeclaringClass + val exactTypes = Reflection.getExactParameterTypes(ctor, targetType) + val enumInfo = enumInfoFor(clazz) + val params = javaParams.iterator.zipWithIndex.map { case (p, i) => + val jvmType = exactTypes(i) + val tpe: Param.Type = enumInfo.get(i) match { + case Some(enumeration) => + Param.Type.ScalaEnum(jvmType, enumeration.values.toList) + case None => Param.Type.Jvm(jvmType) + } + Param(i, p.getName, tpe) + }.toVector + Some(params) + } + } + + private def enumInfoFor(clazz: Class[?]): Map[Int, Enumeration] = { + enumCache.getOrElseUpdate(clazz, computeEnumInfo(clazz)) + } + + private def computeEnumInfo(clazz: Class[?]): Map[Int, Enumeration] = { + ru.synchronized { + try { + val tpe = mirror.classSymbol(clazz).toType + val ctorSym = tpe.decl(ru.termNames.CONSTRUCTOR) + if (ctorSym == ru.NoSymbol || !ctorSym.isMethod) Map.empty + else { + ctorSym.asMethod.paramLists.headOption + .map( + _.iterator.zipWithIndex + .flatMap { case (sym, idx) => + resolveEnumeration(sym.typeSignature).map(idx -> _) + } + .toMap + ) + .getOrElse(Map.empty) + } + } catch { + case _: Throwable => Map.empty + } + } + } + + private def resolveEnumeration(t: ru.Type): Option[Enumeration] = { + val dealiased = t.dealias + if (!(dealiased <:< enumValueType)) None + else { + dealiased match { + case ru.TypeRef(pre, _, _) => extractModule(pre) + case _ => None + } + } + } + + private def extractModule(pre: ru.Type): Option[Enumeration] = { + val sym = pre.termSymbol + if (sym == ru.NoSymbol || !sym.isModule) None + else { + try { + mirror.reflectModule(sym.asModule).instance match { + case e: Enumeration => Some(e) + case _ => None + } + } catch { + case _: Throwable => None + } + } + } +} diff --git a/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/JavaReflectionSyntaxExtensions.scala b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/JavaReflectionSyntaxExtensions.scala new file mode 100644 index 000000000..da1f9fa63 --- /dev/null +++ b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/JavaReflectionSyntaxExtensions.scala @@ -0,0 +1,80 @@ +/* + * Copyright © 2025-2026 Apple Inc. and the Pkl project authors. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.pkl.config.scala.mapper + +import org.pkl.config.java.mapper.Reflection + +import java.lang.reflect.{GenericArrayType, ParameterizedType, Type} + +/** + * Provides aims to provide type-safe syntax extension to Java Reflection classes. + */ +private[mapper] object JavaReflectionSyntaxExtensions { + + /** + * `ParameterizedType` syntax extension. + */ + implicit class ParametrizedTypeSyntaxExtension(val x: Type) extends AnyVal { + + /** + * Retrieves the first type parameter of a `ParameterizedType`. + * + * @return + * The first `Type` parameter. + * + * @example + * Usage: + * {{{ + * val parameterizedType: ParameterizedType = // obtain a ParameterizedType instance + * val firstParamType = parameterizedType.params1 + * }}} + */ + def params1: Option[Type] = { + val tpe = x match { + case x: ParameterizedType => Some(x.getActualTypeArguments.apply(0)) + case x: GenericArrayType => Some(x.getGenericComponentType) + case x: Class[?] if x.isArray => Some(x.componentType()) + case _ => None + } + + tpe map Reflection.normalize + } + + /** + * Retrieves the first two type parameters of a `ParameterizedType`. + * + * @return + * A tuple containing the first and second `Type` parameters. + * + * @example + * Usage: + * {{{ + * val parameterizedType: ParameterizedType = // obtain a ParameterizedType instance + * val (firstParamType, secondParamType) = parameterizedType.params2 + * }}} + */ + def params2: Option[(Type, Type)] = x match { + case x: ParameterizedType => + Some( + ( + Reflection.normalize(x.getActualTypeArguments.apply(0)), + Reflection.normalize(x.getActualTypeArguments.apply(1)) + ) + ) + case _ => None + } + } +} diff --git a/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/Param.scala b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/Param.scala new file mode 100644 index 000000000..8ed438fbb --- /dev/null +++ b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/Param.scala @@ -0,0 +1,53 @@ +/* + * Copyright © 2026 Apple Inc. and the Pkl project authors. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.pkl.config.scala.mapper + +import java.lang.reflect.Type as JType + +/** A resolved case class ctor parameter. Pure data — no converter, no cache, no runtime state. */ +private[mapper] final case class Param(index: Int, name: String, tpe: Param.Type) + +private[mapper] object Param { + + /** + * Describes a case class primary-constructor parameter's type. + * + * `Param.Type` makes the enum-ness of a param explicit in the type system, rather than encoding + * it as an optional aspect of the `Param` descriptor. Everything reachable through `Param.Type` + * is plain data, so a `Param` remains `println`-friendly. + */ + private[mapper] sealed trait Type { + + def jvmType: JType + + } + private[mapper] object Type { + + /** + * A param whose type carries no Scala-specific structure beyond what Java reflection exposes. + */ + private[mapper] final case class Jvm(jvmType: JType) extends Type + + /** + * A param whose declared Scala type is a subtype of some `Enumeration#Value`. Carries the full + * list of members of the originating `Enumeration`, recovered via Scala runtime reflection. + */ + private[mapper] final case class ScalaEnum( + jvmType: JType, + members: List[Enumeration#Value] + ) extends Type + } +} diff --git a/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ScalaConversions.scala b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ScalaConversions.scala new file mode 100644 index 000000000..05c85cef5 --- /dev/null +++ b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ScalaConversions.scala @@ -0,0 +1,85 @@ +/* + * Copyright © 2025-2026 Apple Inc. and the Pkl project authors. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.pkl.config.scala.mapper + +import org.pkl.config.java.mapper.Conversion +import org.pkl.core.{PClassInfo, Duration as PDuration} + +import java.time.Instant +import java.util.concurrent.TimeUnit +import java.util.regex.Pattern +import scala.concurrent.duration.{Duration, FiniteDuration} +import scala.util.matching.Regex + +/** + * Provides conversions between Java types backing PKL and Scala types, enabling seamless + * interoperability for configuration values within PKL. + */ +object ScalaConversions { + + val pStringToInstant: Conversion[String, Instant] = { + Conversion.of( + PClassInfo.String, + classOf[Instant], + (v: String, _) => Instant.parse(v) + ) + } + + val pIntToInstant: Conversion[java.lang.Long, Instant] = { + Conversion.of( + PClassInfo.Int, + classOf[Instant], + (v: java.lang.Long, _) => Instant.ofEpochMilli(v) + ) + } + + val pDurationToDuration: Conversion[PDuration, Duration] = { + Conversion.of( + PClassInfo.Duration, + classOf[Duration], + (v: PDuration, _) => Duration.fromNanos(v.inNanos()).toCoarsest + ) + } + + val pDurationToFiniteDuration: Conversion[PDuration, FiniteDuration] = { + Conversion.of( + PClassInfo.Duration, + classOf[FiniteDuration], + (v: PDuration, _) => FiniteDuration(v.inWholeNanos(), TimeUnit.NANOSECONDS).toCoarsest + ) + } + + val pStringToScalaRegex: Conversion[String, Regex] = { + Conversion.of(PClassInfo.String, classOf[Regex], (v: String, _) => v.r) + } + + val pRegexToScalaRegex: Conversion[Pattern, Regex] = { + Conversion.of( + PClassInfo.Regex, + classOf[Regex], + (v: Pattern, _) => v.pattern().r + ) + } + + def all: List[Conversion[?, ?]] = List( + pIntToInstant, + pStringToInstant, + pDurationToFiniteDuration, + pDurationToDuration, + pStringToScalaRegex, + pRegexToScalaRegex + ) +} diff --git a/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ScalaConverterFactories.scala b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ScalaConverterFactories.scala new file mode 100644 index 000000000..89af4ca63 --- /dev/null +++ b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ScalaConverterFactories.scala @@ -0,0 +1,109 @@ +/* + * Copyright © 2025-2026 Apple Inc. and the Pkl project authors. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.pkl.config.scala.mapper + +import org.pkl.config.java.mapper.{ConverterFactory, ValueMapper} +import org.pkl.core.{PClassInfo, PNull, Pair} + +import java.lang.reflect.Type +import java.util.Optional +import scala.collection.immutable +import scala.jdk.CollectionConverters.* +import scala.jdk.OptionConverters.* +import scala.language.implicitConversions + +/** + * Default set of PKL → Scala converter factories. + * + * The factories here mirror the types that Pkl codegen produces in every language; users who + * hand-author Scala classes with other collection shapes (Set, Vector, mutable collections, etc.) + * can register their own converter factories. + */ +object ScalaConverterFactories { + + private type Conv1[S, T] = Type => (S, CachedSourceTypeInfo, ValueMapper) => T + + private type Conv2[S, T] = (Type, Type) => ( + S, + (CachedSourceTypeInfo, CachedSourceTypeInfo), + ValueMapper + ) => T + + val pObjectToCaseClass: ConverterFactory = ScalaPObjectToCaseClass + + val pAnyToOption: ConverterFactory = { + CachedConverterFactories.forParametrizedType1[Any, Option[?]]( + _ => true, + t1 => { (value, s1, vm) => + { + value match { + case _: PNull | null => None + case v: Option[_] => v.map(s1.updateAndGet(_, t1, vm)) + case v: Optional[_] => v.toScala.map(s1.updateAndGet(_, t1, vm)) + case v => Option(s1.updateAndGet(v, t1, vm)) + } + } + } + ) + } + + val pPairToTuple: ConverterFactory = { + CachedConverterFactories.forParametrizedType2[Pair[?, ?], (?, ?)]( + PClassInfo.Pair, + (t1, t2) => { (value, cc, vm) => + { + val (s1, s2) = cc + val p1 = s1.updateAndGet(value.getFirst, t1, vm) + val p2 = s2.updateAndGet(value.getSecond, t2, vm) + (p1, p2) + } + } + ) + } + + val pMapToImmutableMap: ConverterFactory = CachedConverterFactories + .forParametrizedType2[java.util.Map[?, ?], immutable.Map[?, ?]]( + PClassInfo.Map, + (t1, t2) => { (value, cc, vm) => + { + val (s1, s2) = cc + value.asScala.map { case (k, v) => + (s1.updateAndGet(k, t1, vm), s2.updateAndGet(v, t2, vm)) + }.toMap + } + } + ) + + val pCollectionToImmutableSeq: ConverterFactory = CachedConverterFactories + .forParametrizedType1[java.util.Collection[?], immutable.Seq[?]]( + x => x == PClassInfo.Collection || x == PClassInfo.Set || x == PClassInfo.List, + t1 => (value, cache, vm) => value.asScala.iterator.map(cache.updateAndGet(_, t1, vm)).toSeq + ) + + // Do not shuffle converter factories within this list. Order matters. + // As a general rule, try to keep more generic types lower and more specific higher + val all: List[ConverterFactory] = List( + pAnyToOption, + pPairToTuple, + pMapToImmutableMap, + pCollectionToImmutableSeq, + pObjectToCaseClass + ) + + private implicit def pClassInfoToPredicate( + x: PClassInfo[?] + ): PClassInfo[?] => Boolean = _ == x +} diff --git a/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ScalaPObjectToCaseClass.scala b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ScalaPObjectToCaseClass.scala new file mode 100644 index 000000000..c93653f01 --- /dev/null +++ b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ScalaPObjectToCaseClass.scala @@ -0,0 +1,153 @@ +/* + * Copyright © 2025-2026 Apple Inc. and the Pkl project authors. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.pkl.config.scala.mapper + +import org.pkl.config.java.mapper.{ + ConversionException, + Converter, + ConverterFactory, + Reflection, + ValueMapper +} +import org.pkl.core.util.CodeGeneratorUtils +import org.pkl.core.{Composite, PClassInfo, PObject} + +import java.lang.invoke.{MethodHandle, MethodHandles} +import java.lang.reflect.Type as JType +import java.util.Optional +import scala.collection.concurrent.TrieMap +import scala.jdk.OptionConverters._ + +/** + * Scala-aware replacement for `org.pkl.config.java.mapper.PObjectToDataObject`. + * + * Accepts only case classes. Uses [[ConstructorParamResolver]] to describe each ctor parameter — + * including path-dependent `Enumeration#Value` information that Java reflection erases. Enum + * parameters are converted directly to `Enumeration#Value` without going through the generic + * `ValueMapper.getConverter` lookup (which can't see past the erased `scala.Enumeration$Value`). + */ +private[mapper] object ScalaPObjectToCaseClass extends ConverterFactory { + private val lookup = MethodHandles.lookup() + + override def create( + sourceType: PClassInfo[_], + targetType: JType + ): Optional[Converter[_, _]] = { + if (sourceType != PClassInfo.Module && sourceType.getJavaClass != classOf[PObject]) { + Optional.empty() + } else { + val rawClass = Reflection.toRawType(targetType) + if (!classOf[scala.Product].isAssignableFrom(rawClass)) Optional.empty() + else { + val result: Option[Converter[_, _]] = for { + ctor <- rawClass.getDeclaredConstructors.headOption + params <- ConstructorParamResolver.resolve(ctor, targetType) + } yield { + val handle = { + try lookup.unreflectConstructor(ctor) + catch { + case e: IllegalAccessException => + throw new ConversionException(s"Error accessing constructor `$ctor`.", e) + } + } + new ScalaCaseClassConverter(targetType, handle, params) + } + result.toJava + } + } + } + + /** + * Matches a Pkl `String` against an `Enumeration`'s members, tolerating the + * `CodeGeneratorUtils.toEnumConstantName` transformation that Pkl codegen applies. Throws + * `ConversionException` with the full candidate list if no member matches. + */ + private def matchEnumMember( + value: String, + members: List[Enumeration#Value] + ): Enumeration#Value = members + .find { v => + val n = v.toString + n == value || CodeGeneratorUtils.toEnumConstantName(n) == value + } + .getOrElse( + throw new ConversionException( + s"Cannot convert String `$value` to Enumeration value. " + + s"Expected one of: ${members.map(_.toString).mkString(", ")}." + ) + ) + + private final class ScalaCaseClassConverter( + targetType: JType, + ctorHandle: MethodHandle, + parameters: Seq[Param] + ) extends Converter[Composite, AnyRef] { + + private val perParamCache: TrieMap[String, CachedSourceTypeInfo] = TrieMap.empty + + override def convert(value: Composite, vm: ValueMapper): AnyRef = { + val args = parameters.map { convertParam(_, value, vm) } + try ctorHandle.invokeWithArguments(args: _*).asInstanceOf[AnyRef] + catch { + case t: Throwable => + throw new ConversionException(s"Error invoking constructor `$ctorHandle`.", t) + } + } + + private def convertParam(p: Param, value: Composite, vm: ValueMapper): Object = { + val properties = value.getProperties + val property = Option(properties.get(p.name)).getOrElse { + throw new ConversionException( + "Cannot convert Pkl object to Java object." + + s"\nPkl type : ${value.getClassInfo}" + + s"\nJava type : ${targetType.getTypeName}" + + s"\nMissing Pkl property : ${p.name}" + + s"\nActual Pkl properties: ${properties.keySet}" + ) + } + try { + p.tpe match { + case Param.Type.Jvm(jvmType) => + perParamCache + .getOrElseUpdate(p.name, new CachedSourceTypeInfo()) + .updateAndGet(property, jvmType, vm) + .asInstanceOf[Object] + case Param.Type.ScalaEnum(_, members) => + // No per-param cache here: unlike the Param.Type.Jvm branch (which memoizes + // the expensive `vm.getConverter` dispatch), enum lookup is a direct scan + // over the already-resolved member list — there's no upstream call to cache. + property match { + case s: String => matchEnumMember(s, members).asInstanceOf[Object] + case _ => + throw new ConversionException( + s"Expected String value for Enumeration property `${p.name}`, " + + s"got `${property.getClass.getName}`." + ) + } + } + } catch { + case e: ConversionException => + throw new ConversionException( + s"Error converting property `${p.name}` in Pkl object of type " + + s"`${value.getClassInfo}` to equally named constructor parameter in " + + s"Java class `${Reflection.toRawType(targetType).getTypeName}`: " + + e.getMessage, + e.getCause + ) + } + } + } +} diff --git a/pkl-config-scala/src/main/scala/org/pkl/config/scala/syntax/package.scala b/pkl-config-scala/src/main/scala/org/pkl/config/scala/syntax/package.scala new file mode 100644 index 000000000..36012d1ed --- /dev/null +++ b/pkl-config-scala/src/main/scala/org/pkl/config/scala/syntax/package.scala @@ -0,0 +1,123 @@ +/* + * Copyright © 2025-2026 Apple Inc. and the Pkl project authors. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.pkl.config.scala + +import org.pkl.config.java.mapper.ValueMapperBuilder +import org.pkl.config.java.{Config, ConfigEvaluator, ConfigEvaluatorBuilder} +import org.pkl.config.scala.mapper.{ScalaConversions, ScalaConverterFactories} + +import scala.jdk.CollectionConverters._ +import scala.reflect.ClassTag + +/** + * Entry point for Scala-specific extensions to PKL configuration, enabling type conversions and + * syntax improvements that align PKL's configuration model with Scala types and structures. + * + * The `syntax` package object introduces two main extensions: + * + * 1. `forScala`: Enhances the PKL evaluation stack by adding Scala-specific type conversions and + * converter factories, making it possible to work seamlessly with Scala types. + * 2. `Config.to`: Provides a type-safe `Config` conversion method. + */ +package object syntax { + + /** + * Extension for `ValueMapperBuilder`, enabling Scala-specific type conversions and factories. + * + * Adds conversions from `ScalaConversions` and converter factories from `ScalaConverterFactories` + * to the evaluation stack, allowing PKL to handle Scala-native types effectively. + * + * @example + * Using `forScala` with a ValueMapperBuilder: + * {{{ + * val builder = new ValueMapperBuilder().forScala() + * val evaluator = new ConfigEvaluatorBuilder().setValueMapperBuilder(builder).build + * }}} + */ + implicit class ValueMapperBuilderSyntaxExtension(val x: ValueMapperBuilder) extends AnyVal { + def forScala(): ValueMapperBuilder = { + x.setConversions( + (ScalaConversions.all ++ x.getConversions.asScala).asJava + ).setConverterFactories( + (ScalaConverterFactories.all ++ x.getConverterFactories.asScala).asJava + ) + } + } + + /** + * Extension for `ConfigEvaluatorBuilder`, enabling Scala-specific type handling in the evaluator. + * + * This method sets up a `ConfigEvaluatorBuilder` with a `ValueMapperBuilder` that has been + * extended with Scala conversions, enabling the evaluator to process Scala-specific types in PKL + * configurations. + * + * @example + * Using `forScala` with a ConfigEvaluatorBuilder: + * {{{ + * val evaluatorBuilder = new ConfigEvaluatorBuilder().forScala() + * val evaluator = evaluatorBuilder.build + * }}} + */ + implicit class ConfigEvaluatorBuilderSyntaxExtension( + val x: ConfigEvaluatorBuilder + ) extends AnyVal { + def forScala(): ConfigEvaluatorBuilder = { + x.setValueMapperBuilder(x.getValueMapperBuilder.forScala()) + } + } + + /** + * Extension for `ConfigEvaluator`, applying Scala-specific type conversions to the evaluator. + * + * Builds a `ConfigEvaluator` with a Scala-aware `ValueMapper`, allowing for seamless conversion + * of configuration values to Scala types. + * + * @example + * Using `forScala` with a ConfigEvaluator: + * {{{ + * val evaluator = new ConfigEvaluatorBuilder().build.forScala() + * }}} + */ + implicit class ConfigEvaluatorSyntaxExtension(val x: ConfigEvaluator) extends AnyVal { + def forScala(): ConfigEvaluator = { + x.setValueMapper(x.getValueMapper.toBuilder.forScala().build) + } + } + + /** + * Extension for `Config`, adding a type-safe `to` method to retrieve values as Scala types. + * + * The `to[T]` method provides an intuitive way to retrieve values from a PKL `Config` as specific + * Scala types. For nullable PKL values, declare the target as `Option[T]`; otherwise a + * `ConversionException` propagates from the underlying `ValueMapper`. + * + * @param ct + * Implicit `ClassTag` of the target type `T`. + * + * @throws ConversionException + * if the value cannot be converted to the specified type `T`. + * + * @example + * Retrieving a value: + * {{{ + * val myPklConfig: Config = // load or build a PKL config + * val config: MyCaseClass = myPklConfig.to[MyCaseClass] + * }}} + */ + implicit class ConfigSyntaxExtension(val x: Config) extends AnyVal { + def to[T](implicit ct: ClassTag[T]): T = x.as[T](ct.runtimeClass) + } +} diff --git a/pkl-config-scala/src/test/resources/org/pkl/config/scala/mapper/PPairToScalaTuple.pkl b/pkl-config-scala/src/test/resources/org/pkl/config/scala/mapper/PPairToScalaTuple.pkl new file mode 100644 index 000000000..432ea6455 --- /dev/null +++ b/pkl-config-scala/src/test/resources/org/pkl/config/scala/mapper/PPairToScalaTuple.pkl @@ -0,0 +1,7 @@ +class Person { + name: String + age: Int +} + +ex1 = Pair(1, 3.s) +ex2 = Pair(new Person {name = "pigeon"; age = 40}, new Dynamic {name = "parrot"; age = 30}) \ No newline at end of file diff --git a/pkl-config-scala/src/test/scala/org/pkl/config/scala/ScalaObjectMapperSpec.scala b/pkl-config-scala/src/test/scala/org/pkl/config/scala/ScalaObjectMapperSpec.scala new file mode 100644 index 000000000..d5bb35ce4 --- /dev/null +++ b/pkl-config-scala/src/test/scala/org/pkl/config/scala/ScalaObjectMapperSpec.scala @@ -0,0 +1,303 @@ +/* + * Copyright © 2025-2026 Apple Inc. and the Pkl project authors. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.pkl.config.scala + +import com.softwaremill.diffx._ +import com.softwaremill.diffx.scalatest.DiffShouldMatcher._ +import org.pkl.config.java.ConfigEvaluator +import org.pkl.config.java.mapper.ConversionException +import org.pkl.config.scala.syntax._ +import org.pkl.core.{Duration => PDuration, ModuleSource} +import org.scalatest.funsuite.AnyFunSuite + +import java.time.Instant +import java.util.concurrent.TimeUnit +import scala.concurrent.duration.{Duration, FiniteDuration} +import scala.util.matching.Regex + +class ScalaObjectMapperSpec extends AnyFunSuite { + import ScalaObjectMapperSpec._ + + test("evaluate scala types") { + + val code = { + """ + |module ObjectMappingTestContainer + | + |class Foo { + | value: Int + |} + | + |// Options + |optionalVal1: String? = null + |optionalVal2: String? = "some" + | + |// Instant + |instant1 = 0 + |instant2 = "2024-10-31T02:25:26.036Z" + | + |// Seq + |seq = List(9, 5, 36, 1) + | + |// Duration + |pklDuration: Duration = 5.ms + |scalaFiniteDuration: Duration = 5.ms + |scalaDuration: Duration = 5000000.ns + | + |// Maps + |intStringMap: Map = Map(0, "in map") + |booleanIntStringMapMap: Map> = Map(false, Map(0, "in map in map")) + |typedStringMap: Map = Map( + | new Foo { value = 1 }, "using typed objects", + | new Foo { value = 2 }, "also works") + | + |// Mappings + |intStringMapping: Mapping = new { [42] = "in map" } + | + |// Listings → Seq + |intListing: Listing = new { 42 1337 } + | + |simpleEnumViaString = "Bbb" + |simpleEnum2ViaString = "Ccc" + |""".stripMargin + } + + val result = ConfigEvaluator + .preconfigured() + .forScala() + .evaluate(ModuleSource.text(code)) + .to[ObjectMappingTestContainer] + + result shouldMatchTo ObjectMappingTestContainer( + optionalVal1 = None, + optionalVal2 = Some("some"), + pklDuration = PDuration.ofMillis(5), + scalaDuration = Duration(5, TimeUnit.MILLISECONDS), + scalaFiniteDuration = FiniteDuration(5, TimeUnit.MILLISECONDS), + instant1 = Instant.ofEpochMilli(0), + instant2 = Instant.parse("2024-10-31T02:25:26.036Z"), + seq = Seq(9, 5, 36, 1), + intStringMap = Map(0 -> "in map"), + booleanIntStringMapMap = Map(false -> Map(0 -> "in map in map")), + typedStringMap = Map( + TypedKey(1) -> "using typed objects", + TypedKey(2) -> "also works" + ), + intStringMapping = Map(42 -> "in map"), + intListing = Seq(42, 1337), + simpleEnumViaString = SimpleEnum.Bbb, + simpleEnum2ViaString = SimpleEnum2.Ccc + ) + } + + test("unknown enum value raises ConversionException listing valid members") { + val code = { + """ + |module M + |color = "Purple" + |""".stripMargin + } + + val ex = intercept[ConversionException] { + ConfigEvaluator + .preconfigured() + .forScala() + .evaluate(ModuleSource.text(code)) + .to[EnumContainer] + } + val msg = ex.getMessage + assert(msg.contains("Purple"), s"expected input name in message, got: $msg") + assert( + msg.contains("Aaa") && msg.contains("Bbb") && msg.contains("Ccc"), + s"expected candidate members in message, got: $msg" + ) + } + + test("missing required property on case class raises ConversionException") { + val code = { + """ + |module M + |name = "Alice" + |""".stripMargin // 'age' is missing + } + + val ex = intercept[ConversionException] { + ConfigEvaluator + .preconfigured() + .forScala() + .evaluate(ModuleSource.text(code)) + .to[Person] + } + val msg = ex.getMessage + assert(msg.contains("age"), s"expected missing property name in message, got: $msg") + } + + test("type mismatch between Pkl value and case class field raises ConversionException") { + val code = { + """ + |module M + |value = "not an int" + |""".stripMargin + } + + val ex = intercept[ConversionException] { + ConfigEvaluator + .preconfigured() + .forScala() + .evaluate(ModuleSource.text(code)) + .to[IntContainer] + } + val msg = ex.getMessage + assert( + msg.toLowerCase.contains("cannot convert") || + msg.toLowerCase.contains("string") || + msg.toLowerCase.contains("int"), + s"expected type-mismatch hint in message, got: $msg" + ) + } + + test("enum property receiving non-String Pkl value raises ConversionException") { + val code = { + """ + |module M + |color = 42 + |""".stripMargin + } + + val ex = intercept[ConversionException] { + ConfigEvaluator + .preconfigured() + .forScala() + .evaluate(ModuleSource.text(code)) + .to[EnumContainer] + } + val msg = ex.getMessage + assert( + msg.contains("Expected String value"), + s"expected explicit non-String hint in message, got: $msg" + ) + } + + test("pStringToScalaRegex converts Pkl String to Scala Regex") { + val code = { + """ + |module M + |pattern = "^[0-9]+$" + |""".stripMargin + } + + val result = ConfigEvaluator + .preconfigured() + .forScala() + .evaluate(ModuleSource.text(code)) + .to[RegexContainer] + assert(result.pattern.pattern.pattern() == "^[0-9]+$") + } + + test("pRegexToScalaRegex converts Pkl Regex to Scala Regex") { + val code = { + """ + |module M + |pattern: Regex = Regex("^[a-z]+$") + |""".stripMargin + } + + val result = ConfigEvaluator + .preconfigured() + .forScala() + .evaluate(ModuleSource.text(code)) + .to[RegexContainer] + assert(result.pattern.pattern.pattern() == "^[a-z]+$") + } + + test("forScala extension on ConfigEvaluatorBuilder wires Scala converters") { + import org.pkl.config.java.ConfigEvaluatorBuilder + + val code = { + """ + |module M + |name = "via-builder" + |age = 7 + |""".stripMargin + } + + val evaluator = ConfigEvaluatorBuilder.preconfigured().forScala().build() + try { + val result = evaluator.evaluate(ModuleSource.text(code)).to[Person] + assert(result == Person("via-builder", 7)) + } finally { + evaluator.close() + } + } +} + +object ScalaObjectMapperSpec { + + case class TypedKey(value: Int) + object TypedKey { + implicit val diffx: Diff[TypedKey] = Diff.derived[TypedKey] + } + + object SimpleEnum extends Enumeration { + case class V() extends Val(nextId) + + val Aaa = V() + val Bbb = V() + val Ccc = V() + } + + object SimpleEnum2 extends Enumeration { + val Aaa, Bbb, Ccc = Value + } + + case class EnumContainer(color: SimpleEnum2.Value) + case class Person(name: String, age: Int) + case class IntContainer(value: Int) + case class RegexContainer(pattern: Regex) + + case class ObjectMappingTestContainer( + // Options + optionalVal1: Option[String], + optionalVal2: Option[String], + // Duration + pklDuration: PDuration, + scalaFiniteDuration: FiniteDuration, + scalaDuration: Duration, + // Instant + instant1: Instant, + instant2: Instant, + // Seq + seq: Seq[Int], + // Maps + intStringMap: Map[Int, String], + booleanIntStringMapMap: Map[Boolean, Map[Int, String]], + typedStringMap: Map[TypedKey, String], + // Mapping + intStringMapping: Map[Int, String], + // Listing → Seq + intListing: Seq[Int], + // Enums (nested-Val and plain-Value forms) + simpleEnumViaString: SimpleEnum.V, + simpleEnum2ViaString: SimpleEnum2.Value + ) + + object ObjectMappingTestContainer { + implicit def anyDiffx[T]: Diff[T] = Diff.useEquals[T] + implicit val diffx: Diff[ObjectMappingTestContainer] = { + Diff.derived[ObjectMappingTestContainer] + } + } +} diff --git a/pkl-config-scala/src/test/scala/org/pkl/config/scala/mapper/PPairToScalaTupleSpec.scala b/pkl-config-scala/src/test/scala/org/pkl/config/scala/mapper/PPairToScalaTupleSpec.scala new file mode 100644 index 000000000..617ee1f26 --- /dev/null +++ b/pkl-config-scala/src/test/scala/org/pkl/config/scala/mapper/PPairToScalaTupleSpec.scala @@ -0,0 +1,106 @@ +/* + * Copyright © 2025-2026 Apple Inc. and the Pkl project authors. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.pkl.config.scala.mapper + +import org.pkl.config.java.mapper.{Types, ValueMapperBuilder} +import org.pkl.core.{Duration, Evaluator, PClassInfo, PObject} +import org.pkl.core.ModuleSource.modulePath +import org.scalatest.BeforeAndAfterAll +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers._ +import org.pkl.config.scala.syntax._ + +import scala.jdk.CollectionConverters._ + +class PPairToScalaTupleSpec extends AnyFunSuite with BeforeAndAfterAll { + import PPairToScalaTupleSpec._ + + private val evaluator = Evaluator.preconfigured() + + private val module = { + evaluator.evaluate( + modulePath("org/pkl/config/scala/mapper/PPairToScalaTuple.pkl") + ) + } + + private val mapper = ValueMapperBuilder.preconfigured().forScala().build() + + override def afterAll(): Unit = { + evaluator.close() + } + + test("Pair or scalar values") { + val ex1 = module.getProperty("ex1") + val mapped: (Int, Duration) = { + mapper.map( + ex1, + Types.parameterizedType( + classOf[Tuple2[_, _]], + classOf[Integer], + classOf[Duration] + ) + ) + } + + mapped shouldBe (1, Duration.ofSeconds(3)) + } + + test("Pair of PObject") { + val ex2 = module.getProperty("ex2") + val mapped: (PObject, PObject) = { + mapper.map( + ex2, + Types.parameterizedType( + classOf[Tuple2[_, _]], + classOf[PObject], + classOf[PObject] + ) + ) + } + + mapped._1.getProperties.asScala should contain only ( + "name" -> "pigeon", + "age" -> 40L + ) + + mapped._2.getProperties.asScala should contain only ( + "name" -> "parrot", + "age" -> 30L + ) + } + + test("Pair of case class") { + val ex2 = module.getProperty("ex2") + val mapped: (Animal, Animal) = { + mapper.map( + ex2, + Types.parameterizedType( + classOf[Tuple2[_, _]], + classOf[Animal], + classOf[Animal] + ) + ) + } + + mapped._1 shouldBe Animal("pigeon", 40L) + mapped._2 shouldBe Animal("parrot", 30L) + } +} + +object PPairToScalaTupleSpec { + + case class Animal(name: String, age: Long) +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 9028896ee..47bf29ba9 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -39,6 +39,8 @@ include("pkl-config-java") include("pkl-config-kotlin") +include("pkl-config-scala") + include("pkl-core") include("pkl-doc") From 5916ca9334676f71ec404d6b4aa2ef79858e7ba7 Mon Sep 17 00:00:00 2001 From: Andriy Onyshchuk Date: Thu, 28 May 2026 08:37:46 -0700 Subject: [PATCH 02/14] Remove Scala 2 Enumeration support Drops the scala-reflect dependency and the bespoke ConstructorParamResolver / Param / ScalaPObjectToCaseClass plumbing that existed only to recover path-dependent Enumeration#Value types past Java reflection erasure. Reverts pObjectToCaseClass to the inline PObjectToDataObject anonymous subclass shape used by pkl-config-kotlin. Phase-1 scope; Scala 3 enum support follows in a later commit on this branch. --- gradle/libs.versions.toml | 1 - pkl-config-scala/pkl-config-scala.gradle.kts | 5 +- .../mapper/ConstructorParamResolver.scala | 117 -------------- .../org/pkl/config/scala/mapper/Param.scala | 53 ------ .../mapper/ScalaConverterFactories.scala | 16 +- .../mapper/ScalaPObjectToCaseClass.scala | 153 ------------------ .../config/scala/ScalaObjectMapperSpec.scala | 70 +------- 7 files changed, 16 insertions(+), 399 deletions(-) delete mode 100644 pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ConstructorParamResolver.scala delete mode 100644 pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/Param.scala delete mode 100644 pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ScalaPObjectToCaseClass.scala diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d0b74f6fb..1813c59e8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -120,7 +120,6 @@ nullawayPlugin = { group = "net.ltgt.gradle", name = "gradle-nullaway-plugin", v paguro = { group = "org.organicdesign", name = "Paguro", version.ref = "paguro" } pklConfigJavaAll025 = { group = "org.pkl-lang", name = "pkl-config-java-all", version = "0.25.0" } scalaLibrary = { group = "org.scala-lang", name = "scala-library", version.ref = "scala" } -scalaReflect = { group = "org.scala-lang", name = "scala-reflect", version.ref = "scala" } scalaTest = { group = "org.scalatest", name = "scalatest_2.13", version.ref = "scalaTest" } scalaTestPlusJunit = { group = "org.scalatestplus", name = "junit-5-12_2.13", version.ref = "scalaTestPlusJunit" } shadowPlugin = { group = "com.gradleup.shadow", name = "com.gradleup.shadow.gradle.plugin", version.ref = "shadowPlugin" } diff --git a/pkl-config-scala/pkl-config-scala.gradle.kts b/pkl-config-scala/pkl-config-scala.gradle.kts index 0a0094ff5..8698925a0 100644 --- a/pkl-config-scala/pkl-config-scala.gradle.kts +++ b/pkl-config-scala/pkl-config-scala.gradle.kts @@ -19,10 +19,7 @@ plugins { id("pklPublishLibrary") } -dependencies { - implementation(projects.pklConfigJava) - api(libs.scalaReflect) -} +dependencies { implementation(projects.pklConfigJava) } publishing { publications { diff --git a/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ConstructorParamResolver.scala b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ConstructorParamResolver.scala deleted file mode 100644 index 8ee6ddca2..000000000 --- a/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ConstructorParamResolver.scala +++ /dev/null @@ -1,117 +0,0 @@ -/* - * Copyright © 2025-2026 Apple Inc. and the Pkl project authors. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.pkl.config.scala.mapper - -import org.pkl.config.java.mapper.Reflection - -import java.lang.reflect.{Constructor, Type as JType} -import scala.collection.concurrent.TrieMap - -/** - * Resolves a case class's primary-constructor parameters into [[Param]]s. - * - * Uses Java reflection for names + generic-aware erased types, and `scala.reflect.runtime.universe` - * to recover path-dependent `Enumeration` information that Java reflection erases. - * - * The expensive part — scala-reflect mirror work — is cached per `Class`. Java-reflection type - * resolution is cheap and varies with `targetType` (generic bindings), so it is recomputed per - * call. - */ -private[mapper] object ConstructorParamResolver { - import scala.reflect.runtime.universe as ru - - private val mirror: ru.Mirror = ru.runtimeMirror(getClass.getClassLoader) - private val enumValueType: ru.Type = ru.typeOf[scala.Enumeration#Value] - - private val enumCache = TrieMap.empty[Class[?], Map[Int, Enumeration]] - - /** - * Resolves the primary-constructor parameters of `ctor`. Returns `None` if the constructor has no - * parameters, or any parameter name is unavailable at runtime. - */ - def resolve(ctor: Constructor[?], targetType: JType): Option[Seq[Param]] = { - val javaParams = ctor.getParameters - if (javaParams.isEmpty || javaParams.exists(!_.isNamePresent)) None - else { - val clazz = ctor.getDeclaringClass - val exactTypes = Reflection.getExactParameterTypes(ctor, targetType) - val enumInfo = enumInfoFor(clazz) - val params = javaParams.iterator.zipWithIndex.map { case (p, i) => - val jvmType = exactTypes(i) - val tpe: Param.Type = enumInfo.get(i) match { - case Some(enumeration) => - Param.Type.ScalaEnum(jvmType, enumeration.values.toList) - case None => Param.Type.Jvm(jvmType) - } - Param(i, p.getName, tpe) - }.toVector - Some(params) - } - } - - private def enumInfoFor(clazz: Class[?]): Map[Int, Enumeration] = { - enumCache.getOrElseUpdate(clazz, computeEnumInfo(clazz)) - } - - private def computeEnumInfo(clazz: Class[?]): Map[Int, Enumeration] = { - ru.synchronized { - try { - val tpe = mirror.classSymbol(clazz).toType - val ctorSym = tpe.decl(ru.termNames.CONSTRUCTOR) - if (ctorSym == ru.NoSymbol || !ctorSym.isMethod) Map.empty - else { - ctorSym.asMethod.paramLists.headOption - .map( - _.iterator.zipWithIndex - .flatMap { case (sym, idx) => - resolveEnumeration(sym.typeSignature).map(idx -> _) - } - .toMap - ) - .getOrElse(Map.empty) - } - } catch { - case _: Throwable => Map.empty - } - } - } - - private def resolveEnumeration(t: ru.Type): Option[Enumeration] = { - val dealiased = t.dealias - if (!(dealiased <:< enumValueType)) None - else { - dealiased match { - case ru.TypeRef(pre, _, _) => extractModule(pre) - case _ => None - } - } - } - - private def extractModule(pre: ru.Type): Option[Enumeration] = { - val sym = pre.termSymbol - if (sym == ru.NoSymbol || !sym.isModule) None - else { - try { - mirror.reflectModule(sym.asModule).instance match { - case e: Enumeration => Some(e) - case _ => None - } - } catch { - case _: Throwable => None - } - } - } -} diff --git a/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/Param.scala b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/Param.scala deleted file mode 100644 index 8ed438fbb..000000000 --- a/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/Param.scala +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright © 2026 Apple Inc. and the Pkl project authors. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.pkl.config.scala.mapper - -import java.lang.reflect.Type as JType - -/** A resolved case class ctor parameter. Pure data — no converter, no cache, no runtime state. */ -private[mapper] final case class Param(index: Int, name: String, tpe: Param.Type) - -private[mapper] object Param { - - /** - * Describes a case class primary-constructor parameter's type. - * - * `Param.Type` makes the enum-ness of a param explicit in the type system, rather than encoding - * it as an optional aspect of the `Param` descriptor. Everything reachable through `Param.Type` - * is plain data, so a `Param` remains `println`-friendly. - */ - private[mapper] sealed trait Type { - - def jvmType: JType - - } - private[mapper] object Type { - - /** - * A param whose type carries no Scala-specific structure beyond what Java reflection exposes. - */ - private[mapper] final case class Jvm(jvmType: JType) extends Type - - /** - * A param whose declared Scala type is a subtype of some `Enumeration#Value`. Carries the full - * list of members of the originating `Enumeration`, recovered via Scala runtime reflection. - */ - private[mapper] final case class ScalaEnum( - jvmType: JType, - members: List[Enumeration#Value] - ) extends Type - } -} diff --git a/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ScalaConverterFactories.scala b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ScalaConverterFactories.scala index 89af4ca63..6ce81197e 100644 --- a/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ScalaConverterFactories.scala +++ b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ScalaConverterFactories.scala @@ -15,10 +15,10 @@ */ package org.pkl.config.scala.mapper -import org.pkl.config.java.mapper.{ConverterFactory, ValueMapper} +import org.pkl.config.java.mapper.{ConverterFactory, PObjectToDataObject, ValueMapper} import org.pkl.core.{PClassInfo, PNull, Pair} -import java.lang.reflect.Type +import java.lang.reflect.{Constructor, Type} import java.util.Optional import scala.collection.immutable import scala.jdk.CollectionConverters.* @@ -42,7 +42,17 @@ object ScalaConverterFactories { ValueMapper ) => T - val pObjectToCaseClass: ConverterFactory = ScalaPObjectToCaseClass + val pObjectToCaseClass: ConverterFactory = new PObjectToDataObject { + override def selectConstructor(clazz: Class[?]): Optional[Constructor[?]] = { + clazz.getDeclaredConstructors.headOption + .filter(_ => { + // case classes all implement Product + clazz.getInterfaces + .exists(i => classOf[scala.Product].isAssignableFrom(i)) + }) + .toJava + } + } val pAnyToOption: ConverterFactory = { CachedConverterFactories.forParametrizedType1[Any, Option[?]]( diff --git a/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ScalaPObjectToCaseClass.scala b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ScalaPObjectToCaseClass.scala deleted file mode 100644 index c93653f01..000000000 --- a/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ScalaPObjectToCaseClass.scala +++ /dev/null @@ -1,153 +0,0 @@ -/* - * Copyright © 2025-2026 Apple Inc. and the Pkl project authors. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.pkl.config.scala.mapper - -import org.pkl.config.java.mapper.{ - ConversionException, - Converter, - ConverterFactory, - Reflection, - ValueMapper -} -import org.pkl.core.util.CodeGeneratorUtils -import org.pkl.core.{Composite, PClassInfo, PObject} - -import java.lang.invoke.{MethodHandle, MethodHandles} -import java.lang.reflect.Type as JType -import java.util.Optional -import scala.collection.concurrent.TrieMap -import scala.jdk.OptionConverters._ - -/** - * Scala-aware replacement for `org.pkl.config.java.mapper.PObjectToDataObject`. - * - * Accepts only case classes. Uses [[ConstructorParamResolver]] to describe each ctor parameter — - * including path-dependent `Enumeration#Value` information that Java reflection erases. Enum - * parameters are converted directly to `Enumeration#Value` without going through the generic - * `ValueMapper.getConverter` lookup (which can't see past the erased `scala.Enumeration$Value`). - */ -private[mapper] object ScalaPObjectToCaseClass extends ConverterFactory { - private val lookup = MethodHandles.lookup() - - override def create( - sourceType: PClassInfo[_], - targetType: JType - ): Optional[Converter[_, _]] = { - if (sourceType != PClassInfo.Module && sourceType.getJavaClass != classOf[PObject]) { - Optional.empty() - } else { - val rawClass = Reflection.toRawType(targetType) - if (!classOf[scala.Product].isAssignableFrom(rawClass)) Optional.empty() - else { - val result: Option[Converter[_, _]] = for { - ctor <- rawClass.getDeclaredConstructors.headOption - params <- ConstructorParamResolver.resolve(ctor, targetType) - } yield { - val handle = { - try lookup.unreflectConstructor(ctor) - catch { - case e: IllegalAccessException => - throw new ConversionException(s"Error accessing constructor `$ctor`.", e) - } - } - new ScalaCaseClassConverter(targetType, handle, params) - } - result.toJava - } - } - } - - /** - * Matches a Pkl `String` against an `Enumeration`'s members, tolerating the - * `CodeGeneratorUtils.toEnumConstantName` transformation that Pkl codegen applies. Throws - * `ConversionException` with the full candidate list if no member matches. - */ - private def matchEnumMember( - value: String, - members: List[Enumeration#Value] - ): Enumeration#Value = members - .find { v => - val n = v.toString - n == value || CodeGeneratorUtils.toEnumConstantName(n) == value - } - .getOrElse( - throw new ConversionException( - s"Cannot convert String `$value` to Enumeration value. " + - s"Expected one of: ${members.map(_.toString).mkString(", ")}." - ) - ) - - private final class ScalaCaseClassConverter( - targetType: JType, - ctorHandle: MethodHandle, - parameters: Seq[Param] - ) extends Converter[Composite, AnyRef] { - - private val perParamCache: TrieMap[String, CachedSourceTypeInfo] = TrieMap.empty - - override def convert(value: Composite, vm: ValueMapper): AnyRef = { - val args = parameters.map { convertParam(_, value, vm) } - try ctorHandle.invokeWithArguments(args: _*).asInstanceOf[AnyRef] - catch { - case t: Throwable => - throw new ConversionException(s"Error invoking constructor `$ctorHandle`.", t) - } - } - - private def convertParam(p: Param, value: Composite, vm: ValueMapper): Object = { - val properties = value.getProperties - val property = Option(properties.get(p.name)).getOrElse { - throw new ConversionException( - "Cannot convert Pkl object to Java object." + - s"\nPkl type : ${value.getClassInfo}" + - s"\nJava type : ${targetType.getTypeName}" + - s"\nMissing Pkl property : ${p.name}" + - s"\nActual Pkl properties: ${properties.keySet}" - ) - } - try { - p.tpe match { - case Param.Type.Jvm(jvmType) => - perParamCache - .getOrElseUpdate(p.name, new CachedSourceTypeInfo()) - .updateAndGet(property, jvmType, vm) - .asInstanceOf[Object] - case Param.Type.ScalaEnum(_, members) => - // No per-param cache here: unlike the Param.Type.Jvm branch (which memoizes - // the expensive `vm.getConverter` dispatch), enum lookup is a direct scan - // over the already-resolved member list — there's no upstream call to cache. - property match { - case s: String => matchEnumMember(s, members).asInstanceOf[Object] - case _ => - throw new ConversionException( - s"Expected String value for Enumeration property `${p.name}`, " + - s"got `${property.getClass.getName}`." - ) - } - } - } catch { - case e: ConversionException => - throw new ConversionException( - s"Error converting property `${p.name}` in Pkl object of type " + - s"`${value.getClassInfo}` to equally named constructor parameter in " + - s"Java class `${Reflection.toRawType(targetType).getTypeName}`: " + - e.getMessage, - e.getCause - ) - } - } - } -} diff --git a/pkl-config-scala/src/test/scala/org/pkl/config/scala/ScalaObjectMapperSpec.scala b/pkl-config-scala/src/test/scala/org/pkl/config/scala/ScalaObjectMapperSpec.scala index d5bb35ce4..2d5fefa31 100644 --- a/pkl-config-scala/src/test/scala/org/pkl/config/scala/ScalaObjectMapperSpec.scala +++ b/pkl-config-scala/src/test/scala/org/pkl/config/scala/ScalaObjectMapperSpec.scala @@ -69,9 +69,6 @@ class ScalaObjectMapperSpec extends AnyFunSuite { | |// Listings → Seq |intListing: Listing = new { 42 1337 } - | - |simpleEnumViaString = "Bbb" - |simpleEnum2ViaString = "Ccc" |""".stripMargin } @@ -97,32 +94,7 @@ class ScalaObjectMapperSpec extends AnyFunSuite { TypedKey(2) -> "also works" ), intStringMapping = Map(42 -> "in map"), - intListing = Seq(42, 1337), - simpleEnumViaString = SimpleEnum.Bbb, - simpleEnum2ViaString = SimpleEnum2.Ccc - ) - } - - test("unknown enum value raises ConversionException listing valid members") { - val code = { - """ - |module M - |color = "Purple" - |""".stripMargin - } - - val ex = intercept[ConversionException] { - ConfigEvaluator - .preconfigured() - .forScala() - .evaluate(ModuleSource.text(code)) - .to[EnumContainer] - } - val msg = ex.getMessage - assert(msg.contains("Purple"), s"expected input name in message, got: $msg") - assert( - msg.contains("Aaa") && msg.contains("Bbb") && msg.contains("Ccc"), - s"expected candidate members in message, got: $msg" + intListing = Seq(42, 1337) ) } @@ -169,28 +141,6 @@ class ScalaObjectMapperSpec extends AnyFunSuite { ) } - test("enum property receiving non-String Pkl value raises ConversionException") { - val code = { - """ - |module M - |color = 42 - |""".stripMargin - } - - val ex = intercept[ConversionException] { - ConfigEvaluator - .preconfigured() - .forScala() - .evaluate(ModuleSource.text(code)) - .to[EnumContainer] - } - val msg = ex.getMessage - assert( - msg.contains("Expected String value"), - s"expected explicit non-String hint in message, got: $msg" - ) - } - test("pStringToScalaRegex converts Pkl String to Scala Regex") { val code = { """ @@ -251,19 +201,6 @@ object ScalaObjectMapperSpec { implicit val diffx: Diff[TypedKey] = Diff.derived[TypedKey] } - object SimpleEnum extends Enumeration { - case class V() extends Val(nextId) - - val Aaa = V() - val Bbb = V() - val Ccc = V() - } - - object SimpleEnum2 extends Enumeration { - val Aaa, Bbb, Ccc = Value - } - - case class EnumContainer(color: SimpleEnum2.Value) case class Person(name: String, age: Int) case class IntContainer(value: Int) case class RegexContainer(pattern: Regex) @@ -288,10 +225,7 @@ object ScalaObjectMapperSpec { // Mapping intStringMapping: Map[Int, String], // Listing → Seq - intListing: Seq[Int], - // Enums (nested-Val and plain-Value forms) - simpleEnumViaString: SimpleEnum.V, - simpleEnum2ViaString: SimpleEnum2.Value + intListing: Seq[Int] ) object ObjectMappingTestContainer { From aecbe2bc7270b9bbcff04ab992bc641da8d34e21 Mon Sep 17 00:00:00 2001 From: Andriy Onyshchuk Date: Thu, 28 May 2026 08:38:24 -0700 Subject: [PATCH 03/14] Migrate to Scala 3.3.7; add Scala 3 enum support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build: - Bump scala = "3.3.7" (LTS); switch scala-library, scalatest, scalatestplus-junit, diffx artifacts to their _3 variants - Drop -Xsource:3 (Scala 2 dialect flag); -release:N alone enforces bytecode level on the Scala 3 compiler Source: zero changes from Scala 2.13 source — the prior -Xsource:3 discipline already had the module on the Scala 3 dialect. Adds PStringToScalaEnum factory for idiomatic `enum Foo { case A, B }`, which Class.isEnum doesn't recognize (Scala 3 only sets ACC_ENUM when the user opts in via `extends java.lang.Enum`). Looks up cases via the companion module's synthetic values() method. Defers when Class.isEnum is true so pkl-config-java's stock PStringToEnum handles the Java-compat form, avoiding double-handling. No scala-reflect dependency. --- .../main/kotlin/pklScalaLibrary.gradle.kts | 3 +- gradle/libs.versions.toml | 10 +-- .../scala/mapper/PStringToScalaEnum.scala | 71 +++++++++++++++++++ .../mapper/ScalaConverterFactories.scala | 3 +- .../config/scala/ScalaObjectMapperSpec.scala | 56 ++++++++++++++- 5 files changed, 133 insertions(+), 10 deletions(-) create mode 100644 pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/PStringToScalaEnum.scala diff --git a/build-logic/src/main/kotlin/pklScalaLibrary.gradle.kts b/build-logic/src/main/kotlin/pklScalaLibrary.gradle.kts index a49e16fc4..14674c5ab 100644 --- a/build-logic/src/main/kotlin/pklScalaLibrary.gradle.kts +++ b/build-logic/src/main/kotlin/pklScalaLibrary.gradle.kts @@ -38,8 +38,7 @@ dependencies { scala { scalaVersion = libs.versions.scala } tasks.withType().configureEach { - scalaCompileOptions.additionalParameters = - listOf("-Xsource:3", "-release:${buildInfo.jvmTarget}", "-target:${buildInfo.jvmTarget}") + scalaCompileOptions.additionalParameters = listOf("-release:${buildInfo.jvmTarget}") } tasks.test { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1813c59e8..cf9c25ff1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -60,7 +60,7 @@ nullaway = "0.13.4" nullawayPlugin = "3.0.0" nuValidator = "26.4.16" paguro = "3.10.3" -scala = "2.13.17" +scala = "3.3.7" scalafmt = "3.10.0" scalaTest = "3.2.19" scalaTestPlusJunit = "3.2.19.0" @@ -76,7 +76,7 @@ clikt = { group = "com.github.ajalt.clikt", name = "clikt", version.ref = "clikt cliktMarkdown = { group = "com.github.ajalt.clikt", name = "clikt-markdown", version.ref = "clikt" } commonMark = { group = "org.commonmark", name = "commonmark", version.ref = "commonMark" } commonMarkTables = { group = "org.commonmark", name = "commonmark-ext-gfm-tables", version.ref = "commonMark" } -diffx = { group = "com.softwaremill.diffx", name = "diffx-scalatest-should_2.13", version.ref = "diffx" } +diffx = { group = "com.softwaremill.diffx", name = "diffx-scalatest-should_3", version.ref = "diffx" } downloadTaskPlugin = { group = "de.undercouch", name = "gradle-download-task", version.ref = "downloadTaskPlugin" } #noinspection UnusedVersionCatalogEntry errorProne = { group = "com.google.errorprone", name = "error_prone_core", version.ref = "errorProne" } @@ -119,9 +119,9 @@ nullawayPlugin = { group = "net.ltgt.gradle", name = "gradle-nullaway-plugin", v # to be replaced with https://github.com/usethesource/capsule or https://github.com/lacuna/bifurcan paguro = { group = "org.organicdesign", name = "Paguro", version.ref = "paguro" } pklConfigJavaAll025 = { group = "org.pkl-lang", name = "pkl-config-java-all", version = "0.25.0" } -scalaLibrary = { group = "org.scala-lang", name = "scala-library", version.ref = "scala" } -scalaTest = { group = "org.scalatest", name = "scalatest_2.13", version.ref = "scalaTest" } -scalaTestPlusJunit = { group = "org.scalatestplus", name = "junit-5-12_2.13", version.ref = "scalaTestPlusJunit" } +scalaLibrary = { group = "org.scala-lang", name = "scala3-library_3", version.ref = "scala" } +scalaTest = { group = "org.scalatest", name = "scalatest_3", version.ref = "scalaTest" } +scalaTestPlusJunit = { group = "org.scalatestplus", name = "junit-5-12_3", version.ref = "scalaTestPlusJunit" } shadowPlugin = { group = "com.gradleup.shadow", name = "com.gradleup.shadow.gradle.plugin", version.ref = "shadowPlugin" } slf4jApi = { group = "org.slf4j", name = "slf4j-api", version.ref = "slf4j" } slf4jSimple = { group = "org.slf4j", name = "slf4j-simple", version.ref = "slf4j" } diff --git a/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/PStringToScalaEnum.scala b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/PStringToScalaEnum.scala new file mode 100644 index 000000000..bcaa29f21 --- /dev/null +++ b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/PStringToScalaEnum.scala @@ -0,0 +1,71 @@ +/* + * Copyright © 2026 Apple Inc. and the Pkl project authors. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.pkl.config.scala.mapper + +import org.pkl.config.java.mapper.{ConversionException, Converter, ConverterFactory, Reflection} +import org.pkl.core.PClassInfo +import org.pkl.core.util.CodeGeneratorUtils + +import java.lang.reflect.Type as JType +import java.util.Optional +import scala.jdk.OptionConverters.* +import scala.util.Try + +/** + * Converter from Pkl `String` to Scala 3 `enum` cases. + * + * Scala 3 enum cases implement `scala.reflect.Enum`. When the enum also extends `java.lang.Enum` + * (the opt-in Java-compat form), `Class.isEnum` returns true and `pkl-config-java`'s + * `PStringToEnum` already handles them; this factory defers in that case. For the plain + * `enum Foo { case A, B }` form, `isEnum` is false and this factory looks up the case via the + * companion module's synthetic `values()` method. + */ +private[mapper] object PStringToScalaEnum extends ConverterFactory { + + override def create(sourceType: PClassInfo[?], targetType: JType): Optional[Converter[?, ?]] = { + (sourceType, Reflection.toRawType(targetType)) match { + case (PClassInfo.String, cls) + if !cls.isEnum && classOf[scala.reflect.Enum].isAssignableFrom(cls) => + readEnumValues(cls).map(mkConverter(cls, _)).toJava + case _ => + Optional.empty() + } + } + + private def readEnumValues(cls: Class[?]): Option[Map[String, AnyRef]] = Try { + val moduleCls = Class.forName(cls.getName + "$", false, cls.getClassLoader) + val module = moduleCls.getField("MODULE$").get(null) + val values = moduleCls.getMethod("values").invoke(module).asInstanceOf[Array[?]] + values.iterator.collect { case v: scala.reflect.Enum => + v.productPrefix -> v.asInstanceOf[AnyRef] + }.toMap + }.toOption + + private def mkConverter(cls: Class[?], byName: Map[String, AnyRef]): Converter[String, AnyRef] = { + (value, _) => + { + byName + .get(value) + .orElse(Option(CodeGeneratorUtils.toEnumConstantName(value)).flatMap(byName.get)) + .getOrElse { + throw new ConversionException( + s"Cannot convert String `$value` to Enum value of type `${cls.getTypeName}`. " + + s"Expected one of: ${byName.keys.mkString(", ")}." + ) + } + } + } +} diff --git a/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ScalaConverterFactories.scala b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ScalaConverterFactories.scala index 6ce81197e..ef3fa6ad9 100644 --- a/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ScalaConverterFactories.scala +++ b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ScalaConverterFactories.scala @@ -110,7 +110,8 @@ object ScalaConverterFactories { pPairToTuple, pMapToImmutableMap, pCollectionToImmutableSeq, - pObjectToCaseClass + pObjectToCaseClass, + PStringToScalaEnum ) private implicit def pClassInfoToPredicate( diff --git a/pkl-config-scala/src/test/scala/org/pkl/config/scala/ScalaObjectMapperSpec.scala b/pkl-config-scala/src/test/scala/org/pkl/config/scala/ScalaObjectMapperSpec.scala index 2d5fefa31..6f7dcb9ba 100644 --- a/pkl-config-scala/src/test/scala/org/pkl/config/scala/ScalaObjectMapperSpec.scala +++ b/pkl-config-scala/src/test/scala/org/pkl/config/scala/ScalaObjectMapperSpec.scala @@ -69,6 +69,9 @@ class ScalaObjectMapperSpec extends AnyFunSuite { | |// Listings → Seq |intListing: Listing = new { 42 1337 } + | + |// Scala 3 enum (string-literal union) + |simpleEnumViaString = "Bbb" |""".stripMargin } @@ -94,10 +97,46 @@ class ScalaObjectMapperSpec extends AnyFunSuite { TypedKey(2) -> "also works" ), intStringMapping = Map(42 -> "in map"), - intListing = Seq(42, 1337) + intListing = Seq(42, 1337), + simpleEnumViaString = SimpleEnum.Bbb ) } + test("unknown enum value raises ConversionException listing valid members") { + val code = { + """ + |module M + |color = "Purple" + |""".stripMargin + } + + val ex = intercept[ConversionException] { + ConfigEvaluator + .preconfigured() + .forScala() + .evaluate(ModuleSource.text(code)) + .to[EnumContainer] + } + val msg = ex.getMessage + assert(msg.contains("Purple"), s"expected input name in message, got: $msg") + } + + test("Java-compat Scala 3 enum (extends java.lang.Enum) routes through PStringToEnum") { + val code = { + """ + |module M + |color = "Yyy" + |""".stripMargin + } + + val result = ConfigEvaluator + .preconfigured() + .forScala() + .evaluate(ModuleSource.text(code)) + .to[JavaCompatEnumContainer] + assert(result.color == JavaCompatEnum.Yyy) + } + test("missing required property on case class raises ConversionException") { val code = { """ @@ -205,6 +244,17 @@ object ScalaObjectMapperSpec { case class IntContainer(value: Int) case class RegexContainer(pattern: Regex) + enum SimpleEnum { + case Aaa, Bbb, Ccc + } + + enum JavaCompatEnum extends java.lang.Enum[JavaCompatEnum] { + case Xxx, Yyy, Zzz + } + + case class EnumContainer(color: SimpleEnum) + case class JavaCompatEnumContainer(color: JavaCompatEnum) + case class ObjectMappingTestContainer( // Options optionalVal1: Option[String], @@ -225,7 +275,9 @@ object ScalaObjectMapperSpec { // Mapping intStringMapping: Map[Int, String], // Listing → Seq - intListing: Seq[Int] + intListing: Seq[Int], + // Scala 3 enum + simpleEnumViaString: SimpleEnum ) object ObjectMappingTestContainer { From 446c417de8e09061060f7808cea5378e9126d63d Mon Sep 17 00:00:00 2001 From: Andriy Onyshchuk Date: Thu, 28 May 2026 08:43:54 -0700 Subject: [PATCH 04/14] Polish: ConfigDecoder extensions, regression-pin tests, order-preserving enum lookup - Add forScala() extensions on ConfigDecoderBuilder and ConfigDecoder, matching pkl-config-kotlin's five-receiver surface. - Switch PStringToScalaEnum's internal store from Map to VectorMap so the error message lists candidate cases in enum-declaration order. - Extract dedicated `idiomatic Scala 3 enum routes through PStringToScalaEnum` test (was previously buried in the kitchen-sink test). - Strengthen the unknown-enum-value test to assert declaration order. - Add regression-pin test covering inherited Java conversions: BigInteger, BigDecimal, URI, URL, Path, File, Character, Bytes, DataSize, Listing, Mapping. Catches accidental factory-ordering regressions and documents that primitive Char isn't covered (only java.lang.Character is). --- .../scala/mapper/PStringToScalaEnum.scala | 39 +++++---- .../org/pkl/config/scala/syntax/package.scala | 26 +++++- .../config/scala/ScalaObjectMapperSpec.scala | 80 ++++++++++++++++++- 3 files changed, 126 insertions(+), 19 deletions(-) diff --git a/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/PStringToScalaEnum.scala b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/PStringToScalaEnum.scala index bcaa29f21..be7ca72ea 100644 --- a/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/PStringToScalaEnum.scala +++ b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/PStringToScalaEnum.scala @@ -21,6 +21,7 @@ import org.pkl.core.util.CodeGeneratorUtils import java.lang.reflect.Type as JType import java.util.Optional +import scala.collection.immutable.VectorMap import scala.jdk.OptionConverters.* import scala.util.Try @@ -45,27 +46,31 @@ private[mapper] object PStringToScalaEnum extends ConverterFactory { } } - private def readEnumValues(cls: Class[?]): Option[Map[String, AnyRef]] = Try { + private def readEnumValues(cls: Class[?]): Option[VectorMap[String, AnyRef]] = Try { val moduleCls = Class.forName(cls.getName + "$", false, cls.getClassLoader) val module = moduleCls.getField("MODULE$").get(null) val values = moduleCls.getMethod("values").invoke(module).asInstanceOf[Array[?]] - values.iterator.collect { case v: scala.reflect.Enum => - v.productPrefix -> v.asInstanceOf[AnyRef] - }.toMap + values.iterator + .collect { case v: scala.reflect.Enum => + v.productPrefix -> v.asInstanceOf[AnyRef] + } + .to(VectorMap) }.toOption - private def mkConverter(cls: Class[?], byName: Map[String, AnyRef]): Converter[String, AnyRef] = { - (value, _) => - { - byName - .get(value) - .orElse(Option(CodeGeneratorUtils.toEnumConstantName(value)).flatMap(byName.get)) - .getOrElse { - throw new ConversionException( - s"Cannot convert String `$value` to Enum value of type `${cls.getTypeName}`. " + - s"Expected one of: ${byName.keys.mkString(", ")}." - ) - } - } + private def mkConverter( + cls: Class[?], + byName: VectorMap[String, AnyRef] + ): Converter[String, AnyRef] = { (value, _) => + { + byName + .get(value) + .orElse(Option(CodeGeneratorUtils.toEnumConstantName(value)).flatMap(byName.get)) + .getOrElse { + throw new ConversionException( + s"Cannot convert String `$value` to Enum value of type `${cls.getTypeName}`. " + + s"Expected one of: ${byName.keys.mkString(", ")}." + ) + } + } } } diff --git a/pkl-config-scala/src/main/scala/org/pkl/config/scala/syntax/package.scala b/pkl-config-scala/src/main/scala/org/pkl/config/scala/syntax/package.scala index 36012d1ed..cd43cf14f 100644 --- a/pkl-config-scala/src/main/scala/org/pkl/config/scala/syntax/package.scala +++ b/pkl-config-scala/src/main/scala/org/pkl/config/scala/syntax/package.scala @@ -16,7 +16,13 @@ package org.pkl.config.scala import org.pkl.config.java.mapper.ValueMapperBuilder -import org.pkl.config.java.{Config, ConfigEvaluator, ConfigEvaluatorBuilder} +import org.pkl.config.java.{ + Config, + ConfigDecoder, + ConfigDecoderBuilder, + ConfigEvaluator, + ConfigEvaluatorBuilder +} import org.pkl.config.scala.mapper.{ScalaConversions, ScalaConverterFactories} import scala.jdk.CollectionConverters._ @@ -97,6 +103,24 @@ package object syntax { } } + /** + * Extension for `ConfigDecoderBuilder`, enabling Scala-specific type handling in the decoder. + */ + implicit class ConfigDecoderBuilderSyntaxExtension(val x: ConfigDecoderBuilder) extends AnyVal { + def forScala(): ConfigDecoderBuilder = { + x.setValueMapperBuilder(x.getValueMapperBuilder.forScala()) + } + } + + /** + * Extension for `ConfigDecoder`, applying Scala-specific type conversions to the decoder. + */ + implicit class ConfigDecoderSyntaxExtension(val x: ConfigDecoder) extends AnyVal { + def forScala(): ConfigDecoder = { + x.setValueMapper(x.getValueMapper.toBuilder.forScala().build) + } + } + /** * Extension for `Config`, adding a type-safe `to` method to retrieve values as Scala types. * diff --git a/pkl-config-scala/src/test/scala/org/pkl/config/scala/ScalaObjectMapperSpec.scala b/pkl-config-scala/src/test/scala/org/pkl/config/scala/ScalaObjectMapperSpec.scala index 6f7dcb9ba..e37d4721b 100644 --- a/pkl-config-scala/src/test/scala/org/pkl/config/scala/ScalaObjectMapperSpec.scala +++ b/pkl-config-scala/src/test/scala/org/pkl/config/scala/ScalaObjectMapperSpec.scala @@ -102,7 +102,23 @@ class ScalaObjectMapperSpec extends AnyFunSuite { ) } - test("unknown enum value raises ConversionException listing valid members") { + test("idiomatic Scala 3 enum routes through PStringToScalaEnum") { + val code = { + """ + |module M + |color = "Bbb" + |""".stripMargin + } + + val result = ConfigEvaluator + .preconfigured() + .forScala() + .evaluate(ModuleSource.text(code)) + .to[EnumContainer] + assert(result.color == SimpleEnum.Bbb) + } + + test("unknown enum value raises ConversionException listing valid members in declaration order") { val code = { """ |module M @@ -119,6 +135,13 @@ class ScalaObjectMapperSpec extends AnyFunSuite { } val msg = ex.getMessage assert(msg.contains("Purple"), s"expected input name in message, got: $msg") + val aaaIdx = msg.indexOf("Aaa") + val bbbIdx = msg.indexOf("Bbb") + val cccIdx = msg.indexOf("Ccc") + assert( + aaaIdx >= 0 && bbbIdx > aaaIdx && cccIdx > bbbIdx, + s"expected Aaa, Bbb, Ccc in declaration order, got: $msg" + ) } test("Java-compat Scala 3 enum (extends java.lang.Enum) routes through PStringToEnum") { @@ -231,6 +254,45 @@ class ScalaObjectMapperSpec extends AnyFunSuite { evaluator.close() } } + + test( + "inherited Java conversions: BigInteger, BigDecimal, URI, URL, Path, File, Char, Bytes, DataSize, Listing, Mapping" + ) { + val code = { + """ + |module M + |bigInt = 9007199254740993 + |bigDec = 1.5 + |uri = "https://example.com" + |url = "https://example.com/path" + |path = "/tmp/foo" + |file = "/tmp/bar" + |char = "A" + |bytes = Bytes(72, 105) + |dataSize: DataSize = 5.kb + |listing: Listing = new { 1; 2; 3 } + |mapping: Mapping = new { ["a"] = 1; ["b"] = 2 } + |""".stripMargin + } + + val result = ConfigEvaluator + .preconfigured() + .forScala() + .evaluate(ModuleSource.text(code)) + .to[InheritedTypesContainer] + + assert(result.bigInt == new java.math.BigInteger("9007199254740993")) + assert(result.bigDec == java.math.BigDecimal.valueOf(1.5)) + assert(result.uri == new java.net.URI("https://example.com")) + assert(result.url == new java.net.URI("https://example.com/path").toURL) + assert(result.path == java.nio.file.Path.of("/tmp/foo")) + assert(result.file == new java.io.File("/tmp/bar")) + assert(result.char == java.lang.Character.valueOf('A')) + assert(result.bytes.sameElements(Array[Byte](72, 105))) + assert(result.dataSize == org.pkl.core.DataSize.ofKilobytes(5)) + assert(result.listing == Seq(1, 2, 3)) + assert(result.mapping == Map("a" -> 1, "b" -> 2)) + } } object ScalaObjectMapperSpec { @@ -255,6 +317,22 @@ object ScalaObjectMapperSpec { case class EnumContainer(color: SimpleEnum) case class JavaCompatEnumContainer(color: JavaCompatEnum) + case class InheritedTypesContainer( + bigInt: java.math.BigInteger, + bigDec: java.math.BigDecimal, + uri: java.net.URI, + url: java.net.URL, + path: java.nio.file.Path, + file: java.io.File, + // boxed Character: Java's pStringToCharacter conversion targets java.lang.Character, + // not the JVM primitive `char` that Scala's `Char` becomes in case-class signatures. + char: java.lang.Character, + bytes: Array[Byte], + dataSize: org.pkl.core.DataSize, + listing: Seq[Int], + mapping: Map[String, Int] + ) + case class ObjectMappingTestContainer( // Options optionalVal1: Option[String], From 86338ca2a2bf1c7772cfc4e8ba0f3465110685ed Mon Sep 17 00:00:00 2001 From: Andriy Onyshchuk Date: Fri, 29 May 2026 20:25:14 -0700 Subject: [PATCH 05/14] Polish: drop dead @file:Suppress, add ScalaWeakerAccess on public-by-design objects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ScalaConversions and ScalaConverterFactories expose val members that are only referenced internally from .all but are public-by-design (users may register a subset). IntelliJ flags these as ScalaWeakerAccess; suppress to silence the noise. The pklScalaLibrary @file:Suppress("HttpUrlsUsage", "unused") was carried over from a template — file has no HTTP URLs and no unused declarations. --- build-logic/src/main/kotlin/pklScalaLibrary.gradle.kts | 2 -- .../scala/org/pkl/config/scala/mapper/ScalaConversions.scala | 1 + .../org/pkl/config/scala/mapper/ScalaConverterFactories.scala | 1 + 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/build-logic/src/main/kotlin/pklScalaLibrary.gradle.kts b/build-logic/src/main/kotlin/pklScalaLibrary.gradle.kts index 14674c5ab..54e171470 100644 --- a/build-logic/src/main/kotlin/pklScalaLibrary.gradle.kts +++ b/build-logic/src/main/kotlin/pklScalaLibrary.gradle.kts @@ -13,8 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -@file:Suppress("HttpUrlsUsage", "unused") - import org.gradle.accessors.dm.LibrariesForLibs import org.gradle.kotlin.dsl.withType diff --git a/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ScalaConversions.scala b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ScalaConversions.scala index 05c85cef5..4ecee70af 100644 --- a/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ScalaConversions.scala +++ b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ScalaConversions.scala @@ -28,6 +28,7 @@ import scala.util.matching.Regex * Provides conversions between Java types backing PKL and Scala types, enabling seamless * interoperability for configuration values within PKL. */ +//noinspection ScalaWeakerAccess object ScalaConversions { val pStringToInstant: Conversion[String, Instant] = { diff --git a/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ScalaConverterFactories.scala b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ScalaConverterFactories.scala index ef3fa6ad9..7031469f9 100644 --- a/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ScalaConverterFactories.scala +++ b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ScalaConverterFactories.scala @@ -32,6 +32,7 @@ import scala.language.implicitConversions * hand-author Scala classes with other collection shapes (Set, Vector, mutable collections, etc.) * can register their own converter factories. */ +//noinspection ScalaWeakerAccess object ScalaConverterFactories { private type Conv1[S, T] = Type => (S, CachedSourceTypeInfo, ValueMapper) => T From 450fd9453b288ade106f1906e1b3d7b6e594e207 Mon Sep 17 00:00:00 2001 From: Andriy Onyshchuk Date: Fri, 29 May 2026 20:26:00 -0700 Subject: [PATCH 06/14] Polish: replace `_` wildcards with `*`/`?` in pkl-config-scala tests Main module already uses Scala 3 idiom (`*` for import wildcards, `?` for type wildcards); the test files lagged. Sweep imports and `Tuple2[_, _]`. --- .../pkl/config/scala/ScalaObjectMapperSpec.scala | 8 ++++---- .../scala/mapper/PPairToScalaTupleSpec.scala | 14 +++++++------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/pkl-config-scala/src/test/scala/org/pkl/config/scala/ScalaObjectMapperSpec.scala b/pkl-config-scala/src/test/scala/org/pkl/config/scala/ScalaObjectMapperSpec.scala index e37d4721b..6e024a580 100644 --- a/pkl-config-scala/src/test/scala/org/pkl/config/scala/ScalaObjectMapperSpec.scala +++ b/pkl-config-scala/src/test/scala/org/pkl/config/scala/ScalaObjectMapperSpec.scala @@ -15,11 +15,11 @@ */ package org.pkl.config.scala -import com.softwaremill.diffx._ -import com.softwaremill.diffx.scalatest.DiffShouldMatcher._ +import com.softwaremill.diffx.* +import com.softwaremill.diffx.scalatest.DiffShouldMatcher.* import org.pkl.config.java.ConfigEvaluator import org.pkl.config.java.mapper.ConversionException -import org.pkl.config.scala.syntax._ +import org.pkl.config.scala.syntax.* import org.pkl.core.{Duration => PDuration, ModuleSource} import org.scalatest.funsuite.AnyFunSuite @@ -29,7 +29,7 @@ import scala.concurrent.duration.{Duration, FiniteDuration} import scala.util.matching.Regex class ScalaObjectMapperSpec extends AnyFunSuite { - import ScalaObjectMapperSpec._ + import ScalaObjectMapperSpec.* test("evaluate scala types") { diff --git a/pkl-config-scala/src/test/scala/org/pkl/config/scala/mapper/PPairToScalaTupleSpec.scala b/pkl-config-scala/src/test/scala/org/pkl/config/scala/mapper/PPairToScalaTupleSpec.scala index 617ee1f26..746be55c5 100644 --- a/pkl-config-scala/src/test/scala/org/pkl/config/scala/mapper/PPairToScalaTupleSpec.scala +++ b/pkl-config-scala/src/test/scala/org/pkl/config/scala/mapper/PPairToScalaTupleSpec.scala @@ -20,13 +20,13 @@ import org.pkl.core.{Duration, Evaluator, PClassInfo, PObject} import org.pkl.core.ModuleSource.modulePath import org.scalatest.BeforeAndAfterAll import org.scalatest.funsuite.AnyFunSuite -import org.scalatest.matchers.should.Matchers._ -import org.pkl.config.scala.syntax._ +import org.scalatest.matchers.should.Matchers.* +import org.pkl.config.scala.syntax.* -import scala.jdk.CollectionConverters._ +import scala.jdk.CollectionConverters.* class PPairToScalaTupleSpec extends AnyFunSuite with BeforeAndAfterAll { - import PPairToScalaTupleSpec._ + import PPairToScalaTupleSpec.* private val evaluator = Evaluator.preconfigured() @@ -48,7 +48,7 @@ class PPairToScalaTupleSpec extends AnyFunSuite with BeforeAndAfterAll { mapper.map( ex1, Types.parameterizedType( - classOf[Tuple2[_, _]], + classOf[Tuple2[?, ?]], classOf[Integer], classOf[Duration] ) @@ -64,7 +64,7 @@ class PPairToScalaTupleSpec extends AnyFunSuite with BeforeAndAfterAll { mapper.map( ex2, Types.parameterizedType( - classOf[Tuple2[_, _]], + classOf[Tuple2[?, ?]], classOf[PObject], classOf[PObject] ) @@ -88,7 +88,7 @@ class PPairToScalaTupleSpec extends AnyFunSuite with BeforeAndAfterAll { mapper.map( ex2, Types.parameterizedType( - classOf[Tuple2[_, _]], + classOf[Tuple2[?, ?]], classOf[Animal], classOf[Animal] ) From c53d504e646f93f186b5ae865f66c04166cc62c4 Mon Sep 17 00:00:00 2001 From: Andriy Onyshchuk Date: Fri, 29 May 2026 20:26:19 -0700 Subject: [PATCH 07/14] Polish: clarify PStringToScalaEnum scaladoc; align POM description The "this factory defers" wording read like a method call; rewrite to spell out the actual mechanism (return Optional.empty so the chain falls through). POM description now mirrors pkl-config-kotlin's, framing the artifact as extensions to pkl-config-java rather than a standalone library. --- pkl-config-scala/pkl-config-scala.gradle.kts | 2 +- .../org/pkl/config/scala/mapper/PStringToScalaEnum.scala | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/pkl-config-scala/pkl-config-scala.gradle.kts b/pkl-config-scala/pkl-config-scala.gradle.kts index 8698925a0..c619ec8d8 100644 --- a/pkl-config-scala/pkl-config-scala.gradle.kts +++ b/pkl-config-scala/pkl-config-scala.gradle.kts @@ -26,7 +26,7 @@ publishing { named("library") { pom { url.set("https://github.com/apple/pkl/tree/main/pkl-config-scala") - description.set("Scala config library based on the Pkl config language.") + description.set("Scala extensions for pkl-config-java, a Java config library based on the Pkl config language.") } } } diff --git a/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/PStringToScalaEnum.scala b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/PStringToScalaEnum.scala index be7ca72ea..a129ff8af 100644 --- a/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/PStringToScalaEnum.scala +++ b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/PStringToScalaEnum.scala @@ -30,9 +30,10 @@ import scala.util.Try * * Scala 3 enum cases implement `scala.reflect.Enum`. When the enum also extends `java.lang.Enum` * (the opt-in Java-compat form), `Class.isEnum` returns true and `pkl-config-java`'s - * `PStringToEnum` already handles them; this factory defers in that case. For the plain - * `enum Foo { case A, B }` form, `isEnum` is false and this factory looks up the case via the - * companion module's synthetic `values()` method. + * `PStringToEnum` already handles them; this factory returns `Optional.empty()` in that case so + * the chain falls through to `PStringToEnum`. For the plain `enum Foo { case A, B }` form, + * `isEnum` is false and this factory looks up the case via the companion module's synthetic + * `values()` method. */ private[mapper] object PStringToScalaEnum extends ConverterFactory { From 4fdb3fac32c86b2f7067fa5a54df4a1ead14ee73 Mon Sep 17 00:00:00 2001 From: Andriy Onyshchuk Date: Fri, 29 May 2026 20:26:56 -0700 Subject: [PATCH 08/14] Drop dead Option/Optional branches in pAnyToOption; use Some MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pkl never emits Option or java.util.Optional values — nullability surfaces as PNull. The two reflective branches were copy-pasted defensively and could never fire. The remaining catch-all is reachable only after PNull/null is ruled out, so Option(...) was redundant null-checking; Some(...) makes the intent explicit. --- .../org/pkl/config/scala/mapper/ScalaConverterFactories.scala | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ScalaConverterFactories.scala b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ScalaConverterFactories.scala index 7031469f9..e4be33bc5 100644 --- a/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ScalaConverterFactories.scala +++ b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ScalaConverterFactories.scala @@ -62,9 +62,7 @@ object ScalaConverterFactories { { value match { case _: PNull | null => None - case v: Option[_] => v.map(s1.updateAndGet(_, t1, vm)) - case v: Optional[_] => v.toScala.map(s1.updateAndGet(_, t1, vm)) - case v => Option(s1.updateAndGet(v, t1, vm)) + case v => Some(s1.updateAndGet(v, t1, vm)) } } } From 7a61d3523f8941834b296d11c9644e41c9dad3a0 Mon Sep 17 00:00:00 2001 From: Andriy Onyshchuk Date: Fri, 29 May 2026 20:27:36 -0700 Subject: [PATCH 09/14] Delegate case-class constructor selection to PObjectToDataObject.super getDeclaredConstructors.headOption picked an arbitrary constructor when a case class declares secondary constructors (user-written or synthesized). Filter on the Product interface, then defer to the Java base class's selectConstructor, which picks by argument count and matches the existing pkl-config-java policy. Drops the now-unused OptionConverters import. --- .../scala/mapper/ScalaConverterFactories.scala | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ScalaConverterFactories.scala b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ScalaConverterFactories.scala index e4be33bc5..aa2b2f21f 100644 --- a/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ScalaConverterFactories.scala +++ b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ScalaConverterFactories.scala @@ -22,7 +22,6 @@ import java.lang.reflect.{Constructor, Type} import java.util.Optional import scala.collection.immutable import scala.jdk.CollectionConverters.* -import scala.jdk.OptionConverters.* import scala.language.implicitConversions /** @@ -45,13 +44,11 @@ object ScalaConverterFactories { val pObjectToCaseClass: ConverterFactory = new PObjectToDataObject { override def selectConstructor(clazz: Class[?]): Optional[Constructor[?]] = { - clazz.getDeclaredConstructors.headOption - .filter(_ => { - // case classes all implement Product - clazz.getInterfaces - .exists(i => classOf[scala.Product].isAssignableFrom(i)) - }) - .toJava + // case classes all implement Product + if (!clazz.getInterfaces.exists(i => classOf[scala.Product].isAssignableFrom(i))) { + return Optional.empty() + } + super.selectConstructor(clazz) } } From f23299b9b36dcd4d45fb5a1591bc18b5d16ba39c Mon Sep 17 00:00:00 2001 From: Andriy Onyshchuk Date: Fri, 29 May 2026 20:31:39 -0700 Subject: [PATCH 10/14] Preserve PDuration unit in pDurationToDuration / pDurationToFiniteDuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both converters previously collapsed the value to nanos and let toCoarsest reverse-engineer a unit by checking divisibility. Use PDuration's own unit when the value fits in a Long (the common case for Pkl-emitted durations); fall back to nanos+toCoarsest only for fractional values. As a side effect, durations like `5000000.ns` now round-trip as NANOSECONDS instead of being re-coarsified to MILLISECONDS — equivalent length, faithful to the source. --- .../pkl/config/scala/mapper/ScalaConversions.scala | 12 ++++++++++-- .../scala/mapper/ScalaConverterFactories.scala | 5 +++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ScalaConversions.scala b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ScalaConversions.scala index 4ecee70af..a4c570d57 100644 --- a/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ScalaConversions.scala +++ b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ScalaConversions.scala @@ -51,7 +51,7 @@ object ScalaConversions { Conversion.of( PClassInfo.Duration, classOf[Duration], - (v: PDuration, _) => Duration.fromNanos(v.inNanos()).toCoarsest + (v: PDuration, _) => asFiniteDuration(v) ) } @@ -59,10 +59,18 @@ object ScalaConversions { Conversion.of( PClassInfo.Duration, classOf[FiniteDuration], - (v: PDuration, _) => FiniteDuration(v.inWholeNanos(), TimeUnit.NANOSECONDS).toCoarsest + (v: PDuration, _) => asFiniteDuration(v) ) } + private def asFiniteDuration(v: PDuration): FiniteDuration = { + val unit = v.getUnit.toTimeUnit + val value = v.getValue + val l = value.toLong + if (l.toDouble == value) FiniteDuration(l, unit) + else FiniteDuration(v.inWholeNanos(), TimeUnit.NANOSECONDS).toCoarsest + } + val pStringToScalaRegex: Conversion[String, Regex] = { Conversion.of(PClassInfo.String, classOf[Regex], (v: String, _) => v.r) } diff --git a/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ScalaConverterFactories.scala b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ScalaConverterFactories.scala index aa2b2f21f..560203be5 100644 --- a/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ScalaConverterFactories.scala +++ b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ScalaConverterFactories.scala @@ -46,9 +46,10 @@ object ScalaConverterFactories { override def selectConstructor(clazz: Class[?]): Optional[Constructor[?]] = { // case classes all implement Product if (!clazz.getInterfaces.exists(i => classOf[scala.Product].isAssignableFrom(i))) { - return Optional.empty() + Optional.empty() + } else { + super.selectConstructor(clazz) } - super.selectConstructor(clazz) } } From acbd4095bffb776bbf99ada9d6c31f38e09b2504 Mon Sep 17 00:00:00 2001 From: Andriy Onyshchuk Date: Fri, 29 May 2026 20:33:52 -0700 Subject: [PATCH 11/14] Revert POM description change for pkl-config-scala MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer's suggested wording ("Scala extensions for pkl-config-java...") was a partial framing — the artifact does delegate to pkl-config-java but also exposes Scala-native API surface (case-class targeting, syntax package, Scala 3 enum support). Keeping the original. --- pkl-config-scala/pkl-config-scala.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkl-config-scala/pkl-config-scala.gradle.kts b/pkl-config-scala/pkl-config-scala.gradle.kts index c619ec8d8..8698925a0 100644 --- a/pkl-config-scala/pkl-config-scala.gradle.kts +++ b/pkl-config-scala/pkl-config-scala.gradle.kts @@ -26,7 +26,7 @@ publishing { named("library") { pom { url.set("https://github.com/apple/pkl/tree/main/pkl-config-scala") - description.set("Scala extensions for pkl-config-java, a Java config library based on the Pkl config language.") + description.set("Scala config library based on the Pkl config language.") } } } From 1d5634f4680b6f4511f973f547e2264114feb427 Mon Sep 17 00:00:00 2001 From: Andriy Onyshchuk Date: Fri, 29 May 2026 20:34:47 -0700 Subject: [PATCH 12/14] Narrow Try in PStringToScalaEnum.readEnumValues; rethrow with context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Try { ... }.toOption pattern caught Throwable (including OOMEs and StackOverflows) and swallowed the cause, leaving "no converter found" upstream as the only signal of a misconfigured classpath. The block can only realistically throw four things — ClassNotFoundException, NoSuchFieldException, NoSuchMethodException, IllegalAccessException — all ReflectiveOperationException. Catch that, then rethrow as ConversionException with the failing class name and the original cause. --- .../scala/mapper/PStringToScalaEnum.scala | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/PStringToScalaEnum.scala b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/PStringToScalaEnum.scala index a129ff8af..29db5bad4 100644 --- a/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/PStringToScalaEnum.scala +++ b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/PStringToScalaEnum.scala @@ -22,8 +22,6 @@ import org.pkl.core.util.CodeGeneratorUtils import java.lang.reflect.Type as JType import java.util.Optional import scala.collection.immutable.VectorMap -import scala.jdk.OptionConverters.* -import scala.util.Try /** * Converter from Pkl `String` to Scala 3 `enum` cases. @@ -41,22 +39,30 @@ private[mapper] object PStringToScalaEnum extends ConverterFactory { (sourceType, Reflection.toRawType(targetType)) match { case (PClassInfo.String, cls) if !cls.isEnum && classOf[scala.reflect.Enum].isAssignableFrom(cls) => - readEnumValues(cls).map(mkConverter(cls, _)).toJava + Optional.of(mkConverter(cls, readEnumValues(cls))) case _ => Optional.empty() } } - private def readEnumValues(cls: Class[?]): Option[VectorMap[String, AnyRef]] = Try { - val moduleCls = Class.forName(cls.getName + "$", false, cls.getClassLoader) - val module = moduleCls.getField("MODULE$").get(null) - val values = moduleCls.getMethod("values").invoke(module).asInstanceOf[Array[?]] - values.iterator - .collect { case v: scala.reflect.Enum => - v.productPrefix -> v.asInstanceOf[AnyRef] - } - .to(VectorMap) - }.toOption + private def readEnumValues(cls: Class[?]): VectorMap[String, AnyRef] = { + try { + val moduleCls = Class.forName(cls.getName + "$", false, cls.getClassLoader) + val module = moduleCls.getField("MODULE$").get(null) + val values = moduleCls.getMethod("values").invoke(module).asInstanceOf[Array[?]] + values.iterator + .collect { case v: scala.reflect.Enum => + v.productPrefix -> v.asInstanceOf[AnyRef] + } + .to(VectorMap) + } catch { + case e: ReflectiveOperationException => + throw new ConversionException( + s"Failed to introspect Scala 3 enum companion for `${cls.getTypeName}`.", + e + ) + } + } private def mkConverter( cls: Class[?], From 9c04e82ca0e53636629d88aac5bd9d89649a967a Mon Sep 17 00:00:00 2001 From: Andriy Onyshchuk Date: Fri, 29 May 2026 20:41:09 -0700 Subject: [PATCH 13/14] Migrate ScalaObjectMapperSpec and PPairToScalaTupleSpec to FixtureAnyFunSuite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both specs now build their evaluator (and dependent fixtures) inside withFixture per test, with deterministic close in finally. ScalaObjectMapperSpec's fixture is the ConfigEvaluator; PPairToScalaTupleSpec's bundles the evaluated PModule and the configured ValueMapper since every test consumes both. The ConfigEvaluatorBuilder-extension test stays separate — its purpose is to exercise the builder path, not the evaluator one — and now uses scala.util.Using for the same try-with-resources discipline as withFixture. --- .../config/scala/ScalaObjectMapperSpec.scala | 178 ++++++++---------- .../scala/mapper/PPairToScalaTupleSpec.scala | 39 ++-- 2 files changed, 96 insertions(+), 121 deletions(-) diff --git a/pkl-config-scala/src/test/scala/org/pkl/config/scala/ScalaObjectMapperSpec.scala b/pkl-config-scala/src/test/scala/org/pkl/config/scala/ScalaObjectMapperSpec.scala index 6e024a580..cc25e5c86 100644 --- a/pkl-config-scala/src/test/scala/org/pkl/config/scala/ScalaObjectMapperSpec.scala +++ b/pkl-config-scala/src/test/scala/org/pkl/config/scala/ScalaObjectMapperSpec.scala @@ -17,21 +17,31 @@ package org.pkl.config.scala import com.softwaremill.diffx.* import com.softwaremill.diffx.scalatest.DiffShouldMatcher.* -import org.pkl.config.java.ConfigEvaluator +import org.pkl.config.java.{ConfigEvaluator, ConfigEvaluatorBuilder} import org.pkl.config.java.mapper.ConversionException import org.pkl.config.scala.syntax.* import org.pkl.core.{Duration => PDuration, ModuleSource} -import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.Outcome +import org.scalatest.funsuite.FixtureAnyFunSuite import java.time.Instant import java.util.concurrent.TimeUnit import scala.concurrent.duration.{Duration, FiniteDuration} +import scala.util.Using import scala.util.matching.Regex -class ScalaObjectMapperSpec extends AnyFunSuite { +class ScalaObjectMapperSpec extends FixtureAnyFunSuite { import ScalaObjectMapperSpec.* - test("evaluate scala types") { + type FixtureParam = ConfigEvaluator + + override def withFixture(test: OneArgTest): Outcome = { + val evaluator = ConfigEvaluator.preconfigured().forScala() + try withFixture(test.toNoArgTest(evaluator)) + finally evaluator.close() + } + + test("evaluate scala types") { evaluator => val code = { """ @@ -75,11 +85,7 @@ class ScalaObjectMapperSpec extends AnyFunSuite { |""".stripMargin } - val result = ConfigEvaluator - .preconfigured() - .forScala() - .evaluate(ModuleSource.text(code)) - .to[ObjectMappingTestContainer] + val result = evaluator.evaluate(ModuleSource.text(code)).to[ObjectMappingTestContainer] result shouldMatchTo ObjectMappingTestContainer( optionalVal1 = None, @@ -102,7 +108,7 @@ class ScalaObjectMapperSpec extends AnyFunSuite { ) } - test("idiomatic Scala 3 enum routes through PStringToScalaEnum") { + test("idiomatic Scala 3 enum routes through PStringToScalaEnum") { evaluator => val code = { """ |module M @@ -110,57 +116,47 @@ class ScalaObjectMapperSpec extends AnyFunSuite { |""".stripMargin } - val result = ConfigEvaluator - .preconfigured() - .forScala() - .evaluate(ModuleSource.text(code)) - .to[EnumContainer] + val result = evaluator.evaluate(ModuleSource.text(code)).to[EnumContainer] assert(result.color == SimpleEnum.Bbb) } test("unknown enum value raises ConversionException listing valid members in declaration order") { - val code = { - """ - |module M - |color = "Purple" - |""".stripMargin - } - - val ex = intercept[ConversionException] { - ConfigEvaluator - .preconfigured() - .forScala() - .evaluate(ModuleSource.text(code)) - .to[EnumContainer] - } - val msg = ex.getMessage - assert(msg.contains("Purple"), s"expected input name in message, got: $msg") - val aaaIdx = msg.indexOf("Aaa") - val bbbIdx = msg.indexOf("Bbb") - val cccIdx = msg.indexOf("Ccc") - assert( - aaaIdx >= 0 && bbbIdx > aaaIdx && cccIdx > bbbIdx, - s"expected Aaa, Bbb, Ccc in declaration order, got: $msg" - ) + evaluator => + val code = { + """ + |module M + |color = "Purple" + |""".stripMargin + } + + val ex = intercept[ConversionException] { + evaluator.evaluate(ModuleSource.text(code)).to[EnumContainer] + } + val msg = ex.getMessage + assert(msg.contains("Purple"), s"expected input name in message, got: $msg") + val aaaIdx = msg.indexOf("Aaa") + val bbbIdx = msg.indexOf("Bbb") + val cccIdx = msg.indexOf("Ccc") + assert( + aaaIdx >= 0 && bbbIdx > aaaIdx && cccIdx > bbbIdx, + s"expected Aaa, Bbb, Ccc in declaration order, got: $msg" + ) } test("Java-compat Scala 3 enum (extends java.lang.Enum) routes through PStringToEnum") { - val code = { - """ - |module M - |color = "Yyy" - |""".stripMargin - } - - val result = ConfigEvaluator - .preconfigured() - .forScala() - .evaluate(ModuleSource.text(code)) - .to[JavaCompatEnumContainer] - assert(result.color == JavaCompatEnum.Yyy) + evaluator => + val code = { + """ + |module M + |color = "Yyy" + |""".stripMargin + } + + val result = evaluator.evaluate(ModuleSource.text(code)).to[JavaCompatEnumContainer] + assert(result.color == JavaCompatEnum.Yyy) } - test("missing required property on case class raises ConversionException") { + test("missing required property on case class raises ConversionException") { evaluator => val code = { """ |module M @@ -169,41 +165,34 @@ class ScalaObjectMapperSpec extends AnyFunSuite { } val ex = intercept[ConversionException] { - ConfigEvaluator - .preconfigured() - .forScala() - .evaluate(ModuleSource.text(code)) - .to[Person] + evaluator.evaluate(ModuleSource.text(code)).to[Person] } val msg = ex.getMessage assert(msg.contains("age"), s"expected missing property name in message, got: $msg") } test("type mismatch between Pkl value and case class field raises ConversionException") { - val code = { - """ - |module M - |value = "not an int" - |""".stripMargin - } - - val ex = intercept[ConversionException] { - ConfigEvaluator - .preconfigured() - .forScala() - .evaluate(ModuleSource.text(code)) - .to[IntContainer] - } - val msg = ex.getMessage - assert( - msg.toLowerCase.contains("cannot convert") || - msg.toLowerCase.contains("string") || - msg.toLowerCase.contains("int"), - s"expected type-mismatch hint in message, got: $msg" - ) + evaluator => + val code = { + """ + |module M + |value = "not an int" + |""".stripMargin + } + + val ex = intercept[ConversionException] { + evaluator.evaluate(ModuleSource.text(code)).to[IntContainer] + } + val msg = ex.getMessage + assert( + msg.toLowerCase.contains("cannot convert") || + msg.toLowerCase.contains("string") || + msg.toLowerCase.contains("int"), + s"expected type-mismatch hint in message, got: $msg" + ) } - test("pStringToScalaRegex converts Pkl String to Scala Regex") { + test("pStringToScalaRegex converts Pkl String to Scala Regex") { evaluator => val code = { """ |module M @@ -211,15 +200,11 @@ class ScalaObjectMapperSpec extends AnyFunSuite { |""".stripMargin } - val result = ConfigEvaluator - .preconfigured() - .forScala() - .evaluate(ModuleSource.text(code)) - .to[RegexContainer] + val result = evaluator.evaluate(ModuleSource.text(code)).to[RegexContainer] assert(result.pattern.pattern.pattern() == "^[0-9]+$") } - test("pRegexToScalaRegex converts Pkl Regex to Scala Regex") { + test("pRegexToScalaRegex converts Pkl Regex to Scala Regex") { evaluator => val code = { """ |module M @@ -227,17 +212,11 @@ class ScalaObjectMapperSpec extends AnyFunSuite { |""".stripMargin } - val result = ConfigEvaluator - .preconfigured() - .forScala() - .evaluate(ModuleSource.text(code)) - .to[RegexContainer] + val result = evaluator.evaluate(ModuleSource.text(code)).to[RegexContainer] assert(result.pattern.pattern.pattern() == "^[a-z]+$") } - test("forScala extension on ConfigEvaluatorBuilder wires Scala converters") { - import org.pkl.config.java.ConfigEvaluatorBuilder - + test("forScala extension on ConfigEvaluatorBuilder wires Scala converters") { _ => val code = { """ |module M @@ -246,18 +225,15 @@ class ScalaObjectMapperSpec extends AnyFunSuite { |""".stripMargin } - val evaluator = ConfigEvaluatorBuilder.preconfigured().forScala().build() - try { + Using.resource(ConfigEvaluatorBuilder.preconfigured().forScala().build()) { evaluator => val result = evaluator.evaluate(ModuleSource.text(code)).to[Person] assert(result == Person("via-builder", 7)) - } finally { - evaluator.close() } } test( "inherited Java conversions: BigInteger, BigDecimal, URI, URL, Path, File, Char, Bytes, DataSize, Listing, Mapping" - ) { + ) { evaluator => val code = { """ |module M @@ -275,11 +251,7 @@ class ScalaObjectMapperSpec extends AnyFunSuite { |""".stripMargin } - val result = ConfigEvaluator - .preconfigured() - .forScala() - .evaluate(ModuleSource.text(code)) - .to[InheritedTypesContainer] + val result = evaluator.evaluate(ModuleSource.text(code)).to[InheritedTypesContainer] assert(result.bigInt == new java.math.BigInteger("9007199254740993")) assert(result.bigDec == java.math.BigDecimal.valueOf(1.5)) diff --git a/pkl-config-scala/src/test/scala/org/pkl/config/scala/mapper/PPairToScalaTupleSpec.scala b/pkl-config-scala/src/test/scala/org/pkl/config/scala/mapper/PPairToScalaTupleSpec.scala index 746be55c5..9ea85d9ac 100644 --- a/pkl-config-scala/src/test/scala/org/pkl/config/scala/mapper/PPairToScalaTupleSpec.scala +++ b/pkl-config-scala/src/test/scala/org/pkl/config/scala/mapper/PPairToScalaTupleSpec.scala @@ -15,34 +15,37 @@ */ package org.pkl.config.scala.mapper -import org.pkl.config.java.mapper.{Types, ValueMapperBuilder} -import org.pkl.core.{Duration, Evaluator, PClassInfo, PObject} +import org.pkl.config.java.mapper.{Types, ValueMapper, ValueMapperBuilder} +import org.pkl.core.{Duration, Evaluator, PModule, PObject} import org.pkl.core.ModuleSource.modulePath -import org.scalatest.BeforeAndAfterAll -import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.Outcome +import org.scalatest.funsuite.FixtureAnyFunSuite import org.scalatest.matchers.should.Matchers.* import org.pkl.config.scala.syntax.* import scala.jdk.CollectionConverters.* -class PPairToScalaTupleSpec extends AnyFunSuite with BeforeAndAfterAll { +class PPairToScalaTupleSpec extends FixtureAnyFunSuite { import PPairToScalaTupleSpec.* - private val evaluator = Evaluator.preconfigured() + case class TestFixture(module: PModule, mapper: ValueMapper) - private val module = { - evaluator.evaluate( - modulePath("org/pkl/config/scala/mapper/PPairToScalaTuple.pkl") - ) - } - - private val mapper = ValueMapperBuilder.preconfigured().forScala().build() + type FixtureParam = TestFixture - override def afterAll(): Unit = { - evaluator.close() + override def withFixture(test: OneArgTest): Outcome = { + val evaluator = Evaluator.preconfigured() + try { + val module = evaluator.evaluate( + modulePath("org/pkl/config/scala/mapper/PPairToScalaTuple.pkl") + ) + val mapper = ValueMapperBuilder.preconfigured().forScala().build() + withFixture(test.toNoArgTest(TestFixture(module, mapper))) + } finally { + evaluator.close() + } } - test("Pair or scalar values") { + test("Pair or scalar values") { case TestFixture(module, mapper) => val ex1 = module.getProperty("ex1") val mapped: (Int, Duration) = { mapper.map( @@ -58,7 +61,7 @@ class PPairToScalaTupleSpec extends AnyFunSuite with BeforeAndAfterAll { mapped shouldBe (1, Duration.ofSeconds(3)) } - test("Pair of PObject") { + test("Pair of PObject") { case TestFixture(module, mapper) => val ex2 = module.getProperty("ex2") val mapped: (PObject, PObject) = { mapper.map( @@ -82,7 +85,7 @@ class PPairToScalaTupleSpec extends AnyFunSuite with BeforeAndAfterAll { ) } - test("Pair of case class") { + test("Pair of case class") { case TestFixture(module, mapper) => val ex2 = module.getProperty("ex2") val mapped: (Animal, Animal) = { mapper.map( From b8cac9dd5655b92f6cecec8e8550000ede6c2d27 Mon Sep 17 00:00:00 2001 From: Andriy Onyshchuk Date: Fri, 29 May 2026 20:54:13 -0700 Subject: [PATCH 14/14] spotlessApply: reflow doc, drop stray blank line --- .../org/pkl/config/scala/mapper/PStringToScalaEnum.scala | 7 +++---- .../scala/org/pkl/config/scala/ScalaObjectMapperSpec.scala | 1 - 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/PStringToScalaEnum.scala b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/PStringToScalaEnum.scala index 29db5bad4..37814d6d8 100644 --- a/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/PStringToScalaEnum.scala +++ b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/PStringToScalaEnum.scala @@ -28,10 +28,9 @@ import scala.collection.immutable.VectorMap * * Scala 3 enum cases implement `scala.reflect.Enum`. When the enum also extends `java.lang.Enum` * (the opt-in Java-compat form), `Class.isEnum` returns true and `pkl-config-java`'s - * `PStringToEnum` already handles them; this factory returns `Optional.empty()` in that case so - * the chain falls through to `PStringToEnum`. For the plain `enum Foo { case A, B }` form, - * `isEnum` is false and this factory looks up the case via the companion module's synthetic - * `values()` method. + * `PStringToEnum` already handles them; this factory returns `Optional.empty()` in that case so the + * chain falls through to `PStringToEnum`. For the plain `enum Foo { case A, B }` form, `isEnum` is + * false and this factory looks up the case via the companion module's synthetic `values()` method. */ private[mapper] object PStringToScalaEnum extends ConverterFactory { diff --git a/pkl-config-scala/src/test/scala/org/pkl/config/scala/ScalaObjectMapperSpec.scala b/pkl-config-scala/src/test/scala/org/pkl/config/scala/ScalaObjectMapperSpec.scala index cc25e5c86..2456624e0 100644 --- a/pkl-config-scala/src/test/scala/org/pkl/config/scala/ScalaObjectMapperSpec.scala +++ b/pkl-config-scala/src/test/scala/org/pkl/config/scala/ScalaObjectMapperSpec.scala @@ -42,7 +42,6 @@ class ScalaObjectMapperSpec extends FixtureAnyFunSuite { } test("evaluate scala types") { evaluator => - val code = { """ |module ObjectMappingTestContainer