pythonでneovimのdenite拡張を作る
neovimにはリモートプラグインというneovim本体とは別のプロセスで動くプラグインがあります。リモートプラグインは、msgpack-rpcという仕様でneovim本体と通信し、通信部分はすべてライブラリがやってくれるので、簡単にプラグインを書くことができ、この記事では内部でリモートプラグインを利用しているdeniteの拡張を作っていきたいと思います。
コード全体は以下にあります。
deniteとは
deniteは、「何か」を集めて一覧で表示し、それに対して「ある動作」を行うことを統一的に行えるプラグインです。例えば現在のディレクトリ以下のファイルを表示し即座にジャンプしたり、ディレクトリ以下に対してgrepした結果を表示してジャンプしたりすることができます。
これらの機能は標準で用意されているものですが、「何か」や「ある動作」に対応するものは自分で定義することもできるため、deniteの機能を拡張することができます。
この「何か」はdenite内ではsource、「ある動作」はkindと呼ばれます。
つまり、deniteの拡張を作るというのはsourceとkindを作ることだと言えます。
この記事では、以下gifのように、競プロなどでよく使うコードスニペットを即座に貼り付けるdenite拡張を書いていきたいと思います。
ディレクトリ構成
コードは {neovimのランタイムパス}/rplugin/python3/denite/source 以下に配置してください。
開発時には、
開発ディレクトリ |-rplugin |-python3 |-denite |-source | |-clipy.py | |-kind |-clipy.py
のようなディレクトリ構成にして、
:set runtimepath+=開発ディレクトリ :UpdateRemotePlugin
とすれば手元で実行できます。
またリリースする際には、このディレクトリをgithubなどで管理していれば使っているプラグイン管理プラグインなどでリポジトリ名を指定してください。
sourceの作成
簡単な例
前述のように、sourceはどのようなリソースを集めるのかを定義します。
deniteの公式ドキュメントにも書いてありますが、sourceは.base内のBaseクラスを継承したSourceクラスを作ることで定義できます。
このクラスに必要なメソッドはinitとgether_candidatesのみであるので、(何もしませんが)最低限実装したクラスを以下に示します。
from .base import Base class Source(Base): def __init__(self, vim): super().__init__(vim) self.name = 'clipy' self.kind = 'clipy' def gather_candidates(self,context): return [{'word':'candidate1'},{'word':'candidate2'}]
この例では、deniteの候補にcandidate1とcandidate2が表示されたと思います。
このようにsourceの定義では、オブジェクトのリストを返すgather_candidates関数を定義します。 また、オブジェクトは以下のようなフィールドを持ちます。
フィールド名 | 説明 |
---|---|
word | 実際に表示され、検索対象に含まれる文字列 |
abbr | これが指定されていると、wordではなくこちらの文字列が表示されるが検索はされない |
また、これ以外にも、フィールドは自分で指定できます。
実際に作ったsource
完成したclipyのsourceを以下に示します。
クリックして表示
from .base import Base
import glob
import os
import re
CLIPY_HIGHLIGHT_SYNTAX = [
{'name':'Title','link':'Statement','re':r'.\+\%(:\)\@='},
{'name':'Description','link':'Comment','re':r'\%(:\)\@<=.\+'},
]
class Source(Base):
def __init__(self, vim):
super().__init__(vim)
self.name = 'clipy'
self.kind = 'clipy'
def on_init(self,context):
context['__bufnr'] = str(self.vim.call('bufnr','%'))
context['__filetype'] = str(self.vim.command_output('echo &filetype'))
def gather_candidates(self,context):
candidates = []
clipy_root = self.vim.vars['clipy_root']
clipy_filetype = self.vim.vars['clipy_filetype'].get(context['__filetype'],[])
for filetype in clipy_filetype:
files = glob.glob("{}/**/*.{}".format(clipy_root,filetype),recursive = True)
for file in files:
candidates.extend(self._extract(file))
return candidates
def _extract(self,filename):
entries = []
extracted = []
with open(filename) as f:
lines = f.readlines()
line_count = 1
for line in lines:
match = re.search(r'<denite-clipy>(.*)</denite-clipy>',line)
if match:
entries.append({'body':match.groups()[0],'line':line_count})
line_count = line_count + 1
for i,entry in enumerate(entries):
if i != len(entries) - 1:
body = entry['body']
line_start = entries[i]['line']
line_end = entries[i+1]['line'] -2
extracted.append({'word':body,'__line':"{}:{}".format(line_start,line_end),'action__path':filename})
else:
body = entry['body']
line_start = entries[i]['line']
extracted.append({'word':body,'__line':"{}:{}".format(line_start,len(lines)),'action__path':filename})
return extracted
def highlight(self):
for syn in CLIPY_HIGHLIGHT_SYNTAX:
self.vim.command(
'syntax match {0}_{1} /{2}/ contained containedin={0}'.format(self.syntax_name, syn['name'], syn['re']))
self.vim.command(
'highlight default link {}_{} {}'.format(self.syntax_name, syn['name'], syn['link']))
gather_candidatesでは、g:clipy_rootで指定されたディレクトリ以下のリストg:clipy_filetype内のファイルタイプにマッチするファイルから、
<denite-clipy>スニペット名:説明</denite-clipy>
というタグに挟まれた文字列を取得します。この文字列はwordフィールドに配置され、構文ハイライトされて候補として表示されます。
また、kindでスニペットとして挿入するために、前後のタグの位置から挿入すべき行範囲を取得しています。
例えば、下のようなファイルに対しては以下のようなオブジェクトを返します。
#file.txt <denite-clipy>hoge:hoge</denite-clipy> hogehoge <denite-clipy>fuga:fuga</denite-clipy> fugafuga
[ {'word':'hoge:hoge','__line':"2:3",'action__path':'file.txt'}, {'word':'fuga:fuga','__line':"5:5",'action__path':'file.txt'} ]
行番号が0始まりになっている理由はkindでのputコマンドの仕様がそうだからです。
リモートプラグインからneovimの機能にアクセスするには、self.vimに用意されているapi群を利用します。詳細は ここ を参照してください。(ただし、self.vim.varsなどはドキュメントに書いてないのでソースから調べる必要がありそうです。)
kindの作成
実際に作ったkind
kindは選ばれた候補に対して何を行うのかを定義します。
kindもsourceと同様に、ベースクラスを継承してやる必要があります。
必要なものは_init_とnameであり、それに加えてactionを定義していきます。
kindの方は複雑でないので完成したものを解説していきます。
from denite.base.kind import Base class Kind(Base): def __init__(self, vim): super().__init__(vim) self.name = 'clipy' self.default_action = 'paste' def action_paste(self,context): targets = context['targets'] filename = targets[0]['action__path'] line = targets[0]['__line'] self.vim.feedkeys(":put =readfile('{}')[{}]\n".format(filename,line))
sourceのkindで指定したものをnameに指定することでこのkindで処理を行うことができます。
選択された候補の情報は、context.targetsにリストとして入っています。
今回やるべきはファイル名と行の範囲から貼り付けるという処理なのでputコマンドにreadfileのスライスを食わせることで実現できます。
まとめ
以上で説明したもの以外にも、非同期で候補を集める処理なども書くことができ、汎用性に優れた拡張が書けそうです。
また、リモートプラグインはmsgpack-rpcを使えれば、どんな言語でもneovimの拡張を書くことができる可能性を秘めているのでこれからも調べていきたいと思います。