Vim scriptでプラグインを作ろう 〜 Vimはいいぞ!ゴリラと学ぶVim講座(8)

こんにちは、ゴリラです。

ゴリラと学ぶVim講座の連載の最終回となりました。これまでみなさんにVimの基本的な操作から筆者のおすすめプラグインまで、色々と解説してきました。最終回はみなさんのお待ちかねのプラグインの作成について解説していきます。

前回の記事ではプラグインがVim scriptを使って作られていることを解説しました。本記事はセッション管理プラグインを作るというテーマを元に、プラグイン作成で使用する最低限のVim script構文と作成方法について解説していきます。長いのですが、ぜひ読みながらご自身でコードを書いてプラグインを完成させていきましょう。

Vim scriptの基本

Vim scriptの実行

Vim scriptはExコマンド(:から始まるコマンド)の集まりです。Exコマンド群をファイルに記述して、それを:source {file}で読み込むことで実行できます。ファイルの拡張子は.vimとつけるのが一般的です。一例ですが、次のコードをsample.vimというファイルに保存して、:source sample.vimを実行するとコマンドラインにgorillaが出力されます。

" 結果 => gorilla
echo 'gorilla'

コメント

Vim scriptでは次のように「"」から行末までがコメントとして解釈されます。複数行をコメントにする場合はそれぞれの行に「"」が必要になります。

" この行はコメント、処理されない

データ型

Vim scriptで主に扱うことができるデータ型は次になります。

データ型
数値(整数、小数、16進数など) 1, 1.5, 0x1E
文字列 'a', "a"
リスト [1, 2, 3]
辞書 {'name': 'godzilla'}

 文字列

Vim scriptで「'」と「"」で囲ったテキストは文字列として解釈されます。「'」はテキストをそのまま文字列として扱いますが、「"」はタブを表す「\t」といった特殊な文字列を扱うことができます。

例えば、echo "hello\tgorilla"hellogorillaの間にタブが入りますが、echo 'hello\tgorilla'はそのままhello\tgorillaが出力されます。

" 結果 => hello gorilla
echo "hello\tgorilla"

" 結果 => hello\tgorilla
echo 'hello\tgorilla'

文字列の結合

.」で文字列同士を結合できます。例えば'hello ' . 'gorilla'hello gorillaになります。

" 結果 => hello gorilla
echo 'hello ' . 'gorilla'

変数

変数の宣言と代入は次のようにletを使用します。

let name = 'gorilla'
" 結果 => gorilla
echo name

宣言ではなく変数に値を代入するときもletを使用する必要があります。

" 再代入もletが必要
let name = 'gorilla'
let name = 'dog'
" 結果 => dog
echo name

変数名

変数名はアルファベット、数字、アンダースコアを使用できます。ただし数字で開始することができません。OKとNGの例はそれぞれ次になります。

OK NG
name 2hand
_name tow-hand
GODZILLA gori+lla

スコープ

変数や後述する関数にはスコープがあり、接頭子によってスコープが変わります。主なスコープは次になります。プラグインの節で掲示するソースでは関数内で接頭子をつけていないのですが、関数内でl:を省略した場合は暗黙的に関数ローカル変数にアクセスします。スコープについてより詳しく知りたい方は:h internal-variablesを参照ください。

接頭子 スコープ
g: グローバルスコープ、どこからも利用可能
s: スクリプトスコープ、スクリプトファイル内のみ使用可能
l: 関数ローカルスコープ、関数内のみ使用可能
a: 関数の引数、関数内のみ使用可能
v: グローバルスコープ、Vimがあらかじめ定義している変数

辞書

Vim scriptにおける辞書はいわゆる連想配列で、1要素はkeyvalueからになります。keyは文字列でなければいけません。次は辞書の例になります。

let animal = {'name': 'gorilla', 'age': 27}
" 結果 => {'age': '27', 'name': 'gorilla'}
echo animal

辞書に新たな要素を追加する方法は{dict}.{key} = {value}{dict}[{key}] = {value}があります。

let animal = {}
let animal.name = 'gorilla'
let animal['age'] = 27

