tscfg.Struct.scala Maven / Gradle / Ivy
The newest version!
package tscfg
import com.typesafe.config.{Config, ConfigValueType}
import tscfg.DefineCase._
import tscfg.exceptions.ObjectDefinitionException
import tscfg.ns.Namespace
import scala.annotation.tailrec
import scala.collection.{Map, mutable}
/** Supports a convenient next representation based on given TS Config object.
* It supports nested member definitions utilizing the 'members' field
*
* @param name
* Name of the config member
* @param members
* Nested config definitions
* @param tsStringValue
* Captures string value to support determining dependencies in terms of RHS
* names (that is, when such a string may be referring to a @define)
*/
case class Struct(
name: String,
members: mutable.HashMap[String, Struct] = mutable.HashMap.empty,
tsStringValue: Option[String] = None
) {
// Non-None when this is a `@define`
var defineCaseOpt: Option[DefineCase] = None
def isDefine: Boolean = defineCaseOpt.isDefined
def isExtends: Boolean = defineCaseOpt match {
case Some(_: ExtendsDefineCase) => true
case Some(_: ImplementsDefineCase) => true
case _ => false
}
def isEnum: Boolean = defineCaseOpt.exists(_.isEnum)
def isLeaf: Boolean = members.isEmpty
def dependencies: Set[String] = {
tsStringValue.toSet ++ members.values.flatMap(_.dependencies)
}
// $COVERAGE-OFF$
def format(indent: String = ""): String = {
val defineStr = defineCaseOpt.map(dc => s" $dc").getOrElse("")
val nameStr = s"${if (name.isEmpty) "(root)" else name}$defineStr"
val dependenciesStr = dependencies.toList match {
case Nil => ""
case l => s" [dependencies=${l.mkString(", ")}]"
}
val nameHeading = nameStr + dependenciesStr
if (members.isEmpty) {
nameHeading
}
else {
val indent2 = indent + " "
s"$nameHeading ->\n" + indent2 + {
members
.map(e => s"${e._1}: " + e._2.format(indent2))
.mkString("\n" + indent2)
}
}
}
// $COVERAGE-ON$
}
object Struct {
import scala.jdk.CollectionConverters._
/** Gets all structs from the given TS Config object, sorted appropriately for
* subsequent processing in ModelBuilder. Any circular reference will throw a
* [[ObjectDefinitionException]].
*/
def getMemberStructs(namespace: Namespace, conf: Config): List[Struct] = {
val struct: Struct = getStruct(conf)
val memberStructs: List[Struct] = struct.members.values.toList
// set any define to each struct:
memberStructs.flatMap { setDefineCase(conf, _) }
val (defineStructs, nonDefineStructs) = memberStructs.partition(_.isDefine)
val sortedDefineStructs = sortDefineStructs(defineStructs)
val sortedStructs = {
// but also sort the "defines" by any name (member type) dependencies:
val definesSortedByNameDependencies = sortByNameDependencies(
sortedDefineStructs
)
definesSortedByNameDependencies ++ nonDefineStructs
}
if (namespace.isRoot) {
scribe.debug(
s"root\n" +
s"struct=${struct.format()}\n" +
s"sortedStructs=\n ${sortedStructs.map(_.format()).mkString("\n ")}"
)
}
sortedStructs
}
private def sortDefineStructs(defineStructs: List[Struct]): List[Struct] = {
val sorted = mutable.LinkedHashMap.empty[String, Struct]
// / `othersBeingAdded` allows to check for circularity
def addExtendStruct(
s: Struct,
othersBeingAdded: List[Struct] = List.empty
): Unit = {
def addExtendsOrImplements(name: String, isExternal: Boolean): Unit = {
sorted.get(name) match {
case Some(_) =>
// ancestor already added. Add this descendent struct:
sorted.put(s.name, s)
case None =>
// ancestor not added yet. Find it in base list:
defineStructs.find(_.name == name) match {
case Some(ancestor) =>
// check for any circularity:
if (othersBeingAdded.exists(_.name == ancestor.name)) {
val path = s :: othersBeingAdded
val via =
path.reverseIterator
.map("'" + _.name + "'")
.mkString(" -> ")
throw ObjectDefinitionException(
s"extension of struct '${s.name}' involves circular reference via $via"
)
}
// ok, add ancestor:
addExtendStruct(ancestor, s :: othersBeingAdded)
// and then add this struct:
sorted.put(s.name, s)
case None if isExternal =>
sorted.put(s.name, s)
case None =>
throw ObjectDefinitionException(
s"struct '${s.name}' with undefined extend '$name'"
)
}
}
}
s.defineCaseOpt.get match {
case SimpleDefineCase | AbstractDefineCase | EnumDefineCase =>
sorted.put(s.name, s)
case c: ExtendsDefineCase =>
addExtendsOrImplements(c.name, c.isExternal)
case c: ImplementsDefineCase =>
addExtendsOrImplements(c.name, c.isExternal)
}
}
defineStructs.foreach(addExtendStruct(_))
assert(
defineStructs.size == sorted.size,
s"defineStructs.size=${defineStructs.size} != sorted.size=${sorted.size}"
)
sorted.toList.map(_._2)
}
private def sortByNameDependencies(structs: List[Struct]): List[Struct] = {
structs.sortWith((a, b) => {
val aDeps = a.dependencies
val bDeps = b.dependencies
if (aDeps.contains(b.name)) {
// a depends on b, so b should come first:
false
}
else if (bDeps.contains(a.name)) {
// b depends on a, so a should come first:
true
}
else {
// no dependency, so sort by name:
a.name < b.name
}
})
}
/** Determines the joint set of all ancestor's members to allow proper
* overriding in child structs.
*
* Note that not-circularity is verified prior to calling this function.
*
* @param struct
* Current (child) struct to consider
* @param memberStructs
* List to find referenced structs
* @param namespace
* Current known name space
* @return
* Mapping from symbol to type definition if struct is an ExtendsDefineCase
*/
def ancestorClassMembers(
struct: Struct,
memberStructs: List[Struct],
namespace: Namespace
): Option[Map[String, model.AnnType]] = {
def handleExtends(
parentName: String,
isExternal: Boolean
): Option[Map[String, model.AnnType]] = {
val defineStructs = memberStructs.filter(_.isDefine)
val greatAncestorMembers =
defineStructs.find(_.name == parentName) match {
case Some(parentStruct) if parentStruct.isExtends =>
ancestorClassMembers(parentStruct, memberStructs, namespace)
case Some(_) => None
case None if isExternal => None
case None =>
throw new RuntimeException(
s"struct '${struct.name}' with undefined extend '$parentName'"
)
}
val parentMembers =
namespace.getRealDefine(parentName).map(_.members) match {
case Some(parentMembers) => parentMembers
case None if isExternal => None
case None =>
throw new IllegalArgumentException(
s"@define '${struct.name}' is invalid because '$parentName' is not @defined"
)
}
// join both member maps
Some(greatAncestorMembers.getOrElse(Map.empty) ++ parentMembers)
}
struct.defineCaseOpt.flatMap {
case c: ExtendsDefineCase =>
handleExtends(c.name, c.isExternal)
case c: ImplementsDefineCase =>
// note: handling it as an extends (todo: revisit this at some point)
handleExtends(c.name, c.isExternal)
case _ => None
}
}
private def getStruct(conf: Config): Struct = {
val structs = mutable.HashMap[String, Struct]("" -> Struct(""))
def resolve(key: String): Struct = {
if (!structs.contains(key)) structs.put(key, Struct(getSimple(key)))
structs(key)
}
// Due to TS Config API, we traverse from the leaves to the ancestors:
conf.entrySet().asScala foreach { e =>
val path = e.getKey
val configValue = e.getValue
// capture string value to determine possible "define" dependency
val tsStringValue: Option[String] = e.getValue.valueType() match {
case ConfigValueType.STRING => Some(configValue.unwrapped().toString)
case _ => None
}
scribe.debug(s"getStruct: path=$path, tsStringValue=$tsStringValue")
val leaf = Struct(path, tsStringValue = tsStringValue)
doAncestorsOf(path, leaf)
def doAncestorsOf(childKey: String, childStruct: Struct): Unit = {
val (parent, simple) = (getParent(childKey), getSimple(childKey))
createParent(parent, simple, childStruct)
@tailrec
def createParent(
parentKey: String,
simple: String,
child: Struct
): Unit = {
val parentGroup = resolve(parentKey)
parentGroup.members.put(simple, child)
if (parentKey != "") {
createParent(
getParent(parentKey),
getSimple(parentKey),
parentGroup
)
}
}
}
}
def getParent(path: String): String = {
val idx = path.lastIndexOf('.')
if (idx >= 0) path.substring(0, idx) else ""
}
def getSimple(path: String): String = {
val idx = path.lastIndexOf('.')
if (idx >= 0) path.substring(idx + 1) else path
}
structs("")
}
private def setDefineCase(conf: Config, s: Struct): Option[DefineCase] = {
val cv = conf.getValue(s.name)
val comments = cv.origin().comments().asScala.toList
val defineLines = comments.map(_.trim).filter(_.startsWith("@define"))
s.defineCaseOpt = defineLines.length match {
case 0 => None
case 1 => DefineCase.getDefineCase(defineLines.head)
case _ =>
throw new IllegalArgumentException(
s"multiple @define lines for ${s.name}."
)
}
s.defineCaseOpt
}
}