VimのtagfuncでLSPを使う

この記事はVim Advent Calendar 2019の6日目の記事です。

今年の4月にv8.1.1228tagfuncという機能が追加されました。
こちらは:tag:tselectなどのタグ系コマンド*1を実行した時、tagsファイルを検索する代わりに呼ばれる関数を設定するためのオプションです。

設定する関数の形式としては次のようなものです。

" pattern: タグ検索中に使用されたタグ識別子
" flags: 関数の挙動を制御するためのフラグのリスト
"   'c' -> ノーマルモードのコマンドで呼び出された
"   'i' -> インサートモードのタグ補完で呼び出された
" info: 以下の情報を持つ辞書
"   {
"     'buf_ffname': 'フルファイル名',
"     'user_data': 'カスタムデータ文字列',
"   }
function! MyTagFunc(pattern, flags, info)
  " タグ情報のリストを返す
  " もしくはv:nullを返すとtagsファイルが使われる
  return [
        \  {
        \    'name': 'タグ名',
        \    'filename': 'タグが定義されているファイル名',
        \    'cmd': 'ファイル内のタグを見つけるためのExコマンド',
        \    'kind': 'タグの種類(Optional)',
        \    'user_data': 'カスタムデータ文字列(Optional)',
        \  },
        \]
endfunction

ヘルプにはtaglist()の結果を並べ替える関数が例として記載されていますが、LSPを使ってタグ情報のリストを生成できればタグジャンプがすごく便利になりそうです。

VimConf 2019ではsettagstack()を使ってタグスタックを直接操作する方法を紹介しましたが、こちらは、

  • タグ系のコマンドを使うために、
    • キーマップの変更が必須
    • Exコマンドはユーザ定義コマンドを定義する必要がある
  • Vim scriptでタグスタックの管理をしなければならない

といったものでした。

これに対し、tagfuncはオプションを設定するだけで、

  • 標準のキーマップ、Exコマンドがそのまま使える
    • タグ補完の時にも使える
  • タグスタックの管理をVim本体に任せられる

と、すごく良さそうです。

試しに任意の文字列で検索するworkspace/symbolvim-lspから呼び出してみます。

function! MyTagFunc(pattern, flags, info) abort
  let l:servers = filter(lsp#get_whitelisted_servers(),
      \ 'lsp#capabilities#has_workspace_symbol_provider(v:val)')
  if len(l:servers) == 0
    echoerr 'not supported: workspace/symbol'
    return []
  endif

  " タグ補完の場合はa:patternの先頭に \< がつくので削除する
  " (サーバ側が正規表現に対応していないかもしれないので...)
  let l:query = a:flags =~# 'i' ?
      \ substitute(a:pattern, '^\\<', '', '') : a:pattern

  let l:ctx = {'result': []}
  for l:server in l:servers
    call lsp#send_request(l:server, {
        \ 'method': 'workspace/symbol',
        \ 'params': {
        \   'query': l:query,
        \ },
        \ 'sync': 1,
        \ 'on_notification': function('s:make_taglist', [l:ctx]),
        \ })
  endfor
  return l:ctx.result
endfunction

" ctx.resultにタグ情報を追加する
func s:make_taglist(ctx, data) abort
  for result in a:data.response.result
    call add(a:ctx.result, {
        \ 'name': result.name,
        \ 'filename': lsp#utils#uri_to_path(result.location.uri),
        \ 'cmd': string(result.location.range.start.line + 1),
        \ })
  endfor
endfunction

上記関数をset tagfunc=MyTagFuncで設定すると、ctagsとほぼ同じ使い勝手のタグジャンプができるようになります。

ただ、workspace/symboltextDocument/definitionなどのようなカーソル位置を起点に対象を探すメソッドとは異なり、100%目当ての場所にジャンプできるとは限りません。

確実にジャンプしたい時はtextDocument/definitionを呼べるような実装にできれば良いのですが、MyTagFuncの引数からだと、それがExコマンドの引数に指定したタグ名なのか、カーソル位置のキーワードなのかを判別できないので、メソッドの呼び分けをするのは非常に困難です。

そのため、tagfuncに設定する関数で全部をやるのではなく、

  • CTRL-]CTRL-W ]textDocument/definition*2を呼ぶ
    • 定義元にジャンプしたい
  • それ以外はtagfuncからworkspace/symbolを呼ぶ
    • 同名の別メソッド/関数やインタフェースの実体などを探したい

というように、用途に応じて使い分けるのがオススメです!

結局settagstack()tagfuncを両方使うことに。。。

[2019/12/06 17:00追記]
flagsで判別可能でした。
@presukuさん、ありがとうございます!

tagfuncだけで完結させるにはMyTagFuncを次のようにします。

function! MyTagFunc(pattern, flags, info) abort
  let l:ctx = {'result': []}

  if a:flags ==# 'c'
    " ノーマルモード(CTRL-]など)の場合はカーソル位置の定義に飛ぶ
    let l:method = 'textDocument/definition'
    let l:servers = filter(lsp#get_whitelisted_servers(),
        \ 'lsp#capabilities#has_definition_provider(v:val)')
    let l:ctx.pattern = a:pattern
    let l:params = {
        \   'textDocument': lsp#get_text_document_identifier(),
        \   'position': lsp#get_position(),
        \ }
  else
    " Exコマンド(:tag {name}など)やタグ補完の場合はa:patternで探す
    let l:method= 'workspace/symbol'
    let l:servers = filter(lsp#get_whitelisted_servers(),
        \ 'lsp#capabilities#has_workspace_symbol_provider(v:val)')

    " タグ補完の場合はa:patternの先頭に \< がつくので削除する
    " (サーバ側が正規表現に対応していないかもしれないので...)
    let l:query = a:flags =~# 'i' ?
        \ substitute(a:pattern, '^\\<', '', '') : a:pattern
    let l:params = {
        \   'query': l:query,
        \ }
  endif

  if len(l:servers) == 0
    echoerr 'not supported: ' . l:method
    return []
  endif

  for l:server in l:servers
    call lsp#send_request(l:server, {
        \ 'method': l:method,
        \ 'params': l:params,
        \ 'sync': 1,
        \ 'on_notification': function('s:make_taglist', [l:ctx, l:method]),
        \ })
  endfor
  return l:ctx.result
endfunction

" ctx.resultにタグ情報を追加する
func s:make_taglist(ctx, method, data) abort
  for result in a:data.response.result
    if a:method ==# 'workspace/symbol'
      let l:name = result.name
      let l:location = result.location
    else
      let l:name = a:ctx.pattern
      let l:location = result
    endif
    call add(a:ctx.result, {
        \ 'name': l:name,
        \ 'filename': lsp#utils#uri_to_path(l:location.uri),
        \ 'cmd': string(l:location.range.start.line + 1),
        \ })
  endfor
endfunction

ただ一番利用頻度の高いgoplsworkspace/symbolに対応していないのでまだ常用できていません。。。