" 結果 => {'age': 27, 'name': 'gorilla'}
echo animal

要素を削除するにはremove({dict}, {key})関数を使います。

let animal = {}
let animal.name = 'gorilla'
let animal['age'] = 27

call remove(animal, 'age')

" 結果 => {'name': 'gorilla'}
echo animal

リスト

Vim scriptでは次にように定義できます。型がないため、異なるデータ型を保持できます。

let list = ['cat', 10, {'name': 'gorilla'}]

要素を取り出すにはインデックスを指定するか、get({target}, {idx}, {default})を使用します。{target}に存在しないインデックスを{idx}に指定した場合は、{default}で指定した値を返します。

let list = ['cat', 10, {'name': 'gorilla'}]

" 結果 => cat
echo list[0]

" 結果 => NONE
echo get(list, 3, 'NONE')

余談ですが、get({targeet}, {idx}, {default})は辞書にも使えます。その場合は{target}は辞書、{idx}はキーを指定します。キーが存在しない場合は{default}が返されます。

let dict = {'name': 'gorilla'}
" 結果 => gorilla
echo get(dict, 'name', 'cat')

また、リストはjoin({list}, {sep})を使用することで{list}の要素を{sep}を使って結合し1つの文字列として返します。

let list = ['hello', 'my', 'name', 'is', 'gorilla']

" 結果 => hello my name is gorilla
echo join(list, ' ')

if文

if文の基本形は次になります。{expr}は式で結果が1の場合はtrue、0の場合はfalseになります。

if {expr}
  " do something
elseif {expr}
  " do something
else
  " do something
endif

三項演算子

三項演算子は次のようになります。

" a の評価結果が1(true)ならb、0(false)ならc
a ? b : c

論理積・和

Vim scriptでの論理積は「&&」、論理和は「||」で表します。例を示します。

" 結果 => 1(true)
echo 1 && 1
" 結果 => 0(false)
echo 1 && 0
" 結果 => 1(true)
echo 1 || 0
" 結果 => 0(false)
echo 0 || 0

比較演算子

Vim scriptにおける主な比較演算子は次になります。ここで注意する必要があるのはignorecaseの設定次第で動きが変わる演算子があることです。

意味 演算子 大小文字を区別する演算子
等しい == ==#
等しくない != !=#
より大きい > >#
より大きいか等しい >= >=#
より小さい < <#
より小さいか等しい <= <=#
同一インスタンス is is#
異なるインスタンス isnot isnot#

比較演算子に「#」を付けずに使用した場合は、ignorecaseの設定次第で動きが変わります。ignorecaseは、大文字小文字の区別を無視するオプションです。デフォルトでは無効になっているので、大文字小文字を区別して比較します。ignorecaseを有効にするには :set ignorecase を設定します。

ユーザの設定によって比較処理の動きが変わらないように、基本的に「#」で大文字小文字を区別するようにしておくと良いです。

バッファについて

バッファはメモリ上にロードされたファイルのことです。バッファには名前と番号があり、名前はファイル名で、番号は作成された順で割り当てられます。バッファは:bwipeoutで明示的に削除するかVimを終了しない限り、メモリに残ります。

バッファの存在チェック

bufexists({expr}){expr}のバッファがあるかを確認できます。ある場合はtrue、なければfalseが返ります。

バッファのテキストを取得

カレントバッファからテキストを取得するにはgetline({lnum}, {end})を使用します。{end}を指定しない場合は{lnum}で指定した行だけを取得します。

" 結果 => 1行目のテキストが出力される
echo getline(1)

" 結果 => 1~3行目のテキストがリストで取得できる
echo getline(1, 3)

バッファにテキストを挿入

カレントバッファにテキストを挿入するにはsetline({lnum}, {text})を使用します。{text}はリストの場合は、{lnum}行目とそれ以降の行に要素が挿入されます。

" 結果 => 1行目に my name is gorilla が挿入される
call setline(1, 'my name is gorilla')

" 結果 => 1行目にmy、2行目にnameが挿入される
call setline(1, ['my', 'name'])

