Парсинг зависимостей из sbt файлов
Опишу одну из доработок sbt парсера для проекта jadd. Парсер основан на scalameta и занимается вытаскиванием зависимостей из build файлов проекта.
Зачем пишу эту статью?
Я нашел и исправил пару багов пока писал. Значит, пишу не зря. Это не последнее изменение sbt парсера в этом проекте и мне самому будет полезно перечитать этот пост спустя некоторое время.
Предыстория
Имплементация №1 была на antlr + регулярные выражения. Я пытался сделать валидный lexer+parser для sbt, у меня не получилось довести это до рабочего состояния и я решил упростить себе задачу. Я регулярками доставал куски кода, похожие на зависимости и отдавал их простенькому antlr парсеру. Версии в sbt зависимостях иногда выносят в переменные (иногда в другие файлы, в директории project), их я доставал регулярками и подставлял в нужные места(ну или не всегда нужные) Эта реализация была очень даже рабочей, несмотря на костыльность.
Имплементация №2. Объявления модулей и версии доставались уже не регулярками и antlr, а более удобным способом - паттерн матчингом на Scalameta деревьях. Версии библиотек точно так же искались по совпадению имени переменной.
better sbt parser
https://github.com/d10xa/jadd/pull/358
Это уже 3я по счету имплементация одного и того же.
Цель: уметь парсить более сложные объявления зависимостей. Например, такие:
val versions = new {
val x = new {
val v = "1"
}
}
libraryDependencies += "a" %% "b" % versions.x.v
Из этого кода нужно вытащить "a" %% "b" %% "1"
, и при этом запомнить местоположение
объявления константы "1"
, для дальнейшей модификации.
Упрощенное дерево SbtTree
В билд файлах есть не только объявление зависимостей, но и много “лишнего”. Я добавил свою структуру, повторяющую дерево из scalameta, но без шума.
SbtTree это sealed trait - упрощенное дерево для поиска зависимостей. Оно состоит из Scope, Module, Value.
- Scope - деревьев общего вида.
- Module - объявление зависимости (“a” % “aa” % aVersion)
- Value - объявление строковой переменной (val aVersion = “3”)
Действия по шагам
Взял для примера такой исходный код файла build.sbt:
val versions = new {
val aVersion = "1" // not used
val bVersion = "2"
}
val dependencies = {
val aVersion = "3"
val bVersion = "4" // not used
val depA = "a" % "aa" % aVersion
val depB = "b" % "bb" % versions.bVersion
}
Зависимость a:aa должна подставить aVersion из ближайшей области видимости, то есть значение 3. Зависимость b:bb из текущей области видимости ничего подставить не может, но чуть выше есть подходящий объект versions, внутри которго объявлена константа bVersion со значением 2 - то, что надо.
Схема создана помочь верхнеуровнево понять действия при парсинге, который я подробнее опишу по шагам ниже.
В дальнейшем я пользуюсь библиотекой
pprint для вывода в удобочитаемом
виде case class’ов. (Дальше код не всегда валидный, например
Self(name = , decltpe = None)
, но это output от pprintln как есть)
Распарсил исходник с помощью scalameta и получил AST:
Source(
stats = List(
Defn.Val(
mods = List(),
pats = List(Pat.Var(name = Term.Name(value = "versions"))),
decltpe = None,
rhs = Term.NewAnonymous(
templ = Template(
early = List(),
inits = List(),
self = Self(name = , decltpe = None),
stats = List(
Defn.Val(
mods = List(),
pats = List(Pat.Var(name = Term.Name(value = "aVersion"))),
decltpe = None,
rhs = Lit.String(value = "1")
),
Defn.Val(
mods = List(),
pats = List(Pat.Var(name = Term.Name(value = "bVersion"))),
decltpe = None,
rhs = Lit.String(value = "2")
)
),
derives = List()
)
)
),
Defn.Val(
mods = List(),
pats = List(Pat.Var(name = Term.Name(value = "dependencies"))),
decltpe = None,
rhs = Term.Block(
stats = List(
Defn.Val(
mods = List(),
pats = List(Pat.Var(name = Term.Name(value = "aVersion"))),
decltpe = None,
rhs = Lit.String(value = "3")
),
Defn.Val(
mods = List(),
pats = List(Pat.Var(name = Term.Name(value = "bVersion"))),
decltpe = None,
rhs = Lit.String(value = "4")
),
Defn.Val(
mods = List(),
pats = List(Pat.Var(name = Term.Name(value = "depA"))),
decltpe = None,
rhs = Term.ApplyInfix(
lhs = Term.ApplyInfix(
lhs = Lit.String(value = "a"),
op = Term.Name(value = "%"),
targs = List(),
args = List(Lit.String(value = "aa"))
),
op = Term.Name(value = "%"),
targs = List(),
args = List(Term.Name(value = "aVersion"))
)
),
Defn.Val(
mods = List(),
pats = List(Pat.Var(name = Term.Name(value = "depB"))),
decltpe = None,
rhs = Term.ApplyInfix(
lhs = Term.ApplyInfix(
lhs = Lit.String(value = "b"),
op = Term.Name(value = "%"),
targs = List(),
args = List(Lit.String(value = "bb"))
),
op = Term.Name(value = "%"),
targs = List(),
args = List(
Term.Select(
qual = Term.Name(value = "versions"),
name = Term.Name(value = "bVersion")
)
)
)
)
)
)
)
)
)
С таким деревом сложно работать, слишком много шума. Преобразую структуру в SbtTree(описал выше), выкинув всё “лишнее”. Функция eval занимается паттерн матчингом кода. Ищет объявление зависимостей (“a” % “aa” % v) или строк(val v = “1”). Преобразует scala.meta.Tree в Option[SbtTree] игнорируя всё “лишнее”
Vector(
Scope(
name = None,
items = Vector(
Scope(
name = Some(value = "versions"),
items = Vector(
Value(path = Vector("aVersion"), value = "1"),
Value(path = Vector("bVersion"), value = "2")
)
),
Scope(
name = Some(value = "dependencies"),
items = Vector(
Scope(
name = None,
items = Vector(
Value(path = Vector("aVersion"), value = "3"),
Value(path = Vector("bVersion"), value = "4"),
Scope(
name = Some(value = "depA"),
items = Vector(
Module(
groupId = LitString(value = "a"),
percentsCount = 1,
artifactId = LitString(value = "aa"),
version = TermNameCompound(values = Vector("aVersion")),
terms = List()
)
)
),
Scope(
name = Some(value = "depB"),
items = Vector(
Module(
groupId = LitString(value = "b"),
percentsCount = 1,
artifactId = LitString(value = "bb"),
version = TermNameCompound(values = Vector("versions", "bVersion")),
terms = List()
)
)
)
)
)
)
)
)
)
)
Вложенность Scope глубже чем хотелось бы, и нужные версии не всегда лежат в пределах одной области видимости (Scope). Функция loopReduce выносит все элементы дерева ближе к корню и делает это до тех пор, пока дерево не перестанет меняться, попутно проставляя версии, лежащие в рамках одной Scope(обратить внимание на зависимость a:aa)
Scope(
name = None,
items = Vector(
Value(path = Vector("versions", "aVersion"), value = "1"),
Value(path = Vector("versions", "bVersion"), value = "2"),
Scope(
name = Some(value = "dependencies"),
items = Vector(
Scope(
name = None,
items = Vector(
Value(path = Vector("aVersion"), value = "3"),
Value(path = Vector("bVersion"), value = "4"),
Module(
groupId = LitString(value = "a"),
percentsCount = 1,
artifactId = LitString(value = "aa"),
version = LitString(value = "3"),
terms = List()
),
Module(
groupId = LitString(value = "b"),
percentsCount = 1,
artifactId = LitString(value = "bb"),
version = TermNameCompound(values = Vector("versions", "bVersion")),
terms = List()
)
)
)
)
)
)
)
Когда глубина дерева стала минимальной, вызывается функция substituteVersionTree. Она подставляет версии из разных областей видимости.
Scope(
name = None,
items = Vector(
Scope(
name = Some(value = "dependencies"),
items = Vector(
Scope(
name = None,
items = Vector(
Module(
groupId = LitString(value = "a"),
percentsCount = 1,
artifactId = LitString(value = "aa"),
version = LitString(value = "3"),
terms = List()
),
Module(
groupId = LitString(value = "b"),
percentsCount = 1,
artifactId = LitString(value = "bb"),
version = LitString(value = "2"),
terms = List()
)
)
)
)
)
)
)
На этом этапе подставлены все возможные значения. Осталось один раз пройтись по дереву и собрать готовые модули.
Чуть подробнее про scalameta
После долгих мучений с antlr, очень приятно рабоать с AST, используя scalameta. Почему я сразу не взял scalameta? Я просто не знал о нём.
В scalameta есть поддержка паттерн матчинга на квазиквотах, но я не знаю будет ли оно работать в scala3, поэтому не пользуюсь.
Допустим, есть задача распарсить следующий код:
libraryDependencies += "com.lihaoyi" %% "pprint" % "0.6.2"
Посмотрим на AST
import scala.util.chaining._
import scala.meta._
dialects
.Sbt1("""libraryDependencies += "com.lihaoyi" %% "pprint" % "0.6.2"""")
.parse[Source]
.get
.pipe(pprint.pprintln(_))
Source(
stats = List(
Term.ApplyInfix(
lhs = Term.Name(value = "libraryDependencies"),
op = Term.Name(value = "+="),
targs = List(),
args = List(
Term.ApplyInfix(
lhs = Term.ApplyInfix(
lhs = Lit.String(value = "com.lihaoyi"),
op = Term.Name(value = "%%"),
targs = List(),
args = List(Lit.String(value = "pprint"))
),
op = Term.Name(value = "%"),
targs = List(),
args = List(Lit.String(value = "0.6.2"))
)
)
)
)
)
Простейшая функция, способная достать эту зависимость:
def eval(t: Tree): List[(String, String, String)] = t match {
case Source(value) => value.flatMap(eval)
case Term.ApplyInfix(
Term.Name("libraryDependencies"),
Term.Name("+="),
targs,
args
) =>
args.flatMap(eval)
case Term.ApplyInfix(
Term.ApplyInfix(
Lit.String(groupId),
Term.Name("%%"),
List(),
List(Lit.String(artifactId))
),
Term.Name("%"),
List(),
List(Lit.String(version))
) =>
List((groupId, artifactId, version))
}
Чего нехватает функции eval?
- match не покрывает все возможные варианты
- В sbt файле могут быть различные блоки, объявления классов, объявления функций, констант и т.д.
- Между groupId и artifactId процентов %% может быть 1, 2 и 3
- нужно уметь не только inline-версии парсить, но и уметь подставлять переменные
- ModuleId может иметь scope (“org.scalatest” %% “scalatest” % “3.2.6” % “it,test”)
Допустим, следующим шагом мы хотим понять сколько процентов между artifactId и groupId. Создадим unapply для процентов и заменим нижний case из паттерн матчинга в функции eval.
object UnapplyPercentChars {
def unapply(s: String): Option[Int] =
if (s.nonEmpty && s.forall(_ == '%')) {
Some(s.length)
} else {
None
}
}
// ...
case Term.ApplyInfix(
Term.ApplyInfix(
Lit.String(groupId),
Term.Name(UnapplyPercentChars(count)),
List(),
List(Lit.String(artifactId))
),
Term.Name("%"),
List(),
List(Lit.String(version))
) =>
List(
(
groupId,
if (count == 2) s"${artifactId}_2.13" else artifactId,
version
)
)
Остальные пункты из списка делаются аналогично, за исключением подставления переменных. Не буду описывать последующие шаги, код при желании можно посмотреть в Pull Request #358