Python
Vim
denite.nvim

【Vim】mattn/memo の Denite source を作った

最近ずっと Denite をいじっております。こいつは Unite の後継だけあって大した時間泥棒プラグインですね! いくらでもカスタム可能なので際限なく設定に凝ることが出来ます。Unite と違って Python で拡張が書けるのでなおさらです。

今回はいつも利用させていただいてます、mattn/memo の source を書いてみました。これはリンク先を見るとわかるとおり、メモを取るためのツールなのですが、本来はターミナルから起動してメモを作成/検索するためのものです。ターミナルからメモを作成/検索するのは簡単なんですが、一度作成したメモを Vim 上から検索したりするのが難しかったんですよね。これを簡単にしてみました。

vim-denite-memo

こんな感じです。インストール方法などは 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 するには相対パスを使うと簡単です。

memo_grep.py
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.pymemo_grep.py より先に読み込まれていることを期待しているので少々 hacky です。

memo_grep.py
# 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 使えると楽ですねえ。