All Downloads are FREE. Search and download functionalities are using the official Maven repository.

tscfg.ModelBuilder.scala Maven / Gradle / Ivy

There is a newer version: 1.1.3
Show newest version
package tscfg

import com.typesafe.config._
import tscfg.generators.tsConfigUtil
import tscfg.model._
import tscfg.model.durations.ms
import tscfg.ns.{Namespace, NamespaceMan}

import scala.jdk.CollectionConverters._

class ModelBuilder(
    rootNamespace: NamespaceMan,
    assumeAllRequired: Boolean = false
) {

  import buildWarnings._

  import collection._

  def build(conf: Config): ModelBuildResult = {
    warns.clear()
    ModelBuildResult(
      objectType = fromConfig(rootNamespace.root, conf),
      warnings = warns.toList.sortBy(_.line)
    )
  }

  private val warns = collection.mutable.ArrayBuffer[Warning]()

  /** Gets the [[model.ObjectType]] corresponding to the given TS Config object.
    */
  private def fromConfig(
      namespace: Namespace,
      conf: Config
  ): model.ObjectType = {
    val memberStructs: List[Struct] = Struct.getMemberStructs(namespace, conf)
    // Note: the returned order of this list is assumed to have taken into account any dependencies between
    // the structs, in terms both of inheritance and member types.
    // TODO a future revision may lessen this requirement by making the `namespace.resolveDefine` call below
    //  and associated handling more flexible.

    val members: immutable.Map[String, model.AnnType] = memberStructs.map {
      childStruct =>
        val name = childStruct.name
        val cv   = conf.getValue(name)

        val (childType, optional, default) = {
          if (childStruct.isLeaf) {
            val valueString = tscfg.util.escapeValue(cv.unwrapped().toString)
            val isEnum      = childStruct.isEnum

            getTypeFromConfigValue(namespace, cv, isEnum) match {
              case typ: model.STRING.type =>
                namespace.resolveDefine(valueString) match {
                  case Some(ort) =>
                    (ort, false, None)

                  case None =>
                    inferAnnBasicTypeFromString(valueString) match {
                      case Some(annBasicType) =>
                        annBasicType

                      case None =>
                        (typ, true, Some(valueString))
                    }
                }

              case typ: model.BasicType =>
                (typ, true, Some(valueString))

              case typ =>
                (typ, false, None)
            }
          }
          else {
            (
              fromConfig(namespace.extend(name), conf.getConfig(name)),
              false,
              None
            )
          }
        }

        val comments        = cv.origin().comments().asScala.toList
        val optFromComments = comments.exists(_.trim.startsWith("@optional"))
        val commentsOpt =
          if (comments.isEmpty) None else Some(comments.mkString("\n"))

        // per Lightbend Config restrictions involving $, leave the key alone if
        // contains $, otherwise unquote the key in case is quoted.
        val adjName =
          if (name.contains("$")) name else name.replaceAll("^\"|\"$", "")

        // effective optional and default:
        val (effOptional, effDefault) =
          if (assumeAllRequired)
            (false, None) // that is, all strictly required.
          // A possible variation: allow the `@optional` annotation to still take effect:
          // (optFromComments, if (optFromComments) default else None)
          else
            (optional || optFromComments, default)

        // println(s"ModelBuilder: effOptional=$effOptional  effDefault=$effDefault " +
        //  s"assumeAllRequired=$assumeAllRequired optFromComments=$optFromComments " +
        //  s"adjName=$adjName")

        /* Get a comprehensive view of members from _all_ ancestors */
        val parentClassMembers =
          Struct.ancestorClassMembers(childStruct, memberStructs, namespace)

        /* build the annType  */
        val annType = buildAnnType(
          childType,
          effOptional,
          effDefault,
          commentsOpt,
          parentClassMembers
        )

        annType.defineCase foreach { namespace.addDefine(name, annType.t, _) }

        adjName -> annType

    }.toMap

    /* filter abstract members from root object as they don't require an instantiation */
    model.ObjectType(
      members.filterNot(fullPathWithObj =>
        fullPathWithObj._2.default.exists(namespace.isAbstractClassDefine)
      )
    )
  }

  private def buildAnnType(
      childType: model.Type,
      effOptional: Boolean,
      effDefault: Option[String],
      commentsOpt: Option[String],
      parentClassMembers: Option[Map[String, model.AnnType]]
  ): AnnType = {

    // if this class is a parent class (abstract class or interface) this is indicated by the childType object
    // that is passed into the AnnType instance that is returned

    // TODO review the following
    val updatedChildType = childType match {
      case objType: ObjectType =>
        if (commentsOpt.exists(AnnType.isAbstract))
          AbstractObjectType(objType.members)
        else objType

      case listType: ListType =>
        listType

      case other => other
    }

    model.AnnType(
      updatedChildType,
      optional = effOptional,
      default = effDefault,
      comments = commentsOpt,
      parentClassMembers = parentClassMembers.map(_.toMap)
    )
  }

  private def getTypeFromConfigValue(
      namespace: Namespace,
      cv: ConfigValue,
      isEnum: Boolean
  ): model.Type = {
    import ConfigValueType._
    cv.valueType() match {
      case STRING => model.STRING

      case BOOLEAN => model.BOOLEAN

      case NUMBER => numberType(cv.unwrapped().toString)

      case LIST if isEnum => enumType(cv.asInstanceOf[ConfigList])

      case LIST => listType(namespace, cv.asInstanceOf[ConfigList])

      case OBJECT => objType(namespace, cv.asInstanceOf[ConfigObject])

      case NULL => throw new AssertionError("null unexpected")
    }
  }

  private def inferAnnBasicTypeFromString(
      valueString: String
  ): Option[(model.BasicType, Boolean, Option[String])] = {

    if (tsConfigUtil.isDurationValue(valueString))
      return Some((DURATION(ms), true, Some(valueString)))

    val tokens       = valueString.split("""\s*\|\s*""")
    val typePart     = tokens(0).toLowerCase
    val hasDefault   = tokens.size == 2
    val defaultValue = if (hasDefault) Some(tokens(1)) else None

    val (baseString, isOpt) =
      if (typePart.endsWith("?"))
        (typePart.substring(0, typePart.length - 1), true)
      else
        (typePart, hasDefault)

    val (base, qualification) = {
      val parts = baseString.split("""\s*:\s*""", 2)
      if (parts.length == 1)
        (parts(0), None)
      else
        (parts(0), Some(parts(1)))
    }

    model.recognizedAtomic.get(base) map { bt =>
      val basicType = bt match {
        case DURATION(_) if qualification.isDefined =>
          DURATION(tsConfigUtil.unifyDuration(qualification.get))
        case _ => bt
      }
      (basicType, isOpt, defaultValue)
    }
  }

  private def listType(namespace: Namespace, cv: ConfigList): model.ListType = {
    if (cv.isEmpty)
      throw new IllegalArgumentException("list with one element expected")

    if (cv.size() > 1) {
      val line = cv.origin().lineNumber()
      val options: ConfigRenderOptions = ConfigRenderOptions.defaults
        .setFormatted(false)
        .setComments(false)
        .setOriginComments(false)
      warns += MultElemListWarning(line, cv.render(options))
    }

    val cv0: ConfigValue = cv.get(0)
    val valueString      = tscfg.util.escapeValue(cv0.unwrapped().toString)
    val typ = getTypeFromConfigValue(namespace, cv0, isEnum = false)

    val elemType = {
      if (typ == model.STRING) {

        namespace.resolveDefine(valueString) match {
          case Some(ort) =>
            ort

          case None =>
            // see possible type from the string literal:
            inferAnnBasicTypeFromString(valueString) match {
              case Some((basicType, isOpt, defaultValue)) =>
                if (isOpt)
                  warns += OptListElemWarning(
                    cv0.origin().lineNumber(),
                    valueString
                  )

                if (defaultValue.isDefined)
                  warns += DefaultListElemWarning(
                    cv0.origin().lineNumber(),
                    defaultValue.get,
                    valueString
                  )

                basicType

              case None =>
                typ
            }
        }
      }
      else typ
    }

    model.ListType(elemType)
  }

  private def enumType(cv: ConfigList): model.EnumObjectType = {
    if (cv.isEmpty)
      throw new IllegalArgumentException(
        "enumeration with at least one element expected"
      )

    model.EnumObjectType(
      cv.iterator().asScala.map(_.unwrapped().toString).toList
    )
  }

  private def objType(
      namespace: Namespace,
      cv: ConfigObject
  ): model.ObjectType =
    fromConfig(namespace, cv.toConfig)

  private def numberType(valueString: String): model.BasicType = {
    try {
      valueString.toInt
      model.INTEGER
    }
    catch {
      case _: NumberFormatException =>
        try {
          valueString.toLong
          model.LONG
        }
        catch {
          case _: NumberFormatException =>
            try {
              valueString.toDouble
              model.DOUBLE
            }
            catch {
              case _: NumberFormatException => throw new AssertionError()
            }
        }
    }
  }
}

