/session.vim

Primary LanguageVim Script

テーマ

Vim scriptを使って簡易なプラグインを作ってみよう

前提知識

  • ターミナルの操作方法

必要環境

  • 以下のバージョンのVimとNeovimを用意

全体の流れ

  • Vim scriptの基礎
  • セッション管理のプラグインを作ってみよう

Vim scriptの基礎

  • Vim scriptはVim上で実行できるスクリプト言語
  • Exコマンド(:で始まるコマンド)の集合体
  • vimrcに記述しているのもVim script
  • Vimのプラグインの多くはVim scriptで書かれている

Vim scriptの実行

以下の手順通りに実施してみてください。コマンドラインにgorillaが表示されれば成功です。

  1. sample.vimを作成
$ mkdir sample
$ cd sample
$ vim sample.vim
  1. Vimで以下のコードを記述して保存
echo 'gorilla'
  1. :sourceでVim scriptを実行
:source sample.vim

コメント

Vim scriptでは"がコメント行として解釈され処理をスキップします。

" この行は処理されない
" echo 'gorilla'

データ型

主に以下のデータを使用できます。

データ型
数値 5
小数 5.5
文字列 'gorilla'、"gorilla"
リスト [1, 2, 3]
辞書 {'name': 'gorilla'}

文字列

"'で囲ったものは文字列になります。 "はタブを表す\tといった特殊な文字をタブとして出力しますが、'は囲った文字列をそのまま出力するといった違いがあります。

sample.vimの先程まで記述したコードを削除して、以下のコードを記述して実行してみてください。

echo 'hello\tgorilla'
echo "hello\tgorilla"

結果は以下になります。

hello\tgorilla
hello	gorilla

変数

  • letを使っての宣言と値を代入する
  • 宣言済みの変数でも値を代入するときはletを使用しなければいけない
let name = 'gorilla'
" letがないのでエラーになる
name = 'cat'

変数名

  • アルファベット、数字、アンダースコアを使用できる
  • 数字で始まることはできない
" OK
let _a1 = 1
echo _a1

" NG
let 1a = 1
let a-b = 1

スコープ

  • 変数や後述する関数にはスコープがある
  • 接頭子によってスコープが変わる
  • 関数内でl:を省略した場合は暗黙的にローカル変数にアクセスする
接頭子 スコープ
g: グローバルスコープ、どこからも利用可能
s: スクリプトスコープ、スクリプトファイル内のみ使用可能
l: ローカルスコープ、関数内のみ使用可能
a: 関数の引数、関数内のみ使用可能
v: グローバルスコープ、Vimが予め定義している変数

辞書

  • {}で囲う
  • 1つ要素は{key}: {value}からなる
  • {key}は文字列でなければいけない
  • 要素は,で区切られる
let animal = {'name': 'gorilla', 'age': 27}
" 結果 => {'age': '27', 'name': 'gorilla'}
echo animal

辞書の要素取得

  • {dict}.{key}
  • {dict}[{key}]
  • get({dict}, {key}, {default})
let animal = {'name': 'gorilla', 'age': 27}
" 結果 => gorilla
echo animal.name
" 結果 =>  27
echo animal['age']
" 結果 =>  banana
echo get(animal, 'name', 'banana')

辞書の要素追加

  • {dict}.{key} = {expr}
  • {dict}[{key}] = {expr}
let animal = {}
let animal.name = 'gorilla'
let animal['age'] = 27

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

辞書の要素削除

  • remove({dict}, {key})
call remove(animal, 'age')
" 結果 => {'name': 'gorilla'}
echo animal

リスト

  • []の中にカンマで区切って複数の要素を保持できるリストを作れる
let list = ['cat', 10, {'name': 'gorilla'}]
" 結果 => ['cat', 10, {'name': 'gorilla'}]
echo list

リストの要素取得

  • {list}[{idx}]
  • get({list}, {idx}, {default})
let list = ['cat', 10, {'name': 'gorilla'}]

" 結果 => cat
echo list[0]

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

リストの結合

  • join({list}, {sep}){list}{sep}で結合して1つの文字列を返す
let list = ['hello', 'my', 'name', 'is', 'gorilla']

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

if文

  • if文の基本形はif {expr} | endif
  • {expr}が1の場合はtrue、0の場合はfalse
if {expr}
  " do something
elseif {expr}
  " do something
else
  " do something
endif

