跳过正文
Background Image

Emacs 中的补全

··2441 字·5 分钟·
lizqwerscott
作者
lizqwerscott

Emacs 中的补全有很多种形式,写代码的时候弹出来的补全框,AI 使用 inline text 实现的续写,使用 M-x 调起来的 minibuffer 补全,某些交互式命令调用时弹出的补全框,都是补全。

但是不管那种补全,都由以下模块组成:

补全后端框架
#

补全后端负责收集和整理数据,为前端提供统一的补全接口。

minibuffer 中的补全
#

补全项的来源都是命令自己产生的数据。

普通 Buffer 中的补全
#

capf
#

这是一个 Emacs 自己构建的系统,前端调用当前 buffer 的 completion-at-point-functions 这个变量里面的函数,来获取补全,可以使用 cape 来增强 capf 的能力。

第三方
#

  • company

    company 自己实现的补全系统,只能给 Company 前端自己使用,或者也可以使用 cape 提供的 cape-company-to-capf 来转化成 capf 的接口。

  • acm

    lsp-bridge 自己构建的补全系统,只能给 acm 前端使用,但是能调用 capf 的接口来提供数据。

补全前端框架
#

有了数据后,需要考虑如何呈现给用户。Emacs 提供了多种前端界面可供选择。

minibuffer 中的补全
#

