在 Emacs 中使用 LSP 开发 C/C++ 工程


写代码的时候,如果能够基于当前的上下文提示补全,不仅能提高写代码的效率,还能提升编程体验,有种行云流水的快感。

Emacs 中之前我用 GNU Global (gtags) 等静态的工具来辅助写代码,最大的问题是无法根据上下文补全,体验不好。现在有了 LSP 协议之后, Emacs 中现在也能实现这个功能了,体验相当不错。

前段时间折腾了一下,在此作个小结。

目前 Emacs 上有两个客户端实现: eglotlsp-mode ,由于 eglot 相对比较简洁,只需要很少的配置,因此我就选它了(暂时还没试过 lsp-mode ,等有需要时再看)。

对于服务端,目前有三个选择 clangd, cqueryccls , ccls 是在 cquery 的基础之上 fork 改进的。 clangd 安装最简单,但功能据说比较弱,我没有试过;一开始我在自己电脑上编译了 ccls ,再把二进制文件拷贝到公司机器,但是补全始终有问题(怀疑必须在使用的机器上编译,直接拷贝行不通),没有找到具体的原因;后来在公司机器上直接编译了 cquery ,可以补全,就没有再折腾 ccls 了。

选定了客户端和服务端之后,接下来就是对具体工程的配置了, cquery 和 ccls 都要求工程根目录有 compilation database 或者 .cquery / .ccls 文件。

由于我们的工程都是用 GNU Make 进行构建的,没法使用 cmake 直接生成 compilation database 。其他方案比如 Bear ,由于我们的工具链太老,没有 cmake 无法编译 Bear ,因此也派不上用场。至此由于无法生成服务器的配置文件,看来似乎与 LSP 无缘了。

后来有一天,我突然想到可以直接自己解析 make 的输出(就用 Elisp ),解析 Entering directoryLeaving directory 以及 g++ 编译的相关行,提取生成为 compile_commands.json ,这样就无须再依赖其他软件。

但是此方法有一个缺点,在新增文件的时候,得人工在 json 文件中增加一条记录,比较麻烦,不易维护。(后来我把这个逻辑实现在一个 web app Compilation Database Generator 中,它可以很方便地解析 GNU Make 输出,得到 json 文件;这样手动维护也不麻烦了。)

其实此种情况下直接用 .cquery 文件最好,无需指定特定的源码文件,只需要配置编译选项以及头文件目录,因此新增文件时不需要修改。举一个 cquery wiki 中的 例子

# it will expend to clang/clang++ according to the extension name
%clang

# C specific options
%c -std=gnu11

# C++ specific options
%cpp -std=gnu++14
-pthread

# Includes
-I/work/cquery/third_party
-I/work/cquery/another_third_party
# -I space_is_not_allowed

到此似乎万事俱备,可以愉快地写代码了,最终却发现还有一个问题:由于历史原因,我们的工程源码都是使用 GBK 编码的,但是 LSP 只支持 UTF-8 ,导致由于编码问题无法显示类、函数注释的问题,当时还在 eglot 提了一个 issue (其实不是 eglot 的问题)。

为了解决这个问题想过几种方法:

  1. cquery 中在输出之前,把内容转为 UTF-8 编码(利用 iconv 库)
  2. eglot 在解析服务端返回的数据时,根据指定的编码进行 decode

第一种方法适用面太窄,因为 LSP 的实现还不是非常成熟,有时需要切换到 ccls 来体验,这就意味着得在 ccls 中也用 iconv 再转一道,太麻烦,不符合 DRY 的原则。

第二种方法,需要在 eglot.el 和其依赖的低层通信库 jsonrpc.el 中同时服务端传回数据的编码方式。当时也实现了,后来觉得不是很直接、优雅,就没再继续用了。另外,如果有一天改用 lsp-mode ,那又需要在 lsp-mode 中做一遍类似的修改,同样也不符合 DRY 原则。

最后使用了适配器的方案 Language Server Adapter, or lsa ,使用 Python 3 实现。这样既不动客户端也不动服务端,在他们中间加一层,用于转换服务端的编码。这样就是一个比较通用的方案了,能适配所有的客户端和服务端。

在 eglot 中根据项目的编码情况(通过 .dir-locals.el 区分)决定是否使用此适配器:

(defcustom ccls-init-args nil
  "Init args for ccls, e.g. '(:clang (:extraArgs (\"-std=c++03\")))")

(defcustom eglot-ls-output-encoding "utf-8"
  "The LS's output encoding")

(defcustom eglot-cpp-ls "cquery"
  "The language server for C/C++.")

(defun whatacold/eglot-ccls-contact (interactive-p)
  "A contact function to assemble args for ccls.
Argument INTERACTIVE-P indicates where it's called interactively."
  (let ((json-object-type 'plist)
        (json-array-type 'list)
        result)
    (cond ((equal "ccls" eglot-cpp-ls)
           (push (format "-log-file=/tmp/ccls-%s.log"
                         (file-name-base
                          (directory-file-name
                           (car
                            (project-roots
                             (project-current))))))
                 result)
           (when ccls-init-args
             (push (format "-init=%s" (json-encode
                                       ccls-init-args))
                   result))
           (push "ccls" result))
          ((equal "cquery" eglot-cpp-ls)
           (setq result (list "cquery" "--log-all-to-stderr")))
          (t ; e.g. clangd
           (push eglot-cpp-ls result)))
    ;; apply the adapter if necessary
    (unless (equal eglot-ls-output-encoding "utf-8")
      (dolist (item (reverse (list "lsa.py"
                                   (concat "--original-response-encoding="
                                           eglot-ls-output-encoding)
                                   "--log-level=DEBUG"
                                   "--")))
        (push item result)))
    ;; cquery should apply the specific class in eglot
    (when (equal "cquery" eglot-cpp-ls)
      (push 'eglot-cquery result))
    result))

(eval-after-load 'eglot
  '(progn
     (add-to-list 'eglot-server-programs
                  (cons '(c-mode c++-mode foo-mode) #'whatacold/eglot-ccls-contact))))

至此 LSP 基本可用了。

Emacs  C++  Python 

也可以看看

comments powered by Disqus