我使用Scala + 小龙虾来编写一篇关于不依赖JVM的语言服务器的故事

此文是2022年Scala Advent Calender第20天的文章。

首先

我原本想用Scala编写语言服务器,但是发现如果使用lsp4j等库来使语言服务器运行,就必须依赖JVM,所以我决定使用名为Langoustine的库来编写一个依赖于node.js而非JVM的语言服务器。

在创建Langoustine语言服务器时,我们使用Cats Effect的IO作为Effect runtime。
即使您不熟悉这个领域,我认为您也可以通过氛围大致阅读它,不过您可以尝试阅读一下一周前发布的Scala Advent Calender 2022第13天的文章,这篇文章介绍了如何使用Cats Effect,这可能会对您有帮助。

完整的代码,包括客户端实现,已放置在 GitHub 上。

言語服务器 / LSP 是什么?

image.png

我今次做的东西

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

为了消除对Java虚拟机(JVM)的依赖,我们选择了Langoustine库作为Language Server的实现。

Langoustine 是使用 Scala3 编写的,只依赖于纯Scala库,支持JavaScript和Native的构建。
这使得使用Langoustine构建的服务器不仅可以针对JVM进行构建,还可以构建为JavaScript和Native目标。

顺便提一下,Langoustine 的直译好像是“手长虾”。为什么图书馆的名字叫 Langoustine,这让我很在意,只有听到答案之前我才能入睡。

语言服务器的实现

对初始请求仅作出最基本的服务器实施回应。

我决定编写一个语言服务器,以对初始化请求进行响应作为第一阶段的初始化。

对于 Langoustine 来说,截至2022年,文档整理还不够完善。为了理解其存在的一些仓库代码的氛围,我阅读了一些示例,并在写作过程中逐渐理解了其中的内容。

然后完成的是以下的实施。

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”,消息没有被输出到编辑器中…。

在我经过一整天的苦思冥想后,我开始解决问题。我研究了LSP的文件、客户端示例等,最终发现了客户端2的代码中有一个名为TransportKind的设置项。我将该设置从ipc更改为stdio后,成功验证了其正常运行。

image.png

后来阅读了有关 LSP 方面的规范,发现规范中并没有定义有关传输方式的通信方法,只是简单提到了“stdio、pipe、socket、node-ipc 中的任一种方法被推荐使用”的表述。

文件读取的实现

由於 LSP 的規範,在進行診斷請求時並不會傳送目標檔案的文本數據,而僅會傳送與文件路徑對應的 URI。因此,我們需要編寫一個從 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}

// 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的示例很可能是在Unix系统环境下编写的。

过时的表达实现

我已经经历了很多挑战,但已经成功实现了文件的读取功能,现在只需编写将诊断结果返回给客户端的处理程序。

在这里幸运的是,我能够立刻制作出正常运作的东西,没有任何困难的情况发生。

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上运行。但是,未来如果能够轻松地摆脱这种依赖并且简单地编写语言服务器,那将是非常令人高兴的事情。

我参考了一个网站。

    • neandertech/langoustine

 

    • neandertech/quickmaffs

 

    • keynmol/grammar-js-lsp

 

    • Language Server Protocol – Microsoft Open Source

 

    • Language Server Extension Guide

 

    • Introduction – Scala.js

 

    exoego/scala-js-nodejs
由于没有Scala3的发布版本,因此我们将使用Cross-building来进行开发。在这个实现中,我自己将客户端实现为VSCode的扩展功能。

事实上,Scala语言服务器Metals依赖于lsp4j。

bannerAds