ウィンドウについて

ウィンドウはバッファを表示するための領域です。ウィンドウにはIDが割り当てられます。複数のウィンドウで複数のバッファを表示できます。:qといったコマンドではウィンドウを閉じるだけなのでバッファは残ります。

ウィンドウIDを取得

win_getid()で現在のウィンドウIDを取得できます。引数を受け取ることもできるので、詳しく知りたい方は:h win_getid()を参照してください。

ウィンドウに移動

win_gotoid({expr}){expr}のIDのウィンドウに移動します。

バッファが表示されているウィンドウのIDを取得

そのバッファが表示されているウィンドウのIDを取得にはbufwinid({expr})を使います。ウィンドウIDがある場合はIDを返し、なければ-1が返ります。バッファが表示されているウィンドウに移動するときはbufwinid()win_gotoid()の組み合わせを使います。

関数

Vim scriptでは関数を使用して処理をまとめることができます。関数の定義は次のようになります。

function! Echo(msg) abort
  echo a:msg
endfunction

関数はfunctionendfunctionで囲います。処理はその間に記述します。

!とabort

!は同名の関数がある場合は上書きします。abortは関数内でエラーが発生した場合、そこで処理を終了します。Vim scriptはデフォルトでエラーがあっても処理が継続されるため基本的にabortをつけると良いです。

引数

引数を使用するときはa:スコープ接頭子を付ける必要があります。

戻り値

return {expr}{expr}の評価結果を返すことができます。

function! MyName() abort
  return 'gorilla'
endfunction

" 結果 => gorilla
echo MyName()

Exコマンド実行

execute {expr} ..{expr}の評価結果の文字列をExコマンドとして実行できます。複数の引数がある場合、それらはスペースで結合されます。

" 結果 => godzilla
execute 'echo' '"godzilla"'

" 結果 => gorilla godzilla
execute 'echo' '"gorilla"' '"godzilla"'

外部コマンド実行

Vim scriptで外部コマンドを実行する方法の1つはsystem({expr}, {input})です。{epxr}は実行したいコマンドの文字列、{input}省略可能で指定した場合は文字列をそのままコマンドの標準入力として渡します

" 結果 => my name is gorilla
echo system('echo "my name is gorilla"')

" 結果 => my name is gorilla
echo system('cat', 'my name is gorilla')

Lambda

{ 引数 -> 式 } という形でLambdaを書くことができます。例えばsort関数を用いた数値のソートです。sort()は第2引数にLambdaを受け取ることができて、Lambdaで書いた自前のソート処理を使用できます

let F = {a, b -> a - b}
" 結果 => [1, 2, 3, 4, 7]
echo sort([3, 7, 2, 1, 4], F)

プラグイン

基本的な構文を解説したので、いよいよプラグインを作っていきましょう。
プラグインを作るときに、まずプラグインの名前を決める必要がありますが、ここで1点注意です。つけようとしている名前のプラグインがすでにあるかどうかを調べておきましょう。名前が被ってしまうとプラグインがバッティングしてしまい、動かなくなる可能性があります。

プラグインを検索するときはVimAwesomeをまず使いましょう。そこで見つからなければGoogleで検索しましょう。今回作成するプラグインはsession.vimという名前にしますが、もしすでに同じ名前のプラグインをインストールされているなら、thinca/vimというdockerのイメージを使って、プラグインが入っていない環境で作業を進めてください。

セッション管理プラグイン

今回作成するプラグインはVimのセッション機能を少し便利にするプラグインで、仕様は以下になります。

  •  let g:session_path = {path}でセッション保存先を設定できる(必須オプション)
  •  :SessionCreate {name}{name}の名前でセッションファイルを保存できる
  •  :SessionListでセッション一覧をバッファに表示し、Enterを押下するとカーソル上にあるセッションをロードできる

ディレクトリ構成

プラグインの基本的なディレクトリ構成は次のようになります。*.vimはスクリプトファイルと呼びます。

session.vim/
├── autoload
│   └── session.vim
├── doc
│   └── session.txt
└── plugin
     └── session.vim

