15
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

vim-go に unite/denite source を作って contribute した話

Posted at

作って PR して入れてもらいました。そもそも vim-go にそんな機能あるの?という話から unite/denite source の作り方まで簡単に解説してみます。

まず完成形から。以下は denite での例を示しますが、unite でも見た目は一緒です。

Denite decls —— ディレクトリ(パッケージ)に含まれる関数と型を列挙します。
スクリーンショット 2017-12-11 20.11.16.png

尚、前提として Vim8(denite 使うなら Python3 も)を使ってるものとします。NeoVim でも多分動きますが僕は使ってないので試してません。

又、この記事では vim-go / unite / denite のインストール・設定については解説しません。以下のような優れた記事がありますのでそちらをご参照ください。

GoDecls / GoDeclsDir って知ってますか?

Show all function and type declarations for the current [file / dir].

と doc には書いてあります。要するに、ファイル・ディレクトリに含まれる関数と型を列挙してくれる機能です。元々これは ctrlp.vim 使ってる人のための機能(最近 fzf サポートも入った)だったので、unite / denite 派の僕には使えませんでした。

GoDecls —— ファイルに含まれる関数と型を列挙します。
スクリーンショット 2017-12-11 20.08.49.png
GoDeclsDir —— ディレクトリ(パッケージ)に含まれる関数と型を列挙します。
スクリーンショット 2017-12-11 20.09.01.png

もっとも、unite / denite には似た機能があります。outline source です。

Denite outline —— ファイルに含まれる関数と型を列挙します。
スクリーンショット 2017-12-11 20.11.04.png

GoDecls と似てますね。これは ctags コマンドを使って関数・型を列挙してくれるものです。普通の言語ならこれで十分なのですが、Go ではこれでは不足でして……なぜかというと、Go では関数・型の名前空間(パッケージ)がディレクトリ単位だからです。

ディレクトリ内のソース全部の関数・型を列挙したい!

今まで Go のソースを読んでるとき「このパッケージにこんな関数なかったっけ……」と思って探すには、ディレクトリ内で grep するしか方法がありませんでした(IDE 使ってる場合を除く)。vim-go の作者も同じ思いだったのでしょう。これを解決するため、便利ツールを作って解決してくれました。

motion は vim-go インストール時に行った :GoInstallBinaries コマンドでインストールされてます(やってますよね?)。適当なディレクトリでコマンドを叩くと、以下のような出力が取れます。

