Re: 从零开始的语言服务器开发冒险:代码补全 & 文档悬停

01 Mar 2024 6369 words 22 minutes BY-SA 4.0
develop lsp

有感于大部分用编辑器写代码的用户都是伸手党(无贬义,毕竟大家各有所长,在某个不擅长的领域想伸也没手可伸不是),希望分享相关知识鼓励更多人参与编辑器相关软件、插件开发。

本文:

  1. 只需要 python 基础。
  2. 只涉及语言服务器,不涉及语言客户端(编辑器或者让编辑器支持 LSP 的插件)。即所有编辑器都适用。

让我们选择一种语言来演示如何实现代码补全和文档悬停的功能。它要:

  1. 足够简单,简单到几乎没有语法
  2. 如果该语言有很多与 python 相关的库会更有利于开发,毕竟我们假定读者只有 python 基础
  3. 最好与 python 相关,这样大多数 python 用户都可能会这门语言

先暂停一下看点别的,之后再看看你想到的结果会是哪一种语言?

标准

基本协议

OSI

OSI usage

语言服务器协议与其姊妹版的构建服务器协议和作为它们交集的 基本协议 位于 OSI 模型的应用层。基于会话层的 JSON-RPC 协议。 再往下可以是 TCP 或 UDP 甚至标准输入输出(这已经不是网络通信了,服务器进程和客户端进程必须在同一台设备上了)进行通信。

基本协议定义了服务器和客户端如何通信。包括:

语言服务器协议解耦了只负责语言功能的语言服务器和只负责文本编辑的编辑器或文本预览的浏览器,使得不同代码编辑器在支持不同语言的编辑上可以高度复用语言服务器的代码。不用重新适配编辑器插件。 同样读者也可以猜到构建服务器协议的作用。

注意, LSP 只规定了语言服务器和语言客户端如何通信,并不规定语言服务器的具体实现。以代码补全这个 feature 为例,以下 3 种实现都是可以的。

调试适配器协议

经常被一并提及的调试适配器协议(DAP)并不基于 JSON-RPC 协议。笔者没有深入使用过,感觉像是使用编辑器充当了调试器的界面,好处可能有用户不用重新记忆不同调试器的快捷键,但 cgdb 或 pudb 这种不支持 DAP 但内置了一门脚本语言(gdb script 或 python)的调试器比较似乎功能仍然不够强大。

语言服务器索引格式

另一个与之相关的协议是语言服务器索引格式 (LSIF)。之前提到语言客户端除了有文本编辑的编辑器外,还有负责文本预览的浏览器。例如,用户可以在 github 上浏览开源仓库的代码。与编辑器最大的区别是后者是只读的,也就是说可以把每次用户执行操作的结果保存下来作为索引。以节省用户重复进行操作的时间。比如用户查看了某个函数定义和引用,下次再执行同样的操作时相应速度就会更快——感觉微软就是在为 github 制定这个标准的呀(LSP 以及与 LSP 相关的协议现在是微软(VSCode )、 IBM Redhat (Eclipse)几家在合作制定标准。尽管如此,我们仍可轻率地暴论微软一个人说了算)!

github

顺带一提, LSIF 定义的相当有趣,格式是 json ,但“查询定义”这种动词放在了 edge 属性上,“查询结果”、“被查询的函数名”这些名词被放在了 vertex 属性上。真不知道微软的灵感来源是哪……

实操

我们选择 requirements.txt

这种语言最初被 pip 用来描述 python 的包依赖关系。后经过 PEP 508 提案被标准化。不过 pip 支持的 requirements.txt 是 PEP 508 的超集。额外支持 pip 的部分命令行选项。我们来实现关于 PEP 508 的 requirements.txt 的一些 LSP feature (当然,看完这篇文章实现补全 pip 的命令行选项也不是什么难题):

如果不加上本地已经安装的限制的话:

将使用以下库:

首先实现一个用标准输入输出通信的语言服务器。出于演示的目的,某些代码并不规范,例如:

from typing import Any

from lsprotocol.types import *
from pygls.server import LanguageServer


class RequirementsLanguageServer(LanguageServer):
    def __init__(self, *args: Any, **kwargs: Any) -> None:
        super().__init__(*args, **kwargs)


if __name__ == "__main__":
    RequirementsLanguageServer(NAME, __version__).start_io()

代码补全

尝试补全 torchtorchvision 。关于如何配置编辑器使用语言服务器参见 语言客户端