それぞれのディレクトリについて解説していきます。

pluginディレクトリについて

plugin配下はプラグインが提供するExコマンドやオプションを記述したスクリプトファイルを置きます。メインの処理はここではなく後述のautoloadに記述します。スクリプトファイル名はプラグイン名と同じにします。

autoloadディレクトリについて

autoload配下はメインの処理を記述したスクリプトファイルを置きます。配下のスクリプトファイルはVim起動時ではなく、コマンド実行時に一度だけ読み込まれます。また、スクリプトファイル名はプラグイン名と同じにします。

plugin配下から呼ぶことができる関数をautoload配下に定義する時、ファイル名#関数名()という命名規則に従う必要があります。これはpluginで定義したコマンドを実行する時にautoload配下のどのファイルのどの関数を呼べば良いのかを知る必要があるからです。そのため、プラグイン名が被るとautoload配下のスクリプトファイル名も被り、最悪違うプラグインの関数で上書きされる可能性があります。これがプラグイン名が被らないようにする必要がある理由です。

docディレクトリについて

doc配下はヘルプファイルを置きます。:h SessionListというようにコマンドのヘルプを引けるようにするためです。基本的にヘルプに書かれているものは公式、書かれていないものは非公式の機能になります。プラグインを公開する時はREADME.mdだけでなくヘルプを書きましょう。

開発の大まかな流れ

少し難しい説明が続きましたが、ここから先はプラグインを実際に作っていきます。作りながらゆっくり理解していきましょう。
大まかには以下の流れになります。{packpath}については前回の記事を参照ください。

  1. ディレクトリ構成通りにファイルとディレクトリを作成(docを除く)
  2. {packpath}/pack/plugins/start 配下にプラグインディレクトリを配置
  3. autoload/session.vim に、セッション保存、一覧取得とロードの処理をを実装
  4. plugin/session.vim にコマンドを定義
  5. doc/session.txt にヘルプを記述

では、作っていきましょう。

autoload/session.vimの実装

セッション保存処理

まずg:session_pathにセッションファイルを作成する関数を実装します。g:session_pathを予めてvimrcに記述してvimrcを再ロードしておいてください。

" 現在のパスからパスセパレータを取得しています。
" ここはそれほど重要ではないので、おまじないと考えておいてください。
" 詳しく知りたい方は`:h fnamemodify()`を参照してください。
let s:sep = fnamemodify('.', ':p')[-1:]

function! session#create_session(file) abort
  " SessionCreateの引数をfileで受け取れるようにします。
  " join()でセッションファイル保存先へのフルパスを生成し、mksession!でセッションファイルを作成します。
  execute 'mksession!' join([g:session_path, a:file], s:sep)

  " redrawで画面を再描画してメッセージを出力します。
  redraw
  echo 'session.vim: created'
endfunction

実装後、autoload/session.vimを:sourceでロード後、:call session#create_session('test')を実行してください。g:session_path配下にtestという名前のファイルが作られていたらOKです。

セッションロード処理

セッション一覧でEnterを押下したときにセッションをロードする関数を用意します。

function! session#load_session(file) abort
  " `:source`で渡されるセッションファイルをロードします。
  execute 'source' join([g:session_path, a:file], s:sep)
endfunction

実装後、再度autoload/session.vimをロードして、:call session#load_session('test')を実行してみてください。ウィンドウなどの状態が戻ったらOKです。

セッション一覧取得処理

セッション一覧を取得する処理は大きく分けて2ステップになります。

  1. セッションファイルのリストを取得
  2. リストを表示するバッファを作成し(すでにあれば表示)、バッファ破棄とセッションをロードするキーマップを設定する

では、それぞれのステップを実装していきましょう。

セッションファイルのリストを取得

g:session_pathからファイルを取得するにはreaddir({dir}, {expr})を使用します。具体的な説明はコメントを参照してください。

" エラーメッセージ(赤)を出力する関数
" echohl でコマンドラインの文字列をハイライトできます。詳細は:h echohlを参照してください
function! s:echo_err(msg) abort
  echohl ErrorMsg
  echomsg 'session.vim:' a:msg
  echohl None
