たにしきんぐダム

プログラミングやったりゲームしてます

Scala3 Interactive Compiler API で遊んでみる

この記事は Scala Advent Calendar 2020 - Qiita 18日目の記事です。

完全に自分用のメモなのですが、コンパイラ周辺ツールの Scala3 対応に備えて、dotty の interactive compiler API の使い方を一部学んでみたのでその学習メモを残しておく。

今回試したコードは以下のリポジトリにまとまっていますが、このブログでは試行錯誤した過程を書いていこうと思います。今回は使い方だけ見たけど次はどう実装されてるかまで読んでいきたい。

github.com

もうちょっと雑なやつ dotty tools で遊んでみる - tanishiking-pub

また使い方を勉強するにあたっては dotty language server を参考にしました。 dotty/DottyLanguageServer.scala at eddd4da41ac14057edf4db6f9a24de6f768dbbb3 · lampepfl/dotty

(dotty language server はあくまで参考実装という感じで、将来的には変わらず intellij-scala と metals が scala3 対応を頑張っていく感じになるんではなかろうか? dotty-language-server は最低限の機能は備えているが、実プロジェクトで利用するにはいろいろと機能が不足している割に暫く手が入ってないので (要出典))

dotty tools の主なエンドポイントは InteractiveDriver というクラス

project を作る

sbt console で遊んでみよう。まずは砂場とするプロジェクトを作る

// project/plugins.sbt
 addSbtPlugin("ch.epfl.lamp" % "sbt-dotty" % "0.4.6")
// build.sbt
val dottyVersion = "3.0.0-M2"

lazy val root = project
  .in(file("."))
  .settings(
    name := "dotty-interactive-playground",
    version := "0.1.0",

    scalaVersion := dottyVersion,

    libraryDependencies ++= List(
      "org.scala-lang" %% "scala3-compiler" % scalaVersion.value,
      "io.get-coursier" % "interface" % "1.0.1",
      "com.lihaoyi" %% "pprint" % "0.6.0",
    )
  )

このproject内で sbt console を実行してみる。

InteractiveDriver の instantiate

 scala> import dotty.tools.dotc.interactive.InteractiveDriver
 
// まずは InteractiveDriver クラスを instantiate
// InteractiveDriver にはコンパイラに与える classpath やコンパイラオプションを与える。
// とりあえず何も与えずにインスタンス化しようとしてみる
 scala> val driver = new InteractiveDriver(List.empty)
 dotty.tools.dotc.MissingCoreLibraryException: Could not find package scalaShadowing from compiler core libraries.
 Make sure the compiler core libraries are on the classpath.

なるほど、scala3-libraryclasspath を与えないと死ぬようになってるのか

The official standard library for Scala 3.0 is the Scala 2.13 library. Not only the source code is unchanged but it is even not compiled and published under 3.0. It could be, but it would be useless because, as we have seen, a Scala 3.0 module can depend on a Scala 2.13 artifact. https://scalacenter.github.io/scala-3-migration-guide/docs/compatibility.html#the-scala-standard-library

とあるように、scala3の標準ライブラリは2.13のライブラリを利用しているため、scala3-libraryclasspath に加えて、scala2.13 の標準ライブラリのクラスパスも与えて

scala-libraryclasspath ってどこにあるんだっけ? - sbt の依存に dotty-libraryが含まれている場合 sbt show runtime:fullClasspath で依存しているライブラリのclasspathが分かるのでコピペできる

scala> val classpaths = Seq(
  "/Users/tanishiking/Library/Caches/Coursier/v1/https/repo1.maven.org/maven2/org/scala-lang/scala3-library_3.0.0-M2/3.0.0-M2/scala3-library_3.0.0-M2-3.0.0-M2.jar",
  "/Users/tanishiking/Library/Caches/Coursier/v1/https/repo1.maven.org/maven2/org/scala-lang/scala-library/2.13.4/scala-library-2.13.4.jar"
)

scala> val driver = new InteractiveDriver(List("-classpath", classpaths.mkString(java.io.File.pathSeparator)))
val driver: dotty.tools.dotc.interactive.InteractiveDriver = dotty.tools.dotc.interactive.InteractiveDriver@4123624c

もしくは別にこんなことしなくても coursier の fetch API で必要なライブラリを取得してきて、そのクラスパスをInteractiveDriverに食わせてやる

import coursierapi.{Fetch, Dependency}
import java.nio.file.Path

val fetch = Fetch.create()

import scala.jdk.CollectionConverters._

fetch.addDependencies(
  Dependency.of("org.scala-lang", "scala3-library_3.0.0-M2", "3.0.0-M2")
)

val extraLibraries: Seq[Path] = fetch
      .fetch()
      .asScala
      .map(_.toPath())
      .toSeq

val driver = new InteractiveDriver(
   List(
     "-color:never",
     "-classpath",
     extraLibraries.mkString(java.io.File.pathSeparator)
   )
)

InteractiveDriver を使っていろんなコードを interactive に解析してみよう

コンパイルを実行する

val uri = new URI("file:///virtual")

driver.run(uri, "object X { }")

pprint.log(driver.openedFiles)
// LinkedHashMap(file:///virtual -> /virtual)

