使用 Scala 实现 sbt.boot.properties 函数式解析
前言
之前在实现构建工具自动配置的小工具的时候, 需要解析 sbt.boot.properties 的内容, 看一下如何实现一个解析器
sbt.boot.properties 文件格式
在官网 有个配置示例, 文件格式还是比较清晰的, 上面有个分块的标签, 然后每个分块里面每一行都是 key:value
这样的格式.
我们的目的是, 将 String 转换成 我们的 AST (抽象语法树), 然后使用 AST 进行查询和插入操作.
实现
AST
实现我们要用类来表达配置内容的格式:
我们使用Tag
来表示方括号部分,
case class Tag(private val name:String)
用 ConfigLine
表示一行配置
case class ConfigLine(name:String,value:String)
使用 Paragraph
来表示由一个 Tag
多个 ConfigLine
组成的段落.
case class Paragraph(tag:Tag, configs: List[ConfigLine] = List.empty)
然后整个配置文件由 SBTProperties
来表示
case class SBTProperties(paragraphs: List[Paragraph])
解析
要实现一个解析器, 方法签名如下, 输入的原始内容是 List[String]
, 输出是上面定义的 SBTProperties
def fromLines(lines:List[String]) : SBTProperties
解析 Paragraph
函数式解析使用的是不变的数据结构, 利用模式匹配递归地解析自己那部分数据, 然后把剩余的部分数据返回.
@tailrec
def parseParagraph(lines:List[String],paragraph: Option[Paragraph] = None): (Option[Paragraph],List[String]) = {
lines.headOption match {
case None => //第一行如果为空, 直接返回
paragraph -> List.empty
case Some(headLine) => //如果第一行不为空
val trim = headLine.trim
if(trim.startsWith("[") && trim.endsWith("]")) { //以 [ 开头, ] 结尾的是 tag
if(paragraph.nonEmpty) {//如果当前有`paragraph`,则直接返回, 因为这里遇到的 tag 是下个 `paragraph` 了
paragraph -> lines
} else {//如果当前没有`paragraph`, 则新建一个 `paragraph` 并且用剩下的 lines 去递归地解析
parseParagraph(lines.tail,Some(Paragraph(Tag(trim))))
}
} else if(trim.isEmpty){//忽略空行
parseParagraph(lines.tail,paragraph)
} else {//如果是ConfigLine,则添加到当前的 `paragraph` 里面
parseParagraph(lines.tail,paragraph.map(p => p.copy(configs = p.configs :+ new ConfigLine(trim) )))
}
}
}
解析 SBTProperties
这个相对简单, 就是调用 parseParagraph
而已
@tailrec
def parseSBTProperties(lines:List[String],current:SBTProperties) : SBTProperties = {
if lines.isEmpty then
current
else
val (paragraph,tailLine) = parseParagraph(lines,None)
paragraph match {
case None =>
parseSBTProperties(tailLine,current)
case Some(value) =>
parseSBTProperties(tailLine,current.copy(paragraphs = current.paragraphs :+ value))
}
}
插入/删除配置的操作
我们实现 Paragraph
的插入, 这样 SBTProperties
的插入调用 Paragraph 的方法即可:
case class Paragraph(tag:Tag, configs: List[ConfigLine] = List.empty) {
/**
* 插入到 afterName 后面, 如果 afterName 不存在, 则插入到 tag 后面(也就是放在 configs 开头)
*/
def insert(name:String,value:String,afterName:String) : Paragraph = {
val newConfigLine = new ConfigLine(name,value)
@tailrec
def go(confs:List[ConfigLine],acc:List[ConfigLine]) : List[ConfigLine] = {
confs match {
case Nil => //confs 为空了, 说明没有匹配的 afterName, 添加到 acc 头部, 也就是 tag 后面
newConfigLine +: acc
case head::tail => //不为空
if head.name == afterName.trim then
(acc :+ head :+ newConfigLine) ++ tail
else
go(tail,acc :+ head)
}
}
this.copy(configs = go(configs,List.empty))
}
//移除配置,这里简单实现, 直接使用 filterNot 即可
def remove(name:String) : Paragraph = {
this.copy(configs = configs.filterNot(_.name == name))
}
}
实现 SBTProperties
的插入 和 删除, 先按照 tag 搜索 paragraph, 然后调用 paragraph 的删除方法
case class SBTProperties(paragraphs: List[Paragraph]) {
def insert(tag:String,name:String,value:String,afterName:String) : SBTProperties = {
val insert = new ConfigLine(name,value)
@tailrec
def go(paragraphs:List[Paragraph],acc:List[Paragraph] = List.empty) : List[Paragraph] = {
paragraphs match {
case Nil =>
acc :+ Paragraph(Tag.trim(tag),List(insert))
case ::(head, next) =>
if head.tag.is(tag) then
head.insert(name,value, afterName) +: next
else
go(next,acc :+ head)
}
}
SBTProperties(go(this.paragraphs))
}
def remove(tag:String,name:String) : SBTProperties = {
this.copy {
paragraphs.collect {
case p if p.tag.is(name) => p.remove(name)
case p => p
}
}
}
}
搜索
语法树出来之后, 搜索其实就是遍历, 当然scala内置了很多函数,直接用就行了
case class SBTProperties(paragraphs: List[Paragraph]) {
def isEmpty:Boolean = paragraphs.isEmpty
def queryFirstParagraph(tag:String) : Option[Paragraph] = {
paragraphs.find(p => p.tag.is(tag))
}
def queryFirst(tag:String,name:String):Option[ConfigLine] = {
queryFirstParagraph(tag).flatMap { paragraph =>
paragraph.configs.find(_.name == name)
}
}
def queryFirst(tag:String,targetValue:String => Boolean): Option[ConfigLine] = {
paragraphs.find(p => p.tag.is(tag)).flatMap { paragraph =>
paragraph.configs.find(config => targetValue(config.value))
}
}
}
结论
- 递归是算法中常用的方式
- 不变的方式往往需要传递值
完整代码
import scala.annotation.tailrec
/**
* @param line Key: Value 这样的形式
*/
case class ConfigLine(name:String,value:String) {
def this(line:String) = {
this(line.trim.takeWhile(_ != ':').trim,line.trim.dropWhile(_ != ':').tail.trim)
}
override def toString: String = s"$name: $value"
}
case class Tag private (name:String) {
def is(_name:String):Boolean = Tag.format(name) == Tag.format(_name)
override def toString : String = Tag.format(name)
}
object Tag {
def format(_name:String) : String = s"[${_name.trim.stripMargin('[').stripSuffix("]")}]"
def trim(string:String):Tag = Tag(format(string))
}
/**
* 表示配置文件里面的一个段落 <br/>
* ```
* [tag] <br/>
* name : value <br/>
* name : value <br/>
* ```
*/
case class Paragraph(tag:Tag, configs: List[ConfigLine] = List.empty) {
/**
* 插入到 after 后面, 如果 after 不存在, 则插入到 tag 后面
*/
def insert(name:String,value:String,afterName:String) : Paragraph = {
val newConfigLine = new ConfigLine(name,value)
@tailrec
def go(confs:List[ConfigLine],acc:List[ConfigLine]) : List[ConfigLine] = {
confs match {
case Nil =>
newConfigLine +: acc
case head::tail =>
if confs.head.name == afterName.trim then
(acc :+ head :+ newConfigLine) ++ tail
else
go(tail,acc :+ head)
}
}
this.copy(configs = go(configs,List.empty))
}
def remove(name:String) : Paragraph = {
this.copy(configs = configs.filterNot(_.name == name))
}
}
/**
* 表示一个配置文件
* @param paragraphs
*/
case class SBTProperties(paragraphs: List[Paragraph]) {
def isEmpty:Boolean = paragraphs.isEmpty
def queryFirstParagraph(tag:String) : Option[Paragraph] = {
paragraphs.find(p => p.tag.is(tag))
}
def queryFirst(tag:String,name:String):Option[ConfigLine] = {
queryFirstParagraph(tag).flatMap { paragraph =>
paragraph.configs.find(_.name == name)
}
}
def queryFirst(tag:String,targetValue:String => Boolean): Option[ConfigLine] = {
paragraphs.find(p => p.tag.is(tag)).flatMap { paragraph =>
paragraph.configs.find(config => targetValue(config.value))
}
}
def insert(tag:String,name:String,value:String,afterName:String) : SBTProperties = {
val insert = new ConfigLine(name,value)
@tailrec
def go(paragraphs:List[Paragraph],acc:List[Paragraph] = List.empty) : List[Paragraph] = {
paragraphs match {
case Nil =>
acc :+ Paragraph(Tag.trim(tag),List(insert))
case ::(head, next) =>
if head.tag.is(tag) then
head.insert(name,value, afterName) +: next
else
go(next,acc :+ head)
}
}
SBTProperties(go(this.paragraphs))
}
def remove(tag:String,name:String) : SBTProperties = {
this.copy {
paragraphs.collect {
case p if p.tag.is(name) =>
p.remove(name)
case p =>
p
}
}
}
}
object SBTProperties {
def fromLines(lines:List[String]) : SBTProperties = {
//解析段落, 没有 tag 部分的配置会被删除
@tailrec
def parseParagraph(lines:List[String],paragraph: Option[Paragraph] = None): (Option[Paragraph],List[String]) = {
lines.headOption match{
case None =>
paragraph -> List.empty
case Some(headLine) =>
val trim = headLine.trim
if(trim.startsWith("[") && trim.endsWith("]")) { //tag
if(paragraph.nonEmpty) {
paragraph -> lines
} else {
parseParagraph(lines.tail,Some(Paragraph(Tag.trim(trim))))
}
} else if(trim.isEmpty){
parseParagraph(lines.tail,paragraph)
} else { //configLine
parseParagraph(lines.tail,paragraph.map(p => p.copy(configs = p.configs :+ new ConfigLine(trim) )))
}
}
}
@tailrec
def parseSBTProperties(lines:List[String],current:SBTProperties) : SBTProperties = {
if lines.isEmpty then
current
else
val (paragraph,tailLine) = parseParagraph(lines,None)
paragraph match {
case None =>
parseSBTProperties(tailLine,current)
case Some(value) =>
parseSBTProperties(tailLine,current.copy(paragraphs = current.paragraphs :+ value))
}
}
parseSBTProperties(lines,SBTProperties(List.empty))
}
trait ToStrList[From] {
extension (from:From)
def strList:List[String]
}
given ToStrList[ConfigLine] with
extension (configLine:ConfigLine)
def strList:List[String] =
List(s" ${configLine.toString}")
given (using ToStrList[ConfigLine]):ToStrList[Paragraph] with
extension (paragraph:Paragraph)
def strList:List[String] =
List(paragraph.tag.toString) ++
paragraph.configs.flatMap(_.strList)
given (using ToStrList[Paragraph]): ToStrList[SBTProperties] with
extension (props:SBTProperties)
def strList:List[String] =
props.paragraphs.flatMap(_.strList)
}