object ModelBuilder {

  import com.typesafe.config.ConfigFactory

  import java.io.File

  /** build model from string */
  def apply(
      rootNamespace: NamespaceMan,
      source: String,
      assumeAllRequired: Boolean = false
  ): ModelBuildResult = {
    val config = ConfigFactory.parseString(source).resolve()
    fromConfig(rootNamespace, config, assumeAllRequired)
  }

  /** build model from TS Config object */
  def fromConfig(
      rootNamespace: NamespaceMan,
      config: Config,
      assumeAllRequired: Boolean = false
  ): ModelBuildResult = {
    new ModelBuilder(rootNamespace, assumeAllRequired).build(config)
  }

  // $COVERAGE-OFF$
  def main(args: Array[String]): Unit = {
    tscfg.util.setLogMinLevel()

    val filename =
      args.headOption.getOrElse("src/main/tscfg/example/example.conf")
    val showTsConfig = args.length > 1 && "-ts" == args(1)
    val file         = new File(filename)
    val bufSource    = io.Source.fromFile(file)
    val source       = bufSource.mkString.trim
    bufSource.close()
    // println("source:\n  |" + source.replaceAll("\n", "\n  |"))
    val config = ConfigFactory.parseString(source).resolve()
    if (showTsConfig) {
      val options = ConfigRenderOptions.defaults
        .setFormatted(true)
        .setComments(true)
        .setOriginComments(false)
      println(config.root.render(options))
    }
    val rootNamespace = new NamespaceMan
    val result        = fromConfig(rootNamespace, config)
    println(
      s"""ModelBuilderResult:
         |${model.util.format(result.objectType)}
         |""".stripMargin
    )
  }

  // $COVERAGE-ON$
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy