我使用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 是什么?

我今次做的东西

环境
-
- 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后,成功验证了其正常运行。

后来阅读了有关 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]
}
}
}

整理
在这篇文章中,我写了关于使用名为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
事实上,Scala语言服务器Metals依赖于lsp4j。