次世代補完フレームワークdeoplete.nvimと、そのソースの内部を詳しく

  • 48
    いいね
  • 0
    コメント

この記事はVim Advent Calandar 2016の7日目の記事です。

なお、この記事はvimconf2016で発表した
Go、C、Pythonのためのdeoplete.nvimのソースの紹介と、Neovim専用にpure Goでvim-goをスクラッチした話
の続きだったりします。

長いので飛ばし飛ばしどうぞ。
あと、この記事で実際伝えたいのは最後のあたりの簡単な作り方です。とりあえずそこだけでも読んでくださいお願いします。

deoplete.nivm

巷で有名なShougoさんが開発されている(Neo)vim用補完フレームワーク、deoplete.nvimをご存知でしょうか?
neocomplete.vimの元に、真の非同期を目指して作られた次世代の補完プラグインです。

github.com/Shougo/deoplete.nvim

🌠 Dark powered asynchronous completion framework for neovim

とあるように、だーくぱわーどな非同期補完フレームワーク for neovimです。
なぜだーくぱわーどなのかは作者が厨二(ry

framework and source

さて、なぜリポジトリの紹介に "framework" と明記されているんでしょうか。

それは、本体にビルドインされたものには特定の言語のものを用意せず、他のリポジトリ(開発者)が書いたコードをインクルードして使うようなインターフェイスになっているからです。
いまではどこでも常識になっている、OSSベースのエコシステムにのるスタイルですね。

いま開いてるバッファがC++で書かれていたら、Pythonだったら、Goだったら…etc。状況に応じてdeoplete.nvimがその言語用のものを呼んでくるようなシステムです。

そこで呼び出されるコードのことを、通称 "ソース (source)" と呼びます。

今回はそのソースに関しての内部構造と、簡単な作り方を紹介したいと思います。

Note:
なお、ご存知かもしれませんがdeoplete.nvimはVimでは動きません。Vimお使いであればneocomplete.vimからどういう変わったか見ていただけたらと思います。
また、前提としてdeoplete.nvim本体のみは既にインストールされているものとします。インストール方法は他にお譲りすることにします。
よって現状ではNeovim用の記事になります。現状では…!

source

さて、ソースの中はどうなってるのでしょう。
ここでは僕が開発した3(~5)つの言語用のソースをベースに紹介したいと思います。

ちなみに開発中のものは以下です。

やっつけのものは、あんまり見ないでください。

==

deoplete.nvimのソースの特徴として、以下のものが挙げられます。

  • Python3 で書ける(でしか書けない)
  • subprocess しようが threading しようがやりたい放題
  • さすがのPythonなので、なんでもできるしなんでも揃ってる
  • deoplete.nvimのために初めてPythonを書いたぼくでも書けたので、大概なんとかなる
  • 余談

Python3 で書ける(でしか書けない)

deoplete.nvimのコア部分は全てPython3で書かれています。Vim script部分も多少ありますが、補完に関する部分はほぼPython3と言って差し支えないと思います。
ですので、インクルードされるソースも自ずとPython3で書く必要があります。

ここまでいちいち 3 をつけているのには理由があり、Python 2 は公式ではサポートしていません。力技でなんとかすればできるかもしれませんが、おすすめはしません。
逆に言うと 3 だけを考えれば良いので、importtryしたりとかなど互換性の部分で困ったりとかはないです。
ぼくはdeoplete.nvimのために初めてPythonを書き始めたので、2と3の違いすら分かってないので全くもって困る要素がありませんでした。

でしか書けない、と言うのは他にも意味があって、Python以外の他の言語でラップして書こうとすると急にハードルが高くなります。
お察しの通り、ujsonやrapidjsonのようにCライブラリにしてimportしなければならないからです。
RubyのパーサーをRubyで書いてPythonにbindingsして…とか考えるとそれだけでなんかやばいので素直にやめておきましょう。(もしかしてそんなのあったりしますか?)

じゃあPython以外の言語用のソースどうすんねんという話なんですが、

subprocess しようが threading しようがやりたい放題

言語別の補完エンジンのバイナリをsubprocessで直接叩きましょう。しょっちゅう叩きたくなかったらthreadingあたりとかasyncioとかなんかその辺で適度にコントロールしましょう。万事解決です。

だいたいどの言語でも天才で物好きな人がASTパーサー書いてるはずですので(ありがたい時代です)、そのバイナリのコマンドフラグとかに従って愚直に書いていけばだいたいそれっぽいのができてしまいます。

例として zchee/deoplete-go なんですが、これはsubprocess一本です。
Go言語のデファクトであるgocodeというエンジンを使っているのですが、幸いjson出力があったのでそれをujsonでパースしてちょいちょい加工してダンプしてdeoplete.nvimに渡しておしまい。
これはgocodeが数ある言語パーサーの中でも断トツ速い部類(だと思ってる、たぶん)だったからですが、Go用のソースはまぁまぁ使えるレベルになっていると思っています。
ただ完全に依存するので、性能や評価はそのエンジン次第になります。実際ぼくもgocodeに言語仕様の追従のために何度かプルリクしてます。
使わせてもらってるので貢献は大事!

さすがのPythonなので、なんでもできるしなんでも揃ってる

Pythonの特徴のひとつだと思うんですが、思いつくものはだいたい既にモジュールがあるという素晴らしいエコシステムを存分に使いましょう。
urllib3とかでREST APIさっと取ってこれるし、ドキュメントのパース必要だったらSphinxベースならなんとかなりそう感ありますし(知らない)
補完エンジンにDeep learninig取り入れて適切に補完したい? Tensorflowあります(ほんとに知らない)

deoplete.nvimのために初めてPythonを書いたぼくでも書けたので、大概なんとかなる

題の通りですが、deoplete.nvimのソース書くまでじつはPython嫌いでした。
その昔 #!/usr/bin/env python でpython2立って、でもそのツールはPython3用だったとかそういうよくある理由からですw
「はーまじ+1のバージョンで動かないのなんでなん?」という初心者だったぼくが、とりあえず動くやつ書けたので皆さんならすぐ書けますたぶん。

あ、今は好きです。

余談

余談ですが、zchee/deoplete-clang のC/C++に関してだけは、libclangのPython binding使っても速度的に結構厳しかったので(ctypesのオーバーヘッドっぽい)、いまバックエンドサーバーをGoで書き直してます。
そのままPythonで頑張れたかもしれませんが、ぼくのPython力ではなんともならんと思ったので素直に諦めました。
サーバーにするのでキャッシュ効いてdb(leveldbのよてい)使えて、あとLLVMのlibclang周りのライブラリをcgoでスタティックコンパイルしてバイナリ配布する予定なので、libclangのインストールいらなくなるとかそんな感じです。

ていうかYouCompleteMeに勝ちたいんです。ほぼ現時点で最速のあのYCM。あれに勝てないとdeopleteにC系ユーザーが移ってこない。
コアC++だからめっちゃ速いんです。書いたけど現存のより遅いの超悔しいんですよ。
だからあえてC++では書きません。(うそです。ぼくはC/C++は読める程度で書けません)

でも知らん言語をパースするのほんと苦行ですね。

How to

いつもの癖で前置きが長くなりましたが、じゃあどうやってソースを書くのか。

Why?

最近はdeoplete.nvimも有名になり(失礼)、割と有名な言語のソースは既に開発されてきてて、それ使えばOKになりつつあります。(See deoplete.nvim.txt)
じゃあなぜ書き方を紹介するのかというと、すごいエッジケースだけど毎回ドキュメントとか見て書くの超だるい、という言語ではないものも簡単に補完できるよということをお伝えしたかったからです。

自分で勝手に思っている最たるものは、大概のプロジェクトに必須なのにもうDSL化してるんじゃないかと思ってる 、.travis.yml
https://github.com/zchee/docker-machine-driver-xhyve/blob/master/.travis.yml#L3-L9
osx_image, language とかいくつあるか分からんしバージョン調べなきゃだし、
https://github.com/Dobiasd/FunctionalPlus/blob/master/.travis.yml#L9-L23 (Dobiasd/FunctionalPlus 引用)
apt sourceとかいちいち調べるより補完して欲しくないですか?

ということで.travim.ymlの補完の簡易版を例に書き方を紹介します。仮に名前は deoplete-travis
ちなみにもし既にそういうのあったら教えてください。全力でdeoplete.nvimポートします。

source location

とりあえずdeoplete.nvimが認識するディレクトリは固定されてて、

plugin-repository-root/rplugin/python3/deoplete/sources

なのです。
が、開発時は何かと面倒なんで、仕様が変わってなければ自分の $XDG_CONFIG_HOME/nvim 以下も読んでくれるはずです。
ので、

cd $XDG_CONFIG_HOME/nvim
mkdir -p rplugin/python3/deoplete/sources
touch rplugin/python3/deoplete/sources/deoplete-travis.py

template

適当なテンプレートです。
まぁいろいろまずいんですが、そういうのは後で良いのです。

基本的には、deoplete.nvimはソースに記述されていると期待するfunction名が決められており、所定のタイミングでそれらのfunctionをコールするような動作です。
Goだと interface に近いんですが、こういうのは一般的になんて言うんですか?

deoplete-template.py
import re
from .base import Base


class Source(Base):

    def __init__(self, vim):
        Base.__init__(self, vim)

        self.name = 'travis'
        self.mark = '[Travis]'
        self.filetypes = ['yaml']
        self.min_pattern_length = 0
        self.rank = 500

    def on_init(self, context):
        vars = context['vars']

        self.foo = vars.get('deoplete#sources#travis#foo', '')
        self.bar = vars.get('deoplete#sources#travis#bar', False)

        try:
            # init(load suorce) only work
            pass
        except Exception:
            # Ignore the error
            pass

    def on_event(self, context):
        if context['event'] == 'BufRead':
            try:
                # vim autocmd event based works
                pass
            except Exception:
                # Ignore the error
                pass

    def get_complete_position(self, context):
        m = re.search(r'\w*$', context['input'])
        return m.start() if m else -1

    def gather_candidates(self, context):
        # return dict in list ([{},{},{}...])

def __init__

ソースのロード時の初期化です。ここがコールされるのはPython側の仕様です(よね?)
いま書いてある self.*** 達はdeoplete.nvimで決められているソースのルール的なもので、deoplete.nvim.txtに詳しいのが書いてあります。
今のコードだと全部のymlファイルで補完が呼ばれますが、まぁとりあえず。

def on_init

ソースがロードされた時だけ呼ばれます。

vars = context['vars'] は呪文です。
あとは self.foo = vars.get('fooooo', '') でvimの g:fooooo 値が self.foo に入ります。無かったら後の '' とか False が入る感じです。

ここでも何かしら呼べるので、 os.path.isfile(fname) とかでファイルチェックしたり、例えばdeoplete-goだと clang.Config.set_library_file(self.libclang_path) でlibclangにライブラリのファイルパス渡したりしてます。

def on_event

autocmd ベースでなんかしたい時に使います。
なんか、BufWritePost でバッファのデータ取ったりとかでしょうか。

def get_complete_position

deoplete.nvimが補完の位置を取る時に呼ばれます。
これはinsertモードで文字打ってるとだいたい呼ばれます。(アバウト)

ちょっと説明難しいので、とりあえずそのままでいいです。

def gather_candidates

これが一番の重要な関数で、deoplete.nvimが補完リストを生成する時に毎回呼ばれます。
なので、この関数の処理をどれだけ軽くできるかで、そのソースの性能が決まってきます。

また、ひとつのリストの中に複数のdictがあるものをreturnで返す必要があります。
中のdictの仕様はvimのomnifuncと同じですので、:help complete-itemsで見れます。

例としては

    def gather_candidates(self, context):
      # some works...
      .
      .
      .
      return [
        {
            'word': '補完されるワード',
            'abbr': '補完リストに表示されるワード',
            'info': '追加情報、プレビュー用など',
            'kind': '要素の種類',
            'dup': 1 # デフォで 1 で良さそう
        },
           # Go言語のcgo補完とかだと、
        {
            'word': 'CBytes',
            'abbr': 'CBytes([]byte) unsafe.Pointer',
            'info': 'CBytes([]byte) unsafe.Pointer',
            'kind': 'function',
            'dup': 1
        },
        ]

.
.
.
.
.
朝だ…

すみません

あさになってしまいました。
ふっとばしてからここまでがやっとでした…!

ごめんなさい。

とりあえず、パッと思いついた .travis.yml のキーワードだけ補完するソースです。

deoplete-travis.py
import re
from .base import Base

KEYWORD = [
    'sudo', 'os', 'osx_image', 'language', 'env', 'global',
    'before_install', 'install', 'before_script', 'script',
    'after_success', 'after_failure',
    'before_deplop', 'deploy', 'after_deploy',
    'after_script'
]


class Source(Base):

    def __init__(self, vim):
        Base.__init__(self, vim)

        self.name = 'travis'
        self.mark = '[Travis]'
        self.filetypes = ['yaml']
        self.min_pattern_length = 0
        self.rank = 500

        self.result_keywords = [{'word': x} for x in KEYWORD]

    def on_init(self, context):
        vars = context['vars']

        self.foo = vars.get('deoplete#sources#travis#foo', '')
        self.bar = vars.get('deoplete#sources#travis#bar', False)

        try:
            # init(load suorce) only work
            pass
        except Exception:
            # Ignore the error
            pass

    def on_event(self, context):
        if context['event'] == 'BufRead':
            try:
                # vim autocmd event based works
                pass
            except Exception:
                # Ignore the error
                pass

    def get_complete_position(self, context):
        m = re.search(r'\w*$', context['input'])
        return m.start() if m else -1

    def gather_candidates(self, context):
        # return dict in list ([{},{},{}...])
        return self.result_keywords

これで test.yml を編集、そしてなんか文字をinsertすれば KEYWORD にあるものが補完されるはずです。
あとはAPIからなにか取ってくるなり、language のリストをディスクにキャッシュしておくなり…

ぼくもたまに補完結果をドキュメントの代わりにしたりしてます。
この50数行でとりあえず動いちゃう deoplete.nvim ソース、カジュアルにもっと書いていくとみんながいろいろなファイル補完できて良いと思います!

…しかしこの簡易版travisソース、ちょっとあれなんで…もうちょいちゃんとしたのに更新しますね…
いいね、かストック?すると通知がいくんでしょうか?
良かったらお願いします🙇

ではでは。良い補完ライフを!