9
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

ScalaAdvent Calendar 2022

Day 20

Scala + LangoustineでJVMに依存しない言語サーバーを書いた話

Last updated at Posted at 2022-12-19

この記事は Scala Advent Calender 2022 20日目の記事です。

はじめに

言語サーバーを Scala で書きたいと思い立ったのですが、lsp4j 等のライブラリを使うと言語サーバーを動作させるために JVM が必要になってしまうため、JVM ではなく nodejs に依存する言語サーバーを Langoustine というライブラリを利用して書いた話です。

Langoustine で言語サーバーを作るにあたって Effect runtime として Cats Effect の IO を使用しています。
ご存知ない方でも雰囲気で読めるとは思いますが、ちょうど1週間前に Scala Advent Calender 2022 13日目の記事として Cats Effect 使ってみたという記事が上がっていますので読んでみると良いかも知れません。

クライアント側実装を含む完全なコードは GitHub に置いてあります。

言語サーバー / LSP とは

従来のエディターは特定言語の拡張機能を作成する場合、エディター独自の API を利用する必要があることが殆どでした。
そのため、複数のエディター向けに同機能の拡張機能を作る場合、各エディターの API を理解する必要があり、開発コストが高い傾向にありました。
そこで Microsoft が2016年に Language Server Protocol (LSP) というプロトコルを標準化しました。
LSP の登場によって、開発者は LSP の仕様に従って実装を行うだけで LSP をサポートするすべてのエディターに対して拡張機能を提供できる様になりました。
image.png

今回作ったもの

今回は、開いているテキストファイル(a.txt)に記載されたパスのテキストファイル(b.txt)に deprecated と記載されている場合、開いているテキストファイルのパスを Deprecated 表示にする拡張機能を作成しました。
image.png

環境

  • Scala 3.2.1
  • SBT 1.7.3
  • scala-js 1.11.0
  • langoustine-app 0.0.17
  • cats-effect 3.3.14
  • scala-js-nodejs-v161 0.14.0

Scala3 で書かれた LSP 実装、 Langoustine

実行時の JVM への依存を取り除くため、Language Server の実装として、 Langoustine というライブラリを利用しました。

Langoustine は Scala3 で書かれており、JavaScript や Native 向けのビルドに対応している純 Scala 製ライブラリにしか依存していません。
これによって、Langoustine 製のサーバーは JVM は勿論、JavaScript や Native をもターゲットとしてビルドすることが出来るようになっています。

ちなみに、Langoustine を直訳すると手長海老らしいですね。なぜライブラリ名が Langoustine なのか気になって朝しか眠れません。

言語サーバーの実装

初期化リクエストに返答するだけの最小限のサーバー実装

まずは最初の段階として初期化(initialize)のリクエストに対してレスポンスを返す言語サーバーを書くことにしました。

Langoustine は2022年現在ドキュメント整備が少々追いついておらず、Example として存在するいくつかのリポジトリのコードを読んで雰囲気を理解して、後は書きながら理解していきました。

そうして出来上がったのが以下の実装です。

import cats.effect.IO

import jsonrpclib.fs2.catsMonadic

import langoustine.lsp.{requests as R, structures as S, LSPBuilder}
import langoustine.lsp.app.LangoustineApp
// Opt 型は opaque type によって定義された Nullable な値を表す型
import langoustine.lsp.runtime.Opt


object DeprecatedDetectionLanguageServer extends LangoustineApp.Simple {

  // この IO が LangoustineApp.Simple 側で自動で実行され、
  // 出てきた LSPBuilder (LSP サーバーの定義) に基づいた言語サーバーが開始される
  def server: IO[LSPBuilder[IO]] = IO(create)

  def create: LSPBuilder[IO] = {
    LSPBuilder
      .create[IO]
      // handleRequest は、言語サーバーが、Request の種別に対して Response をどのように生成すればよいかを指定する関数。
      //   第一引数には処理を結びつける Request の種類 (ここでは R.initialize) を渡し、
      //   第二引数には第一引数の Request 種別に対する言語サーバーの処理を渡す。
      // 
      // 第二引数の関数に渡ってくるパラメーター in には、
      //   リクエストに付随して送られてくるデータ(リクエストによって変わる)や、
      //   クライアント側に Notification を送る為のコールバック (in.toClient.notification) などが入っている。
      .handleRequest(R.initialize)(in => for {
        // LSP において、 R.initialize で指定される Initialize Request では、
        //   サーバーとクライアントが互いに何が出来るのか( = Capabilities)などのメタデータを伝え合う必要がある。
        // ここでは、クライアント側に言語サーバーの情報 (ServerInfo) とサーバーの diagnosticProvider Capability を
        //   Response として返却している。
        // また、デバッグのため、 in.toClient.notification を用いて
        //   クライアントに「メッセージを表示する」 ShowMessage Notification を送っている。
        _ <- in.toClient.notification(
          R.window.showMessage,
          S.ShowMessageParams(E.MessageType.Info, "server activated")
        )
        res <- IO.pure(S.InitializeResult(
          S.ServerCapabilities(
            diagnosticProvider = Opt(
              S.DiagnosticOptions(
                // 診断結果が他のファイルに依存するか
                interFileDependencies = true,
                // ワークスペース全体の診断を行うか
                workspaceDiagnostics = false
              )
            )
          ),
          Opt(
            S.InitializeResult.ServerInfo(
              name = "Deprecated Detection",
              version = Opt("0.1.0")
            )
          )
        ))
      } yield res)
  }
}