class RequirementsLanguageServer(LanguageServer):
    def __init__(self, *args: Any, **kwargs: Any) -> None:
        # ...

        @self.feature(TEXT_DOCUMENT_COMPLETION)
        def completions(params: CompletionParams) -> CompletionList:
            return CompletionList(
                False,
                [
                    CompletionItem(
                        k,
                        kind=CompletionItemKind.Module,
                        insert_text=k,
                        documentation=MarkupContent(
                            MarkupKind.Markdown, "An example"
                        ),
                    for k in ["torch", "torchvision"]
                ],
            )

这种无论用户输入什么都补全 torchtorchvision 的补全毫无意义。我们尝试获得当前光标下的单词:

import re


class RequirementsLanguageServer(LanguageServer):
    # ...
    def _cursor_line(self, uri: str, position: Position) -> str:
        document = self.workspace.get_document(uri)
        return document.source.splitlines()[position.line]

    def _cursor_word(
        self,
        uri: str,
        position: Position,
        include_all: bool = True,
        regex: str = r"\w+",
    ) -> tuple[str, Range]:
        line = self._cursor_line(uri, position)
        for m in re.finditer(regex, line):
            if m.start() <= position.character <= m.end():
                end = m.end() if include_all else position.character
                return (
                    line[m.start() : end],
                    Range(
                        Position(position.line, m.start()),
                        Position(position.line, end),
                    ),
                )
        return (
            "",
            Range(Position(position.line, 0), Position(position.line, 0)),
        )

接下来可以只在用户输入 t, to, tor, … 时补全 torch, torchvision

class RequirementsLanguageServer(LanguageServer):
    def __init__(self, *args: Any, **kwargs: Any) -> None:
        # ...

        @self.feature(TEXT_DOCUMENT_COMPLETION)
        def completions(params: CompletionParams) -> CompletionList:
            word, _ = self._cursor_word(
                params.text_document.uri, params.position, False
            )
            return CompletionList(
                False,
                [
                    CompletionItem(
                        k,
                        kind=CompletionItemKind.Module,
                        insert_text=k,
                        documentation=MarkupContent(
                            MarkupKind.Markdown, "An example"
                        ),
                    for k in ["torch", "torchvision"]
                    if x.startswith(word)
                ],
            )

hello world

考虑如何根据 word 获得所有以 word 开头的包名。由于一开始要求的第 2 条,我们可以额外获得一些库:

pip-cache update 等个 10 秒缓存一下包名先。然后我们就可以获得包名了:

from pip_cache import get_package_names

class RequirementsLanguageServer(LanguageServer):
    def __init__(self, *args: Any, **kwargs: Any) -> None:
        # ...

        @self.feature(TEXT_DOCUMENT_COMPLETION)
        def completions(params: CompletionParams) -> CompletionList:
            word, _ = self._cursor_word(
                params.text_document.uri, params.position, False
            )
            return CompletionList(
                False,
                [
                    CompletionItem(
                        k,
                        kind=CompletionItemKind.Module,
                        insert_text=k,
                        documentation=MarkupContent(
                            MarkupKind.Markdown, "An example"
                        ),
                    for k in get_package_names(word)
                ],
            )

no document

嗯, import 是真的爽。

接着,如何获得包的信息?用 pudb 调试一下 pip showpip list:

pudb -m pip show
pudb -m pip list

很快发现 2 个内部应用编程接口:

from pip._internal.commands.show import _PackageInfo, search_packages_info
from pip._internal.metadata import get_environment

前者很慢,但返回的 _PackageInfo 能比后者多返回一些把包上传到 PYPI 上需要的元信息中所没有的信息,比如你的电脑里有多少包是某个包的反向依赖。

ENV = get_environment(None)

def search_package_names(name: str, search: bool = True) -> dict[str, str]:
    package_names = {
        pkgname: "Not found installed package!" for pkgname in (get_package_names(name) if search else [name])
    }
    count = len(package_names)
    for pkg in ENV.iter_installed_distributions():
        if count == 0:
            break
        if name not in package_names:
            continue
        package_names[pkg.canonical_name] = render_document(
            pkg.metadata_dict
        )
        count -= 1
    return package_names

def render_document(metadata: dict[str, Any]) -> str:
    # ...
    return f"""
{metadata["summary"]}

...
"""

class RequirementsLanguageServer(LanguageServer):
    def __init__(self, *args: Any, **kwargs: Any) -> None:
        # ...

        @self.feature(TEXT_DOCUMENT_COMPLETION)
        def completions(params: CompletionParams) -> CompletionList:
            word, _ = self._cursor_word(
                params.text_document.uri, params.position, False
            )
            return CompletionList(
                False,
                [
                    CompletionItem(
                        k,
                        kind=CompletionItemKind.Module,
                        insert_text=k,
                        documentation=MarkupContent(
                            MarkupKind.Markdown, doc
                        ),
                    for k, doc in search_package_names(word)
                ],
            )

completion

文档悬停

文档悬停因为也很简单一并实现了叭:

class RequirementsLanguageServer(LanguageServer):
    def __init__(self, *args: Any, **kwargs: Any) -> None:
        # ...

        @self.feature(TEXT_DOCUMENT_HOVER)
        def hover(params: TextDocumentPositionParams) -> Hover | None:
            word, _range = self._cursor_word(
                params.text_document.uri, params.position, True
            )
            return Hover(
                MarkupContent(
                    MarkupKind.Markdown,
                    search_package_names(word, False)[word],
                ),
                _range(),
            )

hover

本文代码开源于 requirements-language-server

反思

观察本文实现的代码,我们注意到以下致命的问题:

comment

为了解决这个问题,我们需要引入抽象语法树。而这些内容,请待下回分解。

to be continued

https://user-images.githubusercontent.com/32936898/199681341-1c5cfa61-4411-4b67-b268-7cd87c5867bb.png https://user-images.githubusercontent.com/32936898/199681363-1094a0be-85ca-49cf-a410-19b3d7965120.png https://user-images.githubusercontent.com/32936898/199681368-c34c2be7-e0d8-43ea-8c2c-d3e865da6aeb.png