endfunction

" 結果 => ['file1', 'file2', ...]
function! s:files() abort
  " g:session_pat hからセッションファイルの保存先を取得します
  " g: はグローバルな辞書変数なので get() を使用して指定したキーの値を取得できます
  let session_path = get(g:, 'session_path', '')

  " g:session_pathが設定されていない場合はエラーメッセージを出し空のリストを返します
  if session_path is# ''
    call s:echo_err('g:session_path is empty')
    return []
  endif

  " file という引数を受けとり、そのファイルがディレクトリでなければ1を返すLambdaです
  let Filter = { file -> !isdirectory(session_path . s:sep . file) }

  " readdir の第2引数に Filter を使用することでファイルだけが入ったリストを取得できます
  return readdir(session_path, Filter)
endfunction
リストを表示するバッファを作成(すでにあれば表示)

s:files()で取得できたファイル一覧を一時バッファに書き出し、ユーザが選択できるようにします。

" セッション一覧を表示するバッファ名
let s:session_list_buffer = 'SESSIONS'

function! session#sessions() abort
  let files = s:files()
  if empty(files)
    return
  endif

  " バッファが存在している場合
  if bufexists(s:session_list_buffer)
    " バッファがウィンドウに表示されている場合は`win_gotoid`でウィンドウに移動します
    let winid = bufwinid(s:session_list_buffer)
    if winid isnot# -1
      call win_gotoid(winid)

    " バッファがウィンドウに表示されていない場合は`sbuffer`で新しいウィンドウを作成してバッファを開きます
    else
      execute 'sbuffer' s:session_list_buffer
    endif

  else
    " バッファが存在していない場合は`new`で新しいバッファを作成します
    execute 'new' s:session_list_buffer

    " バッファの種類を指定します
    " ユーザが書き込むことはないバッファなので`nofile`に設定します
    " 詳細は`:h buftype`を参照してください
    set buftype=nofile

    " 1. セッション一覧のバッファで`q`を押下するとバッファを破棄
    " 2. `Enter`でセッションをロード
    " の2つのキーマッピングを定義します。
    "
    " <C-u>と<CR>はそれぞれコマンドラインでCTRL-uとEnterを押下した時の動作になります
    " <buffer>は現在のバッファにのみキーマップを設定します
    " <silent>はキーマップで実行されるコマンドがコマンドラインに表示されないようにします
    " <Plug>という特殊な文字を使用するとキーを割り当てないマップを用意できます
    " ユーザはこのマップを使用して自分の好きなキーマップを設定できます
    "
    " \ は改行するときに必要です
    nnoremap <silent> <buffer>
      \ <Plug>(session-close)
      \ :<C-u>bwipeout!<CR>

    nnoremap <silent> <buffer>
      \ <Plug>(session-open)
      \ :<C-u>call session#load_session(trim(getline('.')))<CR>

    " <Plug>マップをキーにマッピングします
    " `q` は最終的に :<C-u>bwipeout!<CR>
    " `Enter` は最終的に :<C-u>call session#load_session()<CR>
    " が実行されます
    nmap <buffer> q <Plug>(session-close)
    nmap <buffer> <CR> <Plug>(session-open)
  endif

  " セッションファイルを表示する一時バッファのテキストをすべて削除して、取得したファイル一覧をバッファに挿入します
  %delete _
  call setline(1, files)
endfunction

実装後、再度autoload/session.vimをロードして、:call session#sessions()を実行してみてください。セッションファイル一覧がSESSIONSというバッファに表示され、Enterを押下するとカーソル下のセッションがロードされたらOKです。

plugin/session.vimの実装

plugin/session.vimではまずプラグインを無効化する手段を用意します。g:loaded_{plugin_name}というグローバル変数を用意して、それが存在しているならfinishでスクリプトの読み取りを停止します。こうすることでユーザはg:loaded_session = 1をvimrcに記述するだけでプラグインを無効化できます。

