动态

详情 返回 返回

LSP介紹並實現語言服務 - 动态 详情

首發於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

{
  "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 就可以跳轉到這個位置

{
  "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項目,下面是我們項目中需要的所有依賴和插件。

[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文件。

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中找到JSON5g4文件,之後將grammar JSON5;改為grammar J5;,將單行註釋和多行註釋的 -> skip給去掉。

// 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
    ;

之後編譯項目就會生成J5LexerJ5Parser

接着創建一個server項目用於實現我們的語言服務。

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接口。

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")
    }
}

接着依次實現TextDocumentServiceWorkspaceService

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")
    }
}
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方法,這個方法主要是需要返回我們這個語言服務器為支持什麼功能。

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枚舉類。

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"),
}

之後實現一下剩餘的方法。

override fun shutdown(): CompletableFuture<Any> {
    return CompletableFuture.completedFuture(true)
}
override fun exit() {
}
override fun getTextDocumentService(): TextDocumentService
    return J5TextDocumentService()
}
override fun getWorkspaceService(): WorkspaceService {
    return J5WorkspaceService()
}

然後實現代碼同步功能。

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方法。

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))
}

最後我們需要創建一個主方法來啓動我們的語言服務。

fun main() {
    val server = J5LanguageServer()
    val launcher = Launcher.createLauncher(server, LanguageClient::class.java, System.`in`, System.out)
    launcher.startListening()
}

測試

新建一個後綴為j5的文件,然後輸入以下內容。

{
  /* 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中添加以下內容。

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 {}

neovim

源碼

user avatar u_16297326 头像 kongxudexiaoxiongmao 头像 huangxunhui 头像 lu_lu 头像 nianqingyouweidenangua 头像 chenjiabing666 头像 gvison 头像 lvweifu 头像 changqingdezi 头像 itxiaoma 头像 xiongshihubao 头像 dreamlu 头像
点赞 18 用户, 点赞了这篇动态!
点赞

Add a new 评论

Some HTML is okay.