比較演算子

  • Vim scriptで主な比較演算子は次の通り
  • ignorecaseの設定次第で動きが変わる演算子がある
  • 基本的に#がつく大文字小文字考慮の比較演算子を使うと良い
ignorecase次第 大小文字考慮 大小文字無視 意味
== ==# ==? 等しい
!= !=# !=? 等しくない
> ># >? より大きい
>= >=# >=? より大きいか等しい
< <# <? より小さい
<= <=# <=? より小さいか等しい
is is# is? 同一のインスタンス
isnot isnot# isnot? 異なるのインスタンス

バッファについて

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

バッファの存在チェック

  • bufexists({expr}){expr}のバッファがあるかを確認できる
  • {expr}が数値の場合はバッファ番号、文字列の場合はバッファ名とみなされる

バッファのタイプ

  • set buftype={type}でバッファのタイプを設定できる
  • 一時的に使うバッファはnofileというタイプするのが一般的
  • 詳細は:h buftypeを参照

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

  • カレントバッファからテキストを取得するには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を取得

  • winnr()で現在のウィンドウIDを取得できる
  • 引数を受け取ることもできるので詳細は:h winnr()を参照

ウィンドウに移動

  • win_gotoid({expr}){expr}のIDのウィンドウに移動

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

  • bufwinid({expr}){expr}のバッファが表示されているウィンドウのIDを取得

関数

  • 関数はfunctionendfunctionで囲い、処理はその間に記述
function! Echo(msg) abort
  echo a:msg
endfunction

関数の存在チェック

  • exists({expr}){expr}の関数があるかをチェックできる
  • 関数をチェックするとき関数名の前に*をつける
if exists('*readdir')
  " do something
else

!abort

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

引数

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

戻り値

  • return {expr}{expr}の評価結果を返すことができる
" 結果 => gorillaが返る
function! MyName() abort
  return 'gorilla'
endfunction

Exコマンド実行

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

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

外部コマンド実行

  • system({expr}, {input}){expr}の評価結果の文字列を外部コマンドとして実行できる
  • {input}は省略可能で指定した場合はその文字列をそのままコマンドの標準入力として渡される
" 結果 => my name is gorilla
echo system('echo "my name is gorilla"')

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

Lambda

  • { args -> expr }という形でLambdaを書くことができる
let F = {a, b -> a - b}
" 結果 => [1, 2, 3, 4, 7]
echo sort([3, 7, 2, 1, 4], F)

セッション管理のプラグインを作ってみよう

今回作成するプラグインは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配下に定義する時、ファイル名#関数名()という命名規則に従う必要があります。 これはコマンドを実行する時にautoload配下のどのファイルのどの関数を呼べば良いのかを知る必要があるからです。

そのため、プラグイン名が被るとautoload配下のスクリプトファイル名も被り、最悪違うプラグインの関数で上書きされる可能性があります。 これがプラグイン名がかぶらないようにする必要がある理由です。

docディレクトリについて

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

プラグインディレクトリsession.vimの作成

開発中のプラグインを動作確認をするために、プラグインをロードする必要があります。 今回ではVimにビルドインされているパッケージ機能を利用して、開発中のプラグインをロードします。 開発の準備としてパッケージ機能で使用するディレクトリと、今回開発するプラグインのディレクトリ構成を作成します。

# パッケージ機能で使用するディレクトを作成します。ここにプラグインのディレクトリを置くとVim起動時にruntimepathに追加され、プラグインがロードされます
$ mkdir -p ~/.vim/pack/plugins/start/
# Neovimの場合は以下のディレクトリになります。以下手順は適宜読み替えてください
$ mkdir -p ~/.config/nvim/pack/plugins/start/

# プラグインのディレクトリ構成を作成します
$ cd ~/.vim/pack/plugins/start/
# Neovimの場合は以下
$ cd ~/.config/nvim/pack/plugins/start/

$ mkdir session.vim
$ cd session.vim
$ mkdir autoload plugin
$ touch autoload/session.vim
$ touch plugin/session.vim

セッションファイルを保存するディレクトリの作成

# Vimの方は~/.vim/session
mkdir -p ~/.vim/session

# Neovimの方は~/.config/nvim/session
mkdir -p ~/.config/nvim/session

autoload/session.vimの実装

1. セッションを保存する関数

まずはg:session_pathにセッションファイルを保存する関数を作ります。

let s:sep = fnamemodify('.', ':p')[-1:]

function! session#create_session(file) abort
  execute 'mksession!' join([g:session_path, a:file], s:sep)
  redraw
  echo 'session.vim: created'
endfunction

関数を実装したら、so %で一度スクリプトファイルをロードします。そうすると関数を実行できるようになります。 次にコマンドラインでg:session_pathを設定します。それぞれの環境に合わせて先ほど作成したパスを設定してください。

:let g:session_path = {path}

では実際関数を実行して、セッションファイルを作ってみましょう。正常に作成できたらsession.vim: createdメッセージが出力されます。

:call session#create_session('test')

2. セッションをロードする関数

以下の関数を作ります。

function! session#load_session(file) abort
  execute 'source' join([g:session_path, a:file], s:sep)
endfunction

関数を作ったら、一度Vimを再起動して先程保存したセッションファイルを実際ロードしてみましょう。 ウィンドウの状態が戻ったらOKです。

call session#load_session('test')

3. エラーメッセージを出力する関数

処理中に何かしらエラーが発生した場合、エラーメッセージであることがわかるように、 echohlを使ってコマンドラインに赤いメッセージを出力する関数を作ります。

function! s:echo_err(msg) abort
  echohl ErrorMsg
  echomsg 'session.vim:' a:msg
  echohl None
endfunction

実際メッセージは赤くなるのかを確かめるため、グローバルな関数TestEcho()を作ります。

function! TestEcho(msg) abort
    call s:echo_err(a:msg)
endfunction

上記2つの関数を作ったらTestEechoを実行して、赤いメッセージが出たらOKです。

:call TestEcho('I am gorilla')

これは動作確認の関数なので削除しておきましょう。:delfunc TestEchoで削除するか、一度Vimを再起動するかしましょう。

4. g:session_pathからセッションファイル一覧を取得する関数を実装

s:readdir関数を使ってg:session_path配下にあるファイルのリストを取得します。

ここでのキモはexists()readdir()関数があるかを確認するところです。

readdir()がなければglob()関数を使ってファイルとディレクトリ一覧を取得する関数s:readdir()を用意している部分です。Neovimではreaddir()がないためglob()を使う必要があります。

readdir()がある場合はfunction()で関数への参照を取得してs:readdir変数に代入して、NeovimでもVimでも同じ変数名でファイル一覧を取得できるようにします。

ファイル、ディレクトリ一覧を取得したあとに、ファイルのみを抽出するためにFilterLambdaを用意しfilter()関数を使って絞り込みます。

if exists('*readdir')
  let s:readdir = function('readdir')
else
  function! s:readdir(dir) abort
    return map(glob(a:dir . s:sep . '*', 1, 1), 'fnamemodify(v:val, ":t")')
  endfunction
endif

function! s:files() abort
  let session_path = get(g:, 'session_path', '')
  if session_path is# ''
    call s:echo_err('session_path is empty')
    return []
  endif

  let session_path = expand(session_path)
  let Filter = { file -> !isdirectory(session_path . s:sep . file) }
  return filter(s:readdir(session_path), Filter)
endfunction

では、実際ファイル一覧を取得できるかを確認してみましょう。グローバルな関数TestFiles()を作ります。

function! TestFiles() abort
  echo s:files()
endfunction

作った関数を実行して、先程作成したtestファイルが出力されればOKです。

:call TestFiles()

このテストのための関数も不要なので削除しておきましょう。

5. セッション一覧を表示する

セッションファイルをリストで取得できるようになったので、次に取得したセッション一覧をバッファに書き出します。

let s:session_list_buffer = 'SESSIONS'

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

  execute 'new' s:session_list_buffer
  set buftype=nofile

  call setline(1, files)
endfunction

これで:call session#sessions()を実行するとSESSIONSというバッファにセッションファイル一覧が表示されます。

しかし、このままでは関数を実行するたびに新しいウィンドウが作れてしまうので、以下のことを考慮して改善する必要があります。

  • バッファがなければ新規作成
  • バッファがあるがウィンドウに表示されていないならウィンドウに表示させる
  • バッファがあってウィンドウに表示されているなら、バッファの中身をクリア
function! session#sessions() abort
  let files = s:files()
  if empty(files)
    return
  endif

+ " if buffer exists
+ if bufexists(s:session_list_buffer)
+   " if buffer display in window
+   let winid = bufwinid(s:session_list_buffer)
+   if winid isnot# -1
+     call win_gotoid(winid)
+   else
+     execute 'sbuffer' s:session_list_buffer
+   endif
+ else
    execute 'new' s:session_list_buffer
    set buftype=nofile
+ endif
+
+ " delete buffer contents
+ %delete _
  call setline(1, files)
endfunction

diffの処理を追加したら:so %で再度スクリプトをロードして関数を実行してみましょう。新たなウィンドは作れず既存バッファとウィンドウを使うようになっているはずです。

6. キーマッピングを追加

表示はできたので、最後に以下のキーマッピングを追加していきます。

  • Enterでカーソル下にあるセッションファイルをロード
  • qでバッファを破棄
function! session#sessions() abort
  let files = s:files()
  if empty(files)
    return
  endif

  " if buffer exists
  if bufexists(s:session_list_buffer)
    " if buffer display in window
    let winid = bufwinid(s:session_list_buffer)
    if winid isnot# -1
      call win_gotoid(winid)
    else
      execute 'sbuffer' s:session_list_buffer
    endif
  else
    execute 'new' s:session_list_buffer
    set buftype=nofile

+   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>
+
+   nmap <buffer> q <Plug>(session-close)
+   nmap <buffer> <CR> <Plug>(session-open)
  endif

  " delete buffer contents
  %delete _
  call setline(1, files)
endfunction

<Plug>は特殊でどのキーともマッピングしないです。多くのプラグインではこの<Plug>(xxxx)を提供して、ユーザが自由にキーマッピングできる仕組みを提供しています。

<buffer>は現在のバッファだけにキーマッピングを適用します。今回のような他のバッファに影響しないキーマップを用意するときは付ける必要があります。

以上がautoload/session.vimの実装になります。

plugin/session.vimの実装

続けてplugin配下を実装していきます。plugin/session.vimでやることは2つです。

  • プラグイン無効化、二重ロード防止
  • コマンド定義

プラグイン無効化、二重ロード防止

Vim起動時にplugin配下のスクリプトがロードされるので、そこでロード済みかどうかを判断するグローバル変数を用意します。変数名はプラグイン名にプレフィックスg:loaded_をつけます。

この変数がすでに定義済みなら、finishでロード処理を中止します。ユーザがプラグインを無効化したい場合はこの変数を予めvimrcに設定しておくことで、プラグインを無効化できます。

if exists('g:loaded_session')
  finish
endif
let g:loaded_session = 1

コマンド定義

command関数でExコマンドを定義します。-nargsはコマンドに渡せる引数の数を設定できます。 今回はセッションの作成時にファイル名が必要なので-nargs=1で1つ引数が必要の設定にします。 <q-args>は引数を意味します。詳細は:h <q-args>を参照して下さい。

command! SessionList call session#sessions()
command! -nargs=1 SessionCreate call session#create_session(<q-args>)

以上、plugin/session.vimの実装は終わりです。これでコマンドでセッションの保存とセッション一覧表示とロードが出来るようになります。

実際にVimを再起動して:SessionCreate:SessionListを実行してEnterでロードできるか確認してみましょう。

ヘルプ

プラグインの実装は終わったのですが、プラグインを公開するにあたりヘルプを書く必要があります。ヘルプはユーザがプラグインで使用できる設定変数やコマンド、関数、キーマッピングの使い方を知るのに必要です。

今回はコマンド2つに設定変数が1つなので記述する量は少ないのですが、大きなプラグインとなると記述量も増えます。そこでLeafCage/vimhelpgeneratorを使ってある程度ヘルプのテンプレートを生成します。

プラグインを導入して:VimHelpGeneratorを実行するとdoc/session.txtが作られます。それがヘルプファイルになります。

今回追記する部分は以下になります。

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


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


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

一例ですが、以下の様に設定変数と説明を記述します。*で囲っている部分は実際:hで検索される部分なので、そこは必ず記述しましょう。

------------------------------------------------------------------------------
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*
セッションリストのバッファを閉じます。

ヘルプを記述し終わったら、ちゃんとヘルプを引けるかどうか:helptags docでヘルプタグを生成して実際引いてみましょう。

最後に

これでハンズオンは終わりです。Vim scriptの基礎とプラグインの作り方について一通り解説しましたがわからないところもあるかと思います。不明点などあればいつでもゴリラまで質問してください。

このハンズオンでみなさんにプラグインの作り方について体験して頂くことで、なにかしらを持ち帰っていただけたらと思います。

お疲れさまでした。