diff --git a/buildSrc/src/main/kotlin/pklAllProjects.gradle.kts b/buildSrc/src/main/kotlin/pklAllProjects.gradle.kts index 141fa8cac..5ab9671e9 100644 --- a/buildSrc/src/main/kotlin/pklAllProjects.gradle.kts +++ b/buildSrc/src/main/kotlin/pklAllProjects.gradle.kts @@ -26,11 +26,24 @@ dependencyLocking { lockAllConfigurations() } configurations { val rejectedVersionSuffix = Regex("-alpha|-beta|-eap|-m|-rc|-snapshot", RegexOption.IGNORE_CASE) + val versionSuffixRejectionExcemptions = + setOf( + // I know. + // This looks odd. + // But yes, it's transitively required by one of the relese 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 { componentSelection { all { - if (rejectedVersionSuffix.containsMatchIn(candidate.version)) { + if ( + rejectedVersionSuffix.containsMatchIn(candidate.version) && + !versionSuffixRejectionExcemptions.contains( + "${candidate.group}:${candidate.module}:${candidate.version}" + ) + ) { reject( "Rejected dependency $candidate " + "because it has a prelease version suffix matching `$rejectedVersionSuffix`." diff --git a/buildSrc/src/main/kotlin/pklJavaLibrary.gradle.kts b/buildSrc/src/main/kotlin/pklJavaLibrary.gradle.kts index 8540d3616..2a8f4e8d5 100644 --- a/buildSrc/src/main/kotlin/pklJavaLibrary.gradle.kts +++ b/buildSrc/src/main/kotlin/pklJavaLibrary.gradle.kts @@ -55,6 +55,14 @@ spotless { target("src/*/java/**/*.java") licenseHeaderFile(rootProject.file("buildSrc/src/main/resources/license-header.star-block.txt")) } + scala { + scalafmt(libs.versions.scalafmt.get()) + target("src/*/scala/**/*.scala") + licenseHeaderFile( + rootProject.file("buildSrc/src/main/resources/license-header.star-block.txt"), + "package ", + ) + } kotlin { ktfmt(libs.versions.ktfmt.get()).googleStyle() target("src/*/kotlin/**/*.kt") diff --git a/buildSrc/src/main/kotlin/pklScalaLibrary.gradle.kts b/buildSrc/src/main/kotlin/pklScalaLibrary.gradle.kts new file mode 100644 index 000000000..df6b5a7da --- /dev/null +++ b/buildSrc/src/main/kotlin/pklScalaLibrary.gradle.kts @@ -0,0 +1,49 @@ +/* + * Copyright © 2024-2025 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 { + api(libs.scalaLibrary) + testImplementation(libs.scalaTestPlusJunit) + testImplementation(libs.scalaTest) + testImplementation(libs.diffx) +} + +tasks.withType().configureEach { + scalaCompileOptions.additionalParameters = + listOf("-Xsource:3", "-release:${buildInfo.jvmTarget}", "-target:${buildInfo.jvmTarget}") +} + +tasks.test { + useJUnitPlatform { + includeEngines("scalatest") + testLogging { events("passed", "skipped", "failed") } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1b128a6bf..83923b8f5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,6 +3,7 @@ assertj = "3.+" checksumPlugin = "1.4.0" clikt = "5.+" commonMark = "0.+" +diffx = "0.9.0" downloadTaskPlugin = "5.6.0" geantyref = "1.+" googleJavaFormat = "1.25.2" @@ -43,6 +44,10 @@ msgpack = "0.9.8" nexusPublishPlugin = "2.0.0" nuValidator = "20.+" paguro = "3.+" +scala = "2.13.17" +scalafmt = "3.10.0" +scalaTest = "3.2.19" +scalaTestPlusJunit = "3.2.19.0" shadowPlugin = "9.+" slf4j = "1.+" snakeYaml = "2.+" @@ -55,6 +60,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" } geantyref = { group = "io.leangen.geantyref", name = "geantyref", version.ref = "geantyref" } graalCompiler = { group = "org.graalvm.compiler", name = "compiler", version.ref = "graalVm" } @@ -87,6 +93,10 @@ nuValidator = { group = "nu.validator", name = "validator", version.ref = "nuVal # 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/NOTE.md b/pkl-config-scala/NOTE.md new file mode 100644 index 000000000..e901ea8d2 --- /dev/null +++ b/pkl-config-scala/NOTE.md @@ -0,0 +1,44 @@ +# Scala bindings for PKL language + +## Covered +- classes and case classes +- Scala `Option` for nullable PKL types +- Scala `Regexp` for PKL string/regexp +- Scala `Tuple2` for PKL Pair +- Scala `Duration` and `FiniteDuration` for PKL Duration +- Java `Instant` for PKL int and String (how about the rest of java.time?) +- Collections + - `immutable.Seq` + - `immutable.Vector` + - `immutable.List` + - `immutable.Set` + - `immutable.Map` + - `immutable.Stream` + - `immutable.LazyList` + - `mutable.Map` + - `mutable.Set` + - `mutable.Seq` + - `mutable.Buffer` + - `mutable.Queue` + - `mutable.Stack` +- Scala Enumeration (if annotated) + Scala 2 Enumeration is a runtime construct, we can't access it's members from Type referense. + To work around it, we introduced `@EnumOwner` annotation which you can use like below: + ``` + object SimpleEnum extends Enumeration { + + @EnumOwner(classOf[SimpleEnum.type]) + case class V() extends Val(nextId) + + val Aaa = V() + val Bbb = V() + val Ccc = V() + } + ``` + +## TODO +- more tests +- `Either` (???) +- `sealed traits` +- `object` instances +- cross-version compilation to cover scala `2.12` too 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..c22187a82 --- /dev/null +++ b/pkl-config-scala/pkl-config-scala.gradle.kts @@ -0,0 +1,36 @@ +/* + * Copyright © 2024-2025 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 { + pklAllProjects + pklScalaLibrary + 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/java/org/pkl/config/scala/annotation/EnumOwner.java b/pkl-config-scala/src/main/java/org/pkl/config/scala/annotation/EnumOwner.java new file mode 100644 index 000000000..7b0bb01ed --- /dev/null +++ b/pkl-config-scala/src/main/java/org/pkl/config/scala/annotation/EnumOwner.java @@ -0,0 +1,25 @@ +/* + * Copyright © 2025 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.annotation; + +import java.lang.annotation.*; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface EnumOwner { + + Class value(); +} 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..8a416557f --- /dev/null +++ b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/CachedConverterFactories.scala @@ -0,0 +1,163 @@ +/* + * Copyright © 2025 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..e0b9fac1d --- /dev/null +++ b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/CachedSourceTypeInfo.scala @@ -0,0 +1,78 @@ +/* + * Copyright © 2025 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 + +/** 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. + */ +private[mapper] class CachedSourceTypeInfo { + + // Initially set to an unavailable type and will be updated based on the input value type. + private var classInfo: PClassInfo[Any] = + PClassInfo.Unavailable.asInstanceOf[PClassInfo[Any]] + + // Holds an optional converter, cached upon first retrieval. + private var converter: Option[Converter[Any, Any]] = None + + /** Updates the `classInfo` and retrieves a converter if the type of `v` + * differs from the cached `classInfo`. If the types match, the cached + * converter is reused. + * + * This method leverages caching to avoid redundant converter lookups, + * improving efficiency when the same type conversions are repeatedly + * required. + * + * @param v + * The value for which conversion is needed. + * @param t + * The target type to which the value should be converted. + * @param vm + * The `ValueMapper` responsible for providing the appropriate converter. + * + * @return + * The converted value, transformed to match the specified target type `t`. + * + * @example + * Basic usage: + * {{{ + * val cachedInfo = new CachedSourceTypeInfo() + * val result = cachedInfo.updateAndGet(myValue, targetType, myValueMapper) + * }}} + */ + def updateAndGet(v: Any, t: Type, vm: ValueMapper): Any = { + // Determine if the cached classInfo matches the type of v; if not, update and find new converter. + val c: Converter[Any, Any] = if (!classInfo.isExactClassOf(v)) { + classInfo = PClassInfo.forValue(v) + vm.getConverter(classInfo, t) + } else { + // Use the cached converter or obtain a new one if not cached yet. + converter getOrElse vm.getConverter(classInfo, t) + } + + converter = Some(c) // Cache the converter for subsequent conversions + c.convert(v, vm) // Convert and return the value + } +} 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..62fad6a68 --- /dev/null +++ b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/JavaReflectionSyntaxExtensions.scala @@ -0,0 +1,134 @@ +/* + * Copyright © 2025 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 org.pkl.config.scala.annotation.EnumOwner + +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 + } + + /** Attempts to recover the full list of enumeration values from a given + * runtime class. + * + * This method is designed to work with Scala 2 `Enumeration` values that + * were defined using a custom subclass of `Enumeration.Val`, annotated + * with `@EnumOwner`, where the annotation holds a reference to the + * singleton `Enumeration` object. + * + * The method checks whether the provided `Type` is a subclass of + * `Enumeration#Value`, and if so, attempts to locate the `@EnumOwner` + * annotation on its class. If present, it uses reflection to access the + * singleton `Enumeration` instance and returns its list of values. + * + * @example + * {{{ + * object SimpleEnum extends Enumeration { + * + * @EnumOwner(classOf[SimpleEnum.type]) + * case class V() extends Val(nextId) + * + * val Aaa = V() + * val Bbb = V() + * val Ccc = V() + * } + * }}} + * + * @return + * Some(list of `Enumeration#Value`) if the enumeration can be resolved, + * None otherwise + */ + def asCustomEnum: Option[List[Enumeration#Value]] = { + + def derive(enumClass: Class[_]): Option[List[Enumeration#Value]] = { + try { + val f = enumClass.getDeclaredField("MODULE$") + f.setAccessible(true) + + val enumInstance = f.get(null).asInstanceOf[Enumeration] + Some(enumInstance.values.toList) + } catch { + case _: Throwable => None + } + } + + x match { + case x: Class[_] if classOf[Enumeration#Value].isAssignableFrom(x) => + for { + anno <- Option(x.getAnnotation(classOf[EnumOwner])) + enum <- derive(anno.value()) + } yield enum + + case _ => + None + } + } + } +} diff --git a/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/PStringOrIntToEnumeration.scala b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/PStringOrIntToEnumeration.scala new file mode 100644 index 000000000..50d5bd861 --- /dev/null +++ b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/PStringOrIntToEnumeration.scala @@ -0,0 +1,59 @@ +/* + * Copyright © 2025 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, ValueMapper} +import org.pkl.config.scala.mapper.JavaReflectionSyntaxExtensions.ParametrizedTypeSyntaxExtension +import org.pkl.core.PClassInfo +import org.pkl.core.util.CodeGeneratorUtils + +import java.lang.reflect.Type +import java.util.Optional +import scala.jdk.OptionConverters._ + +private[mapper] object PStringOrIntToEnumeration extends ConverterFactory { + + override def create( + sourceType: PClassInfo[_], + targetType: Type + ): Optional[Converter[_, _]] = { + targetType.asCustomEnum.map { members => + (new Converter[Any, Any] { + override def convert(value: Any, valueMapper: ValueMapper): Any = { + val res = value match { + case i: Long => + members.collectFirst { + case value if value.id == i => value + } + case name: String => + members.collectFirst { + case value if { + val n = value.toString; + n == name || CodeGeneratorUtils + .toEnumConstantName(n) + .equals(name) + } => + value + } + case _ => None + } + + res.orNull + } + }).asInstanceOf[Converter[_, _]] + }.toJava + } +} 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..df03bf0fb --- /dev/null +++ b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ScalaConversions.scala @@ -0,0 +1,79 @@ +/* + * Copyright © 2025 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 => 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..515386f37 --- /dev/null +++ b/pkl-config-scala/src/main/scala/org/pkl/config/scala/mapper/ScalaConverterFactories.scala @@ -0,0 +1,246 @@ +/* + * Copyright © 2025 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, + PObjectToDataObject, + ValueMapper +} +import org.pkl.config.scala.mapper.JavaReflectionSyntaxExtensions.ParametrizedTypeSyntaxExtension +import org.pkl.core.util.CodeGeneratorUtils +import org.pkl.core.{PClassInfo, PNull, PObject, Pair} + +import java.lang.reflect.{Constructor, Type} +import java.util.Optional +import scala.annotation.nowarn +import scala.collection.{immutable, mutable} +import scala.jdk.CollectionConverters._ +import scala.jdk.OptionConverters._ +import scala.language.implicitConversions + +/** Defines a set of PKL to Scala 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 = 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[_]]( + _ => 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 pMapToMutableMapConv: Conv2[java.util.Map[_, _], mutable.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)) + } + } + + def pCollectionToMutableCollectionConv[T[_]]( + toSpecific: IterableOnce[_] => T[_] + ): Conv1[java.util.Collection[_], T[_]] = + t1 => + (value, cache, vm) => + toSpecific(value.asScala.map(x => cache.updateAndGet(x, t1, vm))) + + val pMapToImmutableMap: ConverterFactory = CachedConverterFactories + .forParametrizedType2[java.util.Map[_, _], immutable.Map[_, _]]( + PClassInfo.Map, + (t1, t2) => + (value, cc, vm) => pMapToMutableMapConv(t1, t2)(value, cc, vm).toMap + ) + + val pMapToMutableMap: ConverterFactory = CachedConverterFactories + .forParametrizedType2[java.util.Map[_, _], mutable.Map[_, _]]( + PClassInfo.Map, + pMapToMutableMapConv + ) + + val pObjectToImmutableMap: ConverterFactory = + CachedConverterFactories.forParametrizedType2[PObject, immutable.Map[_, _]]( + x => x == PClassInfo.Object | x == PClassInfo.Dynamic, + (t1, t2) => + (value, cc, vm) => + pMapToMutableMapConv(t1, t2)(value.getProperties, cc, vm).toMap + ) + + val pObjectToMutableMap: ConverterFactory = + CachedConverterFactories.forParametrizedType2[PObject, mutable.Map[_, _]]( + x => x == PClassInfo.Object | x == PClassInfo.Dynamic, + (t1, t2) => + (value, cc, vm) => + pMapToMutableMapConv(t1, t2)(value.getProperties, cc, vm) + ) + + // val pCollectionToArray: ConverterFactory = CachedConverterFactories + // .forParametrizedType1[java.util.Collection[_], Array[_]]( + // x => x == PClassInfo.Collection | x == PClassInfo.Set | x == PClassInfo.List, + // pCollectionToMutableCollectionConv[Array](_.iterator.toArray[Any]) + // ) + + val pCollectionToImmutableSet: ConverterFactory = CachedConverterFactories + .forParametrizedType1[java.util.Collection[_], immutable.Set[_]]( + x => + x == PClassInfo.Collection | x == PClassInfo.Set | x == PClassInfo.List, + pCollectionToMutableCollectionConv(_.iterator.toSet) + ) + + val pCollectionToMutableSet: ConverterFactory = CachedConverterFactories + .forParametrizedType1[java.util.Collection[_], mutable.Set[_]]( + x => + x == PClassInfo.Collection | x == PClassInfo.Set | x == PClassInfo.List, + pCollectionToMutableCollectionConv(mutable.Set.from) + ) + + val pCollectionToImmutableVector: ConverterFactory = CachedConverterFactories + .forParametrizedType1[java.util.Collection[_], immutable.Vector[_]]( + x => + x == PClassInfo.Collection | x == PClassInfo.Set | x == PClassInfo.List, + pCollectionToMutableCollectionConv(_.iterator.toVector) + ) + + val pCollectionToImmutableSeq: ConverterFactory = CachedConverterFactories + .forParametrizedType1[java.util.Collection[_], immutable.Seq[_]]( + x => + x == PClassInfo.Collection | x == PClassInfo.Set | x == PClassInfo.List, + pCollectionToMutableCollectionConv(_.iterator.toSeq) + ) + + val pCollectionToMutableSeq: ConverterFactory = CachedConverterFactories + .forParametrizedType1[java.util.Collection[_], mutable.Seq[_]]( + x => + x == PClassInfo.Collection | x == PClassInfo.Set | x == PClassInfo.List, + pCollectionToMutableCollectionConv(mutable.Seq.from) + ) + + val pCollectionToMutableBuffer: ConverterFactory = CachedConverterFactories + .forParametrizedType1[java.util.Collection[_], mutable.Buffer[_]]( + x => + x == PClassInfo.Collection | x == PClassInfo.Set | x == PClassInfo.List, + pCollectionToMutableCollectionConv(mutable.Buffer.from) + ) + + val pCollectionToImmutableList: ConverterFactory = CachedConverterFactories + .forParametrizedType1[java.util.Collection[_], immutable.List[_]]( + x => + x == PClassInfo.Collection | x == PClassInfo.Set | x == PClassInfo.List, + pCollectionToMutableCollectionConv(_.iterator.toList) + ) + + val pCollectionToMutableQueue: ConverterFactory = CachedConverterFactories + .forParametrizedType1[java.util.Collection[_], mutable.Queue[_]]( + x => + x == PClassInfo.Collection | x == PClassInfo.Set | x == PClassInfo.List, + pCollectionToMutableCollectionConv(mutable.Queue.from) + ) + + val pCollectionToMutableStack: ConverterFactory = CachedConverterFactories + .forParametrizedType1[java.util.Collection[_], mutable.Stack[_]]( + x => + x == PClassInfo.Collection | x == PClassInfo.Set | x == PClassInfo.List, + pCollectionToMutableCollectionConv(mutable.Stack.from) + ) + + @nowarn("cat=deprecation") + val pCollectionToImmutableStream: ConverterFactory = CachedConverterFactories + .forParametrizedType1[java.util.Collection[_], immutable.Stream[_]]( + x => + x == PClassInfo.Collection | x == PClassInfo.Set | x == PClassInfo.List, + pCollectionToMutableCollectionConv(_.iterator.toStream) + ) + + val pCollectionToImmutableLazyList + : ConverterFactory = CachedConverterFactories + .forParametrizedType1[java.util.Collection[_], immutable.LazyList[_]]( + x => + x == PClassInfo.Collection | x == PClassInfo.Set | x == PClassInfo.List, + pCollectionToMutableCollectionConv(_.iterator.to(LazyList)) + ) + + // 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, + pCollectionToImmutableStream, + pCollectionToImmutableSet, + pCollectionToImmutableList, + pCollectionToImmutableVector, + pCollectionToImmutableLazyList, + pCollectionToImmutableSeq, + pObjectToImmutableMap, + pMapToMutableMap, + pObjectToMutableMap, + pCollectionToMutableStack, + pCollectionToMutableSet, + pCollectionToMutableQueue, + pCollectionToMutableBuffer, + pCollectionToMutableSeq, + pObjectToCaseClass, + PStringOrIntToEnumeration + // pCollectionToArray, + ) + + private implicit def pClassInfoToPredicate( + x: PClassInfo[_] + ): PClassInfo[_] => Boolean = _ == x +} 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..780c81ee2 --- /dev/null +++ b/pkl-config-scala/src/main/scala/org/pkl/config/scala/syntax/package.scala @@ -0,0 +1,137 @@ +/* + * Copyright © 2025 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.{ConversionException, 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. If a `null` is returned or the retrieved + * value does not match the target type, a `ConversionException` is thrown. + * This encourages the use of `Option` for nullable values in configurations. + * + * @param ct + * Implicit `ClassTag` of the target type `T`. + * + * @throws ConversionException + * if the value is `null` or does not match the specified type `T`. + * + * @example + * Retrieving a value as an Option: + * {{{ + * val myPklConfig: Config = // load or build a PKL config + * val config: MyScalaConfig = myPklConfig.to[MyCaseClass] + * }}} + */ + implicit class ConfigSyntaxExtension(val x: Config) extends AnyVal { + def to[T](implicit ct: ClassTag[T]): T = { + val result = x.as[T](ct.runtimeClass) + if (result == null || !result.isInstanceOf[T]) { + throw new ConversionException( + "Expected a non-null value but got `null`. " + + "To allow optional values, use `Option`. e.g. `Option[String]`." + ) + } + result + } + } +} 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..35d0c60a3 --- /dev/null +++ b/pkl-config-scala/src/test/scala/org/pkl/config/scala/ScalaObjectMapperSpec.scala @@ -0,0 +1,279 @@ +/* + * Copyright © 2025 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.ConfigEvaluator +import org.pkl.config.scala.syntax._ +import org.scalatest.funsuite.AnyFunSuite +import org.pkl.core.{ModuleSource, Duration => PDuration} + +import java.time.{Instant, Duration => JDuration} +import java.util.concurrent.TimeUnit +import scala.concurrent.duration.{Duration, FiniteDuration} +import com.softwaremill.diffx._ +import com.softwaremill.diffx.scalatest.DiffShouldMatcher._ +import org.pkl.config.scala.annotation.EnumOwner + +import scala.annotation.nowarn +import scala.collection.mutable + +@nowarn("cat=deprecation") +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" + | + |// Vector + |vector = List(1, 6, 9) + | + |// Seq + |seq = List(9, 5, 36, 1) + |mutableSeq = List("d", "a") + | + |// Buffer + |mutableBuffer = List("hoo", "ray") + | + |// Queue + |mutableQueue = Set("hoo", "ray") + | + |// Stack + |mutableStack = Set("hoo", "ray") + | + |// Duration + |pklDuration: Duration = 5.ms + |scalaFiniteDuration: Duration = 5.ms + |scalaDuration: Duration = 5000000.ns + | + |// Sets + |stringSet: Set = Set("in set") + |intSet: Set = Set(1,2,4,8,16,32) + |booleanSetSet: Set> = Set(Set(false), Set(true), Set(true, false)) + |mutableSet = Set("aaa", "cc", "b") + | + |// Lists + |stringList: List = List("in list") + |intList: List = List(1,2,3,5,7,11) + |booleanListList: List> = List(List(false), List(true), List(true, false)) + | + |// Streams + |stream: List = List("stream1", "stream2") + | + |// LazyList + |lazyList: List = List(5, 4, 7, 1) + | + |// Maps + |intStringMap: Map = Map(0, "in map") + |booleanIntStringMapMap: Map> = Map(false, Map(0, "in map in map")) + |booleanIntMapStringMap: Map, String> = Map(Map(true, 42), "in map with map keys") + | + |// Listings + |stringSetListing: Listing> = new { Set("in set in listing") } + |intListingListing: Listing> = new { new { 1337 } new { 100 } } + | + |// Mappings + |intStringMapping: Mapping = new { [42] = "in map" } + |stringStringSetMapping: Mapping> = new { ["key"] = Set("in set in map") } + | + |// Mutable Map + |mutableMap = Map("foo", "bar") + | + |// Map & Mappings with structured keys + |intSetListStringMap: Map>, String> = Map(List(Set(27)), "in map with structured key") + |typedStringMap: Map = Map( + | new Foo { value = 1 }, "using typed objects", + | new Foo { value = 2 }, "also works") + |dynamicStringMap: Map = Map( + | new Dynamic { value = 42 }, "using Dynamics", + | new Dynamic { hello = "world" }, "also works") + | + |intListingStringMapping: Mapping, String> = new { + | [new Listing { 42 1337 }] = "structured key works" + |} + |intSetListStringMapping: Mapping>, String> = new { + | [List(Set(27))] = "in mapping with structured key" + |} + |local intListing: Listing = new { 0 0 7 } + |thisOneGoesToEleven: Mapping>, Map, Mapping>> = new { + | [List(Set(0), Set(0), Set(7))] = Map(intListing, intStringMapping) + |} + | + |simpleEnumViaString = "Bbb" + |simpleEnumViaInt = 0 + |""".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"), + stringSet = Set("in set"), + intSet = Set(1, 2, 32, 16, 8, 4), + booleanSetSet = Set(Set(false), Set(true), Set(true, false)), + stringList = List("in list"), + intList = List(1, 2, 3, 5, 7, 11), + booleanListList = List(List(false), List(true), List(true, false)), + vector = Vector(1, 6, 9), + seq = Seq(9, 5, 36, 1), + stream = Stream("stream1", "stream2"), + lazyList = LazyList(5, 4, 7, 1), + intStringMap = Map(0 -> "in map"), + booleanIntStringMapMap = Map(false -> Map(0 -> "in map in map")), + booleanIntMapStringMap = Map(Map(true -> 42) -> "in map with map keys"), + intSetListStringMap = Map(List(Set(27)) -> "in map with structured key"), + typedStringMap = Map( + TypedKey(1) -> "using typed objects", + TypedKey(2) -> "also works" + ), + dynamicStringMap = Map( + Map("value" -> 42) -> "using Dynamics", + Map("hello" -> "world") -> "also works" + ), + mutableMap = mutable.Map("foo" -> "bar"), + mutableSet = mutable.Set("cc", "aaa", "b"), + mutableSeq = mutable.Seq("d", "a"), + mutableBuffer = mutable.Buffer("hoo", "ray"), + mutableQueue = mutable.Queue("hoo", "ray"), + mutableStack = mutable.Stack("hoo", "ray"), + stringSetListing = List(Set("in set in listing")), + intListingListing = List(List(1337), List(100)), + intStringMapping = Map(42 -> "in map"), + stringStringSetMapping = Map("key" -> Set("in set in map")), + intListingStringMapping = Map(List(42, 1337) -> "structured key works"), + intSetListStringMapping = + Map(List(Set(27)) -> "in mapping with structured key"), + thisOneGoesToEleven = Map( + List(Set(0), Set(0), Set(7)) -> Map( + List(0, 0, 7) -> Map(42 -> "in map") + ) + ), + simpleEnumViaString = SimpleEnum.Bbb, + simpleEnumViaInt = SimpleEnum.Aaa + ) + } +} + +@nowarn("cat=deprecation") +object ScalaObjectMapperSpec { + + case class TypedKey(value: Int) + object TypedKey { + implicit val diffx: Diff[TypedKey] = Diff.derived[TypedKey] + } + + object SimpleEnum extends Enumeration { + @EnumOwner(classOf[SimpleEnum.type]) + case class V() extends Val(nextId) + + val Aaa = V() + val Bbb = V() + val Ccc = V() + } + + case class ObjectMappingTestContainer( + // Options + optionalVal1: Option[String], + optionalVal2: Option[String], + // Duration + pklDuration: PDuration, + scalaFiniteDuration: FiniteDuration, + scalaDuration: Duration, + // Instant + instant1: Instant, + instant2: Instant, + // Sets + stringSet: Set[String], + intSet: Set[Int], + booleanSetSet: Set[Set[Boolean]], + // Lists + stringList: List[String], + intList: List[Int], + booleanListList: List[List[Boolean]], + // Stream + stream: Stream[String], + // LazyList + lazyList: LazyList[Int], + // Vector + vector: Vector[Int], + // Seq + seq: Seq[Int], + // Maps + intStringMap: Map[Int, String], + booleanIntStringMapMap: Map[Boolean, Map[Int, String]], + booleanIntMapStringMap: Map[Map[Boolean, Int], String], + intSetListStringMap: Map[List[Set[Int]], String], + typedStringMap: Map[TypedKey, String], + dynamicStringMap: Map[Map[String, Any], String], + // mutable.Map + mutableMap: mutable.Map[String, String], + // mutable.Set + mutableSet: mutable.Set[String], + // mutable.Seq + mutableSeq: mutable.Seq[String], + // mutable.Buffer + mutableBuffer: mutable.Buffer[String], + // mutable.Queue + mutableQueue: mutable.Queue[String], + // mutable.Stack + mutableStack: mutable.Stack[String], + // Listings + stringSetListing: List[Set[String]], + intListingListing: List[List[Int]], + // Mapping + intStringMapping: Map[Int, String], + stringStringSetMapping: Map[String, Set[String]], + // Map & Mapping with structured keys + intListingStringMapping: Map[List[Int], String], + intSetListStringMapping: Map[List[Set[Int]], String], + thisOneGoesToEleven: Map[ + List[Set[Int]], + Map[List[Int], Map[Int, String]] + ], + // enums + simpleEnumViaString: SimpleEnum.V, + simpleEnumViaInt: SimpleEnum.V + ) + + 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..29859daae --- /dev/null +++ b/pkl-config-scala/src/test/scala/org/pkl/config/scala/mapper/PPairToScalaTupleSpec.scala @@ -0,0 +1,102 @@ +/* + * Copyright © 2025 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 de11c8314..c6906a4a1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -35,6 +35,8 @@ include("pkl-config-java") include("pkl-config-kotlin") +include("pkl-config-scala") + include("pkl-core") include("pkl-doc")