Λlisue's blog

つれづれなるままに更新されないブログ

Vital.vim用の高機能なオプションパーサーを作った

どうも、有末です。今回はVimscriptを書いてプラグインを作っているようなお変態な方にしか興味が持たれないであろう記事です。

vim-gistaの製作時にどうしてもオプションパーサーが必要だったのですが、vital.vim既存のvital-OptionParserでは少し機能不足だったためオリジナルのものを作成して使用していました。 Lingrにてその話を少ししたところ、何人かの方が食いついてくれたので外部Vitalモジュール化し皆さんに共有することにしました。 結構時間がかかってしまったのですが、vim-gistaでは載せていなかった補完やヘルプの自動生成など、僕が欲しいなと思う機能はすべて載った強力なものが出来ました(当社比)。今回はその機能紹介です。

使い方

インストール等

vital.vimが使えるようになっているのが前提です。その状態でneobundle.vimなどを使用しインストールを行なってください。

" neobundle.vim の場合
NeoBundle 'lambdalisue/vital-ArgumentParser'

これで基本的にはvital.vimの標準モジュールと同じように使えるはずです。開発時は下記のようにすれば利用できます。

let s:V = vital#of('vital')
let s:P = s:V.import('ArgumentParser')

またリリース時は通常のモジュールと同様に:Vitalizeコマンドを下記のように利用できます。

:Vitalize . +ArgumentParser

簡単な使い方

既存のvital-OptionParserと使用方法は似ていますが、関数名などは大きく違います(細かい違いが多かったため敢えて違いを大きくしています)。 基本的な流れは

  1. let s:parser = s:P.new({settings}) としてパーサーのインスタンスを生成
  2. call s:parser.add_argument({name}, {short}, {description}, {settings}) にて必要なオプションを追加(各引数は省略可能、詳細はヘルプを)
  3. let args = s:parser.parse(<bang>, [<line1>, <line2>], <f-args>) にて入力値をパース。この際Validationが走り、Failした場合は空辞書が戻ってくる
  4. 補完関係は return s:parser.complete(<arglead>, <cmdline>, <cursorpos>) で行う

となります。下記に一般的な使い方例を載せます。

call vital#of('vital').unload()
let s:V = vital#of('vital')
let s:P = s:V.import('ArgumentParser')

let s:parser = s:P.new()

call s:parser.add_argument('--foo', 'description of the argument')
call s:parser.add_argument('--bar', 'description of the argument', {
      \ 'kind': s:parser.kinds.switch,
      \})
call s:parser.add_argument('--hoge', '-h',
      \ 'description of the argument', {
      \ 'choices': ['a', 'b', 'c'],
      \})
call s:parser.add_argument('--piyo', '-p',
      \ 'description of the argument', {
      \ 'required': 1,
      \})
call s:parser.add_argument('--ahya', '-a',
      \ 'description of the argument', {
      \ 'default': 'AHYA',
      \})

" sample function
function! Command(...)
  echo call(s:parser.parse, a:000, s:parser)
endfunction

function! Complete(...)
  return call(s:parser.complete, a:000, s:parser)
endfunction

" sample command
command! -nargs=? -range=% -bang
      \ -complete=customlist,Complete Hoge
      \ :call Command(<q-bang>, [<line1>, <line2>], <f-args>)

この例の完全版はArgumentParser_manual_test.vimに、より実践的な例はvim-gistaのオプション設定をご覧ください。

既存 OptionParser と比較した際の利点

