欢迎使用 Nox -- 轻量级 LSP 客户端

周末的时候,把LSP的协议细节全部读了一遍:https://microsoft.github.io/language-server-protocol/specifications/specification-current/

理解协议细节后,用Python写了一个LSP协议代理器,利用Python的多线程和代理进程来减缓LSP Server巨大数据对Emacs的冲击,以提高代码补全的性能。 因为多线程的设计,性能非常好,和LSP Server斗智斗勇两天,被各种协议细节折腾的身心疲惫。

下午花了一个小时,魔改了一下最新版eglot, 居然发现魔改版本一点都不卡,立马切换。

eglot修改的地方:

  1. 用posframe来替换eldoc的文档提示方式,这样不用写一个单词就提示一下文档,性能提升很明显,需要的时候,可以通过命令 nox-show-doc 在弹出窗口中显示文档
  2. 去掉了 flymake 代码,性能又提升一大截
  3. 去掉了所有处理代码诊断的回调,根本就不搭理LSP Server关于语法错误的返回消息,所以不管大多数据,都不会卡
  4. 候选词用 text + params 的形式替换 snippet text + detail 的方式,同时限制最大宽度,默认显示的补全信息更实用
  5. 默认只补全文本,不补全 snippet, 因为现在 yasnippet 状态管理太脆弱,稍微写错了,删除参数效率更低
  6. 去掉 documentHighlight 协议,没啥用,反而到处 overlay 把代码都搞脏了

代码在 https://github.com/manateelazycat/nox ,基本就是安装后,直接在 ~/.emacs 配置下面代码就可以了:

