PKL Config Scala#1076
Conversation
c101308 to
bb90a58
Compare
bioball
left a comment
There was a problem hiding this comment.
Made a first pass. Thanks for the contribution, this looks like a great start!
| rootProject.file("buildSrc/src/main/resources/license-header.star-block.txt"), | ||
| "package ", | ||
| ) | ||
| } |
There was a problem hiding this comment.
Move this to pklScalaLibrary.gradle.kts
| val libs = the<LibrariesForLibs>() | ||
|
|
||
| dependencies { | ||
| api(libs.scalaLibrary) |
There was a problem hiding this comment.
Remove (this is redundant when setting scala.scalaVersion)
| api(libs.scalaLibrary) |
There was a problem hiding this comment.
Removed api(libs.scalaLibrary) — redundant now that the plugin wires it in.
| 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 |
There was a problem hiding this comment.
These should be thread-safe, because ValueMappers can possibly be shared across threads.
Maybe put inside AtomicReference?
There was a problem hiding this comment.
Done. Merged the two vars into a single AtomicReference[Entry(classInfo, converter)] so the paired state updates coherently — no risk of a reader seeing a new classInfo with a stale converter. Under contention, threads may each compute a fresh converter (wasted getConverter lookup), but never see a torn state.
| override def create( | ||
| sourceType: PClassInfo[_], | ||
| targetType: Type | ||
| ): Optional[Converter[_, _]] = { |
There was a problem hiding this comment.
Two comments:
- This should return
Optional.emptyifsourceTypeis notPClassInfo.String - The return type should be
Optional[Converter[String, Enumeration]]right? Is there any case where the mapped value wouldn't be anEnumeration?
There was a problem hiding this comment.
Both points addressed, but the final shape diverged from the initial refactor. Enum params no longer go through the generic ConverterFactory.create path at all — they're dispatched directly via pattern match on Param.Type.ScalaEnum inside ScalaPObjectToCaseClass (see #12). So the question of whether to tighten the Optional[Converter[_, _]] signature is moot: the wildcard never participates in enum resolution anymore. The inner lookup is strongly typed: String => Enumeration#Value.
| // .forParametrizedType1[java.util.Collection[_], Array[_]]( | ||
| // x => x == PClassInfo.Collection | x == PClassInfo.Set | x == PClassInfo.List, | ||
| // pCollectionToMutableCollectionConv[Array](_.iterator.toArray[Any]) | ||
| // ) |
There was a problem hiding this comment.
Removed — both the commented-out block and its entry in all.
| * Some(list of `Enumeration#Value`) if the enumeration can be resolved, | ||
| * None otherwise | ||
| */ | ||
| def asCustomEnum: Option[List[Enumeration#Value]] = { |
There was a problem hiding this comment.
I'm not totally following the code here.
Why do we need to care about case classes here? Can we expect enums to simply look like this instead?
object SimpleEnum extends Enumeration {
val Aaa, Bbb, Ccc = Value
}And we can just use .values to access enum members during conversion.
Pkl only turns string literal unions into enums in every other language; this means that we don't need our enum values to be parameterized.
There was a problem hiding this comment.
Done — and the annotation is deleted, not just optional. Both forms work now:
// plain — previously unsupported
object Color extends Enumeration { val Red, Green, Blue = Value }
case class Palette(primary: Color.Value)
// nested-Val form, still supported, no annotation needed
object Shape extends Enumeration {
case class V() extends Val(nextId)
val Circle, Square = V()
}
case class Drawing(shape: Shape.V)Getting there required more than .getDeclaringClass. For the plain form the runtime class is scala.Enumeration$Value, shared across every Enumeration in the JVM — Java reflection can't distinguish owners. I explored three workarounds before settling on the current approach:
- Pre-substitute resolved
Valueinstances into thePObject's property map and delegate. Dead —PClassInfo.forValuethrows on non-Pkl values. - Swap enum-param
Types for a sentinelParameterizedTypecarrying theEnumerationreference. Dead —ValueMapperImpl.getConvertercallsReflection.getExactSubtype(...)before factory dispatch, which normalises customTypes back to their raw class. - Upstream patch (make
createnon-final / add a per-param hook). Out of scope for this PR.
What we landed on:
ConstructorParamResolverusesscala.reflect.runtime.universeonly to walk ctor params and recover the path-dependentEnumeration#Valueprefix. It emits pure-dataParams with a sealedParam.Type { Jvm | ScalaEnum }.ScalaPObjectToCaseClassforksPObjectToDataObject.ConverterImpland dispatches onParam.Typedirectly. ForScalaEnumit bypassesValueMapper.getConverter(which can't see past the erasure) and does a direct member lookup.
The fork is the main cost — roughly 150 lines duplicated from PObjectToDataObject.ConverterImpl.convert. Happy to collapse it if you'd accept an upstream patch exposing a per-param hook; that would reduce this module to a slim subclass. Architectural details are in the updated PR description.
5a85162 to
023c5be
Compare
023c5be to
c8a7a01
Compare
Purpose
Opens the road to first-class Scala support in Pkl. This PR adds
pkl-config-scala— the binding/runtime layer that lets hand-authored Scala case classes consume Pkl configs. A follow-up PR will addpkl-codegen-scala(the source generator that pairs with the existingpkl-codegen-java/pkl-codegen-kotlin), closing the loop so users can either hand-write or generate their Scala types.How it looks:
What's covered
class Foo { x: Int }case class Foo(x: Int)scala.Enumeration— both plain (val A, B = Value) and nested-Val formsString,Int,Boolean,FloatString,Int/Long,Boolean,Double(via built-in JVM mapping)Durationscala.concurrent.duration.Duration,FiniteDurationInt(epoch) /String(ISO-8601)java.time.InstantString/Regexscala.util.matching.RegexPair<A, B>(A, B)/Tuple2[A, B]List<T>,Set<T>,Listing<T>,Collection<T>Seq[T]Map<K, V>,Mapping<K, V>immutable.Map[K, V]T?Option[T]Intentionally not covered
These route through a
ConversionException; users with hand-authored types register their own converter factories:Set[T],Vector[T], mutable collections — matches the minimal default set inpkl-config-java/pkl-config-kotlin.enum Foo { case A, B, C }— deferred; the current resolver targets Scala 2scala.Enumeration. A follow-up can add a parallel path.Architectural decisions
Two coupled decisions drive the bulk of the non-obvious code. Calling them out because they break from the common infrastructure pattern in
pkl-config-java.1. Narrow use of
scala-reflectfor enum resolutionProblem. Pkl codegen compiles a string-literal union into a language-native enum in every target. For Scala 2, the natural target is
scala.Enumeration, and the user writescase class Palette(color: SimpleEnum.Value). ButSimpleEnum.Valueis a path-dependent type: Java reflection erases it toscala.Enumeration$Value, a class shared across everyEnumerationsubclass in the JVM. At the point where aConverterFactoryis asked for a converter, we've lost the information needed to know whichEnumerationto look up.Approaches considered and rejected.
Valueinstances into thePObject's property map and delegate to stockPObjectToDataObject. Dead —PClassInfo.forValue(...)throws on any non-Pkl value.ParameterizedTypethat carries theEnumerationreference. Dead —ValueMapperImpl.getConverternormalises customTypesubclasses back to their rawClassviaReflection.getExactSubtype(...)before dispatching to factories, stripping any embedded information.pkl-config-java(makingcreatenon-final or adding a per-param hook). Out of scope for this PR.What we landed on. A narrow use of Scala runtime reflection, surfaced in
ConstructorParamResolver:Class, usingscala.reflect.runtime.universeto recover path-dependentEnumeration#Valuetypes and their originatingEnumerationmodule.Seq[Param]where each param is pure data:Paramdescribes everything statically known about the slot; no converters, no runtime state — inspectable in a debugger or a log line.TrieMap[Class[?], Map[Int, Enumeration]]; mirror calls are wrapped inuniverse.synchronizedbecause scala-reflect is not thread-safe by default.2. Fork of
PObjectToDataObject.ConverterImplProblem. Even with
ConstructorParamResolvertelling us "param 3 is aScalaEnumof{Aaa, Bbb, Ccc}," the stockPObjectToDataObject.ConverterImpl.convertdispatches per-param throughValueMapper.getConverter(PClassInfo.String, paramType). TheparamTypearriving at the factory is still the erasedscala.Enumeration$Value— no generic factory can recover the specific enumeration from it. And the stockcreate(...)isfinal, the innerConverterImplisprivate, so we can't interpose.What we landed on.
ScalaPObjectToCaseClassre-implements the ctor-mapping loop in Scala. The dispatch is a direct pattern match onParam.Type:Param.Type.Jvm→ delegates to a per-paramCachedSourceTypeInfo(already present in the module for collection-factory caching). This memoises the expensiveValueMapper.getConverterlookup atomically.Param.Type.ScalaEnum→ direct linear scan over pre-resolved members via a small helper function. No external dispatch means no cache is needed.Trade-offs.
pkl-config-java/PObjectToDataObject.ConverterImpl.convert. If upstream gains features (new annotation support, etc.), we'll need to port them. This is the biggest structural cost of the PR.createnon-final or exposing a hook would let this fork collapse back into a small subclass override. Open to doing that if maintainers prefer — would shrink this PR considerably.ConstructorParamResolveruses the Scala 2 reflection API (scala.reflect.runtime.universe). A Scala 3 migration will need a parallel resolver. Deferred.Supporting decisions (lower-stakes)
CachedSourceTypeInfo— cache entries areAtomicReference[Entry(classInfo, converter)]so the paired fields update coherently, without the tearing risk of separatevars.pkl-config-java/pkl-config-kotlin(Seq, Map, Option, Pair, case class, enum). Users register their own forSet/Vector/ mutable collections.TrieMapoverConcurrentHashMap, ScalaOption+.toJavaat Java boundaries,Seq[Param]at API surfaces withVectorat construction time). Java types kept only where the API demands them (java.lang.reflect.*,MethodHandle,Optionalreturn).-Xsource:3+ scalafmt'srunner.dialect = scala3. The module compiles on Scala 2.13 today; the syntax migration reduces churn whenever the rest of the codebase moves to Scala 3.