僕の作成したvital-ArgumentParserでは既存のOptionParserが持つ機能に加え、下記のような機能が揃っています。

  1. ダブルコーテーション(")やシングルコーテーション(')による空白を含む文字列の扱い
  2. オプション同士の衝突を自動的に判断し、警告表示・補完に反映
  3. オプション同士の従属関係を判定し、警告表示・補完に反映
  4. オプション同士の必要条件を判定し、警告表示(補完には反映されない)
  5. オプションの種類(Any, Switch, Value, Choice)により入力値をチェック・警告表示・補完に反映
  6. オプションに付いている絶対必要フラグをチェックし、存在しない場合警告を表示
  7. フック関数によりオプションの自動適用等

それぞれ簡単に見ていきます。

ダブルコーテーション(")やシングルコーテーション(')による空白を含む文字列の扱い

これは OptionParser を使用する上で最も問題だった部分だと思います。この部分が改善できただけでも使う価値はあると思います。 ただ、これを行うためにオプションの書き方が大きく変更されました。下記を参照してください。

" OptionParserで'--foo'に文字列を渡す場合
Hoge --foo=HogeHoge

" ArgumentParserで'--foo'に文字列を渡す場合
Hoge --foo HogeHoge
Hoge --foo "Hoge Hoge"
Hoge --foo 'Hoge Hoge'
Hoge --foo Hoge\ Hoge

オプション同士の衝突を自動的に判断し、警告表示・補完に反映

個人的にはこの機能が欲しくて作ったようなものですが、具体例がないとわかりにくいですね。 vim-gistaは'--open'や'--post'など行動を示すオプションが多々有ります。この行動を示すオプションは、例えば:Gista --open --postの用に一緒に使用することができません。他にも'--private'と'--public'という相反するオプションがありますが、これも一緒には使えません。 通常この様な場合はパース後に衝突チェックなどを行い、綺麗なオプションにしてから関数などに渡すと思いますが、量が多いと面倒くさいですよね。そこでvital-ArgumentParserではconflict_withというオプションを使って下記のように衝突回避を行います。

let s:parser = s:Parser.new()
call s:parser.add_argument('--open', 'open something', {
        \ 'conflict_with': 'command',
        \})
call s:parser.add_argument('--post', 'post something', {
        \ 'conflict_with': 'command',
        \})
call s:parser.add_argument('--private', 'private something', {
        \ 'conflict_with': ['visibility', 'publish_status'],
        \})
call s:parser.add_argument('--public', 'public something', {
        \ 'conflict_with': ['visibility', 'publish_status'],
        \})
call s:parser.add_argument('--anonymous', 'anonymous something', {
        \ 'conflict_with': ['publish_status'],
        \})

この様に指定することで'--open'と'--post'は'command'というConflictグループに所属します。このグループは仮想的なものなのでどのような名前でも構いません。同じConflictグループに所属しているオプションが既に設定されていた場合、補完に表示されないように成っています。また、無理やり二つ指定してEnterを押すとオプションが衝突しているので実行できませんと怒られます。

同様に'--private', '--public', '--anonymous'も指定していますが、これらはリストでグループを指定しています。この様に複数のグループに所属させることも可能です。所属しているグループのどれか一つでも衝突した場合は警告を表示します。

オプション同士の従属関係を判定し、警告表示・補完に反映

これは上記機能によく似ていますが、オプションの従属関係を指定するために使用します。 vim-gistaの場合では'--private'や'--public'は'--post'コマンド実行時にしか使用しません。そのため'--post'が指定されていないのに補完候補に表示されたり、ユーザーが適用されていると勘違いして別のコマンドに対して'--private'などを指定するなどは、こちらの想定とは異なります。 この様な自体を避けるために、vital-ArgumentParserではsubordination_ofというオプションを使って下記のように従属関係の指定を行います。

let s:parser = s:Parser.new()
call s:parser.add_argument('--open', 'open something', {
        \ 'conflict_with': 'command',
        \})
call s:parser.add_argument('--post', 'post something', {
        \ 'conflict_with': 'command',
        \})
call s:parser.add_argument('--private', 'private something', {
        \ 'conflict_with': 'visibility',
        \ 'subordination_of': 'post',
        \})
call s:parser.add_argument('--public', 'public something', {
        \ 'conflict_with': 'visibility',
        \ 'subordination_of': ['post'],
        \})

conflict_withではConflictグループという仮想概念を指定しましたが、subordination_ofでは直接オプションを指定することに注意してください。 conflict_withの時と同様に文字列もしくはリストを指定することができます。

このような設定を行うと'--private'および'--public'は通常時は補完に表示されません。'--post'が設定された後に初めて補完候補として出てくることになります。 また'--post'が指定されていない状態で'--private'などを指定すると怒られます。

オプション同士の必要条件を判定し、警告表示(補完には反映されない)

これは上記のものと非常によく似ていますが、適用範囲が異なります。subordination_ofの場合はsubordination_ofに指定したオプションのどれか一つでも指定されていれば警告は出ません。ただ、幾つかのオプションは動作に絶対必要な下位オプションを多数持っている場合があるので、このオプションを追加しました。 vim-gistaの場合で言えばGistに含まれるファイルを削除する'--remove'オプションはGist ID('--gistid')とファイル名('--filename')を要求します。しかし従属関係で言えば'--remove'に'--gistid'や'--filename'が従属しているのでsubordination_ofは使えません。 この様な場合にdepend_onというオプションを使い、下記のようにチェックを行うことができます。

let s:parser = s:Parser.new()
call s:parser.add_argument('--remove', 'remove something', {
        \ 'conflict_with': 'command',
        \ 'depend_on': ['gistid', 'filename'],
        \})
call s:parser.add_argument('--gistid', 'gistid something', {
        \ 'subordination_of': 'remove',
        \})
call s:parser.add_argument('--filename', 'filename something', {
        \ 'subordination_of': 'remove',
        \})

これにより'--gistid'や'--filename'は'--remove'に従属しているが'--remove'を使うためには'--gistid'と'--filename'が指定されている必要がある、という少し複雑なルールを記載することが出来ました。 なお、参照が循環するためこのオプションは補完に影響を及ぼしません('--gistid'と'--filename'が無いと'--remove'は使えないはずだが、'--gistid'や'--filename'が指定されていない場合でも'--remove'は補完候補に出る)。このオプションは最後のValidation専用のオプションとなります。

オプションの種類(Any, Switch, Value, Choice)により入力値をチェック・警告表示・補完に反映

vital-ArgumentParserには4種類のオプションがあります。これらに関しては下記表をご覧ください。

名前 説明
any switch + value なオプション。値を省略すると switch として働き、値を記載すると value として働く
switch 真偽値として使うためのオプション。値を記載することは出来ず、オプションが指定された段階で真となる(デフォルト)
value 値を取るためのオプション。値を省略することは出来ない
choice 値を取るためのオプション。'choices'という別オプションに指定されている値のみを取ることが出きる

上記表で「出来ない」となっている部分はValidation時に警告が表示され、処理が中断されます。実際には下記のように指定します。

let s:parser = s:Parser.new()
call s:parser.add_argument('--any', 'something', {
        \ 'kind': s:parser.kinds.any,
        \})
call s:parser.add_argument('--switch', 'something', {
        \ 'kind': s:parser.kinds.switch,    " デフォルトなので不要
        \})
call s:parser.add_argument('--value', 'something', {
        \ 'kind': s:parser.kinds.value,
        \})
call s:parser.add_argument('--choice', 'something', {
        \ 'choices': ['a', 'b', 'c'],       " 'choices'が指定されると自動で'kind'が'choice'になる
        \})

オプションに付いている絶対必要フラグをチェックし、存在しない場合警告を表示

これはよくあるものなのですね。'required'というオプションが指定されたものが省略されると警告します。下記コードを見てください。

let s:parser = s:Parser.new()
call s:parser.add_argument('--required', 'something', {
        \ 'required': 1,
        \})

フック関数によりオプションの自動適用等

vital-ArgumentParserはかなり汎用的に作っていますが、どうしても特殊な処理を行いたい場合などがあると思います。その際に用意されている幾つかのHook関数が使用できます。vim-gistaでは下記のような処理にフックを利用しました。

  1. 'command'グループに属するオプションが指定されていない場合は'--post'を自動指定
  2. '--gistid'が指定されていない場合はカレントバッファの情報から自動推定
  3. '--yank'に値が指定されていないときはg:gista#default_yank_methodの値を設定
  4. '--private'が指定されたときはlet args.public = 0を実行し、代わりにprivateを削除する

この様に独特な処理が必要な場合に利用できます。下記はvim-gistaの該当部分のコードなので参考にしてください。

function! s:parser.hooks.pre_completion(args) abort " {{{
  let args = copy(a:args)
  " gistid (GistPost does not require gistid but use)
  let gistid = gista#utils#find_gistid(0, '$')
  if !empty(gistid)
    let args.gistid = 1
  endif
  " filename
  if exists('b:gistinfo') &&
        \ self.has_subordination_of('filename', args)
    let args.filename = 1
  endif
  return args
endfunction " }}}
function! s:parser.hooks.pre_validation(args) abort " {{{
  let args = copy(a:args)
  " post (if no conflict options are specified)
  if !self.has_conflict_with('post', args)
    let args.post = self.true
  endif
  " gistid (GistPost does not require gistid but use)
  if self.has_subordination_of('gistid', args)
    let gistid = gista#utils#find_gistid(
          \   a:args.__range__[0],
          \   a:args.__range__[1],
          \)
    if !empty(gistid)
      let args.gistid = gistid
    endif
  endif
  " filename
  if exists('b:gistinfo') &&
        \ self.has_subordination_of('filename', args)
    let args.filename = b:gistinfo.filename
  endif
  " yank
  if has_key(args, 'yank')
    if type(args.yank) != 1 && args.yank == self.true
      unlet args.yank
      let args.yank = g:gista#default_yank_method
    endif
  endif
  return args
endfunction " }}}
function! s:parser.hooks.post_transform(args) abort " {{{
  let args = copy(a:args)
  " private => public
  if has_key(args, 'private')
    let args.public = !args.private
    unlet args['private']
  endif
  return args
endfunction " }}}

最後に

ダブルコーテーションやシングルコーテーションを上手にパースする関数はmattn/gist.vimを参考にさせていただきました。 またヘルプの作成や補完のやり方などはvital.vimvital-OptionParserを参考にさせていただきました。