こんにちは、 ピクシブ株式会社 Advent Calendar 2016 の10日目の記事を担当します、エンジニアのkanaです。弊社は様々なサービスを開発・運営していますが、私はその中でもイラストコミュニケーションサービスのpixivの開発に携わっています。
今回は日々の開発の中で気になったちょっとしたVimの話をします。
発端
コードを読み書きしてると「この便利メソッドが中でやってる処理がどうにも臭うぞ……」という場面にしばしば遭遇します。そういう時はタグジャンプを使います。
- universal-ctagsをインストールする
- プロジェクトのルートディレクトリで
ctags -R
を実行してtags
ファイルを生成する
という前準備を済ませたら、後は
というキーバインドを覚えるだけでコードツリーを高速で飛び回る事ができます。これで毎回 grep NankaAyashiiMethod
しなくても一発で目的の位置まで飛べるようになりました。
問題
タグジャンプを覚えると生産性は2000倍くらいに上昇するのですが、日々使っていると問題がある事に気付きます。 同名の定義に弱い のです。
例えばプロジェクトに以下のファイルがあるとしましょう:
<?php final class Illust { ... public static function getByIds(array $illust_ids) { ... } ... }
<?php final class Novel { ... public static function getByIds(array $novel_ids) { ... } ... }
<?php final class User { ... public static function getByIds(array $user_ids) { ... } ... }
そして小説の検索画面のコードを読んでいて以下のコードに遭遇したとしましょう:
<?php ... $query = [ /* ... 何だか凄そうなクエリー ... */ ]; $novel_ids = SearchEngine::get($query); $novels = Novel::getByIds($novel_ids); // ←何か気になる ...
ここで getByIds
にカーソルを合わせて <C-]>
を押下すると Novel::getByIds
の定義に飛……びません。 Illust::getByIds
の定義に飛びます。
タグジャンプはカーソル下の識別子の定義位置を tags
ファイルから探します。この例だと getByIds
の定義位置を tags
ファイルから探します。しかし getByIds
の定義は3つあります。このような場合、(ファイル名の辞書順で)最初の定義に飛びます。 Novel::getByIds(...)
とクラス名が自明な呼び出し方なので Novel::getByIds
の定義に飛んで欲しいところですが、そういう文脈は読み取ってくれません。
こういう場合は :tnext
や :tprevious
で他の同名の定義位置を行き来する事が出来ります。これで当座は凌げるのですが、何回も同じ状況に遭遇するにつれてストレスが溜まっていきます。
しかも、この位の問題なら既に誰かが解決してるだろうと思い検索してみても全然出てきません。PHP限定ならどうにかなるかと思って調べてみると、 phpcomplete.vim にそれっぽいオプションがある事は分かりましたが、実際に試してみるとイマイチでした(self::hoge()
や $this->hoge()
に対しては現在編集中のクラスと関係なく最初の定義に飛び、 $var->hoge()
に対しては $var
の型を無視して現在のファイル内の定義が優先される。 ClassName::hoge()
のみ適切な定義位置に飛ぶ)。これは困りました。
実装方針
出来合いの物が無いなら無いで仕方がないので自分でどうにかするしかありません。ざっくり実装してしまいましょう。完璧な精度を追い求めるとキリが無いので、実用上困らないレベルで押さえておくことにします。具体的には以下の条件にします:
- 対応する言語はPHPに限定する
- pixivの開発ではPHPを読み書きする頻度が圧倒的に高い為
- クラス名が比較的自明な
self::method()
と$this->method()
とClassName::method()
なら該当するクラスのメソッド定義に飛べるようにする- pixivのコーディング規約ではインスタンス作成は極力避ける事になっており、上記3点に対応すれば実務で困る事はまず起きない為
後はどう実装するかですが、これはざっくり3ステップに分解できます:
tags
ファイルの生成時にメソッドの定義位置だけでなくクラス名も含める- カーソル位置の識別子にマッチする全てのタグ情報を
tags
から取得する - 呼び出し方とどのクラス名を使うかを判定して適切な位置へジャンプする
これで方針が決まったので一つ一つ実装していきましょう。
実装
tags
ファイルの生成時にメソッドの定義位置だけでなくクラス名も含める
これは楽勝です。universal-ctagsを使っていれば何もする必要がありません。
$ ctags *.php $ grep getByIds tags getByIds lib/Illust.php /^ public static function getByIds($illust_ids)$/;" f class:Illust getByIds lib/Novel.php /^ public static function getByIds($novel_ids)$/;" f class:Novel getByIds lib/User.php /^ public static function getByIds($user_ids)$/;" f class:User
カーソル位置の識別子にマッチする全てのタグ情報を tags
から取得する
これも楽勝です。Vimでは
ので、
taglist(expand('<cword>'))
で目的のものが得られます。
呼び出し方とどのクラス名を使うかを判定して適切な位置へジャンプする
これは……楽勝ではなさそうです。とはいえ一手一手詰めて行けば直ぐできるでしょう。 必要そうな処理に分解して実装していく事にします。
対象の識別子の位置を特定する
function! s:_GetCwordStartPos() let cword = expand('<cword>') let cword_pattern = '\V' . escape(cword, '\') let cword_end_pos = searchpos(cword_pattern, 'ceW', line('.')) let cword_start_pos = searchpos(cword_pattern, 'bcW', line('.')) return cword_start_pos endfunction
カーソル下の識別子をそのまま検索して、結果のカーソル位置から識別子の位置を割り出そうという試みです。
識別子の直前のコードから呼び出し方を判定する
function! s:GuessClassName() let cursor_pos = getpos('.') let class_name = s:_GuessClassName() call setpos('.', cursor_pos) return class_name endfunction function! s:_GuessClassName() let line = getline('.') let prefix_end_index = s:_GetCwordStartPos()[1] - 2 let prefix = prefix_end_index >= 0 ? line[:prefix_end_index] : '' " 識別子の直前のコードが if prefix =~# '\<self::$' || prefix =~# '$this->$' " self:: または $this-> return s:_GetCurrentClassName() elseif prefix =~# '\<\k\+::$' " 多分クラス名 return matchstr(prefix, '\<\zs\k\+\ze::$') else " 不明 return '' endif endfunction
これは軽くパターンマッチするだけなので簡単ですね。
現在のクラス名を取得する
function s:_GetCurrentClassName() normal! 999[{ if search('\<class\>', 'bW') == 0 return '' endif normal! W return expand('<cword>') endif
[{
で釣り合いの取れた {
に移動できるので、これを繰り返せば一番外側 = クラス定義の {
にまで辿り着けるだろうという試みです。
複数ある候補の優先順位を調整する
function! s:ReorderTags(tags) let cword = expand('<cword>') let current_filename = expand('%:p') let exact_tags_in_current_file = [] let other_tags = [] for tag in a:tags if tag['name'] ==# cword && fnamemodify(tag['filename'], ':p') ==# current_filename call add(exact_tags_in_current_file, tag) else call add(other_tags, tag) endif endfor return exact_tags_in_current_file + other_tags endfunction
<C-]>
は現在のファイルに存在する定義に優先して飛ぼうとします。
tags
ファイル内の出現順序はジャンプ先の優先順序と一致しません。
なので taglist()
の並び順が実際の優先順序と一致するよう調整する必要があります。
複数ある候補の何番目に飛べば良いか判定して実際に飛ぶ
function! s:IikanjiNiJumpShite() let class_name = s:GuessClassName() let jump_count = '' if class_name != '' let tags = s:ReorderTags(taglist(expand('<cword>'))) for tag in tags if has_key(tag, 'class') && tag['class'] ==# class_name let jump_count = index(tags, tag) + 1 break endif endfor endif execute 'normal!' jump_count."\<C-]>" endfunction
- 文脈から飛びたいクラス名が判明している
- 実際に
tags
に該当クラスのメソッド定義がある
という状況ならその定義に飛ぶという試みです。
確信が持てない状況ならデフォルトの <C-]>
に任せます。
仕上げ
後は一連のコードを ~/.vim/after/ftplugin/php.vim
辺りに貼り付けて
nnoremap <buffer> <silent> <C-]> :<C-u>call <SID>IikanjiNiJumpShite()<CR>
も追加すれば良い感じにジャンプできるようになります。
動作確認
実際に試してみましょう:
ちゃんと動いてるようです。よかったよかった。
まとめ
これで明後日の定義に飛ぶ事が無くなって、より快適に開発ができるようになりました。やったぜ!
ピクシブ株式会社では、このように日々の開発効率を地道に上げるのが好きなエンジニア・アルバイトを募集しています。使用エディタは問いません。
明日はtadsanさんによるpixiv開発を支えるEmacsの話です。ご期待ください。