首发于Enaium的个人博客
LSP (Language Server Protocol) 介绍
前段时间我为Jimmer DTO实现了一个 LSP
的语言服务,这是我第一次实现 LSP
,所以在这里我分享一下我实现LSP
的经验。
首先来看一下效果,图片太多,我就放一部分,更多的可以看jimmer-dto-lsp
LSP
是一种协议,用于在 IDE
和语言服务器之间通信。IDE
通过 LSP
请求语言服务器提供代码分析服务,语言服务器通过 LSP
响应 IDE 的请求。在没有 LSP
之前,每个 IDE
都需要为每种语言实现一套代码分析服务,而 LSP
的出现使得 IDE 只需要实现一套 LSP
协议,就可以使用任何支持 LSP
的语言服务器。所以就大大降低了 IDE
的开发成本。
列如,需要从一个地方跳转到其他地方,IDE
会发送一个请求,位置是第 3
行第 12
列
json
{
"jsonrpc": "2.0",
"id": 1,
"method": "textDocument/definition",
"params": {
"textDocument": {
"uri": "file:///p%3A/mseng/VSCode/Playgrounds/cpp/use.cpp"
},
"position": {
"line": 3,
"character": 12
}
}
}
之后服务端会返回一个响应,位置是第 0
行第 4
列到第 0
行第 11
列,这样 IDE
就可以跳转到这个位置
json
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"uri": "file:///p%3A/mseng/VSCode/Playgrounds/cpp/provide.cpp",
"range": {
"start": {
"line": 0,
"character": 4
},
"end": {
"line": 0,
"character": 11
}
}
}
}
实现
上面的例子中是使用纯文本实现的,我们可以直接使用封装好的库,比如lsp4j。由于只是简单的教学,我这里只实现代码的高亮,语言是JSON5
,词法分析就使用antlr4
。
首先我们需要创建一个Gradle
项目,下面是我们项目中需要的所有依赖和插件。
toml
[versions]
kotlin = "2.1.0"
antlr = "4.13.0"
lsp4j = "0.23.1"
shadow = "9.0.0-beta4"
[libraries]
antlr = { group = "org.antlr", name = "antlr4", version.ref = "antlr" }
lsp4j = { module = "org.eclipse.lsp4j:org.eclipse.lsp4j", version.ref = "lsp4j" }
[plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
shadow = { id = "com.gradleup.shadow", version.ref = "shadow" }
接着创建一个叫langauge
的子项目,并在src\main\antlr\cn\enaium\j5
下创建一个J5.g4
文件。
kotlin
import org.gradle.kotlin.dsl.withType
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
alias(libs.plugins.kotlin.jvm)
antlr
}
repositories {
mavenCentral()
}
dependencies {
antlr(libs.antlr)
testImplementation(kotlin("test"))
}
tasks.test {
useJUnitPlatform()
}
tasks.withType<Jar>().configureEach {
dependsOn(tasks.withType<AntlrTask>())
}
tasks.withType<KotlinCompile>().configureEach {
dependsOn(tasks.withType<AntlrTask>())
}
在grammars-v4中找到JSON5
的g4
文件,之后将grammar JSON5;
改为grammar J5;
,将单行注释和多行注释的 -> skip
给去掉。
antlr
// Student Main
// 2020-07-22
// Public domain
// JSON5 is a superset of JSON, it included some feature from ES5.1
// See https://json5.org/
// Derived from ../json/JSON.g4 which original derived from http://json.org
// $antlr-format alignTrailingComments true, columnLimit 150, minEmptyLines 1, maxEmptyLinesToKeep 1, reflowComments false, useTab false
// $antlr-format allowShortRulesOnASingleLine false, allowShortBlocksOnASingleLine true, alignSemicolons hanging, alignColons hanging
grammar J5;
json5
: value? EOF
;
obj
: '{' pair (',' pair)* ','? '}'
| '{' '}'
;
pair
: key ':' value
;
key
: STRING
| IDENTIFIER
| LITERAL
| NUMERIC_LITERAL
;
value
: STRING
| number
| obj
| arr
| LITERAL
;
arr
: '[' value (',' value)* ','? ']'
| '[' ']'
;
number
: SYMBOL? (NUMERIC_LITERAL | NUMBER)
;
// Lexer
SINGLE_LINE_COMMENT
: '//' .*? (NEWLINE | EOF)
;
MULTI_LINE_COMMENT
: '/*' .*? '*/'
;
LITERAL
: 'true'
| 'false'
| 'null'
;
STRING
: '"' DOUBLE_QUOTE_CHAR* '"'
| '\'' SINGLE_QUOTE_CHAR* '\''
;
fragment DOUBLE_QUOTE_CHAR
: ~["\\\r\n]
| ESCAPE_SEQUENCE
;
fragment SINGLE_QUOTE_CHAR
: ~['\\\r\n]
| ESCAPE_SEQUENCE
;
fragment ESCAPE_SEQUENCE
: '\\' (
NEWLINE
| UNICODE_SEQUENCE // \u1234
| ['"\\/bfnrtv] // single escape char
| ~['"\\bfnrtv0-9xu\r\n] // non escape char
| '0' // \0
| 'x' HEX HEX // \x3a
)
;
NUMBER
: INT ('.' [0-9]*)? EXP? // +1.e2, 1234, 1234.5
| '.' [0-9]+ EXP? // -.2e3
| '0' [xX] HEX+ // 0x12345678
;
NUMERIC_LITERAL
: 'Infinity'
| 'NaN'
;
SYMBOL
: '+'
| '-'
;
fragment HEX
: [0-9a-fA-F]
;
fragment INT
: '0'
| [1-9] [0-9]*
;
fragment EXP
: [Ee] SYMBOL? [0-9]*
;
IDENTIFIER
: IDENTIFIER_START IDENTIFIER_PART*
;
fragment IDENTIFIER_START
: [\p{L}]
| '$'
| '_'
| '\\' UNICODE_SEQUENCE
;
fragment IDENTIFIER_PART
: IDENTIFIER_START
| [\p{M}]
| [\p{N}]
| [\p{Pc}]
| '\u200C'
| '\u200D'
;
fragment UNICODE_SEQUENCE
: 'u' HEX HEX HEX HEX
;
fragment NEWLINE
: '\r\n'
| [\r\n\u2028\u2029]
;
WS
: [ \t\n\r\u00A0\uFEFF\u2003]+ -> skip
;
之后编译项目就会生成J5Lexer
和J5Parser
。
接着创建一个server
项目用于实现我们的语言服务。
kotlin
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.shadow)
}
repositories {
mavenCentral()
}
dependencies {
implementation(project(":language"))
implementation(libs.lsp4j)
testImplementation(kotlin("test"))
}
tasks.test {
useJUnitPlatform()
}
tasks.jar {
dependsOn(tasks.shadowJar)
}
首先我们需要实现一个LanguageServer
接口。
kotlin
package cn.enaium.j5.lsp
import org.eclipse.lsp4j.InitializeParams
import org.eclipse.lsp4j.InitializeResult
import org.eclipse.lsp4j.services.LanguageServer
import org.eclipse.lsp4j.services.TextDocumentService
import org.eclipse.lsp4j.services.WorkspaceService
import java.util.concurrent.CompletableFuture
/**
* @author Enaium
*/
class J5LanguageServer : LanguageServer {
override fun initialize(params: InitializeParams): CompletableFuture<InitializeResult> {
TODO("Not yet implemented")
}
override fun shutdown(): CompletableFuture<in Any> {
TODO("Not yet implemented")
}
override fun exit() {
TODO("Not yet implemented")
}
override fun getTextDocumentService(): TextDocumentService {
TODO("Not yet implemented")
}
override fun getWorkspaceService(): WorkspaceService {
TODO("Not yet implemented")
}
}
接着依次实现TextDocumentService
和WorkspaceService
。
kotlin
package cn.enaium.j5.lsp
import org.eclipse.lsp4j.DidChangeTextDocumentParams
import org.eclipse.lsp4j.DidCloseTextDocumentParams
import org.eclipse.lsp4j.DidOpenTextDocumentParams
import org.eclipse.lsp4j.DidSaveTextDocumentParams
import org.eclipse.lsp4j.services.TextDocumentService
/**
* @author Enaium
*/
class J5TextDocumentService : TextDocumentService {
override fun didOpen(params: DidOpenTextDocumentParams) {
TODO("Not yet implemented")
}
override fun didChange(params: DidChangeTextDocumentParams) {
TODO("Not yet implemented")
}
override fun didClose(params: DidCloseTextDocumentParams) {
TODO("Not yet implemented")
}
override fun didSave(params: DidSaveTextDocumentParams) {
TODO("Not yet implemented")
}
}
kotlin
package cn.enaium.j5.lsp
import org.eclipse.lsp4j.DidChangeConfigurationParams
import org.eclipse.lsp4j.DidChangeWatchedFilesParams
import org.eclipse.lsp4j.services.WorkspaceService
/**
* @author Enaium
*/
class J5WorkspaceService : WorkspaceService {
override fun didChangeConfiguration(params: DidChangeConfigurationParams) {
}
override fun didChangeWatchedFiles(params: DidChangeWatchedFilesParams) {
}
}
实现initialize
方法,这个方法主要是需要返回我们这个语言服务器为支持什么功能。
kotlin
override fun initialize(params: InitializeParams): CompletableFuture<InitializeResult> {
return CompletableFuture.completedFuture(InitializeResult(ServerCapabilities().apply {
setTextDocumentSync(TextDocumentSyncOptions().apply {
openClose = true
change = TextDocumentSyncKind.Full
setSave(SaveOptions().apply {
includeText = true
})
})
semanticTokensProvider = SemanticTokensWithRegistrationOptions().apply {
legend = SemanticTokensLegend().apply {
tokenTypes = SemanticType.entries.map { it.type }
}
setFull(true)
}
}))
}
首先任何一个语言服务都需要具备这个文档同步功能,这个功能会在打开关闭修改和保存文件是触发。之后是提供语义,提供语义之后,IDE
就可以根据这个语义来实现代码高亮。
我们需要定义一个SemanticType
枚举类。
kotlin
enum class SemanticType(val id: Int, val type: String) {
COMMENT(0, "comment"),
KEYWORD(1, "keyword"),
FUNCTION(2, "function"),
STRING(3, "string"),
NUMBER(4, "number"),
DECORATOR(5, "decorator"),
MACRO(6, "macro"),
TYPE(7, "type"),
TYPE_PARAMETER(8, "typeParameter"),
CLASS(9, "class"),
VARIABLE(10, "variable"),
PROPERTY(11, "property"),
STRUCT(12, "struct"),
INTERFACE(13, "interface"),
PARAMETER(14, "parameter"),
ENUM_MEMBER(15, "enumMember"),
NAMESPACE(16, "namespace"),
}
之后实现一下剩余的方法。
kotlin
override fun shutdown(): CompletableFuture<Any> {
return CompletableFuture.completedFuture(true)
}
override fun exit() {
}
override fun getTextDocumentService(): TextDocumentService
return J5TextDocumentService()
}
override fun getWorkspaceService(): WorkspaceService {
return J5WorkspaceService()
}
然后实现代码同步功能。
kotlin
val cache = mutableMapOf<String, String>()
override fun didOpen(params: DidOpenTextDocumentParams) {
cache[params.textDocument.uri] = params.textDocument.text
}
override fun didChange(params: DidChangeTextDocumentParams) {
cache[params.textDocument.uri] = params.contentChanges[0].text
}
override fun didClose(params: DidCloseTextDocumentParams) {
cache.remove(params.textDocument.uri)
}
override fun didSave(params: DidSaveTextDocumentParams) {
cache[params.textDocument.uri] = params.text
}
接着我们需要再在J5TextDocumentService
的实现类中实现一个semanticTokensFull
方法。
kotlin
override fun semanticTokensFull(params: SemanticTokensParams): CompletableFuture<SemanticTokens> {
val document = cache[params.textDocument.uri] ?: return CompletableFuture.completedFuture(SemanticTokens())
val data = mutableListOf<Int>()
var previousLine = 0
var previousChar = 0
val j5Lexer = J5Lexer(CharStreams.fromString(document))
val token = CommonTokenStream(j5Lexer)
token.fill()
token.tokens.forEach { token ->
val semanticType = when (token.type) {
J5Lexer.STRING -> SemanticType.STRING
J5Lexer.NUMBER -> SemanticType.NUMBER
J5Lexer.NUMERIC_LITERAL -> SemanticType.NUMBER
J5Lexer.LITERAL -> SemanticType.KEYWORD
J5Lexer.SINGLE_LINE_COMMENT -> SemanticType.COMMENT
J5Lexer.MULTI_LINE_COMMENT -> SemanticType.COMMENT
J5Lexer.IDENTIFIER -> SemanticType.VARIABLE
J5Lexer.SYMBOL -> SemanticType.KEYWORD
else -> return@forEach
}
token.text.split("\n").forEachIndexed { index, s ->
val start = Position(token.line - 1, token.charPositionInLine)
val currentLine = start.line + index
val currentChar = if (index == 0) start.character else 0
data.add(currentLine - previousLine)
data.add(if (previousLine == currentLine) currentChar - previousChar else currentChar)
data.add(s.length)
data.add(semanticType.id)
data.add(0)
previousLine = currentLine
previousChar = currentChar
}
}
return CompletableFuture.completedFuture(SemanticTokens(data))
}
最后我们需要创建一个主方法来启动我们的语言服务。
kotlin
fun main() {
val server = J5LanguageServer()
val launcher = Launcher.createLauncher(server, LanguageClient::class.java, System.`in`, System.out)
launcher.startListening()
}
测试
新建一个后缀为j5
的文件,然后输入以下内容。
json5
{
/* play with comments
{ true, NaN ] , {}* / aaa{}
// make sure we included all \p{L},
yes, json5, and ECMAScript 5+ supports them
//*/
全世界无产者: "联合起来",
n1: 1e2,
n2: 0.2e-4,
// May not works in some poor IDE
// but works in official parser
Infinity: -Infinity,
NaN: -NaN,
true: true,
false: false,
// yes, it works in their parser too
一: "Unicode!"
}
// comment ends with eof
之后我这里使用neovim
来测试,确保你已经安装了lspconfig
。
· 在init.lua
中添加以下内容。
lua
vim.cmd [[au BufRead,BufNewFile *.j5 set filetype=J5]]
local lsp = require('lspconfig')
local lsp_config = require('lspconfig.configs')
lsp_config.j5 = {
default_config = {
cmd = { 'java', '-cp', 'D:/Projects/teaching-lsp/server/build/libs/server-1.0-SNAPSHOT-all.jar', 'cn.enaium.j5.lsp.MainKt' },
filetypes = { 'J5' },
root_dir = function(fname)
return lsp.util.root_pattern('*.j5')(fname)
end,
}
}
lsp_config.j5.setup {}