しかし、いくらデバッグしても Initialize Request に対して一切 Response が返って来ず、メッセージがエディタに出力されません...。

丸一日悩んだ果に issue を立てたり、LSP のドキュメントやクライアント側の Example 等を読み漁っていたところ、クライアント側2のコードに TransportKind という設定項目があることに気づきました。
その設定を ipc から stdio に変えたところ、正常に動作を確認出来ました。

image.png

(後に TransportKind について LSP 側の仕様を読んだところ仕様側で通信方法は定義されておらず、「stdio, pipe, socket, node-ipc のいずれかを推奨する」と言った表現に留まる程度でした。)

ファイル読み込みの実装

LSP の仕様では今回利用する診断(diagnostic)のリクエストでは対象ファイルのテキストデータは送られて来ず、ファイルパスに対応する URI のみが送られてくる仕様のため、URI からファイルのテキストデータを読む処理を作る必要がありました。
例によってドキュメントが乏しいため Example を参考にまずは以下のような実装を行いました。

import scala.util.chaining.*

import cats.effect.IO

import jsonrpclib.fs2.catsMonadic

import langoustine.lsp.{requests as R, structures as S, LSPBuilder}
import langoustine.lsp.app.LangoustineApp
import langoustine.lsp.runtime.{Opt, DocumentUri}

// nodejs側のfsを利用する
import io.scalajs.nodejs.fs.{Fs, FsExtensions}


object DeprecatedDetectionLanguageServer extends LangoustineApp.Simple {

  def server: IO[LSPBuilder[IO]] = IO(create)

  def create: LSPBuilder[IO] = {
    LSPBuilder
      .create[IO]
      .handleRequest(R.initialize)(_in => IO.pure {
        /* 省略 */
      })
      .handleRequest(R.textDocument.diagnostic)(in => for {
        doc         <- IO.pure(in.params.textDocument)
        docContents <- doc.getText

        // 読み込んだ後の処理

      } yield ???)
  }


  extension (textDocument: TextDocument) {
    def getText: IO[String] = {
      IO(Fs.readFileFuture(textDocument.uri.toPath, "utf8")).pipe(IO.fromFuture)
    }
  }

  extension (docUri: DocumentUri) {
    def toPath: String = {
      // Example と完全に同一の実装。頭を切り落としてパスに変換している。
      docUri.value.drop("file://".length)
    }
  }
}

しかし、今度はデバッグしてみるとテキストを取得しようとした瞬間に言語サーバーがエラーを吐いてクラッシュするなど、お世辞にも正常に動作しているとは言えない状態となってしまいました。

パスの解釈がおかしそうなので、DocumentUri に生やした toPath に問題がありそうだということがわかっていました。
そこで、頭を切り落としてパスとして解釈するのではなく、nodejs の標準ライブラリの URL ユーティリティモジュールでファイル URI の解釈を行うことにしてみました。

import scala.util.chaining.*

import cats.effect.IO

import jsonrpclib.fs2.catsMonadic

import langoustine.lsp.{requests as R, structures as S, LSPBuilder}
import langoustine.lsp.app.LangoustineApp
import langoustine.lsp.runtime.{Opt, DocumentUri}

+import io.scalajs.nodejs.url.URL
// nodejs側のfsを利用する
import io.scalajs.nodejs.fs.{Fs, FsExtensions}


object DeprecatedDetectionLanguageServer extends LangoustineApp.Simple {

  /* 省略 */

  extension (docUri: DocumentUri) {
    def toPath: String = {
-     docUri.value.drop("file://".length)
+     URL.fileURLToPath(docUri.value)
    }
  }
}

すると想定した通りの挙動をしました。(画像はありませんが)

頭を切り落としてパスにする実装は Unix 環境下であれば問題なく動作するため、おそらく Langoustine の Example は Unix 系統の環境下で書かれたものなのだと思われます。

Deprecated 表示の実装

だいぶ様々を踏み抜きましたがファイルの読み込みも実装できたため、後は診断結果をクライアント側に返す処理を書くだけです。

ここは幸いにも詰まることは無く、正常に動作するものをすぐに作ることが出来ました。