" すでにスクリプトをロードした場合は終了
if exists('g:loaded_session')
  finish
endif
let g:loaded_session = 1

" command はExコマンドを定義します
" 次の定義では :SessionList コマンドを実行すると call session#sessions() が実行されるようになります
command! SessionList call session#sessions()

" -nargs でコマンドが受け取る引数の数を設定できます
" デフォルトは引数を受け取らないので、1つの変数を受け取れるように設定します
"
" <q-args> は引数を意味します
command! -nargs=1 SessionCreate call session#create_session(<q-args>)

ヘルプの記述

autoload/session.vimplugin/sesison.vimの実装が終わったのでこれでプラグインは動くようになりました。最後にプラグインを公開する前にヘルプを書きましょう。

ヘルプを書くにあたり、テンプレート生成プラグインのLeafCage/vimhelpgeneratorを使います。筆者は普段このプラグインを使ってヘルプのテンプレートを生成してから、細かい説明を書いています。使い方はシンプルで、:VimHelpGeneratorを実行してeを押すだけです。そうするとdoc/session.vimが生成されます。これがヘルプのテンプレートで、そこに今回のプラグインで作成したコマンド、変数、キーマップの説明を追記していきます。追記する箇所は次になります。

------------------------------------------------------------------------------
VARIABLES                                            *session-variables*
ここにユーザが使用できる変数の説明を記述


------------------------------------------------------------------------------
COMMANDS                                             *session-commands*
ここにユーザが使用できるコマンドの説明を記述


------------------------------------------------------------------------------
KEY-MAPPINGS                                         *session-key-mappings*
ここにユーザが使用できるキーマップの説明を記述

ヘルプに公式標準のレイアウトはないので、基本的にユーザがわかりやすいように心がけてヘルプを記述すれば良いですが、「*」で囲っている部分は実際ヘルプを引く際に検索される部分なのでそこは必ず記述しましょう。筆者の場合は次のように記述しています。この例では:h :SessionCreateなら*:SessionCreate*のところにジャンプします。

------------------------------------------------------------------------------
VARIABLES                                            *session-variables*

g:session_path                                       *g:session_path*
セッションを保存するファイルパスを設定します。

------------------------------------------------------------------------------
COMMANDS                                             *session-commands*

:SessionList                                         *:SessionList*
    セッション一覧を開きます。
    Enterでカーソル上にあるセッションをロードします。

:SessionCreate {name}                                *:SessionCreate*
    セッションを{name}で保存します。

------------------------------------------------------------------------------
KEY-MAPPINGS                                         *session-key-mappings*

<CR>                                                 *session-list-<cr>*
    カーソル下のセッションをロードします。

q                                                    *session-list-q*
    セッションリストのバッファを閉じます。

ヘルプを記述したら、これでプラグインは出来上がりです。あとはGitHubに公開するなり宣伝するなりしていきましょう。ちなみにpackage機能を使う場合はヘルプのtagsファイルを手動で生成する必要があるので、:helptags docを実行しておきしょう。tagsファイルがないとヘルプを引けないので、package機能を使ってプラグイン管理する方は忘れずにやりましょう。

まとめ

長くなりましたが、以上がVim scriptを使ったプラグイン作成の方法になります。皆さんへの宿題というわけではないのですが、作成したプラグインにぜひ機能(例えばセッションファイル削除)を追加してみてください。

また、必要最低限のVim scriptしか解説していないので、もっと詳しく知りたいという方はぜひ:h usr_41を読んでみてください。不明点などありましたらTwitter(@gorilla0513)のDMもしくはリプをいただければ回答しますので遠慮なく聞いてください。

最後に

本記事で連載は終了になります。8ヶ月間という短い連載でしたが、筆者にとってとても良い経験になりました。みなさんにとってどんな連載でしたか?少しでも「Vimって良さそうだな」と感じていただけたら嬉しいなと思っています。

最後に、連載の間レビューや校正などをしてくださった法林さん、ありがとうございました。おかげさまでとても良い経験をさせていただきました。改めてお礼を申し上げます。