使用 Emacs 原生补全接口
#

  • Ido

    Ido 包允许您以最少的按键次数切换缓冲区并访问文件和目录。它是 Stephen Eglen 开发的交互式缓冲区切换包 Iswitchb 的超集,给默认切换 buffer 和打开文件提供了更加强大的功能,显示补全项的方式是直接在输入的后面使用 { a | b } 来显示。但是默认不支持 M-x 的补全显示,需要安装 smex 来使用 ido 风格的补全显示方式。

  • Icomplete

    是 Emacs 原生的 minibuffer 补全方式。

  • Fido (Fake ido)

    Fido 模式是 Icomplete 模式的替代方案。它与 Icomplete 模式非常相似,但保留了来自名为 Ido 模式的流行扩展的一些功能。(默认使用 flex 风格 来匹配)

  • icomplete-vertico-mode

    和 icomplete 是一样的,但是垂直展示补全项。

  • fido-vertical-mode

    和 fido 是一样的,但是垂直展示补全项。

  • mct

    使用原生 *Completions* buffer 来弹出补全项,但是能自动更新 *Completions* buffer, 为原生补全提供更多的智能化。

  • vertico

    目前最新的框架,可以支持前端样式,能根据补全的内容自定义补全样式。

    能支持同时显示多种类别的补全样式。

    • vertico 添加箭头

      可以在补全项之前添加一个箭头,来源是Vertico 官方 Wiki, 但是做了一些修改。

      (defcustom vertico-show-arrow t
        "Vertico arrow."
        :group 'vertico
        :type 'boolean)
      
      (defvar +vertico-current-arrow t)
      
      (cl-defmethod vertico--format-candidate :around
        (cand prefix suffix index start &context ((and +vertico-current-arrow
                                                       (not (bound-and-true-p vertico-flat-mode)))
                                                  (eql t)))
        (setq cand (cl-call-next-method cand prefix suffix index start))
        (let ((arrow (propertize (if (char-displayable-p ) "» " "> ") 'face 'font-lock-keyword-face)))
          (if (and (not (bound-and-true-p vertico-grid-mode))
                   vertico-show-arrow)
              (if (= vertico--index index)
                  (concat arrow cand)
                (concat "  " cand))
            cand)))
      
    • 类似 ivy/ido 风格的路径输入

      来自 Vertico 官方 Wiki

      如果当前按键 / 时该候选文件是目录,my/vertico-insert 会插入该候选文件,允许 IDO 和 Ivy 用户熟悉的样式快速导航文件系统。如果前一个字符是 /~ 或 :,则不会插入第一个候选。这使得在将搜索提示重置为文件系统根、主目录或作 TRAMP 前缀时,行为与 ido 和 ivy 的行为一致。

      (defun my/vertico-insert ()
        (interactive)
        (let* ((mb (minibuffer-contents-no-properties))
               (lc (if (string= mb "") mb (substring mb -1))))
          (cond ((string-match-p "^[/~:]" lc) (self-insert-command 1 ?/))
                ((file-directory-p (vertico--candidate)) (vertico-insert))
                (t (self-insert-command 1 ?/)))))
      
      (keymap-set vertico-map "/" #'my/vertico-insert)
      

第三方
#

  • helm

    主要不使用 minibuffer 来进行补全,而是使用单独的 buffer 来进行补全项的展示。

普通 Buffer 中的补全
#

使用 Emacs capf 作为后端
#

  • 使用 *Completion*

    使用一个单独的 buffer 来展示补全项。

  • Icomplete

    设置变量 icomplete-in-buffer 为 t 启用(可能需要开关 icomplete-mode ),默认使用 icomplete-mode 的补全风格,在输入的后面显示补全项。

  • completion-preview-mode

    使用 inline Text 的方式展示预览项,默认只展示一个。

  • corfu

    比较轻量的补全前端,使用 posframe 的方式弹出补全菜单,界面比较现代。

第三方
#

  • company

    使用 overlay 来弹出补全菜单。

匹配,过滤和排序
#

补全样式
#

在补全过程中,补全项会根据输入内容匹配相应条目。Emacs 默认配置了多种补全样式

补全样式的确定流程如下:

  1. 检查 completion-category-overrides 中是否有当前补全项的分类,若有则使用;
  2. 若无,检查 completion-category-defaults
  3. 若两者均无或补全项无分类,则使用默认的 completion-styles

确定样式列表后,Emacs 按列表顺序尝试补全:若首个样式返回 nil ,则依次使用后续样式,形成 降级回退 机制。

匹配和过滤
#

orderless
#

它提供了一个强大的匹配和过滤机制,一下是来自 orderless 官方的介绍。

它将模式分割成以空格分隔的组件,并匹配所有组件(顺序不限)的候选词。每个组件可以通过多种方式匹配:字面匹配、正则表达式匹配、首字母缩写匹配、灵活匹配或多个单词前缀匹配。默认情况下,正则表达式匹配和字面匹配已启用。

并且它还提供了一个很强大的功能, Style dispatchers, 可以支持将带有前缀的输入内容,单独使用其他匹配规则。

基与这个功能和 pyim 提供的词库,就可以简单实现一个输入对应前缀,然后输入拼音,就可以直接匹配和过滤中文。1

(require 'pyim)

(defun chinese-orderless-regexp (component)
  "Match COMPONENT as a chinese regexp."
  (condition-case nil
      (pyim-cregexp-build
       (progn (string-match-p component "")
              component))
    (invalid-regexp nil)))

(with-eval-after-load 'orderless
  (add-to-list 'orderless-affix-dispatch-alist
               `(?= . ,#'chinese-orderless-regexp)))

orderless-affix-dispatch-alist 这个变量里面保存的就是 orderless 用来检测输入的前缀对应的匹配函数。

fussy
#

这个包提供了一个给匹配项排序的功能,通过评分算法计算出匹配项的评分,然后根据评分来进行排序。并且可以自己定义评分和匹配过滤函数。

这是一个为 Emacs 提供 completion-style 包,能够利用 flx 以及各种其他库,例如 fzf-native 提供智能评分和排序。

匹配函数可利用 orderless 的过滤功能,但默认评分函数不支持其前缀特性,导致匹配项得分均为零或负数。

为此,我自定义了支持前缀评分的函数,完整实现详见配置中的 fussy-orderless 文件。

主要核心内容就是给每一个 orderless 的前缀添加评分,最后计算总共的评分,将各个评分相加起来。

例如,以下代码用于计算中文内容的评分: 通过评估匹配内容在整体中的占比及其位置信息和匹配到的长度来计算得分2

pyim 根据 query 计算出来 regexp 进行一个缓存,来加快计算速度。

(defvar fussy-orderless--chinese-regexp-cache (make-hash-table :test #'equal)
  "Chinese regexp cache.")

(defun fussy-orderless--chinese-cache-put (key value)
  "Store VALUE in the Chinese regexp cache under KEY.

KEY is a string used as the lookup key, and VALUE is the corresponding regexp
pattern to store.

The actual cache key is computed by applying `secure-hash' with MD5 algorithm to
KEY for efficient storage and lookup.

This function updates the hash table `fussy-orderless--chinese-regexp-cache'
with the computed hash key and VALUE pair."
  (let ((hash-key (secure-hash 'md5 key)))
    (puthash hash-key value fussy-orderless--chinese-regexp-cache)))

(defun fussy-orderless--chinese-cache-get (key)
  "Retrieve the cached regexp value for KEY from the Chinese regexp cache.

KEY is a string used as the lookup key.

This function computes the MD5 hash of KEY using `secure-hash' and looks up the
corresponding value in the hash table `fussy-orderless--chinese-regexp-cache'.

Return the cached regexp pattern if found, or nil if no entry exists for KEY."
  (let ((hash-key (secure-hash 'md5 key)))
    (gethash hash-key fussy-orderless--chinese-regexp-cache)))

(defun fussy-orderless-chinese-regexp-score (string query)
  "Use QUERY and STRING calc chinese regexp score."
  (require 'pyim)
  (when-let* (((string-match-p "\\cc" string))
              (regexp (when (fboundp 'pyim-cregexp-build)
                        (let* ((cache (fussy-orderless--chinese-cache-get query)))
                          (if cache
                              cache
                            (let ((regexp (pyim-cregexp-build query)))
                              (fussy-orderless--chinese-cache-put query regexp)
                              regexp))))))
    (string-match regexp string)
    (pcase-let* ((`(,start ,end) (match-data))
                 (len (length string)))
      (when (< end len)
        (list (+ (* 20 (/ (float (- end start))
                          len))
                 (* 60 (- end start))
                 (* 80 (/ (float (- len start)) len)))
              start
              end)))))