(require 'nox)

(dolist (hook (list
               'js-mode-hook
               'rust-mode-hook
               'python-mode-hook
               'ruby-mode-hook
               'java-mode-hook
               'sh-mode-hook
               'php-mode-hook
               'c-mode-common-hook
               'c-mode-hook
               'c++-mode-hook
               'haskell-mode-hook
               ))
  (add-hook hook '(lambda () (nox-ensure))))

我会继续改进nox项目,项目目标如下:

  1. 只提供最核心的代码补全、代码定义跳转和代码引用展示的功能,像语法检查和代码模板补全,flycheck和yasnnipet做的更好
  2. 保持界面简单专注,不会像 lsp-ui 提供花里胡哨的功能,避免分散编程注意力
  3. 不断的提升各方面的性能

LSP我认为最有用的功能就是代码补全、代码定义、代码引用和语法重命名四个功能,其他功能都不够好,所以这个项目不会像lsp-mode和eglot那样提供所有协议支持。 如果你喜欢LSP全功能支持,lsp-mode和eglot是更好的选择。

同时也欢迎和我有一样喜好的朋友试用,反馈问题,一起贡献。

33赞

支持, lsp用的是真的卡, 也可能是我电脑不行😝️

终于来了,待会就试一下

emacs可太难了vim有lua 有js 写的lsp 都好用的飞起。。emacs就是什么都要elisp感觉固步自封。。跟不上时代。

2赞

似乎也是可以用haskellocaml来写的

懒猫不想用 rust 实现了吗?

  1. 这个修改点非常棒,eldoc的提示特别烦,容易占满屏幕。
  2. 只是单纯去掉flymake的话,lsp-mode里也可以简单通过(setq lsp-diagnostic-package :none)来关闭
  3. 这一点是不是有点过。。
  4. 不懂
  5. 同理,也比较容易在lsp-mode里通过(setq lsp-enable-snippet nil)来禁用
  6. documentHighlight是指symbol-highlighting这个选项吗?如果是的话也比较容易 的在lsp-mode(setq lsp-enable-symbol-highlighting nil)来关闭

周五看完LSP协议细节以后,写了三个版本的LSP Client: rust, golang, python, 最后发现LSP客户端只要是现代语言,都不会卡,反而 golang 和 rust 的jsonrpc库都写的好绕。:wink:

研究了一天的LSP协议,发现Python性能足够了,主要是有多线程都不会卡住Emacs,但是协议实现的越多,发现其实只要去掉不用的协议,特别是那些实时响应的协议支持(比如eldoc实时显示文档)性能也可以。

现在用我自己魔改的eglot版本,已经很爽了。

我去,懒猫真是大神啊,也闲得慌,居然写了三个版本 :grinning: :grinning: :grinning:

Update: 会不会eglotlsp-mode去掉一些不必要的实时协议也会变得很顺畅?

其实Emacs只是缺少语法上下文的语法补全和定义跳转,LSP协议提供的其他功能都可以通过Emacs插件来低成本替代。

lsp-mode 太复杂了,一大堆我不想要的功能,虽然我知道有这些选项,但是还是不想用追求大而全的lsp-mode.

其实我一直喜欢eglot,实现LSP协议的过程中,发现魔改eglot就足够了。

所有的代码诊断,其实 flycheck 配合专业工具就足够了,不用像LSP做的那么重, 特别是代码写的越快,这些过程中诊断的错误就是浪费资源,降低流畅性。

2赞

是啊,因为我写Python textDocument/completion 协议实现的时候,发现其实代码补全根本就不占用性能,我就在思考去掉那些无聊的代码诊断代码会不会快很多。 在eglot上大幅删除代码以后,果然快很多。

lsp-mode是一启动就require所有支持的language,这个有必要吗?其实我只是编辑一个python文件而已。

;;; Require
(require 'dbus)
(require 'cl-lib)

;;; Code:

(defcustom nox-buffer-name "*nox*"
  "Name of Nox buffer."
  :type 'string)

(defcustom nox-python-command "python3"
  "The Python interpreter used to run nox.py."
  :type 'string)

(defcustom nox-completion-delay 0.2
  "The completion delay after change buffer, in millisecond."
  :type 'float)

(defcustom nox-flash-line-delay .3
  "How many seconds to flash `nox-font-lock-flash' after navigation.

Setting this to nil or 0 will turn off the indicator."
  :type 'number)

(defface nox-font-lock-flash
  '((t (:inherit highlight)))
  "Face to flash the current line.")

(defvar nox-python-file (expand-file-name "nox.py" (file-name-directory load-file-name)))

(defvar nox--process nil)

(defvar nox--last-buffer nil)

(defvar nox--completion-item-kind-alist
  '(
    (1 . "text")
    (2 . "method")
    (3 . "fn")
    (4 . "constructor")
    (5 . "field")
    (6 . "var")
    (7 . "class")
    (8 . "interface")
    (9 . "module")
    (10 . "property")
    (11 . "unit")
    (12 . "value")
    (13 . "enum")
    (14 . "keyword")
    (15 . "snippet")
    (16 . "color")
    (17 . "file")
    (18 . "reference")
    (19 . "folder")
    (20 . "enum member")
    (21 . "constant")
    (22 . "struct")
    (23 . "event")
    (24 . "operator")
    (25 . "type parameter")
    ))

(defvar nox--language-alist
  '(
    ("py" . ("python" . "python -m pyls"))
    ))

(defvar company-nox-keywords '())

(defun nox-start-server ()
  "Start Nox server and init LSP client."
  (interactive)
  (if (process-live-p nox--process)
      ;; If nox server process has start, try init LSP client once.
      (unless (boundp 'nox-init-lsp-client)
        (nox-call "init_lsp_client" nox-language-id nox-language-cmd nox-root-uri)
        (setq-local nox-init-lsp-client t))
    ;; Otherwise start server process and init LSP client.
    (setq nox--process (apply #'start-process nox-buffer-name nox-buffer-name
                              nox-python-command
                              (list nox-python-file nox-language-id nox-language-cmd nox-root-uri)))
    (message "Start Nox server...")
    ))

(defun nox-get-root-uri ()
  "Get project uri or current file path.
Use for init LSP client."
  (let ((project (project-current)))
    (concat "file://"
            (if project
                (expand-file-name (cdr project))
              (buffer-file-name)
              ))))

(defun nox-stop-server ()
  "Stop Nox process."
  (interactive)
  ;; Delete Nox buffer.
  (when (get-buffer nox-buffer-name)
    (kill-buffer nox-buffer-name))
  ;; Kill Nox process.
  (if (process-live-p nox--process)
      (progn
        (delete-process nox--process)
        (message "Nox process terminated."))
    (message "Nox process has terminated.")))

(defun nox-mode-enable ()
  "Enable nox mode."
  (interactive)
  ;; Init local variables.
  (unless (boundp 'nox-root-uri)
    (setq-local nox-root-uri (nox-get-root-uri)))
  (unless (boundp 'nox-language-id)
    (let ((language-info (cdr (assoc (file-name-extension (buffer-file-name)) nox--language-alist))))
      (setq-local nox-language-id (car language-info))
      (setq-local nox-language-cmd (cdr language-info))))

  ;; Start Nox server.
  (nox-start-server)

  ;; Add file change hook.
  (add-hook 'post-command-hook #'nox-monitor-file-change t t))

(defun nox-monitor-file-change ()
  "When user modify file, add some delay and try completion."
  (when (buffer-modified-p)
    (setq-local nox-change-id (nox-current-time))
    (run-with-timer nox-completion-delay nil 'nox-try-completion)))

(defun nox-try-completion ()
  "Try send completion request to LSP server."
  ;; If user type so fast than completion delay, cancel send completion request to LSP.
  (when (< (+ nox-change-id (* nox-completion-delay 1000)) (nox-current-time))
    (nox-call "completion" nox-language-id nox-root-uri (concat "file://" (buffer-file-name)) (format-mode-line "%l") (format-mode-line "%c") (char-before))
    ))

(defun nox-goto-define ()
  (interactive)
  (nox-call "goto_define" nox-language-id nox-root-uri (concat "file://" (buffer-file-name)) (format-mode-line "%l") (format-mode-line "%c")))

(defun nox-current-time ()
  "Get current time, use to compare completion delay, in millisecond."
  (* 1000 (time-to-seconds (current-time))))

(defun nox-call (method &rest args)
  "Call NOX Python process using `dbus-call-method' with METHOD and ARGS."
  (apply #'dbus-call-method
         :session                   ; use the session (not system) bus
         "com.lazycat.nox"          ; service name
         "/com/lazycat/nox"         ; path name
         "com.lazycat.nox"          ; interface name
         method
         :timeout 1000000
         args))

(dbus-register-signal
 :session "com.lazycat.nox" "/com/lazycat/nox"
 "com.lazycat.nox" "echo"
 #'message)

(dbus-register-signal
 :session "com.lazycat.nox" "/com/lazycat/nox"
 "com.lazycat.nox" "popup_completion_menu"
 #'nox--popup-completion-menu)

(defun nox--popup-completion-menu (items)
  "Update completion candidates from popup_completion_menu signal."
  (setq company-nox-keywords items))

(dbus-register-signal
 :session "com.lazycat.nox" "/com/lazycat/nox"
 "com.lazycat.nox" "open_define_position"
 #'nox--open-define-position)

(defun nox--open-define-position (file start-row start-column end-row end-column)
  (let ((pulse-iterations 1)
        (pulse-delay nox-flash-line-delay)
        start end)
    ;; Open file.
    (find-file file)

    ;; Find end position.
    (save-excursion
      (goto-line end-row)
      (nox-jump-to-column end-column)
      (setq end (point)))

    ;; Jump to start position.
    (goto-line start-row)
    (nox-jump-to-column start-column)
    (setq start (point))

    ;; Flash match line.
    (pulse-momentary-highlight-region start end 'nox-font-lock-flash)

    ;; Message to user.
    (message "Jump to the definition of %s" (symbol-at-point))
    ))

(defun nox-jump-to-column (column)
  "This function use for jump to correct column positions in multi-byte strings.
Such as, mixed string of Chinese and English.

Function `move-to-column' can't handle mixed string of Chinese and English correctly."
  (let ((scan-column 0)
        (first-char-point (point)))

    (while (> column scan-column)
      (forward-char 1)
      (setq scan-column (string-bytes (buffer-substring first-char-point (point)))))

    (backward-char 1)))

(defun company-nox--make-candidate (candidate)
  "Bulid candidate line."
  (let ((text (nth 1 candidate))
        (meta (format "[%s] %s"
                      (cdr (assoc (nth 0 candidate) nox--completion-item-kind-alist))
                      (nth 2 candidate))))
    (propertize text 'meta meta)))

(defun company-nox--candidates (prefix)
  "Filter candidates with user input prefix."
  (let (res)
    (dolist (item company-nox-keywords)
      (when (string-prefix-p prefix (nth 1 item))
        (push (company-nox--make-candidate item) res)))
    res))

(defun company-nox--meta (candidate)
  (format "%s of %s"
          (get-text-property 0 'meta candidate)
          (substring-no-properties candidate)))

(defun company-nox--annotation (candidate)
  "Format candidate annotation."
  (format " %s" (get-text-property 0 'meta candidate)))

(defun company-nox (command &optional arg &rest ignored)
  "Company backend for Nox."
  (interactive (list 'interactive))
  (cl-case command
    (interactive (company-begin-backend 'company-nox))
    (prefix (company-grab-symbol-cons "\\.\\|->" 2))
    (candidates (company-nox--candidates arg))
    (annotation (company-nox--annotation arg))
    (meta (company-nox--meta arg))))

(defun nox--monitor-buffer-change ()
  "Start Nox mode if user switch to file that support in `nox--language-alist'."
  (unless (eq (current-buffer)
              nox--last-buffer)
    ;; Try start node.
    (nox-try-start)

    ;; Switch buffer clean nox completion candidates.
    (setq company-nox-keywords '())

    ;; Record last buffer.
    (setq nox--last-buffer (current-buffer))))

(add-hook 'post-command-hook #'nox--monitor-buffer-change)

(defun nox-try-start ()
  (when (and (buffer-file-name)
             (assoc (file-name-extension (buffer-file-name)) nox--language-alist))
    (nox-mode-enable)))

(nox-try-start)

(setq company-tooltip-align-annotations t)

(provide 'nox)
;;; nox.el ends here
from dbus.mainloop.glib import DBusGMainLoop
import dbus
import dbus.service
from gi.repository import GLib
import subprocess
import lsp
import os
import threading

NOX_DBUS_NAME = "com.lazycat.nox"
NOX_OBJECT_NAME = "/com/lazycat/nox"

class NOX(dbus.service.Object):
    def __init__(self, args):
        dbus.service.Object.__init__(
            self,
            dbus.service.BusName(NOX_DBUS_NAME, bus=dbus.SessionBus()),
            NOX_OBJECT_NAME)

        self.lsp_clients = {}
        self.init_lsp_client(args[0], args[1], args[2])

    @dbus.service.method(NOX_DBUS_NAME, in_signature="sss", out_signature="")
    def init_lsp_client(self, language, command, root_uri):
        if language not in self.lsp_clients or root_uri not in self.lsp_clients[language]:
            p = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True)
            read_pipe = lsp.ReadPipe(p.stderr)
            read_pipe.start()
            json_rpc_endpoint = lsp.JsonRpcEndpoint(p.stdin, p.stdout)
            lsp_endpoint = lsp.LspEndpoint(json_rpc_endpoint)

            lsp_client = lsp.LspClient(lsp_endpoint)
            self.lsp_clients[language] = {}
            self.lsp_clients[language][root_uri] = lsp_client
            workspace_folders = [{'name': 'nox', 'uri': root_uri}]
            lsp_client.server_capabilities = lsp_client.initialize(os.getppid(), None, root_uri, None, lsp.capabilities, "verbose", workspace_folders)

    @dbus.service.method(NOX_DBUS_NAME, in_signature="ssssss", out_signature="")
    def completion(self, language, root_uri, file_path, row, column, last_char):
        # Use sub thread avoid completoin action block input.
        threading.Thread(target=self.get_completion, args=(language, root_uri, file_path, row, column, last_char)).start()

    def get_completion(self, language, root_uri, file_path, row, column, last_char):
        if language in self.lsp_clients and root_uri in self.lsp_clients[language]:
            # Init completion context.
            context = lsp.CompletionContext(lsp.CompletionTriggerKind.Invoked)

            # Change to TriggerCharacter type when last char is trigger char.
            trigger_chars = self.lsp_clients[language][root_uri].server_capabilities["capabilities"]["completionProvider"]["triggerCharacters"]
            is_trigger_char = chr(last_char) in trigger_chars
            if is_trigger_char:
                context = lsp.CompletionContext(lsp.CompletionTriggerKind.TriggerCharacter, ".")

            items = self.lsp_clients[language][root_uri].completion(lsp.TextDocumentItem(file_path, language, 1, ""),
                                                                    lsp.Position(int(row) - 1, int(column)),
                                                                    context).items
            if len(items) > 0:
                candidates = []
                if is_trigger_char:
                    candidates = list(map(lambda i: (i.kind, i.insertText, i.label),
                             filter(lambda x: "{}".format(x.detail) != "builtins", items)))
                else:
                    candidates = list(map(lambda i: (i.kind, i.insertText, i.label), items))
                self.popup_completion_menu(candidates)

    @dbus.service.method(NOX_DBUS_NAME, in_signature="sssss", out_signature="")
    def goto_define(self, language, root_uri, file_path, row, column):
        if language in self.lsp_clients and root_uri in self.lsp_clients[language]:
            location = self.lsp_clients[language][root_uri].definition(
                lsp.TextDocumentItem(file_path, language, 1, ""),
                lsp.Position(int(row) - 1, int(column)))
            if len(location) > 0:
                self.open_define_position(location[0].uri,
                                          location[0].range.start.line + 1,
                                          location[0].range.start.character + 1,
                                          location[0].range.end.line + 1,
                                          location[0].range.end.character + 1)
            else:
                self.echo("Can't found definition at cursor.")

    @dbus.service.signal(NOX_DBUS_NAME)
    def echo(self, message):
        pass

    @dbus.service.signal(NOX_DBUS_NAME)
    def popup_completion_menu(self, items):
        pass

    @dbus.service.signal(NOX_DBUS_NAME)
    def open_define_position(self, file, start_line, start_column, end_line, end_column):
        pass

if __name__ == "__main__":
    import sys
    import signal

    DBusGMainLoop(set_as_default=True) # WARING: only use once in one process

    bus = dbus.SessionBus()
    if bus.request_name(NOX_DBUS_NAME) != dbus.bus.REQUEST_NAME_REPLY_PRIMARY_OWNER:
        print("NOX process is already running.")
    else:
        print("NOX process starting...")

        NOX(sys.argv[1:])

        GLib.MainLoop().run()

        signal.signal(signal.SIGINT, signal.SIG_DFL)
        sys.exit()

周末写的Python LSP版本,用多线程处理消息,非常流畅,只是最后发现不用做的这么复杂,哈哈哈。

求不要@ lsp-mode 的作者,我不想像eglot作者那样和他争,我就完全自己搞一个语法补全和定义跳转就可以了,其他功能我都不想要。

1赞

lspmode的作者好像看不懂中文,可以假装看不懂英文

你可以用dynamic module,能编译出C ABI的语言都能用(是的,你可以用Emscript编译JS)

只是注册 clients,并不会启动。之前不是chenbin 也分析改进过吗?优化已经 merge 进去了。我不认为这个对性能有多大影响。你是发现什么问题了吗?如果你觉得影响性能,最好 profiling 下。

好吧,我删掉。

谢谢,我继续去改进Nox了,现在流畅起来很兴奋。