$ cd ~/.go/src/github.com/golang/appengine
$ motion -mode decls -include func,type -dir .
{
        "mode": "decls",
        "decls": [
                {
                        "keyword": "func",
                        "ident": "TestValidGeoPoint",
                        "full": "func TestValidGeoPoint(t *testing.T)",
                        "filename": "appengine_test.go",
                        "line": 11,
                        "col": 1
                },
                {
                        "keyword": "func",
                        "ident": "BackgroundContext",
                        "full": "func BackgroundContext() context.Context",
                        "filename": "appengine_vm.go",
                        "line": 18,
                        "col": 1
                },

...

JSON 以外に Vim 形式での出力も可能です。

$ motion -mode decls -include func,type -dir .
{"mode": "decls", "decls": [{"keyword": "func", "ident": "IsTimeoutError", "full": "func IsTimeoutError(err error) bool", "filename": "timeout.go", "line": 10, "col": 1}, ...

ここまで出来てれば簡単ですね! このコマンドの出力を読み取って、いい感じに成形して表示すればいい訳です。

unite source の作り方

もうありとあらゆるところで解説されていることですから詳しく書くこともないでしょう。そもそも unite.vim 自体がすでに開発が終了してますからね。

Shougo/unite.vim: Unite and create user interfaces
Note: Active development on unite.vim has stopped. The only future changes will be bug fixes.

unite source を作るのに unite.vim のソースをいじる必要はありません。vim-go レポジトリの中で規定のパスにソースを置くだけです。完全なソースはリンク先を見ていただくとして要点だけ書いていきます。

source 属性の定義

unite source でまず必要なのは source の名前などを定義する辞書です。

let s:source = {
      \ 'name': 'decls',  " ソースの名前
      \ 'description': 'GoDecls implementation for unite', " ソースの説明
      \ 'syntax': 'uniteSource__Decls', " 候補を列挙する際のシンタックスハイライト
      \ 'action_table': {},
      \ 'hooks': {},
      \ }

この中で name だけが必須です。今回は候補を綺麗に色づけしたかったので syntax も設定しています。

候補の列挙

そして source のキモとなるのが gather_candidates 関数です。名前の通り、候補を列挙する関数です。

function! s:source.gather_candidates(args, context) abort
  let l:bin_path = go#path#CheckBinPath('motion')
  if empty(l:bin_path)
    return []
  endif

  let l:path = expand(get(a:args, 0, '%:p:h'))
  if isdirectory(l:path)
    let l:mode = 'dir'
  elseif filereadable(l:path)
    let l:mode = 'file'
  else
    return []
  endif

  let l:include = get(g:, 'go_decls_includes', 'func,type')
  let l:command = printf('%s -format vim -mode decls -include %s -%s %s', l:bin_path, l:include, l:mode, shellescape(l:path))
  let l:candidates = []
  try
    let l:result = eval(unite#util#system(l:command))
    let l:candidates = get(l:result, 'decls', [])
  catch
    call unite#print_source_error(['command returned invalid response.', v:exception], s:source.name)
  endtry

  return map(l:candidates, "{
        \ 'word': printf('%s :%d :%s', fnamemodify(v:val.filename, ':~:.'), v:val.line, v:val.full),
        \ 'kind': 'jump_list',
        \ 'action__path': v:val.filename,
        \ 'action__line': v:val.line,
        \ 'action__col': v:val.col,
        \ }")
endfunction

長いですが、大半は motion コマンドに渡すオプションを作成しています。大事なのは3点。

1. motion コマンドのパス

let l:bin_path = go#path#CheckBinPath('motion')

これは vim-go の便利関数で求められます。

2. コマンドの実行とエラー処理

try
  let l:result = eval(unite#util#system(l:command))
  let l:candidates = get(l:result, 'decls', [])
catch
  call unite#print_source_error(['command returned invalid response.', v:exception], s:source.name)
endtry

実行には unite#util#system()、エラーを吐くときは unite#print_source_error() という便利関数を使いましょう。

3. 候補の作成

return map(l:candidates, "{
      \ 'word': printf('%s :%d :%s', fnamemodify(v:val.filename, ':~:.'), v:val.line, v:val.full),
      \ 'kind': 'jump_list',
      \ 'action__path': v:val.filename,
      \ 'action__line': v:val.line,
      \ 'action__col': v:val.col,
      \ }")

各候補の情報を設定します。大体は見たまんまです。

項目 説明
word 画面に表示される文字列。
kind 候補の“kind”(後述)。
action__path 候補を開く際に使うパス。
action__line ファイルの中で候補が存在する行。
action__col ファイルの中で候補が存在する桁。

kind がちょっと特殊ですね。これは名前の通り候補の「種別」を表すものです。「種別」はファイル、ディレクトリ、Vim のバッファー、URL などなどいろいろ用意されており、それぞれの種別によって取れる“action”が異なってきます。この辺はドキュメントが非常に詳しいので :h unite-kinds で読んでみてください。

今回はファイルの特定の場所に飛びたいだけですので jump_list という kind を使いました。

候補のシンタックスハイライト

実はここが一番大変でした。Vim のシンタックスハイライト機構は非常に多機能なのですが、正直言ってその記法は複雑怪奇です。今回説明する以外にも色んな機能がありますので詳細は :h syntax を見てみてください。

unite では hooks.on_syntax() 関数を定義し、その中で Vim のシンタックスハイライト関数を呼んで設定します。

function! s:source.hooks.on_syntax(args, context) abort
  syntax match uniteSource__Decls_Filepath /[^:]*\ze:/ contained containedin=uniteSource__Decls
  syntax match uniteSource__Decls_Line /\d\+\ze :/ contained containedin=uniteSource__Decls
  syntax match uniteSource__Decls_WholeFunction /\vfunc %(\([^)]+\) )?[^(]+/ contained containedin=uniteSource__Decls
  syntax match uniteSource__Decls_Function /\S\+\ze(/ contained containedin=uniteSource__Decls_WholeFunction
  syntax match uniteSource__Decls_WholeType /type \S\+/ contained containedin=uniteSource__Decls
  syntax match uniteSource__Decls_Type /\v( )@<=\S+/ contained containedin=uniteSource__Decls_WholeType

各候補には予め uniteSource__(ソース名) という syntax region が設定されています。これを親として(containedin=uniteSource__Decls)、各パーツを正規表現で設定します。一部は孫 region まで作ってます。

  highlight default link uniteSource__Decls_Filepath Comment
  highlight default link uniteSource__Decls_Line LineNr
  highlight default link uniteSource__Decls_Function Function
  highlight default link uniteSource__Decls_Type Type

各パーツに色を設定します。通常は新規の色を設定するのではなく、カラースキームと連携させた方が使い勝手がいいでしょう。highlight default link は文字通り既存の色と“リンク”させてこれを実現してくれます。関数名には Function の色を使ってくれる訳ですね。

  syntax match uniteSource__Decls_Separator /:/ contained containedin=uniteSource__Decls conceal
  syntax match uniteSource__Decls_SeparatorFunction /func / contained containedin=uniteSource__Decls_WholeFunction conceal
  syntax match uniteSource__Decls_SeparatorType /type / contained containedin=uniteSource__Decls_WholeType conceal
endfunction

最後に、画面から消したい文字列を conceal 機能で隠します。func とか自明ですからね。

denite source の作り方

今回の記事はそもそも denite の入門記事を書こうと思って始めたのですが、unite source と対比させた方が分かりやすいだろうと思いましてそっちを先に詳しく載せました。

denite source の一番の特徴は denite のそれです。つまり Python3 で書いてあるのです。そりゃ Vimscript も別に嫌いじゃないし、僕も denite のために Python3 触ったくらいなので特に思い入れもなかったんですが、unite source で散々苦労した後なので感慨もひとしおでした。denite source というか、Python3 のいいところは

  • 何でもできるし、道具も最初から揃ってる。
  • 開発を助けるツールがたくさんある。
  • 分からないことは Google 先生に聞けば一瞬で分かる。

に尽きます(特に最後)。やっぱりメジャーな言語はいいなあ……

更にこれは denite に限った特徴ですが、denite 本体が Python 3.5+ を必須として書かれています。今回のような拡張機能を書くときに古い環境のことを考えなくていいのはいいこと(?)です。

いい加減本題に入ります。denite も unite と同様、特定のパスにソースを置くとそれを実行時に読み込んでくれます。今回は vim-go の中のここにソースを置いています。

unite の時と同じく要点だけを書いていきましょう。

Source クラスの定義

class Source(Base):

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

        self.name = 'decls'
        self.kind = 'file'

unite の時と比べるとずいぶん簡潔……というか分かりやすい。Vimscript ではクラスとかないのでいろいろ黒魔術が使われてましたからね……。

候補の列挙

これは gather_candidates メソッドで行っています。やることは unite の時と同じですのでここも要点だけにしましょう。

    def gather_candidates(self, context):
        bin_path = self.vim.call('go#path#CheckBinPath', 'motion')
        if bin_path == '':
            return []

Python3 から Vim のユーザー定義関数を呼び出す際は self.vim.call() メソッドを使います。ここでは unite の時と同じく motion コマンドのパスを検索しています。

        expand = context['args'][0] if context['args'] else '%:p:h'
        target = self.vim.funcs.expand(expand)

Vim の関数のうち expand() のような Vim 本体の関数ならばショートカットが用意されています。self.vim.funcs.(関数名)() です。

        try:
            cmd = subprocess.run(command, stdout=subprocess.PIPE, check=True)
        except subprocess.CalledProcessError as err:
            denite.util.error(self.vim,
                              'command returned invalid response: ' + str(err))
            return []

        txt = cmd.stdout.decode('utf-8')
        output = json.loads(txt, encoding='utf-8')

コマンドの出力結果(JSON)をデコードしています。denite.util.error() は denite に用意されたエラー出力関数です。

        def make_candidates(row):
            name = self.vim.funcs.fnamemodify(row['filename'], ':~:.')
            return {
                'word': '{0} :{1} :{2}'.format(name, row['line'], row['full']),
                'action__path': row['filename'],
                'action__line': row['line'],
                'action__col': row['col'],
                }
        return list(map(make_candidates, output['decls']))

最後に候補を成形して終わり。unite の時と一緒なので特に説明は要らんと思いますが一つ、kind だけが抜けてますね。unite では jump_list となっていた kind が denite では file に変わっています。今回のソースではクラスのプロパティで self.kind = 'file' しちゃってますので、ここでは特に指定しなくて大丈夫です。

候補のシンタックスハイライト

DECLS_SYNTAX_HIGHLIGHT = [
    {'name': 'FilePath', 're': r'[^:]*\ze:', 'link': 'Comment'},
    {'name': 'Line', 're': r'\d\+\ze :', 'link': 'LineNr'},
    {'name': 'WholeFunction', 're': r'\vfunc %(\([^)]+\) )?[^(]+'},
    {'name': 'Function', 'parent': 'WholeFunction',
     're': r'\S\+\ze(', 'link': 'Function'},
    {'name': 'WholeType', 're': r'type \S\+'},
    {'name': 'Type', 'parent': 'WholeType',
     're': r'\v( )@<=\S+', 'link': 'Type'},
    {'name': 'Separator', 're': r':', 'conceal': True},
    {'name': 'SeparatorFunction', 'parent': 'WholeFunction',
     're': r'func ', 'conceal': True},
    {'name': 'SeparatorType', 'parent': 'WholeType',
     're': r'type ', 'conceal': True},
    ]

...

    def highlight(self):
        for syn in DECLS_SYNTAX_HIGHLIGHT:
            containedin = self.syntax_name
            containedin += '_' + syn['parent'] if 'parent' in syn else ''
            conceal = ' conceal' if 'conceal' in syn else ''

            self.vim.command(
                'syntax match {0}_{1} /{2}/ contained containedin={3}{4}'
                .format(self.syntax_name, syn['name'], syn['re'],
                        containedin, conceal))

            if 'link' in syn:
                self.vim.command('highlight default link {0}_{1} {2}'.format(
                    self.syntax_name, syn['name'], syn['link']))

Vim の「関数」ではなく「ex コマンド」を呼び出す場合は self.vim.command() を使います。やってることは unite の時と一緒です。

ここだけは Python っぽく書いたら逆に見づらくなったかもしんない……。素直に self.vim.command() を羅列した方がいいのかも。


以上です。今回説明した基本だけ分かっとけば他のコマンド出力から source を作るのも簡単だと思いますので、皆さんもいろいろ試してみてください。

15
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?