From c8a7a016473fcd4c78c0cc32ac5ce875dea97d13 Mon Sep 17 00:00:00 2001 From: Andriy Onyshchuk Date: Fri, 1 May 2026 10:38:38 -0700 Subject: [PATCH] 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")