次世代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ということなのでこれからに期待だ。