pprint.log(driver.openedTrees(uri))
//  List(
//    SourceTree(
//      tree = TypeDef(
//        name = X$,
//        rhs = Template(
//          constr = DefDef(
//            name = <init>,
//            tparams = List(),
//            vparamss = List(List()),
//            tpt = TypeTree[TypeRef(ThisType(TypeRef(NoPrefix,module class scala)),class Unit)],
//            preRhs = Thicket(trees = List())
//          ),
//          parentsOrDerived = List(
//            Apply(
//              fun = Select(
//                qualifier = New(
//                  tpt = TypeTree[TypeRef(ThisType(TypeRef(NoPrefix,module class lang)),class Object)]
//                ),
//                name = <init>
//              ),
//              args = List()
//            )
//          ),
//          self = ValDef(
//            name = _,
//            tpt = SingletonTypeTree(ref = Ident(name = X)),
//            preRhs = Thicket(trees = List())
//          ),
//          preBody = List()
//        )
//      ),
//      source = /virtual
//    )
//  )

エラーがあるコードを与えてみる

val sourceParital = "object X { def x = 1.toSt } "
val uriPartial = new URI("file:///partial")
val diag = driver.run(uriPartial, sourceParital)
pprint.log(diag)
// List(
//   class dotty.tools.dotc.reporting.Diagnostic$Error at /virtual:[19..21..25]: value toSt is not a member of Int - did you mean (1 : Int).toInt?,
//   class dotty.tools.dotc.reporting.Diagnostic$Info at ?: 1 error found
// )

エラーが帰ってきた。しかし openedTree には error symbol っぽいものを使ってエラー回復したと思われる部分的な構文木が登録されている。これのおかげで部分的なコードでも補完とかnavigationが実行できるわけだね

pprint.log(driver.openedTrees(uriPartial))
// Mode.Interactive makes parser error resillient using <error> symbol?
// ...
// preBody = List(
//       DefDef(
//         name = x,
//         tparams = List(),
//         vparamss = List(),
//         tpt = TypeTree[dotty.tools.dotc.core.Types$PreviousErrorType@752771a8],
//         preRhs = Select(qualifier = Literal(const = ( = 1)), name = toSt)
//       )
//     )

completion の実行

Completions.completion を使って、指定したポジションで completion API を実行してみる。さっきのコードの 1.toS のところで補完を実行してみよう。

import dotty.tools.dotc.interactive.{InteractiveDriver, Interactive, Completion}
import dotty.tools.dotc.util.{Spans, SourcePosition}
import dotty.tools.dotc.core.Contexts._



val pos = new SourcePosition(
  driver.openedFiles(uriPartial),
  Spans.Span(sourceParital.indexOf(".toSt") + ".toS".length) // run completion at "1.toS"
)
val completions = Completion.completions(pos)(using driver.currentCtx.fresh.setCompilationUnit(driver.compilationUnits.get(uriPartial).get))
pprint.log(completions)
// (
//   21,
//   List(
//     Completion(label = "toShort", description = "=> Short", symbols = List(method toShort)),
//     Completion(label = "toString", description = "(): String", symbols = List(method toString))
//   )
// )

toStringtoShort が補完された

find definition

次は定義ジャンプなんかを実装するために利用する Definition.findDefinition を使って、あるシンボルの定義元を探す機能を利用してみる。

val sourceDefinition = "object Definition { def x = 1; val y = x + 1 }"
val uriDefinition = new URI("file:///def")
driver.run(uriDefinition, sourceDefinition)
given ctx as Context = driver.currentCtx

val pos = new SourcePosition(driver.openedFiles(uriDefinition), Spans.Span(sourceDefinition.indexOf("x + 1")))
val path = Interactive.pathTo(driver.openedTrees(uriDefinition), pos)

// Feeding path to the pos, and return definition's tree
val definitions = Interactive.findDefinitions(path, pos, driver)
pprint.log(definitions)
// List(
//   SourceTree(
//     tree = DefDef(
//       name = x,
//       tparams = List(),
//       vparamss = List(),
//       tpt = TypeTree[dotty.tools.dotc.core.Types$PreviousErrorType@6b6b68d0],
//       preRhs = Select(qualifier = Literal(const = ( = 1)), name = toSt)
//     ),
//     source = /partial
//   ),
//   SourceTree(
//     tree = DefDef(
//       name = x,
//       tparams = List(),
//       vparamss = List(),
//       tpt = TypeTree[TypeRef(ThisType(TypeRef(NoPrefix,module class scala)),class Int)],
//       preRhs = Literal(const = ( = 1))
//     ),
//     source = /def
//   )
// )

本来欲しいのは後者だけだったのだけれど、他所の compilation unit で定義した x も引いてきてしまったが、とりあえず定義元のコードの構文木を取得することができた。

おまけ parser で遊んで見る

scala> import dotty.tools.dotc.core.Contexts._

scala> given Context = (new ContextBase).initialCtx
lazy val given_Context: dotty.tools.dotc.core.Contexts.Context

scala> import dotty.tools.dotc.parsing.Parsers

scala> import dotty.tools.dotc.util.SourceFile

scala> val parser = new Parsers.Parser(SourceFile.virtual("<meta>", "class X {} extends Base;"))
val parser: dotty.tools.dotc.parsing.Parsers.Parser = dotty.tools.dotc.parsing.Parsers$Parser@175db8b0

scala> parser.parse()
val res1: dotty.tools.dotc.ast.untpd.Tree = PackageDef(Ident(<empty>),List(TypeDef(X,Template(DefDef(<init>,List(),List(),TypeTree,EmptyTree),List(),ValDef(_,EmptyTree,EmptyTree),List(EmptyTree)))))