Skip to content

Commit

Permalink
Config comments (#2173)
Browse files Browse the repository at this point in the history
* wip

* wip: config docs in jsonschemagenerator

* wip: scala3

* works

* wip: scala3

* wip: cleanups

* wip: cleanups
  • Loading branch information
pshirshov authored Sep 12, 2024
1 parent 7f89508 commit 8914ed6
Show file tree
Hide file tree
Showing 16 changed files with 203 additions and 71 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ class ConstructorUtil[Q <: Quotes](using val qctx: Q) { self =>
def makeFunctoid[R: Type](params: List[ParamRepr], argsLambda: Expr[Seq[Any] => R], providerType: Expr[ProviderType]): Expr[Functoid[R]] = {

val paramDefs = params.map {
case ParamRepr(n, s, t) => paramsMacro.makeParam(n, Right(t), s)
case ParamRepr(n, s, t) => paramsMacro.makeParam(n, Right(t), s, Right(t))
}

val out = '{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package izumi.distage.config.codec

import com.typesafe.config.ConfigValueFactory
import izumi.distage.config.codec.ConfigMetaType.ConfigField
import izumi.distage.config.model.ConfigDoc
import magnolia1.{CaseClass, SealedTrait, TypeName}
import pureconfig.generic.{CoproductHint, ProductHint}

Expand All @@ -25,26 +27,33 @@ object MetaInstances {
}

private def configMetaJoin[A](ctx: CaseClass[DIConfigMeta, A])(implicit productHint: ProductHint[A]): ConfigMetaType = {
val maybeDocs = extractAnnos(ctx.annotations)

def fields0: ConfigMetaType.TCaseClass = ConfigMetaType.TCaseClass(
convertId(ctx.typeName),
ctx.parameters.map(
p => {
val maybeFieldAnnos = extractAnnos(p.annotations)
val realLabel = {
productHint.to(Some(ConfigValueFactory.fromAnyRef("x")), p.label) match {
case Some((processedLabel, _)) => processedLabel
case None => p.label
}
}
(realLabel, p.typeclass.asInstanceOf[DIConfigMeta[Any]].tpe)
ConfigField(realLabel, p.typeclass.asInstanceOf[DIConfigMeta[Any]].tpe, maybeFieldAnnos)
}
),
maybeDocs,
)

if (ctx.typeName.full.startsWith("scala.Tuple")) ConfigMetaType.TUnknown()
else if (ctx.isValueClass) fields0.fields.head._2 /* NB: AnyVal codecs are not supported on Scala 3 */
else if (ctx.isValueClass) fields0.fields.head.tpe /* NB: AnyVal codecs are not supported on Scala 3 */
else fields0
}

private def configMetaSplit[A](ctx: SealedTrait[DIConfigMeta, A])(implicit coproductHint: CoproductHint[A]): ConfigMetaType = {
val maybeDocs = extractAnnos(ctx.annotations)

// Only support Circe-like sealed trait encoding
if (coproductHint == PureconfigHints.circeLikeCoproductHint) {
ConfigMetaType.TSealedTrait(
Expand All @@ -54,6 +63,7 @@ object MetaInstances {
val realLabel = s.typeName.short // no processing is required for Circe-like hint
(realLabel, s.typeclass.asInstanceOf[DIConfigMeta[Any]].tpe)
}.toSet,
maybeDocs,
)
} else {
ConfigMetaType.TUnknown()
Expand All @@ -63,5 +73,10 @@ object MetaInstances {
private def convertId(name: TypeName): ConfigMetaTypeId = {
ConfigMetaTypeId(Some(name.owner), name.short, name.typeArguments.map(convertId))
}

private def extractAnnos(annos: Seq[Any]) = {
Option(annos.collect { case d: ConfigDoc => d }.map(_.doc)).filter(_.nonEmpty).map(_.mkString("\n"))
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ object MetaAutoDerive {

@inline def derived[A](implicit ev: MetaAutoDerive[A]): DIConfigMeta[A] = ev.value

inline implicit def materialize[A](implicit m: Mirror.Of[A]): MetaAutoDerive[A] = {
transparent inline implicit def materialize[A](implicit m: Mirror.Of[A]): MetaAutoDerive[A] = {
new MetaAutoDerive[A](izumi.distage.config.codec.MetaInstances.configReaderDerivation.derived[A](using m))
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package izumi.distage.config.codec

import izumi.distage.config.codec.ConfigMetaType.ConfigField
import izumi.distage.config.model.ConfigDoc
import izumi.fundamentals.platform.reflection.ReflectionUtil
import pureconfig.generic.derivation.Utils

import scala.compiletime.ops.int.+
Expand All @@ -12,15 +15,21 @@ object MetaInstances {
// magnolia on scala3 abuses inlines and requires high inlines limit (e.g. -Xmax-inlines:1024).
// so we had to re-implement derivation manually and copy-paste the type id logic from Magnolia
object configReaderDerivation {
inline def derived[A](using m: Mirror.Of[A]): DIConfigMeta[A] = {
transparent inline def summonDIConfigMeta[A]: DIConfigMeta[A] =
summonFrom {
case reader: DIConfigMeta[A] => reader
case given Mirror.Of[A] => derived[A]
}

transparent inline def derived[A](using m: Mirror.Of[A]): DIConfigMeta[A] = {
inline m match {
case given Mirror.ProductOf[A] => derivedProduct
case given Mirror.SumOf[A] => derivedSum
}
}

/** Override pureconfig's default `kebab-case` fields – force CamelCase product-hint */
inline def derivedProduct[A](using m: Mirror.ProductOf[A]): DIConfigMeta[A] = {
transparent inline def derivedProduct[A](using m: Mirror.ProductOf[A]): DIConfigMeta[A] = {
val tname = constValue[m.MirroredLabel]

inline erasedValue[A] match {
Expand All @@ -30,20 +39,26 @@ object MetaInstances {
case _ =>
val tpe: ConfigMetaType = {
val labels: Array[String] = Utils.transformedLabels[A](PureconfigInstances.configReaderDerivation.fieldMapping).toArray

val codecs = readTuple[m.MirroredElemTypes, 0]

import izumi.fundamentals.collections.IzCollections.*
val annos = fieldAnnos[A].map { case (k, v) => (PureconfigInstances.configReaderDerivation.fieldMapping(k), v) }.toMultimap

ConfigMetaType.TCaseClass(
typeId[A],
labels.iterator
.zip(codecs).map {
case (label, reader) => (label, reader.tpe)
case (label, reader) => ConfigField(label, reader.tpe, annos.get(label).flatMap(_.headOption))
}.toSeq,
typeDocs[A],
)
}
DIConfigMeta[A](tpe)
}
}

inline def readTuple[T <: Tuple, N <: Int]: List[DIConfigMeta[Any]] =
transparent inline def readTuple[T <: Tuple, N <: Int]: List[DIConfigMeta[Any]] =
inline erasedValue[T] match {
case _: (h *: t) =>
val reader = summonDIConfigMeta[h]
Expand All @@ -54,12 +69,6 @@ object MetaInstances {
Nil
}

inline def summonDIConfigMeta[A]: DIConfigMeta[A] =
summonFrom {
case reader: DIConfigMeta[A] => reader
case given Mirror.Of[A] => derived[A]
}

/** Override pureconfig's default `type` field type discriminator for sealed traits.
* Instead, use `circe`-like format with a single-key object. Example:
*
Expand All @@ -79,7 +88,7 @@ object MetaInstances {
* }
* }}}
*/
inline def derivedSum[A](using m: Mirror.SumOf[A]): DIConfigMeta[A] = {
transparent inline def derivedSum[A](using m: Mirror.SumOf[A]): DIConfigMeta[A] = {
val options: Map[String, DIConfigMeta[A]] =
Utils
.transformedLabels[A](PureconfigInstances.configReaderDerivation.fieldMapping)
Expand All @@ -91,18 +100,19 @@ object MetaInstances {
options.map {
case (label, reader) => (label, reader.tpe)
}.toSet,
typeDocs[A],
)

DIConfigMeta[A](tpe)
}

inline def deriveForSubtypes[T <: Tuple, A]: List[DIConfigMeta[A]] =
transparent inline def deriveForSubtypes[T <: Tuple, A]: List[DIConfigMeta[A]] =
inline erasedValue[T] match {
case _: (h *: t) => deriveForSubtype[h, A] :: deriveForSubtypes[t, A]
case _: EmptyTuple => Nil
}

inline def deriveForSubtype[A0, A]: DIConfigMeta[A] =
transparent inline def deriveForSubtype[A0, A]: DIConfigMeta[A] =
summonFrom {
case reader: DIConfigMeta[A0] =>
reader.asInstanceOf[DIConfigMeta[A]]
Expand All @@ -116,6 +126,36 @@ object MetaInstances {
// https://github.com/softwaremill/magnolia/blob/scala3/core/src/main/scala/magnolia1/macro.scala#L135
private inline def typeId[T]: ConfigMetaTypeId = ${ typeIdImpl[T] }

private inline def typeDocs[T]: Option[String] = ${ typeDocsImpl[T] }

transparent inline def fieldAnnos[T]: List[(String, String)] = ${ fieldAnnosImpl[T] }

def fieldAnnosImpl[T: Type](using qctx: Quotes): Expr[List[(String, String)]] = {
import quotes.reflect.*

val tpe = TypeRepr.of[T]
val caseClassFields = tpe.typeSymbol.caseFields
val idAnnotationSym: Symbol = TypeRepr.of[ConfigDoc].typeSymbol

val fieldNames = caseClassFields.flatMap {
f =>
ReflectionUtil
.findSymbolAnnoString(f, idAnnotationSym)
.map(doc => f.name -> doc)
}

Expr.ofList(fieldNames.map(Expr.apply))
}

private def typeDocsImpl[T: Type](using Quotes): Expr[Option[String]] = {
import quotes.reflect.*

val idAnnotationSym: Symbol = TypeRepr.of[ConfigDoc].typeSymbol
val tpe = TypeRepr.of[T]

Expr(ReflectionUtil.findTypeAnnoString(tpe, idAnnotationSym))
}

private def typeIdImpl[T: Type](using Quotes): Expr[ConfigMetaTypeId] = {

import quotes.reflect.*
Expand Down Expand Up @@ -144,7 +184,6 @@ object MetaInstances {
)

def typeInfo(tpe: TypeRepr): Expr[ConfigMetaTypeId] = tpe match {

case AppliedType(tpe, args) =>
'{
ConfigMetaTypeId(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,10 @@ final class ConfigValueTest extends AnyWordSpec {
}

"Config fields meta" should {

"properly derive config maps" in {
getConfTag(TestConfigReaders.mapDefinition).tpe match {
case c: ConfigMetaType.TCaseClass =>
assert(c.fields.toMap.apply("mymap").isInstanceOf[TMap])
assert(c.fields.map(f => (f.name, f.tpe)).toMap.apply("mymap").isInstanceOf[TMap])
case _ =>
fail()
}
Expand All @@ -31,7 +30,7 @@ final class ConfigValueTest extends AnyWordSpec {
"properly derive config lists" in {
getConfTag(TestConfigReaders.listDefinition).tpe match {
case c: ConfigMetaType.TCaseClass =>
assert(c.fields.toMap.apply("mylist").isInstanceOf[TList])
assert(c.fields.map(f => (f.name, f.tpe)).toMap.apply("mylist").isInstanceOf[TList])
case _ =>
fail()
}
Expand All @@ -40,8 +39,8 @@ final class ConfigValueTest extends AnyWordSpec {
"be as expected for config options" in {
getConfTag(TestConfigReaders.optDefinition).tpe match {
case c: ConfigMetaType.TCaseClass =>
assert(c.fields.toMap.apply("optInt").isInstanceOf[TOption])
assert(c.fields.toMap.apply("optCustomObject").isInstanceOf[TOption])
assert(c.fields.map(f => (f.name, f.tpe)).toMap.apply("optInt").isInstanceOf[TOption])
assert(c.fields.map(f => (f.name, f.tpe)).toMap.apply("optCustomObject").isInstanceOf[TOption])
case _ =>
fail()
}
Expand All @@ -50,7 +49,7 @@ final class ConfigValueTest extends AnyWordSpec {
"be unknown for config tuples" in {
getConfTag(TestConfigReaders.tupleDefinition).tpe match {
case c: ConfigMetaType.TCaseClass =>
assert(c.fields.toMap.apply("tuple").isInstanceOf[TUnknown])
assert(c.fields.map(f => (f.name, f.tpe)).toMap.apply("tuple").isInstanceOf[TUnknown])
case _ =>
fail()
}
Expand All @@ -59,16 +58,16 @@ final class ConfigValueTest extends AnyWordSpec {
"be unknown for custom codecs" in {
getConfTag(TestConfigReaders.customCodecDefinition).tpe match {
case c: ConfigMetaType.TCaseClass =>
assert(c.fields.toMap.apply("customObject").isInstanceOf[TUnknown])
assert(c.fields.map(f => (f.name, f.tpe)).toMap.apply("customObject").isInstanceOf[TUnknown])

c.fields.toMap.apply("mapCustomObject") match {
c.fields.map(f => (f.name, f.tpe)).toMap.apply("mapCustomObject") match {
case m: TMap =>
assert(m.valueType.isInstanceOf[TUnknown])
case _ =>
fail()
}

c.fields.toMap.apply("mapListCustomObject") match {
c.fields.map(f => (f.name, f.tpe)).toMap.apply("mapListCustomObject") match {
case m: TMap =>
m.valueType match {
case l: TList =>
Expand All @@ -88,7 +87,7 @@ final class ConfigValueTest extends AnyWordSpec {
"be as expected for backticks" in {
getConfTag(TestConfigReaders.backticksDefinition).tpe match {
case c: ConfigMetaType.TCaseClass =>
assert(c.fields.toMap.apply("boo-lean").isInstanceOf[TBasic])
assert(c.fields.map(f => (f.name, f.tpe)).toMap.apply("boo-lean").isInstanceOf[TBasic])
case _ =>
fail()
}
Expand All @@ -97,7 +96,7 @@ final class ConfigValueTest extends AnyWordSpec {
"be as expected for case classes with private fields" in {
getConfTag(TestConfigReaders.privateFieldsCodecDefinition).tpe match {
case c: ConfigMetaType.TCaseClass =>
assert(c.fields.toMap.apply("private-custom-field-name").isInstanceOf[TBasic])
assert(c.fields.map(f => (f.name, f.tpe)).toMap.apply("private-custom-field-name").isInstanceOf[TBasic])
case _ =>
fail()
}
Expand All @@ -106,8 +105,8 @@ final class ConfigValueTest extends AnyWordSpec {
"be as expected for case classes with partially private fields" in {
getConfTag(TestConfigReaders.partiallyPrivateFieldsCodecDefinition).tpe match {
case c: ConfigMetaType.TCaseClass =>
assert(c.fields.toMap.apply("private-custom-field-name").isInstanceOf[TBasic])
assert(c.fields.toMap.apply("publicField").isInstanceOf[TBasic])
assert(c.fields.map(f => (f.name, f.tpe)).toMap.apply("private-custom-field-name").isInstanceOf[TBasic])
assert(c.fields.map(f => (f.name, f.tpe)).toMap.apply("publicField").isInstanceOf[TBasic])
case _ =>
fail()
}
Expand All @@ -117,7 +116,7 @@ final class ConfigValueTest extends AnyWordSpec {
"be as expected for sealed traits" in {
getConfTag(TestConfigReaders.sealedDefinition).tpe match {
case c: ConfigMetaType.TCaseClass =>
val sealedTrait = c.fields.toMap.apply("sealedTrait1").asInstanceOf[TSealedTrait]
val sealedTrait = c.fields.map(f => (f.name, f.tpe)).toMap.apply("sealedTrait1").asInstanceOf[TSealedTrait]
assert(sealedTrait.branches.toMap.apply("CaseClass1").isInstanceOf[TCaseClass])
assert(sealedTrait.branches.toMap.apply("CaseClass2").isInstanceOf[TCaseClass])
case _ =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,39 +12,48 @@ final case class ConfigMetaTypeId(owner: Option[String], short: String, typeArgu

sealed trait ConfigMetaType {
def id: ConfigMetaTypeId
def doc: Option[String]
}
object ConfigMetaType {
final case class TCaseClass(id: ConfigMetaTypeId, fields: Seq[(String, ConfigMetaType)]) extends ConfigMetaType {
override def toString: String = s"Class $id with fields ${fields.map { case (n, v) => s"$n: $v" }.niceList().shift(2)}"
case class ConfigField(name: String, tpe: ConfigMetaType, doc: Option[String])
final case class TVariant(id: ConfigMetaTypeId, variants: Set[ConfigMetaType], doc: Option[String]) extends ConfigMetaType {
override def toString: String = s"Choice $id with variants ${variants.niceList().shift(2)}"
}

final case class TCaseClass(id: ConfigMetaTypeId, fields: Seq[ConfigField], doc: Option[String]) extends ConfigMetaType {
override def toString: String = s"Class $id with fields ${fields.map { case ConfigField(n, v, _) => s"$n: $v" }.niceList().shift(2)}"
}
final case class TSealedTrait(id: ConfigMetaTypeId, branches: Set[(String, ConfigMetaType)]) extends ConfigMetaType {
final case class TSealedTrait(id: ConfigMetaTypeId, branches: Set[(String, ConfigMetaType)], doc: Option[String]) extends ConfigMetaType {
override def toString: String = s"ADT $id with branches ${branches.map { case (n, v) => s"$n: $v" }.niceList().shift(2)}"
}
final case class TBasic(tpe: ConfigMetaBasicType) extends ConfigMetaType {
override def id: ConfigMetaTypeId = ConfigMetaTypeId(None, tpe.toString, Seq.empty)
def doc: Option[String] = None
}
final case class TList(tpe: ConfigMetaType) extends ConfigMetaType {
override def id: ConfigMetaTypeId = ConfigMetaTypeId(None, "List", Seq(tpe.id))
def doc: Option[String] = None
}
final case class TSet(tpe: ConfigMetaType) extends ConfigMetaType {
override def id: ConfigMetaTypeId = ConfigMetaTypeId(None, "Set", Seq(tpe.id))
def doc: Option[String] = None
}
final case class TOption(tpe: ConfigMetaType) extends ConfigMetaType {
override def id: ConfigMetaTypeId = ConfigMetaTypeId(None, "Option", Seq(tpe.id))
def doc: Option[String] = None
}
final case class TMap(keyType: ConfigMetaType, valueType: ConfigMetaType) extends ConfigMetaType {
override def id: ConfigMetaTypeId = ConfigMetaTypeId(None, "Map", Seq(keyType.id, valueType.id))
def doc: Option[String] = None
}
final case class TUnknown(pos: CodePosition) extends ConfigMetaType {
override def id: ConfigMetaTypeId = ConfigMetaTypeId(None, "???", Seq.empty)
def doc: Option[String] = None
}
object TUnknown {
def apply()(implicit ev: CodePositionMaterializer, dummyImplicit: DummyImplicit): TUnknown = new TUnknown(ev.get)
}

final case class TVariant(id: ConfigMetaTypeId, variants: Set[ConfigMetaType]) extends ConfigMetaType {
override def toString: String = s"Choice $id with variants ${variants.niceList().shift(2)}"
}
}

sealed trait ConfigMetaBasicType
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package izumi.distage.config.model

import izumi.distage.model.definition.DIStageAnnotation

final class ConfigDoc(val doc: String) extends DIStageAnnotation
Loading

0 comments on commit 8914ed6

Please sign in to comment.