Emacs
EmacsDay 5

Vimライクな jump/marker を実現する jumar.el の紹介

More than 1 year has passed since last update.

jumar.el の紹介

これは何?

この記事は Emacs Advent Calendar 2014 (http://qiita.com/advent-calendar/2014/emacs) の5日目の記事
です.

「今年作ったものについて書く予定です」と書いていたのですが, 丁度作っていたものが思ったよりも
ぎりぎりになったので遅くなってしまいました. 日付が変わるまでに投稿できていればいいのですが.
(スクリーンショットなどは撮っている暇がないので多分追記するでしょう.)

4日目は kaz-yos さんによる ESS+R使いがClojureで遊びたくて作ったeval-in-repl.el (C-RETでREPL評価)
の紹介 でした. (http://qiita.com/kaz-yos/items/3cf663d67f69d3cc2fc2)

6日目はmhayashi1120 さんの予定です.

jumar.el

Jumar (じゅまーる, と発音するらしい) は Vim の marker と jump をエミュレートすることを目的として開発
された Emacs 拡張です. 普通に前後にジャンプすることに加え, Vim の :jumps の代替として Helm
を使ってマーカーを表示し, インタラクティブにマーカーを覗く, ジャンプする, 削除するなどが可能になって
います.

大体終わりましたが, まだ開発段階です.

GitHub で公開しています: https://github.com/kenoss/jumar

三行で説明

  • 新しいマーカーオブジェクト jumar:jumarker は自動で管理され kill したバッファに対しても有効に.
  • マークリングに代わって jumar:tree を使い, デフォルトでDWINにマーカーのツリーとリストを操作できる.
  • (オプショナルで) Helm でマーカーの集合を可視化できる. :jumps より強力.

スクリーンショット

jLoUv7oeW0.gif

GIF: http://g.recordit.co/jLoUv7oeW0.gif
ビデオ版: http://recordit.co/jLoUv7oeW0

インストール

Jumar (https://github.com/kenoss/jumar) は以下を必要とします:
- Emacs 24 (またはそれ以降のversion)
- ERFI (https://github.com/kenoss/erfi)

まだ MELPA には登録されていません. 数日中に申請する予定です.

使うには ERFI と jumar を(git clone なり zip でなり)直接ダウンロードしてパスを通します.

$ cd /path/to
$ git clone https://github.com/kenoss/erfi
$ git clone https://github.com/kenoss/jumar
(add-to-list 'load-path "/path/to/erfi/lisp")
(add-to-list 'load-path "/path/to/jumar")

試しに使ってみる

以下を評価するか .emacs に書いて Emacs を再起動します.

(require 'jumar)
(require 'jumar-dwin)
(require 'helm)  ; if you use visualizer.

;; If one needs highlight the line after jump.
(require 'erfi-emacs)
(add-hook 'jumar-post-jump-hook 'erfi-emacs-hl-turn-on-until-next-command)

;; Initialization
(jumar-dwin-use-preconfigured-scheme 'list+history)
(jumar-init)

;; As you like.
(define-key global-map (kbd "C-'")     'jumar-dwin-add-marker)
(define-key global-map (kbd "C-\"")    'jumar-dwin-jump-current)
(define-key global-map (kbd "C-,")     'jumar-dwin-jump-backward)
(define-key global-map (kbd "C-.")     'jumar-dwin-jump-forward)
(define-key global-map (kbd "C-x C-'") 'helm-jumar-dwin-jumarkers)

;; As you need.  Advise jump commands, like `find-tag' and `gtags-find-tag',
;; to add jumarker before/after jump.
(jumar-dwin-advise-jump-command-to-add-jumarker 'find-tag)
; (jumar-dwin-advise-jump-command-to-add-jumarker 'elisp-slime-nav-find-elisp-thing-at-point)

試しに以下を評価してみましょう. 10個のマーカーを登録します.

(save-excursion
  (goto-char (window-start))
  (dotimes (i 10)
    (call-interactively 'jumar-dwin-add-marker)
    (forward-line 2)))

ここで C-x C-' (helm-jumar-dwin-jumarkers) すると登録されたマーカーが Helm で表示されます.
上下移動して Enter を押すとそのマーカーにジャンプします. C-z (helm-execute-persistent-action)
で Helm を保ったままそのマーカーを覗きます. ジャンプすると > マークが移動していることに気づく
でしょう. これはカレントなマーカーを表す印です. (取り敢えず上の "Jumarkers in list" だけに
注目しておきましょう.)

Helm を使わずにジャンプすることもできます. C-. (jumar-dwin-jump-forward) で > のついた一つ
後ろのマーカーへ. C-, (jumar-dwin-jump-backward) で一つ前へ. C-' (jumar-dwin-add-marker)
で新しいマーカーを登録できます. ジャンプは異なるバッファにマーカーがある場合, バッファを
切り替えます.

Helm の各行の情報は左から, カレントを表すマーク, ブランチの情報, マーカーのある行, 列, バッファ,
その位置のバッファの内容が表示されます.

Jumar はマーカーが存在するバッファが生きているかどうかを自動で判別してうまいことやりくりします.
他のどうでもいいバッファに切り替えてマーカーを登録してから kill-buffer してみましょう.
この状態で Helm を見るとカレントマーカーの部分に Killed buffer と表示されているはずです.
これにジャンプしようとするとバッファを開き直し, そのバッファの全てのマーカーを復元しようとします.
(復元は Helm を通じて開き直したときだけではなく他のコマンドによって開き直されたときにもなされます.)
デフォルトでは jumar-dwin-jump-forwardjumar-dwin-jump-backward は先頭と最後の unavailable
なマーカーはスキップして次の有効なマーカーにジャンプします.

Jumar はデフォルトで二つのマーカーの集合を使用します. ツリーとリストです. (どちらも構造としては
jumar:tree ですが.) DWINコマンド群はこれらをどのように使うかを jumar-dwin-action-control-alist
変数によって判断します. この変数によって挙動を逐一変えることもできますが, 通常は
jumar-dwin-use-preconfigured-scheme で用意された設定を使えば十分でしょう. 用意されている用法は
以下の通りです.

引数 説明
'list-main リストを主に使い, ツリーは補助.
'tree-main ツリーを主に使い, リストは補助.
'list-only リストのみ使う.
'tree-only ツリーのみ使う.
'list+history 'list-main と似ているが, ツリーを全履歴保持のためのリストとして使う.

例えば 'list+history の場合, jumar-dwin-add-marker はツリーとリストの両方に追加,
jumar-dwin-jump-forward などは常にリストの方を参照します. ジャンプ系コマンドは C-u 付きで
呼び出すと使っていない方の集合を参照します. (普段リストを参照していればツリーを参照する.)

コマンドまとめ(上の設定で):

キー コマンド 説明
C-' jumar-dwin-add-marker マーカーを追加する
C-" jumar-dwin-jump-current カレントマーカーへジャンプ
C-, jumar-dwin-jump-backward カレントマーカーの一つ前へジャンプ
C-. jumar-dwin-jump-forward カレントマーカーの一つ後ろへジャンプ
C-x C-' helm-jumar-dwin-jumarkers Helm で表示

Helm で有効なコマンド:

キー コマンド C-i (helm-select-action)したときの説明 説明
Enter helm-jumar-jump/set-current Jump to marker 選択されたマーカーをカレントにしてジャンプ
C-z helm-jumar-persistent-action 現在行にあるマーカーを覗く
C-u C-z helm-jumar-persistent-action 現在行にあるマーカーを削除
C-c d helm-jumar-delete-nodes-below Delete markers below 現在行から下にあるマーカーを削除
C-c k helm-jumar-delete-marked-nodes Delete marker(s) マークされたマーカーを削除
M-f helm-jumar-forward-branch Forward branch ブランチの切り替え(後)
M-b helm-jumar-backward-branch Backward branch ブランチの切り替え(前)
C-r helm-jumar-run-recenter 選択されたノードを中心にして再表示

helm-jumar-forward-branchhelm-jumar-backward-branch はブランチを切り替えるためのコマンド
です. ツリーは Helm で表示する際, 選択された子を辿ったパンくずリストの形にされています.
行数の左側が | でないもの(-+ とか -+- とか)はブランチを複数持っています.
('tree-main でカレントマーカーが最後以外のときに jumar-dwin-add-marker するか,
jumar-add-markerjumar-add-marker* を使うとブランチが出現します.)

pop-tag-mark などの置き換え

上記の様に (jumar-dwin-advise-jump-command-to-add-jumarker 'find-tag) とすれば find-tag
したときにマーカーを記録できます. (連続した tag jump のときは最小限の記録をします.)
M-* (pop-tag-mark) の代りに使えるようになります. pop-tag-mark とは違い一度元の場所に戻った
後でもジャンプした先とを行き来できます.

(どうでもよさそうなコメント: pop-tag-mark はマークリングを使っているのではく,
マーカーのリングを使っていて ring-remove するのでマーカーは本当に pop される.)

作った経緯や内部の詳細など, という体の与太話

作ろうと思った経緯と既存の解法

従来の Emacs の jump/marker 機能は貧弱でした. 使われている構造はマークリングで,
push と pop しかできず, バッファを横断するマーカーは一応あれどもマーカーが差すバッファが存在しない
場合はエラーを吐くという代物でした. Vim ですら(という言い方は Vim に対して失礼ですが)
:jumps によってマーカーのリストの中身の情報を見ることができ, バッファが閉じられている場合は
自動で開き直してくれるのに, です. ソースコードを読むのにタグジャンプは必須ですが,
pop-tag-mark を僕は上手く扱えませんでした. また, タグジャンプ以外にも「このファイルのあの辺りも
弄らないといけないけどここに戻ってきたい」という需要がありました. この用途には Emacs 側としては
C-@ (set-mark-command) と C-x C-x (exchange-point-and-mark) 使えばいい」という意見を
ググった結果から感じとりましたが, transient-mark-mode の下では C-@ 一発ではなく C-@ C-@
を使わねばならず, なおかつこのマークが他のコマンド群(例えば kill-line)によって頻繁に変更されるため
信用ならないという欠点がありました.
(これらのコマンド群にはハードコードされていてオフにすることができない.)
(まぁ mark-ring やそれを暗黙のうちに変更する基本的なコマンド群は simple.el で定義されている
わけだからしゃーないっちゃしゃーないのだが何故もちっとリッチなマーカーを誰も作ろうとしなかったのか.)
(いや mark-ring を cycle していくコマンドを定義すれば C-x C-x でアクセスできないという状況は
回避できるけどさ, おれはマーカーの集合を弄るタイミングを自分で制御したいんじゃ.)
(因みに単方向限定ではあるけど set-mark-commandC-u 付きで呼び出せば cycle していける.
set-mark-command-repeat-popt にすればなお良し.)

この問題に対する既存の解法をまとめておきます:

  • デフォルトの(バッファローカル, グローバル)マークリングを使う. もしくは marker と ring.el などを組み合せて適当なコマンドを作る. (因みに Emacs の mark と marker は完全に別物: http://www.fan.gr.jp/~ring/doc/elisp_20/elisp_31.html)
  • Evil の C-o/C-i. 現状ではバッファローカルのみ. https://github.com/bling/evil-jumper を取り込むという話が上っている. (Persistency 有り.) (https://bitbucket.org/lyro/evil/issue/42/c-o-across-buffers-after-c-broken) ただし, 見た感じでは Vim の per-window jump-list をエミュレートしようとしていてオプショナルに していないおかげで elscreen.el などの window をまるっと挿げ替える系の拡張の存在下では 上手く動かなそう. (試してないけど. elscreen 側で hash-table をまるっと入れ替えればなんとかなりそう ではあるけど, window-configuration-change-hook が実行されるタイミングとかちゃんと見て window-configuration-change-hook からフックが呼び出される順番をしっかり保証しないとだめなんじゃ なかろうか.) (そういえば window-local variable っぽいものが同様に hash-table を使えば作れるという話を小耳に 挟んだけど同じ問題があると思う. Emacs が window 毎にカーソルの色変えられないのもこういう問題がある のかな.)
  • Icicles が buffer-local/global mark ring へのアクセサを提供しているようだ. http://stackoverflow.com/questions/3393834/how-to-move-forward-and-backward-in-emacs-mark-ring Icicles を使っていないので使用感はわからない. ただ set-mark-command とは違い双方向に飛べるようだ.

marker を使うわけではないけど移動系コマンドは他にも色々ある. 大体は buffer-local だけど:

  • goto-chg.el 過去に変更した箇所に飛ぶ. undo/redo しない undo/redu みたいな感じ.
  • point-undo.el ポイントの移動を全て記録しておいて辿れるようにしてある.

(どうでもよさそうなコメント: point-undo.el, 記録しているポイントのリストに長さの制限がなくて
移動する毎にリストが伸びていくので長時間作業してるとメモリ食いそう. 実際はどうなんだろ.)

実装するに当たって気を配った点と内部の詳細

  • buffer が kill されたときにも有効な marker object を作る. jumar:jumarker がそれに当たります. これは別に何てことはなくて, kill-buffer-hook, before-revert-hook, find-file-hook でバッファの生存状態を監視してバッファが生きているときは ただの Emacs の marker object の wrapper, 死んでいるときはバッファのファイル名やポイントを保持 するだけです. それらのデータから元の marker を正確に「復元」することは kill する前の バッファの内容と開き直したファイルの内容が一致する場合に限られるので, オプション変数 jumar-revive-marker-function でより良い復元をできるように余地を残しています. 例えば, 少しファイルの内容が変更されただけであれば, 元の marker 付近のテキストと一致する場所を, 開き直したファイルの元々 marker があったポイント付近で探せばいいでしょう. ただこういう smart な復元は(多分) Vim ではなされていない気がしますし, 自分に必要ないので 単純な復元しかデフォルトでは提供していません.
  • jumarker のための集合. jumar:tree です. わぁい Parametric polymorphism, あかり Parametric polymorphism 大好き. 参考にしたのは undo-tree.el です. 可視化をちゃんと用意してやれば履歴にツリー使えるじゃん, と. 元々はツリーしか提供する予定はなく, ツリーをブランチなしで使うことによって Vim のエミュレーション を提供する予定でした. 結局, 使ってるうちにツリーは普通のユーザーにとって難しすぎるのではと思い 元々の Vim のリストに戻ってツリーを補助として使う用に. ただ DWIN の scheme に 'list+history を用意しちゃったのでもうツリーじゃなくていいんじゃないかな... ('list+history でも難しい人は 'list-only を使おう!) 因みに後述するけど per-window/per-screen jump-list を実装することを意識している. あと unit test のためという名目で content は jumarker でなくてもいいようになっている.
  • ツリービューワとしての Helm の利用 元々 visualizer を自前で書いていたのだけれど, 機能を足していくうちに Helm に近付いていく 気がしたのでもう Helm でいいじゃん, と. ただ Helm は「候補を出して選択させる」インターフェース であって, 常に表示しておく(e2wm.el のガジェットみたいな)用途には全く使えないのでもしかしたら 復活するやもしらん. 常に表示しておく用途が現状では想像できないけど. あとこれを使って undo-tree.el の visualizer を Helm に乗せることも可能だと思う. (helm-jumar:make-cadidates の部分をごにょればいい気がするけど, そこまで undo-tree.el の visualizer に不満はないので結局書かないパターン.)

1800行近くあっていまだに開発版かよ

  • buffer-local markers や smart filtering
    • buffer-local なマーカーだけをワンキーで filtering して 登録順/位置順 をトグルできるようにする. Helm を介さないときにも「位置順で次のマーカー」とか...は DWIN 考えるのめんどくさそうだ.
    • 現状では Helm による filtering はツリーの場合 breadcrumb list 限定なので 検索かけるときだけツリー全体をリストに flatten する方がいいんかな? こっちは需要ないとやらない.
  • Vim みたいな persistency と register 需要待ち. 個人的には Vim みたいな使い方で Emacs を使う場合は emacsclient 使うだろうから特に 何もしなくても persistent だし, 特定のファイルやその中の特定の位置に素早くアクセスしたいなら bookmark.el とか bm.el とか使った方がいいんじゃないかなーと思う. Helm interface あるし.
  • Evil 向けのコマンド そのうち書く.
  • per-window/per-screen jump-list どうやら Vim は window 毎に jump-list を保持しているらしい. (上の Evil のリンクを参照.) (vimmer は未来に生きてるな.) Elscreen の screen 毎に jump-list を保持することは可能で, それがやりやすい作りにしたつもりではある. ただ persistency との兼ね合いがあるので どっちも実装する場合はリストの方だけ挿げ替えるようにしてツリーの方だけ persistent, とかにすると思う. per-window は色々問題があるからあんまり考えたくない.
    • 上にも書いたように window-local な変数なんて幻想なんや.
    • Elscreen との統合がきつい. 絶対想定外のケースが発生する.
    • そもそも現時点では per-window な jump-list の需要がよくわからん. Elscreen ではいかんのか? terminal で emacsclient で使ってるという場合に Elscreen 使うのが(例えば使えるキーが少ないから) めんどい, というのはあるのかも. でもその場合は tmux なり screen なり使って window 切って emacsclient すればいいのでは. (server.el の内部構造知らんけど)対応する側としては per-window よりは楽そう.

まだあった気もする.

要望やバグ報告は Twitter @keno_ss か GitHub の issue か,
Lingr の Emacs 部屋にいるときに言ってもらえれば.

蛇足: Helm の (DISPLAY . REAL) pairs 問題

現状では, ツリーもリストも内部的には jumar:tree で, マーカーの削除やブランチの変更を行なうため
Helm の candidate list には (DISPLAY . REAL) の組を, REAL に jumar:node を入れて使っている.
Helm にはこの用な REAL の使用はドキュメントされておらず, 名前からして使えるはずと思っていた
のだけれど一箇所だけ問題があった. 複数のマーカーを削除しようとしたときにエラーが起こる.
問題は REAL を string= で比較している一箇所だけ(helm-jumar:with-temporary-patch でごにょってた
箇所).
Helm の最新版では equal を使うようになっている. (https://github.com/emacs-helm/helm/issues/706)
どうなるかなと思ったけどパッチ取り込まれて良かった.

そういうわけで, 複数削除を使いたい人は Helm の最新版を使ってください.

(doubly-linked tree のノードの比較だから無限ループになっちゃうと思ったけど Emacs の eqaul はまず
eq するのね. docstring には書いてないけど GNU Emacs Lisp Reference Manual には載ってる.
http://www.gnu.org/software/emacs/manual/html_node/elisp/Equality-Predicates.html)

終わりに

走り書きになりましたが, 新しい jump/marker 環境を提案する Jumar の紹介でした.