次世代auto-complete.elを読んでみる

テキスト入力中に補完候補を自動的に表示してくれる auto-complete.el をリリースしました — ありえるえりあ
情報源による拡張が可能な auto-complete 0.1.0 をリリースしました — ありえるえりあ←[2008/12/01]追記

今、auto-complete.elがアツい。現在、開発版として新機能が塔載されつつある。安定版はEmacsWikiにある。

M-x install-elisp http://www.cx4a.org/pub/auto-complete.el

2008-11-19版のコードを読んでみよう。

まずは流し読みしてみる

前々から気になっているけど、(eval-when-compile (require 'cl)) でCommon Lispマクロを使ったほうがかっこいいと思う。while/push/setq/nreverseでループを回すのがLispっぽくない。直接 (require 'cl) するなとか書いてあるけど、あくまでcl関数群を名前空間にばらまくなと言ってあるに過ぎず、バイトコンパイル時には使っちゃってかまわない。だからこそ eval-when-compile で囲む。とくにloopマクロは圧巻。

(if 条件 (progn 複文) 複文)はcondを使うのが好み。インデントが均等になるから。

(defvar ac-source-abbrev
  `((candidates
     . (lambda ()
         (all-completions ac-target local-abbrev-table)))
    (action
     . expand-abbrev))
  "Source for abbrev.")

ちょwこれってanything-c-sources-*と同じ形式じゃないか!補完といえば、いろいろな情報源から補完できるに越したことはない。ということで最終的に行き付く先はanything.elのようなものだと俺は考えていたのだが、考えていることは一緒のようで。オラ、なんだかワクワクしてきたぞ。

そのうちanything用に書かれたコードをそのまま使えるようになるとおもしろいと思う。必ずしもすべてのanything情報源が流用できる必要はない。そもそもanythingとはベクトルが違うから。anythingはウン千ウン万もの候補の中から(anything-match-pluginで)必要に応じて絞り込み検索をしていくのに対し、auto-completeはpost-command-hookで動くためそんな大規模な検索はできない。そもそも補完にターゲットをしぼっているからね。まあpersistent-actionで関数名・変数名の候補に対してdescribe-*ができると便利だろう。

流れを追ってみる

(define-minor-mode auto-complete-mode
  "AutoComplete mode"
  :lighter " AC"
  :group 'auto-complete
  (if auto-complete-mode
      (progn
        (add-hook 'post-command-hook 'ac-on-post-command nil t)
        (add-hook 'pre-command-hook 'ac-on-pre-command nil t)
        (run-hooks 'auto-complete-mode-hook))
    (remove-hook 'post-command-hook 'ac-on-post-command t)
    (remove-hook 'pre-command-hook 'ac-on-pre-command t)
    (ac-abort)))

post-command-hookとpre-command-hookで打鍵前後で実行する関数を指定している。前回はpost-command-hookしか使っていなかったのですっきりした模様。

(defun ac-on-pre-command ()
  (if (and (not (ac-trigger-command-p))
           (or (not (symbolp this-command))
               (not (string-match "^ac-" (symbol-name this-command)))))
      (ac-abort)))

(defun ac-on-post-command ()
  (if (and ac-auto-start
           (not isearch-mode)
           (ac-trigger-command-p))
      (ac-start)))

ac-on-pre-commandは特定の条件ならば、ac-abortでmenuを消す。条件式がnotを3つも使っててややこしい。えっと、「auto-completeを発動させるコマンドではなくて、かつ『実行中のコマンド名がシンボルではない、あるいは、acから始まるコマンドを実行していない』」か。
わけわかめなのでド・モルガンの法則で簡略化してみる。

(and (not (ac-trigger-command-p))
           (or (not (symbolp this-command))
               (not (string-match "^ac-" (symbol-name this-command)))))

(and (not (ac-trigger-command-p))
     (not (and (symbolp this-command)
               (string-match "^ac-" (symbol-name this-command)))))

(not (or (ac-trigger-command-p)
         (and (symbolp this-command)
              (string-match "^ac-" (symbol-name this-command)))))

条件式がifなのでunlessに書き換えてしまえばいい。

(defun ac-on-pre-command ()
  (unless (or (ac-trigger-command-p)
              (and (symbolp this-command)
                   (string-match "^ac-" (symbol-name this-command))))
      (ac-abort)))

「auto-completeを発動させるコマンドであるか、実行中のコマンドがac-から始まるコマンド」ではない場合はauto-completeを中止する…と読める。これでずいぶんわかりやすくなった。ちゃんと動くので間違ってはいるまい。
「ac-から始まるコマンド」と指定してあるあたりが絶妙だ。

ac-on-post-commandは楽勝だ。ac-auto-startがnon-nilで、isearch中ではなくて、auto-completeを発動させるコマンドならばauto-completeを開始する、と。

さて、ac-startはどうなってるかというと、

(defun ac-start ()
  "Start completion."
  (interactive)
  (let ((point (funcall ac-find-function)))
    (if (or (null point)
            (and ac-menu
                 (/= point ac-point)))
        (ac-abort)
      (when (null ac-menu)
        (ac-setup point)
        (funcall ac-init-function))
      (setq ac-target (buffer-substring point (point)))
      (setq ac-limit ac-candidate-max)
      (ac-update-candidates
       (if (or ac-completing
               (not (integerp ac-auto-start))
               (>= (length ac-target) ac-auto-start))
           (funcall ac-candidate-function))))))

ac-find-functionに設定されている関数でターゲットの開始位置を得る。これはカスタマイズできるようになっているから、日本語が含まれてていや〜んな場合は各自定義すりゃよい。デフォルトはac-default-findで、シンボルの開始位置になっている。
で、targetがみつからない場合、あるいは幅が0の場合は中止する。
targetが有効の場合は、ac-setupでポップアップメニューを作成し、ac-init-functionに設定されている関数で情報源の初期化を行う。その後は各種変数を設定してメニューの内容を設定する。候補はac-candidate-functionに設定されている関数で計算する。anythingの匂いがぷんぷんしてきたぞ。

(defun ac-setup (point)
  "Setup popup menu."
  (save-excursion
    (goto-char point)(setq ac-saved-window-start (window-start))
      (setq ac-saved-window-hscroll (window-hscroll))))
(defun ac-cleanup ()
  "Destroy popup menu."
  (ac-deactivate-mode-map)
  (when ac-menu
    略
    (set-window-start (selected-window) ac-saved-window-start)
    (set-window-hscroll (selected-window) ac-saved-window-hscroll)))

auto-completeの準備が ac-setup で後片付けが ac-cleanup。
ac-setupでメニューを作成する。表示位置の設定もここで行っている。このへんのコードはけっこくややこしいのでパス。ac-menuでメニューオブジェクトを代入。ac-pointも設定する…。
ac-cleanupはメニューを破棄し、各種変数を初期化している。

そこで現在のウィンドウ表示位置の保存と復元も行っている。window-hscroll/set-window-hscrollが盲点だった。

(defun ac-update-candidates (candidates)
  "Update candidates of popup menu."
  (setq ac-menu-offset (if (> ac-menu-direction 0)
                           0
                         (- ac-candidate-menu-height
                            (min ac-candidate-menu-height
                                 (length candidates)))))
  (setq ac-selection ac-menu-offset)
  (setq ac-candidates candidates)
  (setq ac-dwim-enable (= (length candidates) 1))
  (if candidates
      (progn
        (setq ac-completing t)
        (ac-activate-mode-map))
    (setq ac-completing nil)
    (ac-deactivate-mode-map))
  (let ((i ac-menu-offset))
    ;; show line and set string to the line
    (mapcar
     (lambda (candidate)
       (when (< i ac-candidate-menu-height)
         (ac-menu-show-line ac-menu i)
         (ac-menu-set-line-string ac-menu i candidate (if (= i ac-selection) 'ac-selection-face))
         (setq i (1+ i))))
     candidates)
    ;; ensure lines visible
    (if (and (> ac-menu-direction 0)
             (> i (-
                   (max 1 (- (window-height)
                             (if mode-line-format 1 0)
                             (if header-line-format 1 0)))
                   (1+ (count-lines (window-start) (point))))))
        (recenter (- (1+ i))))
    (if (> i ac-menu-offset)
        (let ((window-width (window-width))
              (right (- (+ (ac-menu-column ac-menu) ac-candidate-menu-width)
                        (window-hscroll))))
          (if (> right window-width)
              (scroll-left (- right window-width)))))
    ;; hide remaining lines
    (if (> ac-menu-direction 0)
        (while (< i ac-candidate-menu-height)
          (ac-menu-hide-line ac-menu i)
          (setq i (1+ i)))
      (dotimes (i ac-menu-offset)
        (ac-menu-hide-line ac-menu i)))))

実際の候補を並べるのはac-update-candidatesだ。candidatesがある場合はac-complete-mode-mapを有効にし、ない場合は無効にする。その後に候補を並べ、表示する。っつーかrecenterを含むif文ってホントに必要なのかな?いらないとしたらwindow-startを保存する必要もなくなりそうだが。

(-
  (max 1 (- (window-height)
            (if mode-line-format 1 0)
            (if header-line-format 1 0)))
  (1+ (count-lines (window-start) (point))))

の部分がac-setupとだぶっている。

scroll-leftは行が長くなってきたとき用だろう。適宜スクロールする。
最後にac-menu-hide-lineで空の行を隠している。

(defun ac-activate-mode-map ()
  "Activate `ac-complete-mode-map'."
  (setq ac-saved-local-map overriding-terminal-local-map)
  (if (eq ac-saved-local-map ac-complete-mode-map)
      ;; maybe never reach here
      (setq ac-saved-local-map nil))
  (setq overriding-terminal-local-map ac-complete-mode-map))

(defun ac-deactivate-mode-map ()
  "Deactivate `ac-complete-mode-map'."
  (when (eq overriding-terminal-local-map ac-complete-mode-map)
    (setq overriding-terminal-local-map ac-saved-local-map)
    (setq ac-saved-local-map nil)))

overriding-terminal-local-map はメジャーモードどころかマイナーモードをも上書きする端末ローカルなキーマップだ。isearchの実装に使われている。なるほど。

ac-next、ac-previousは候補選択。 ac-select-candidate でオーバーレイの設定、フェイスの設定。

(defun ac-complete ()
  "Try completion."
  (interactive)
  (let* ((string (overlay-get (ac-menu-line-overlay ac-menu ac-selection) 'real-string))
         (source (get-text-property 0 'source string))
         (complete-function (and source (cdr-safe (assq 'action source)))))
    (ac-expand-1)
    (if complete-function
        (funcall complete-function))
    (ac-abort)))

候補を選択(補完)する。stringが補完文字列。sourceが情報源、complete-functionがanythingでいうaction関数に相当する。complete-functionで設定された関数に従ってアクションが実行される。そして、メニューを破棄する。

anytingのactionは候補を引数に取るが、auto-completeのは無引数のようだ。anythingとまるっきり似ているわけではなさそう。

ac-sourceを追ってみる

さて、今回の目玉のanything風味の情報源。

(defvar ac-sources '(ac-source-words-in-buffer)
  "Sources.")
(defvar ac-source-words-in-buffer
  '((candidates . ac-candidate-words-in-buffer))
  "Simple source like dabbrev.")

ふむ。バッファ中の単語の情報源のみがデフォルトなんだな。ac-candidate-words-in-bufferはac-default-enum-candidatesと同じ内容になっている。だから以前のバージョンと同じ挙動になっているわけか。納得。

他の情報源を見てみると、

(defvar ac-source-symbols
  '((candidates
     . (lambda ()
         (all-completions ac-target obarray))))
  "Source for Emacs lisp symbols.")

(defvar ac-source-abbrev
  `((candidates
     . (lambda ()
         (all-completions ac-target local-abbrev-table)))
    (action
     . expand-abbrev))
  "Source for abbrev.")

(defvar ac-source-files-in-current-dir
  '((candidates
     . (lambda () (all-completions ac-target (directory-files default-directory)))))
  "Source for listing files in current directory.")

(defvar ac-source-imenu
  '((init
     . (lambda ()
         (require 'imenu)
         (setq ac-imenu-index
               (condition-case nil
                   (imenu--make-index-alist)
                 (error nil)))))
    (candidates . ac-imenu-candidate))
  "Source for imenu.")

(defvar ac-source-yasnippet
  '((candidates . ac-yasnippet-candidate)
    (action . yas/expand)
    (limit . 3))
  "Source for Yasnippet.")

ふむ、init、candidates、action、limit属性がサポートされているようだ。

(defun ac-sources-init ()
  "Implementation for `ac-init-function' by sources."
  (dolist (source ac-sources)
    (if (symbolp source)
        (setq source (symbol-value source)))
    (let ((init-function (cdr-safe (assq 'init source))))
      (if init-function
          (funcall init-function)))))

ac-sourcesで設定されている各々の情報源においてinit関数(あれば)を実行。
symbolpを含むif文で、alistでもsymbolでも受け付けるようにしている。anything-sourcesと一緒。cdr-safe + assq は anything で多用されている assoc-default と同じ。

(defun ac-sources-candidate ()
  "Implementation for `ac-cadidates-function' by sources."
  (when (> (length ac-target) 0)
    (let (candidates)
      (dolist (source ac-sources)
        (if (symbolp source)
            (setq source (symbol-value source)))
        (let* ((ac-limit (or (cdr-safe (assq 'limit source)) ac-limit))
               (requires-pattern (cdr-safe (assq 'requires-pattern source)))
               cand)
          (if (or (null requires-pattern)
                  (>= (length ac-target) requires-pattern))
              (setq cand
                    (delq nil
                          (mapcar (lambda (candidate)
                                    (propertize candidate 'source source))
                                  (funcall (cdr (assq 'candidates source)))))))
          (if (and (> ac-limit 1)
                   (> (length cand) ac-limit))
              (setcdr (nthcdr (1- ac-limit) cand) nil))
          (setq candidates (append candidates cand))))
      (delete-dups candidates))))

お、requires-pattern属性もサポートされているね。targetの長さが閾値以上であれば補完が発動する。limit属性はanythingでいうcandidate-number-limit属性に相当するようだ。長いから敬遠されたのかもしれない(^^;
candidates属性で設定された関数を実行し、その各々の候補においてsourceプロパティを設定。これで候補の情報源がわかる。setcdr/nthcdrでlimitを超過した候補を削る。そして、だぶった候補をdelete-dupsで削る。
このやりかただと、candidates関数の個数が多すぎる場合はパフォーマンスに響きそうだ。ここは候補数を数えながらやったほうがよさげ。
id:IMAKADOさんによるとdelete-dupsは要素数が多いと遅いので、本当に高速化したい場合はハッシュテーブルを使ったほうがいいかと。anything.elではそうしている。
まあunstableということなのでこれからに期待だ。