/* Copyright 2010 Aaron J. Radke 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 http://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 cc.drx import scala.collection.concurrent.{TrieMap => CCTrieMap} /** Prop: This top level class offers a quick lookup mechanism based off of Kson.watched files and scoped string map lookups as follows: * ex: Prop.of(File.home/".kson")/"email" | "default email" * WARNING: mutable singleton properties lie within here. * */ object Prop{ //hide a bit a mutability here with caching import Implicit.ec private val watchedFileCache:CCTrieMap[File,StringMap] = CCTrieMap.empty def of(f:File):StringMap = watchedFileCache.getOrElseUpdate(f, Kson.Watched(f)) /**make sure the cache can be cleared*/ def clear() = {watchedFileCache.clear(); println("cleared Prop.watchedFileCache.")} } //TODO keep comments and group them together to be added to the next node if they are by themselves or attached to the current node if on the ending line //TODO add file include/import at a shifted depth and mechanism for watched kson chains /**Keyed simple object notation is a much simpler and more readable key value notation without needing quotes or commas, and intended for a single line * *Kson (Key Separated Object Notation) is a simple format used for many of the configuration formats. *The 'key' idea is to remain simple like CSV (Comma Separated Values). However, instead of using commas, use "<key>:" as *the separation delimiter. Then instead of receiving a list of strings for a line of CSV you receive back *key value pairs for a line of Kson. This provides for a flexible configuration and clean data encoding framework. * *Where CSV returns an table of values, Kson can return a list of key value pairs per line or can squash all the key values *together for a single key value lookup. * * For example "person first:Aaron last:Radke language:scala desc:Use simple keys //with some comments" * gets parsed to Map("/"" -> "person", "first" -> "Aaron", "last" -> "Radke", "language" -> "scala", "quote" -> "Use simple keys") * */ object Kson{ //--constants val interpolationPat = """\$[\w\.]+""".r // val pathSeperator = "." //TODO make the path and key separator configurable or think if this is worth it... optionally using '=' may be worth it, or making it configurable at the root of the kson file. // val keySeperator = ":" //--constructors def empty = new Kson(Vector.empty[KsonLine]) def apply(file:File):Kson = apply(file.in) def apply(string:String):Kson = apply(Input(string)) def apply(in:Input):Kson = { val lines = for(line <- in.lines; kson = KsonLine(line); if kson.nonEmpty) yield kson new Kson(lines.toVector) } def apply(files:Seq[File]):Kson = files.foldLeft( Kson.empty){case (kson, f) => kson ++ Kson(f) } /** * ### simple init * val kson = Kson.watch(f) * * ### init with call back (not needed often since the config is backed by an hash trie cache to automatically hold the newest version) * val kson = Kson.watch(f).onUpdate{k => println(kson("config.parameter") + "was upated")} * * ### delayed init (useful for a global config reference that has not been filled in with a separate file setup arguments) * val kson = Kson.Watched.empty * kson.update(File("config.kson")) //updates the file and starts a watcher */ def watch(file:File)(implicit ec: ExecutionContext):Watched = Watched(file) //--wrapper classes (keeps package hierarchy clean /**mutable that executes call backs on change modifications*/ class Mutable[A <: Mutable[A]] extends StringMap{ //F-bounded polymophism to get type back as self type self:A => //allow the `this` pointer to represent the inherited subtype for onUpdate fluid api //--messy mutable fields private var _kson = Kson.empty //private so only this guy updates it, and initially empty so will work in an monadic environment (foreach over the list will not crash) private var onUpdateList:List[Kson => Unit] = Nil //----- def kson = _kson def update(k:Kson):Unit = { _kson = k //use a new kson tree for(f <- onUpdateList) f(k) //run all the update callbacks } //---string map interfaces that provide all the nice features for a config def getString(key:String):Option[String] = _kson getString key override def get[T](key:String)(implicit parser:Parsable[T]):Option[T] = _kson get key override def split[T](key:String,sep:String)(implicit parser:Parsable[T]):Vector[T] = _kson.split(key,sep) def keys:List[String] = kson.keys //kson.keys:a sorted list of the merged keys based on file order merged.keys:the set of keys (toList is some hash order) //IDEA add listener that executes call backs for changes in specific kson fields /**add a function to execute on updates*/ def onUpdate(f: Kson => Unit):A = {onUpdateList ::= f; this} //return this for some fluent api /**clear the list of callbacks*/ def clear = onUpdateList = Nil } /**mutable that executes call backs on change modifications (call backs are added with `onUpdate`)*/ object Watched{ def apply(file:File)(implicit ec: ExecutionContext) = {val watched = new Watched; watched.update(file); watched} def apply() = new Watched def empty = new Watched } class Watched extends Mutable[Watched]{ //delayed execution the watch def update(file:File)(implicit ec:ExecutionContext):Unit = { update(Kson(file)) //TODO should the initial load of of a file call all the call-backs ??? file.watch{f => update(Kson(f))}; //watch the file for modifications and update the Kson with a new parse in the cached mutable version with callbacks {} } } } class Kson(val unscopedLines:Vector[KsonLine]) extends StringMap.Cached{ def ++(that:Kson):Kson = new Kson(this.unscopedLines ++ that.unscopedLines) override def toString = lines.mkString("\n") def toByteArray:Array[Byte] = Output.toByteArray{out => unscopedLines.foreach(out println _.originString) } def >>(shift:Int) = new Kson(unscopedLines map (_ >> shift)) def <<(shift:Int) = new Kson(unscopedLines map (_ << shift)) private val emptyScopeAndLines:(List[KsonLine], List[KsonLine]) = ( List(), List() ) lazy val lines:List[KsonLine] = unscopedLines.foldLeft( emptyScopeAndLines ){case ((scope,newLines), line) => val thisScope:List[KsonLine] = scope.dropWhile(_.depth >= line.depth) val newLine = new KsonLine(thisScope, line.roots, line.kvs, line.depth) val newScope = newLine :: thisScope (newScope, newLine :: newLines) }._2.reverse lazy val interpolated:Kson = new Kson(unscopedLines map {_ interpolate this}) def interpolate(s:String):String = interpolate(0)(s) /**Generic interpolator function that turns things like $KEY into values if the $KEY does not exist it is left as is * this allows all $ to remain unless they are followed by a special key.. * TODO make this not depend on Regex * TODO add bracketed function interpolators * TODO add implicit op bracketed function interpolators */ private def interpolate(recursionDepth:Int)(str:String):String = /*Log(depth,s"interpolate str:$str")*/{ //--depth stop (number of times to check string replace resolution if(recursionDepth > 100) str else //--regex str.replaceAllWith(Kson.interpolationPat){ pat => getString(pat drop 1) getOrElse pat } } private def stripRefTag(ref:String) = if(ref startsWith "$") ref.drop(1) else ref //get top level merge keyMap interpolated private def getMerged(key:String, recursionDepth:Int):Option[String] = //merged.get(key) map interpolate(recursionDepth) mergedGetWithInheritance(key) map interpolate(recursionDepth) private def getFromPath(key:String,recursionDepth:Int):Option[String] = getMerged(key,recursionDepth) orElse getFromPath(key.split('.').toList,recursionDepth) private def mergedGetWithInheritance(key:String):Option[String] = { //-search for inheritance matching key merged.get(key) //-search walking back through parents walking up tree .orElse{ val path = key.split('.').reverse.toList path match { case Nil => None case k :: Nil => merged get k //-skip parent and go to grand-parent case k :: x :: xs => val inheritedKey = (k :: xs).reverse.mkString(".") //parent-less mergedGetWithInheritance(inheritedKey) } } } private def getFromPath(path:List[String],recursionDepth:Int):Option[String] = { //Log(depth,s"getFromPath path:$path depth:$depth") val recursionDepthNext = recursionDepth+1 //---recursionDepth death loop if(recursionDepth > 100) None //---walk the tree else { path match { case Nil => None case k :: Nil => getMerged(k,recursionDepthNext) //root element case k :: x :: xs => def dereference(newParent:Option[String]):Option[String] = { val altScope = newParent map stripRefTag getOrElse k val altKey = altScope + "." + x val altPath = altKey :: xs //-search for specific field getFromPath(altPath, recursionDepthNext) } //-search for exact matching key def exact = merged get k //-search for a referenced parent def interpolated = getMerged(k,recursionDepth) //interpolated parent found dereference(exact) orElse dereference(interpolated) } } } lazy val forest:Forest[MTree[KsonLine]] = MTree.nest(lines)(_.depth < _.depth) private val keyLineNumber:CCTrieMap[String,Double] = CCTrieMap.empty lazy val merged:Map[String,String] = lines.zipWithIndex.foldLeft(Map.empty[String,String]){case (m, (line,lineNumber)) => val prefixes = (line :: line.scope) flatMap {_.roots.headOption} map (_+".") val prefix:String = prefixes.reverse.mkString val prefixExt:String = prefixes.drop(1).reverse.mkString val prefixedLine = for( (k,v) <- line) yield (prefix+k , v) //prefix the head root value to the keys if one exists val extensions = line.roots zip line.roots.drop(1) map {case (k,v) => (prefixExt+k,"$"+v)} //println(s"$line | extensions:$extensions roots:${line.roots}") //DEBUG for( ((k,_),i) <- prefixedLine.zipWithIndex) keyLineNumber(k) = (lineNumber + i/999d) //use doubles to sort keys within lines val newMerge = m ++ prefixedLine ++ extensions newMerge } //--StringMap.Cached interfaces lazy val keys:List[String] = merged.keys.toList.sortBy{k => keyLineNumber.getOrElse(k, 0d)} def getStringNoCache(key:String) = getFromPath(key,0) //provides getString for StringMap } object KsonLine{ private def escape(string:String):String = string .replace("::", "$colonColon") .replace("://", "$colonSlashSlash") .replace("\\:", "$colon") .replace("\\=", "$equals") .replace("\\/", "$slash") private def unescape(string:String):String = string .replace("$colonColon", "::") .replace("$colonSlashSlash", "://") .replace("$colon", ":") .replace("$equals", "=") .replace("$slash", "/") private val whitespace = Set(' ', '\t') private def rootList(root:String):List[String] = root.split("""\s*<-\s*""").toList @tailrec private def parseRight(baseReversed:String,kvs:List[(String,String)]=Nil):(Option[String],List[(String,String)]) = { val (v,rest) = baseReversed.trim.span(_ != ':') if(rest.isEmpty){ val r = unescape(v.trim.reverse) if(r == "") (None, kvs) else (Some(r), kvs) } else { val (k,restBase) = rest.tail.trim.span(!_.isWhitespace) val moreKvs = (unescape(k.trim.reverse),unescape(v.trim.reverse)) :: kvs if(restBase.trim == "") (None, moreKvs) else parseRight(restBase, moreKvs) } } def apply(string:String):KsonLine = { val escapedString = escape(string) val commentIndex = escapedString indexOf "//" val base = if(commentIndex < 0) escapedString else escapedString take commentIndex //distribued depth calculation // [ ] depth -20 top level?? // [ ] depth -10 val (baseString, depth) = { //-- calculate indentation based depth val (spaces, post) = base span whitespace.apply val spaceDepth = spaces.size //-- git/properties like header if((post startsWith "[") && (post endsWith "]")) post.drop(1).dropRight(1).trim -> (-30 + spaceDepth).sat(-30,0) //-- colon header: else if( post.endsWith(":") ){ post.dropRight(1).trim -> (-10 + spaceDepth).sat(-10,0) } //-- markdown like headers else if(post startsWith "#") { val (bangs, postBang) = post span (_ == '#') postBang.trim -> (-20 + bangs.size + spaceDepth).sat(-20,0) } //-- normal header (positive depth) else post.trim -> spaceDepth } //---new custom val (r,kvs) = parseRight(baseString.reverse) r match { case Some(r) => new KsonLine(List(), rootList(r), kvs, depth) case None => new KsonLine(List(), List(), kvs, depth) } } def unapply(line:KsonLine):Option[(List[String],Option[String],List[(String,String)])] = Some((line.scope.flatMap{_.root}, line.root, line.kvs)) def apply(kvs:List[(String,String)]) = new KsonLine(List(), List(), kvs,0) def apply(root:String,kvs:List[(String,String)]) = new KsonLine(List(), List(root), kvs,0) } class KsonLine(val scope:List[KsonLine], val roots:List[String],val kvs:List[(String,String)], val depth:Int) extends Iterable[(String,String)] with StringMap{ lazy val root:Option[String] = if(roots.isEmpty) None else Some(roots mkString " <- ") //--support StringMap interface def iterator = kvs.iterator override lazy val toList = (for(k <- keys; v <- getString(k)) yield (k,v)).toList //required since StringMap and Iterable collide on this method private lazy val stringMapLineOnly = kvs.toMap private def keysLineOnly = kvs.map{_._1} //keep order of the keys private def getStringLineOnly(key:String):Option[String] = stringMapLineOnly get key //TODO for separation of concerns, should this really climb up the parent list for a match // def keys = kvs.map{_._1} //keep order of the keys // def getString(key:String):Option[String] = getStringLineOnly(key) lazy val keys = scope.reverse.flatMap{_.keysLineOnly} ++ keysLineOnly def getString(key:String) = getString(key, this :: scope) //inherited lookup getStringInherited /**search the scope upward looking for the key (or the closest parent with the key defined*/ private def getString(key:String, scope:List[KsonLine]):Option[String] = scope match { case Nil => None //getString(key) //kvs.toMap get key case p :: ps => p.getStringLineOnly(key) orElse getString(key,ps) } def pathList:List[String] = (this :: scope).flatMap{_.root} // def path:String = if(scope.isEmpty) "" else (this :: scope).flatMap{_.root} def path:String = pathList.reverse.mkString("/") override def toString = { val s = if(scope.isEmpty) "<none>" else scope.flatMap{_.root}.reverse mkString("/") val k = kvs.foldLeft(root.getOrElse("")){case (r,(k,v)) => s"$r $k:$v"}.trim s"KsonLine(depth:$depth scope:$s line:{$k})" } def originString:String = root ++: kvs.map{case (k,v) => s"$k:$v"} mkString " " def kvString:String = kvs.map{case (k,v) => s"$k:$v"} mkString " " override def isEmpty = kvs.isEmpty && root.isEmpty // override def nonEmpty = !kvs.isEmpty || !root.isEmpty override def equals(that:Any) = that match { case that:KsonLine => this.roots == that.roots && this.kvs == that.kvs && this.depth == that.depth case _ => false } override def hashCode:Int = roots.## + 7*depth + 31*kvs.## def >>(shift:Int) = new KsonLine(scope, roots, kvs, depth + shift) def <<(shift:Int) = new KsonLine(scope, roots, kvs, depth - shift) def interpolate(s:String):String = s.replaceAllWith(Kson.interpolationPat){v => getString(v drop 1, scope) getOrElse v} def interpolate(s:String,kson:Kson):String = kson.interpolate(interpolate(s)) def interpolate(kson:Kson):KsonLine = { def f(s:String) = interpolate(s,kson) new KsonLine(Nil, roots.take(1) map f, kvs map {case (k,v) => (f(k), f(v)) }, depth) } }