2021-08-08

Companyのお節介な補完を抑制する

(2022-08-15追記:CompanyからCorfuへ移行しました)

Emacsのバッファ内での入力補完を行うcompany-modeですが、使っていると困ることも色々ありました。それで少し設定を変えて使っていたのですが、まだ不満が残っていたのでここらでゆっくり検討してみました。

一番困るのが誤入力を助長してしまうという点です。テキスト入力中に勝手に候補を出すまでは良いのですが、RETを押すと勝手に出してきた候補を選択してしまい不要な文字列を付け足してしまいます。

例えば 100 と入力して RET を押したら 100 の後に改行が入ることを期待するわけですが、改行を打つ直前に勝手に 100 で始まる文字列を候補に出してきて、それが入力されてしまうわけです。例えばバッファ内に他に 10000 と書いてある部分があると、 100 ではなく 10000 が入力されてしまうわけです。入力時に気がつけば良いのですが、気がつかずに後から計算結果が合わなくて発覚したこともありました。

自動起動禁止!

慌てて行った設定が次。

;; case1
;; 自動起動を禁止する。(self-insert系を除外する)
(setq company-begin-commands
      '(c-scope-operator c-electric-colon c-electric-lt-gt c-electric-slash))

もう補完候補を自動で出すのは止めなさい、と。self-insert系コマンドを company-begin-commands から外せば少なくとも通常の文字を入力しているときに突然候補が出るのを抑制できます。最も安全です。

他の解決策としては、company-dabbrev-char-regexp から数字や日本語を除外して穏当なものだけ候補に出すという方法もよく見かけました1が、文字種によって判別するというのはなんだか違う気がしました。数字だからダメ、アルファベットだから良いという訳では無いのです。 abcdefghi とどこかに書いてあるバッファで abc RETと打っても同じ問題が起きます。

company-backends から company-dabbrev を外すという方法もよく見ましたが2、私は前からdabbrev自体は使っていましたから、companyを使う代わりにdabbrevが使えなくなるのは困ります。

手動と自動でバックエンドを切り替える(それとモードによって自動起動禁止)

そこで思いついたのが、手動で起動したときだけ company-dabbrev を使い、自動で起動したときは使わないという方法です。それなら M-/ でdabbrevを使い続けることが出来ます。数字であっても M-/ で候補を出してくれて構わないんです。

;; case2
;; dabbrevは手動で起動したときだけ有効にする。
;; アイドルタイムから始まった場合は、一時的に company-backends から
;; company-dabbrev を取り除く。
(defun my-company-idle-begin (oldfun &rest args)
  (let ((company-backends (remq 'company-dabbrev company-backends)))
    (apply oldfun args)))
(advice-add 'company-idle-begin :around 'my-company-idle-begin)

ついでにプログラミング系のモード以外では自動起動を禁止してしまいましょう。 org-modeやtext-modeで自動的に候補が出たからと言って何だというのでしょう!

;; いくつかのモードで自動的に候補を出すのを禁止する。
;; プログラミング系のモードでは比較的大丈夫な場合が多い。
;; 文法上入力できるものが限られており、補完が正しい可能性が高いから。
(defun my-company-inhibit-idle-begin ()
  (setq-local company-begin-commands nil))
(add-hook 'org-mode-hook #'my-company-inhibit-idle-begin)
(add-hook 'text-mode-hook #'my-company-inhibit-idle-begin)

思うにプログラミング言語系のモードは候補が自動的に出ても問題が少ないような気がします。プログラミング言語では場所ごとに書けるものが文法的に限定されています。例えば行末には大抵 ; を入れる言語では改行の前(RETを押したくなる直前)に候補が出ること自体がほとんどありません。出てくる候補の正答率も高いでしょう。

というわけでこのくらいでしばらく使っていたのですが、org-modeで自動的に候補が出てこないというのは少し寂しい気もします。 M-/ で候補を出せると言っても候補があること自体に気がつけませんからね。

自動起動や一部のモードは無選択状態で開始

別に候補を出してくれるのは構わないんです。問題なのは勝手にRETやTABを奪ってしまうことなのです。

候補を出しつつ、例えば選択は↓キー(C-n)を押してからでないと出来ないようにすれば良いのです。 何か良い方法は無いか……とcompany.elを眺めていたら次のような文字が目に飛び込んできました。

When `company-selection-default' is nil, add a special pseudo candidates
meant for no selection."

なんと company-selection-default を nil にするだけで無選択状態から開始できるのです。

;; case3
;; 基本的に候補は無選択状態から始める。
;; 誤って確定してしまうのを防ぐ。
(setq-default company-selection-default nil)
(setq-default company-selection nil)

この変数は defvar でなぜか defcustom ではありません。 しかし試してみたところきちんと動いているようです。ザッとコードを確認しても問題は無さそうに見えます。 (2021-08-09追記:細かい問題が見つかりました。修正方法は末尾に追記してあります)

これで常に無選択状態から始まりますが、手動で開始したときは選択状態から始まっていた方が良いでしょう。入力がまだ不完全で補って欲しくて手動で起動(M-/)しているわけですから、候補を選択した状態から始まっていても問題は無いでしょう。嫌ならC-gを押せば良いだけです。また、やはりプログラミング系のモードではこれまで選択状態から始まっていて違和感が少なかったのでとりあえず同じようにしましょう。

;; 手動起動したときには選択状態から始める。何か選びたいはずなので。
;; 自動起動したときでもモードによっては選択状態から始める。
;; 文法的に正しい候補が出せる可能性が高いとき。
(defun my-company-should-select-first-p ()
  (or
   company--manual-action ;;手動で起動したとき。
   (and (boundp 'lsp-mode) lsp-mode))) ;;LSPが使えるモードは補完の精度が高いはずなので。 (memq major-mode '(c-mode c++-mode))とかでも可。
(defun my-company-auto-begin (oldfun)
  (let ((company-selection-default
         (if (my-company-should-select-first-p) 0 nil)))
    (funcall oldfun)))
(advice-add 'company-auto-begin :around 'my-company-auto-begin)

無選択状態の時であればRETやTABがそのまま入力できるかと思いきやそうなっていません。無選択状態なので確定(誤入力)はしませんが、かといってそのままRETやTABがバッファに入ったりはしません。単純に無視されます。なぜならcompany-active-mapにRETやTABが登録されているので、候補が出ている間はそれが実行されてしまうからです。(ちなみに他の通常の文字(company-active-mapに登録されていない)は無選択状態の時(正確にはcompany-require-matchではないとき)にはそのままバッファに入力できます。手動で起動したり一度でも選択操作すると候補とマッチする文字しか入力できなくなります)

なので無選択状態の時はRETやTABをバッファ本来のキーバインドで実行するようにしました(補完を中断してキーを読み取り前に戻す)。

;; 無選択状態の時にTABやRETが入力されたら
;; そのバッファのモード本来のTABやRETを実行する。
(defun my-company-complete-respecting-user-input (&rest args)
  "ユーザー入力を尊重した補完を行う。"
  (interactive)
  (if (null company-selection)
      ;; モード本来の割り当てを実行する。
      (progn
        (company-abort)
        (company--unread-this-command-keys))
    ;; companyの(リマップ元の)コマンドを実行する。
    (apply this-original-command args)))
(define-key company-active-map [remap company-complete-selection]
  ;;RETに割り当てられているコマンドをリマップ
  'my-company-complete-respecting-user-input)
(define-key company-active-map [remap company-complete-common]
  ;;TABに割り当てられているコマンドをリマップ
  'my-company-complete-respecting-user-input)

というわけで、今はこのくらいで使っています。

100 RET と押しても10000が入ったりはしませんし、 org-modeのテーブルセル内で 100 TAB 等と押しても大丈夫です。100とRETの間で一瞬候補は出ますが無視してRETやTABがそのまま入ります。いつも「10000ポイント」とか「10000円」とか候補に出てきてちょっと気が散りますがw どこから候補を持ってきてるんだ。

dabbrevで日本語が入りすぎるのは気になりますが、そのあたりはきっと company-dabbrev-char-regexp を調整すれば良いのでしょう。

またしばらくこれで使ってみようと思います。

P.S. helmやivyでも感じたのですが、RETが候補選択に奪われがちなのは近年の補完インタフェースを見ていて気になるところです。

2021-08-09追記: 候補が一つになったときに候補が表示されなくなる問題の修正

company-selection-default を nil にした時の問題ですが、候補が一つに絞り込まれたときに候補が表示されなくなる現象を見つけました。表示されないだけで補完自体は続いているらしく C-n を押すと候補がポイントの位置に表示されます。

company-selection-default を nil にしたときには「無選択」という仮想的な候補が追加されるわけですが、それを考慮していない場所があるようです。

companyでは候補が一つだけの時のfrontendと候補が二つ以上の時のfrontendが分かれています。一つだけの時はポイントの位置に表示して、二つ以上の時はツールチップ的なオーバーレイで表示します。この「候補が一つだけ」の判定が「無選択」という候補を考慮していませんでした。

次のように修正すれば直ります。

  (defun company--show-inline-p ()
    (and (not (cdr company-candidates))
+        (or company-selection-default (null company-candidates)) ;;追加
         company-common
         (not (eq t (compare-strings company-prefix nil nil
                                     (car company-candidates) nil nil
                                     t)))
         (or (eq (company-call-backend 'ignore-case) 'keep-prefix)
             (string-prefix-p company-prefix company-common))))

adviceで書くなら次のようにします。

(defun my-company--show-inline-p (old-fun)
  (and
   ;; Include "no selection" as candidates
   (or company-selection-default (null company-candidates))
   (funcall old-fun)))
(advice-add 'company--show-inline-p :around 'my-company--show-inline-p)

2021-08-09追記: 選択操作をした後に文字で絞り込むと無選択に戻される問題を修正

同じく company-selection-default を nil にしたときの問題です。

候補の選択操作(C-n等)をした後に現在選択しているのとは違う後続の文字を入力して他の候補を選択しようとすると無選択状態に戻されてしまいます。

例えばemacs-lispにおいて、 def で default と defun が候補に出たとして、 C-n で default を選択してから次に u を押すと無選択状態になってしまいます。ここは defun が選択されていて欲しいところです。このままTABやRETを押すと無選択状態ですから当然defunは挿入されません。

現在選択している候補が消えたのだからデフォルトの選択である「無選択」に戻しただけのつもりなのかもしれません。

しかし選択操作をした段階でユーザーの意識はポイントのカーソルからツールチップの選択状態に移っていますから、そのタイミングで入力した文字は候補の選択を変える動作に使って欲しいのです。companyでもcompany-explicit-action-pという関数があって、ユーザーが明示的に行動を起こしたかによって挙動を変更する仕組みがあります。その思想と整合性がとれていないとも言えます。

調べてみたところ、候補の更新処理である company-update-candidates に問題を見つけました。

(defun company-update-candidates (candidates)
  (setq company-candidates-length (length candidates))
  (if company-selection-changed
      ;; Try to restore the selection
      (let ((selected (and company-selection
                           (nth company-selection company-candidates))))
        (setq company-candidates candidates)
        (when selected
          (setq company-selection 0)
          (catch 'found
            (while candidates
              (let ((candidate (pop candidates)))
                (when (and (string= candidate selected)
                           (equal (company-call-backend 'annotation candidate)
                                  (company-call-backend 'annotation selected)))
                  (throw 'found t)))
              (cl-incf company-selection))
            ;; ★★★ここを直したい!★★★
            (setq company-selection company-selection-default
                  company-selection-changed nil))))
    (setq company-selection company-selection-default
          company-candidates candidates))
  ;; Calculate common.
  (let ((completion-ignore-case (company-call-backend 'ignore-case)))
    ;; We want to support non-prefix completion, so filtering is the
    ;; responsibility of each respective backend, not ours.
    ;; On the other hand, we don't want to replace non-prefix input in
    ;; `company-complete-common', unless there's only one candidate.
    (setq company-common
          (if (cdr company-candidates)
              (let ((common (try-completion "" company-candidates)))
                (when (string-prefix-p company-prefix common
                                       completion-ignore-case)
                  common))
            (car company-candidates)))))

直す場所が深いのですが、adviceで次のようにすれば直ります。

(defun my-company-update-candidates (old-fun candidates)
  (let ((old-selection-changed company-selection-changed)
        (old-selection company-selection)
        ;; call the original function
        (result (funcall old-fun candidates)))
    ;; keep company-selection-changed
    (setq company-selection-changed old-selection-changed)
    ;; keep company-selection that is not nil
    (when (and old-selection (null company-selection) candidates)
      (setq company-selection 0))
    result))
(advice-add 'company-update-candidates :around 'my-company-update-candidates)

呼び出しの前後で「ユーザーが一度でも選択を変更したかフラグ(company-selection-changed)」と「現在の選択肢番号(company-selection)」を適切に維持します。company-selection-changedをnilにしてしまうとユーザーが明示的に選択を変更したという意思がなかったことになってしまうのが違和感の原因だと思います。

2021-08-09追記: 選択状態のfaceを目立つものにする

company-selection-default を nil にする場合は、現在選択している候補がはっきり分かるようにした方が良いと思います。特に上では現在選択しているかどうかでRETやTABの挙動を変えてしまっていますからね。

色のセンスはありませんが、例えば:

(set-face-background 'company-tooltip-selection "#a62")

(実際にはcustomize-faceで変更しています)

1~2日使ってみての感想ですが、自動で候補が出たときに未選択状態から始まるのは慣れるまで時間が必要ですね。下を押してRETを押す習慣を付けなければなりません。あとモードによって選択状態から始めているのは統一感が無くてあまり良くなかったかもしれませんね。