之后只要修改以下内容:

首先需要将想要使用的计分函数添加到 fussy-whitespace-ok-fns 变量中,以确保传递给评分函数的查询内容中的空格不被去除,避免 orderless 无法识别前缀。

(with-eval-after-load 'fussy
  (add-to-list 'fussy-whitespace-ok-fns
               #'fussy-orderless-score-with-flx-rs))

然后就是将过滤函数设置 orderless, 评分函数设置成我上面写的函数,目前只支持 flx-rsflx

(setopt fussy-score-fn 'fussy-orderless-score-with-flx-rs
        fussy-filter-fn 'fussy-filter-orderless-flex)

如果想要与 corfu 一起使用,可以使用一下代码。

(with-eval-after-load 'corfu
  ;; 缓存
  (advice-add 'corfu--capf-wrapper :before 'fussy-wipe-cache)

  (add-hook 'corfu-mode-hook
            (lambda ()
              ;; corfu 补全的时候使用普通计分函数,提高速度。
              (setq-local fussy-score-fn 'flx-rs-score
                          fussy-max-candidate-limit 5000
                          fussy-default-regex-fn 'fussy-pattern-first-letter
                          fussy-prefer-prefix nil))))

附录
#


  1. 这个功能默认覆盖来 orderless= 前缀的功能,也就是 orderless-literal, 如果想要共存可以修改为别的字符,而且根据词库大小,可能会出现卡顿。 ↩︎

  2. 计算比较简陋,应该是有更好的算法的。 ↩︎