pixiv insideは移転しました! ≫ https://inside.pixiv.blog/

pixiv開発を支えるVim (タグジャンプ編)

こんにちは、 ピクシブ株式会社 Advent Calendar 2016 の10日目の記事を担当します、エンジニアのkanaです。弊社は様々なサービスを開発・運営していますが、私はその中でもイラストコミュニケーションサービスのpixivの開発に携わっています。

今回は日々の開発の中で気になったちょっとしたVimの話をします。

発端

コードを読み書きしてると「この便利メソッドが中でやってる処理がどうにも臭うぞ……」という場面にしばしば遭遇します。そういう時はタグジャンプを使います。

  1. universal-ctagsをインストールする
  2. プロジェクトのルートディレクトリで ctags -R を実行して tags ファイルを生成する

という前準備を済ませたら、後は

  • <C-]> で定義に飛ぶ
  • <C-t> で元の位置に戻る

というキーバインドを覚えるだけでコードツリーを高速で飛び回る事ができます。これで毎回 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() で識別子にマッチする全てのタグ情報を tags ファイルから取得でき、
  • <cword> でカーソル下の識別子を取得できる

ので、

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>

も追加すれば良い感じにジャンプできるようになります。

(→ 直ぐ使えるようにプラグイン化しておきました)

動作確認

実際に試してみましょう:

youtu.be

ちゃんと動いてるようです。よかったよかった。

まとめ

これで明後日の定義に飛ぶ事が無くなって、より快適に開発ができるようになりました。やったぜ!

ピクシブ株式会社では、このように日々の開発効率を地道に上げるのが好きなエンジニア・アルバイトを募集しています。使用エディタは問いません。

recruit.pixiv.net

明日はtadsanさんによるpixiv開発を支えるEmacsの話です。ご期待ください。