Kotaro7750's diary

低レイヤを中心とした技術ブログ、たまに日記

pythonでneovimのdenite拡張を作る

neovimにはリモートプラグインというneovim本体とは別のプロセスで動くプラグインがあります。リモートプラグインは、msgpack-rpcという仕様でneovim本体と通信し、通信部分はすべてライブラリがやってくれるので、簡単にプラグインを書くことができ、この記事では内部でリモートプラグインを利用しているdeniteの拡張を作っていきたいと思います。

コード全体は以下にあります。

github.com

deniteとは

deniteは、「何か」を集めて一覧で表示し、それに対して「ある動作」を行うことを統一的に行えるプラグインです。例えば現在のディレクトリ以下のファイルを表示し即座にジャンプしたり、ディレクトリ以下に対してgrepした結果を表示してジャンプしたりすることができます。

これらの機能は標準で用意されているものですが、「何か」や「ある動作」に対応するものは自分で定義することもできるため、deniteの機能を拡張することができます。

この「何か」はdenite内ではsource、「ある動作」はkindと呼ばれます。

つまり、deniteの拡張を作るというのはsourceとkindを作ることだと言えます。

この記事では、以下gifのように、競プロなどでよく使うコードスニペットを即座に貼り付けるdenite拡張を書いていきたいと思います。

f:id:Kotaro7750:20200301200958g:plain
clipy

ディレクトリ構成

コードは {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の拡張を書くことができる可能性を秘めているのでこれからも調べていきたいと思います。