最近ずっと Denite をいじっております。こいつは Unite の後継だけあって大した時間泥棒プラグインですね! いくらでもカスタム可能なので際限なく設定に凝ることが出来ます。Unite と違って Python で拡張が書けるのでなおさらです。
今回はいつも利用させていただいてます、mattn/memo の source を書いてみました。これはリンク先を見るとわかるとおり、メモを取るためのツールなのですが、本来はターミナルから起動してメモを作成/検索するためのものです。ターミナルからメモを作成/検索するのは簡単なんですが、一度作成したメモを Vim 上から検索したりするのが難しかったんですよね。これを簡単にしてみました。
こんな感じです。インストール方法などは README を参照してください。以下の機能が利用できます。
" List up all memos
:Denite memo
" List up all memos or create a new one
:Denite memo memo:new
" Search memo
:Denite memo/grep
" Search memo interactively
:Denite memo/grep::!
特に最後の、「memo のインクリメンタル検索」が欲しくて作ったようなものです。同じ事は peco の --exec オプションを使えばできるのですが、Vim 上でやることに意味があるのです!多分!
以下、vim-go に unite/denite source を作って contribute した話 - Qiita の続きのような内容ですが、開発中に気をつけた点についてまとめておきます。
memo 一覧を非同期に読み込む
grep source が非常に参考になりました。通常は一度だけ呼ばれて候補を返す gather_candidates
メソッドですが、context['is_async'] = True
にしておくと Denite が繰り返し呼んでくれるようになります。
内部では Popen.communicate メソッドを使って 0.01 秒ごと(async_timeout
オプションで変更可能)に結果を返すようにしています。gather_candidates
から処理が戻る度に逐次表示も更新されます。
気をつけなければいけないのは、候補を出し尽くした後は context['is_async'] = False
にしないと永遠に待ち続けてしまうことです。
# 省略しています。詳しくはソースを見てください。
def gather_candidates(self, context):
if context['__proc']:
return self.__async_gather_candidates(context, 0.01)
# process.Process は subprocess.Popen のラッパーです。
context['__proc'] = process.Process(['memo', 'list'], context, context['path'])
return self.__async_gather_candidates(context, 0.01)
def __async_gather_candidates(self, context, timeout):
# エラー処理は省略
outs, errs = context['__proc'].communicate(timeout=timeout)
# eof に到達したら完了
context['is_async'] = not context['__proc'].eof()
# ここで出力結果をごにょごにょして候補を作る
return candidates
タイトルを全角○文字に切り詰める
memo コマンドはターミナルで起動したとき「ファイル名 : タイトル」のように一覧を表示してくれるのですが、長すぎるファイル名は適当にちょん切ってくれます。これを Denite 上で再現するのは結構骨でした。どんな言語でも環境でもそうですが、文字列の「全角○文字詰め」って厄介なんですよねー。
Vim の場合さらに 'ambiwidth'
というオプションがありまして、設定によっては同じ文字が半角だったり全角だったりします。strwidth()
という関数を使うと「'ambiwidth'
オプションを考慮した文字幅」が得られます。
ホントは「'ambiwidth'
オプションを考慮した文字数にカットしてくれる関数」が欲しかったのですが、そういうものは Vim には無いようです。しょうがないので地道にループ廻して一文字ずつカウントしています。
# なんか Python っぽくないコードになった……。
i = 0
while True:
char = string[i:i+1]
next_r = result + char
# Denite の便利メソッドを使うと Vim の関数が簡単に呼べます
nlen = self.vim.funcs.strwidth(next_r)
if nlen > col - 3:
rlen = self.vim.funcs.strwidth(result)
return result + ('....' if rlen < col - 3 else '...')
elif nlen == col - 3:
return next_r + '...'
result = next_r
i += 1
他の source を継承して source を作る
memo/grep
source は grep
source を継承して、ロジックをそのまま使っています。継承のためにモジュールを import するには相対パスを使うと簡単です。
from .grep import Source as Grep
# grep source を継承して必要な情報だけ上書きする
class Source(Grep):
def __init__(self, vim):
super().__init__(vim)
self.name = 'memo/grep'
最初同様のロジックを見たとき、「同じディレクトリに grep.py
なんて存在しないのに、なんでこれでいいの?」って思ったのですが、これは Denite のコードに秘密がありました。denite.load_sources というメソッドで 'runtimepath'
内にある全ての source を動的に読み込んでいるんですね。grep.py
は標準添付の source ですので import 出来た訳です。
さらに memo_grep.py
では同じディレクトリにある memo.py
をも import しています。これは memo.py
が memo_grep.py
より先に読み込まれていることを期待しているので少々 hacky です。
# memo.py は先に読み込まれているはず
from .memo import Memo, CommandNotFoundError
他の kind を継承して kind を作る
「候補を列挙する」ものが source なら「選択した候補で何かする」のが kind です。今回の memo
source による各の候補は単なるファイルと異なりませんから file
kind を継承して作れば良さそうです。
from .file import Kind as File
class Kind(File):
def __init__(self, vim):
super().__init__(vim)
self.name = 'memo'
source の時と一緒ですね。今回は memo を開くための open
action だけを上書きしています。ホントは memo を削除する action も追加しようと思ったのですが、それは file
kind の方に追加した方が良さそうです。
以上です。特に is_async
オプションで簡単に候補の非同期読み込みができるのがいい感じです。Vim だって job/channel による非同期実行は可能になりましたが、やっぱり Python 使えると楽ですねえ。