import scala.util.chaining.*

import cats.Monoid
import cats.implicits.given
import cats.effect.IO

import jsonrpclib.fs2.catsMonadic

import langoustine.lsp.{
  requests as R,
  structures as S,
  enumerations as E,
  aliases as A,
  LSPBuilder
}
import langoustine.lsp.structures.{TextDocumentIdentifier as TextDocument}
import langoustine.lsp.app.LangoustineApp
import langoustine.lsp.runtime.{Opt, DocumentUri}

import io.scalajs.nodejs.path.Path
import io.scalajs.nodejs.url.URL
import io.scalajs.nodejs.fs.{Fs, FsExtensions}


object DeprecatedDetectionLanguageServer extends LangoustineApp.Simple {

  def server: IO[LSPBuilder[IO]] = IO(create)

  def create: LSPBuilder[IO] = {
    LSPBuilder
      .create[IO]
      .handleRequest(R.initialize)(_ => IO.pure {
        /* 省略 */
      })
      .handleRequest(R.textDocument.diagnostic)(in => for {
        // LSP において、 R.textDocument.diagnostic で指定される Document Diagnostics Request では、
        //   クライアントがサーバーに対して指定されたドキュメントの診断を要求する。
        // ここでは、クライアント側に指定されたドキュメント(in.params.textDocument)を診断し、
        //   その結果である DocumentDiagnosticReport を Response として返却している。
  
        // 開いているドキュメントのテキストを取得する
        cDoc         <- IO.pure(in.params.textDocument)
        cDocContents <- cDoc.getText

        // パスとして解釈し、TextDocument に加工する
        tDoc <- IO.pure(TextDocument(cDoc.uri.parent / cDocContents))

        // そのドキュメントが存在するならテキストを取得する
        existsTargetDoc <- tDoc.exists
        tDocContents <- IOExtra.whenA(existsTargetDoc)(tDoc.getText)

        // deprecated かチェックする
        isDocDeprecated <- IO.pure(tDocContents.exists(_ == "deprecated"))

      } yield A.DocumentDiagnosticReport(
        S.RelatedFullDocumentDiagnosticReport(
          relatedDocuments = Opt.empty,
          kind = "full",
          resultId = Opt.empty,
          items = MonoidExtra.whenMonoid(isDocDeprecated) {
            Vector(
              S.Diagnostic(
                range = S.Range(
                  S.Position(0, 0),
                  S.Position(0, cDocContents.length())),
                message = s"deprecated file: $cDocContents",
                tags = Opt(Vector(E.DiagnosticTag.Deprecated))
              )
            )
          }
        )
      ))
  }


  extension (textDocument: TextDocument) {
    def getText: IO[String] = {
      IO(Fs.readFileFuture(textDocument.uri.toPath, "utf8")).pipe(IO.fromFuture)
    }

    def exists: IO[Boolean] = {
      IO(Fs.existsFuture(textDocument.uri.toPath)).pipe(IO.fromFuture)
    }
  }

  extension (docUri: DocumentUri) {
    def parent: DocumentUri = {
      Path.dirname(docUri.toPath).pipe(pathToUri)
    }

    def /(after: String): DocumentUri = {
      Path.join(docUri.toPath, after).pipe(pathToUri)
    }

    def toPath: String = {
      URL.fileURLToPath(docUri.value)
    }
  }

  private def pathToUri(path: String): DocumentUri = {
    DocumentUri(URL.pathToFileURL(path).toString)
  }


  object IOExtra {
    def whenA[A](cond: Boolean)(action: => IO[A]): IO[Option[A]] = {
      if cond then action.map(_.some) else IO.none
    }
  }

  object MonoidExtra {
    def whenMonoid[A: Monoid](cond: Boolean)(a: => A): A = {
      if cond then a else Monoid.empty[A]
    }
  }
}

こうして出来上がったものが、冒頭でも添付したこちらの機能です。
image.png

まとめ

この記事では Langoustine というライブラリを利用して JVM に依存しない言語サーバーを作った話を書かせていただきました。
記事公開時点での Langoustine の最新バージョンは 0.0.18 と低く、(開発者曰く)非常に不安定(らしい)ですが、使った感想としてはドキュメントさえしっかりしていれば十分実用に堪えるライブラリだと思いました。
2022年現在、Scala で言語サーバーが書く際には lsp4j 等を利用して JVM に依存して書く形が殆どだと思いますが3、将来的にその依存無く簡単に書けるようになれば嬉しいですね。

参考にさせて頂いたサイト

  1. Scala3向けのリリースが存在しないため、Cross-buildingを利用します

  2. 今回の実装ではクライアント側をVSCodeの拡張機能として自分で実装をしていました。

  3. 実際、Scalaの言語サーバーであるところの Metals は lsp4jに依存しています。

9
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?