Vim エディターのスクリプトの作成: 第 3 回 組み込みリスト

Vim スクリプトのリストおよび配列のサポートについて調べる

Vim スクリプトは、データのコレクションを操作する機能が優れており、プログラミングに欠かせないツールです。連載第 3 回となるこの記事では、Vim スクリプトの組み込みリストを使用して、リストのフォーマットの再設定や、ファイル名の一覧のフィルタリング、そして行番号のソートなどといった日常的な作業を簡単に行う方法を説明します。また、リストによって Vim の 2 つの一般的な使用方法を拡張および強化できることを実証するために、代入演算子を位置合わせするユーザー定義関数を作成する例、そして組み込みテキスト補完メカニズムを改善する例を、実際のコードと併せて紹介します。

Damian Conway, Dr., CEO and Chief Trainer, Thoughtstream

author photo - damian conwayDamian Conway は、オーストラリア Monash University でコンピューター・サイエンスの Adjunct Associate Professor を務める傍ら、国際的 IT トレーニング会社、Thoughtstream の CEO でもあります。彼が日常的に vi を使うようになって 4 半世紀が過ぎた今、彼がこの依存症を克服する見込みはなさそうです。



2010年 1月 27日

あらゆるプログラミングで中心となるのは、データ構造の作成と操作です。この連載ではこれまで、Vim スクリプトのスカラー・データ型 (文字列、数値、ブール値) と、それを格納するスカラー変数についてだけ検討してきました。しかし Vim プログラミングの真の威力は、スクリプトによって関連データのコレクション全体を一度に操作する際に明らかになります。データ・コレクション全体の操作としては、テキスト行リストの再フォーマット、構成データからなる多次元テーブルへのアクセス、ファイル名の一覧のフィルタリング、そして行番号のソートなどが挙げられます。

今回の記事では、Vim スクリプトの優れた機能であるリストと、リストを格納する配列、そしてリストを極めて簡単かつ効率的に、保守しやすい方法で使用できるようにする Vim スクリプトの各種組み込み関数について詳しく見て行きます。

Vim スクリプトでのリスト

Vim スクリプトにおけるリストとは、スカラー値 (文字列、数値、参照、あるいはこれらの組み合わせ) のシーケンスのことです。

Vim スクリプトで使われているこの「リスト」という呼び方は、誤っていると言えます。ほとんどの言語では、リストは (コンテナーではなく) 値であり、不変の順序が付けられた単純な値のシーケンスを指します。それとは逆に、Vim スクリプトでのリストは可変です。そして多くの点で無名配列のデータ構造 (あるいはこのデータ構造への参照) により近いものがあり、ほとんどの場合、リストを格納する Vim スクリプト変数は配列となります。

リストを作成するには、カンマで区切ったスカラー値のシーケンスを一対の角括弧で囲みます。リストの要素にはゼロから始まる整数のインデックスが付けられます。要素にアクセスして変更するには、通常の表記と同じく要素名の終わりに角括弧を追加し、そのなかに該当するインデックスを含めます。

リスト 1. リストの作成
let data = [1,2,3,4,5,6,"seven"]
echo data[0]                 |" echoes: 1
let data[1] = 42             |" [1,42,3,4,5,6,"seven"]
let data[2] += 99            |" [1,42,102,4,5,6,"seven"]
let data[6] .= ' samurai'    |" [1,42,102,4,5,6,"seven samurai"]

ゼロより小さいインデックスを使用し、リストの終わりから逆に数えていくことも可能です。したがって、上記の例の最後にある文は以下のように記述することもできます。

他のほとんどの動的言語と同じく、Vim スクリプトのリストに明示的なメモリー管理は必要ありません。リストは格納する必要のある要素を収容するために自動的に拡大縮小します。さらに、プログラムにリストが必要なくなると、自動的にガーベッジ・コレクションによって処理されます。

ネストされたリスト

リストは文字列や数値を格納するだけでなく、別のリストを格納することも可能です。C や C++、あるいは Perl での場合と同じく、他のリストが含まれるリストは、多次元配列のように機能します。以下の例を見てください。

リスト 2. ネストされたリストの作成
let pow = [
\   [ 1, 0, 0, 0  ],
\   [ 1, 1, 1, 1  ],
\   [ 1, 2, 4, 8  ],
\   [ 1, 3, 9, 27 ],
\]
" and later...
echo pow[x][y]

上記では、最初のインデックス操作 (pow[x]) が pow のリストに含まれるいずれかの要素を返します。返される要素はそれ自体がリストであるため、2 番目のインデックス操作 ([y]) では、ネストされたリスト内のいずれかの要素が返されることになります。

リストの割り当ておよび別名参照

リストを変数に割り当てるということは、実際にはリストにポインターまたは参照を割り当てるということです。したがって、あるリスト変数から別のリスト変数への割り当てを行うと、両方の変数が指す (参照する) ベースとなるリストが同じという結果になってしまいます。通常これによって、迷惑な遠隔操作による予期せぬ結果がもたらされます。以下はその一例です。

リスト 3. 慎重に割り当てること
let old_suffixes = ['.c', '.h', '.py']
let new_suffixes = old_suffixes
let new_suffixes[2] = '.js'
echo old_suffixes      |" echoes: ['.c', '.h', '.js']
echo new_suffixes      |" echoes: ['.c', '.h', '.js']

このような別名参照の影響を避けるには、組み込み copy() 関数を呼び出してリストを複製した上で、リストではなく、そのコピーを割り当てる必要があります。

リスト 4. リストのコピー
let old_suffixes = ['.c', '.h', '.py']
let new_suffixes = copy(old_suffixes)
let new_suffixes[2] = '.js'
echo old_suffixes      |" echoes: ['.c', '.h', '.py']
echo new_suffixes      |" echoes: ['.c', '.h', '.js']

ただし注意する点として、copy() はリストの最上位レベルしか複製しません。最上位レベルにある値のいずれかがネストされたリストだとすると、その値は実際には別の外部リストへのポインター/参照です。その場合、copy() はポインター/参照を複製することになるため、以下に示すように、元のリストとコピーされたリストが依然として同じネストされたリストを共有します。

リスト 5. シャロー・コピー
let pedantic_pow = copy(pow)
let pedantic_pow[0][0] = 'indeterminate'
" also changes pow[0][0] due to shared nested list

このような事態を望んでいない場合は (大抵は望ましくありません)、代わりに組み込み deepcopy() 関数を使用してください。この関数を使えば、ネストされたデータ構造が「端から端まで」複製されます。

リスト 6. ディープ・コピー
let pedantic_pow = deepcopy(pow)
let pedantic_pow[0][0] = 'indeterminate'
" pow[0][0] now unaffected; no nested list is shared

基本的なリスト操作

Vim のリスト操作のほとんどは、組み込み関数を使って行います。通常、これらの組み込み関数はリストを引数に取り、そのリストのプロパティーを返します。

リスト. 7 サイズ、範囲、インデックスの検索
" Size of list...
let list_length   = len(a_list)
let list_is_empty = empty(a_list)     " same as: len(a_list) == 0" Numeric minima and maxima...
let greatest_elem = max(list_of_numbers)
let least_elem    = min(list_of_numbers)

" Index of first occurrence of value or pattern in list...
let value_found_at = index(list, value)      " uses == comparison
let pat_matched_at = match(list, pattern)    " uses =~ comparison

整数のリストを生成するには、range() 関数を使用することができます。引数に 1 つの整数を指定して呼び出すと、この関数はゼロから始まり、引数から 1 を引いた整数までのリストを生成します。2 つの引数を指定して呼び出すと、最初の引数から 2 番目の引数までを範囲としたリストを生成します。3 つの引数を指定して呼び出した場合も範囲のリストを生成しますが、リストには 3 番目の引数を足していった整数だけが含まれます。

リスト 8. range() 関数によるリストの生成
let sequence_of_ints = range(max)              " 0...max-1
let sequence_of_ints = range(min, max)         " min...max
let sequence_of_ints = range(min, max, step)   " min, min+step,...max

文字列を「単語」のシーケンスに分割することによってリストを生成することもできます。

リスト 9. テキストの分割によるリストの生成
let words = split(str)                         " split on whitespace
let words = split(str, delimiter_pat)          " split where pattern matches

リストを再び結合すると、元のテキストに戻ります。

リスト 10. リストの要素の結合
let str = join(list)                           " use a single space char to join
let str = join(list, delimiter)                " use delimiter string to join

その他のリスト関連のプロシージャー

Vim セッションで :help function-list と入力し、「List manipulation」までスクロールダウンすると、他にも多数のリスト関連の関数について調べることができます。ただし、これらの関数の大多数はリストの引数を直接書き換えることから、実際にはプロシージャーです。

例えば、単一の追加要素をリストに挿入するには insert() または add() を使用することができます。

リスト 11. リストへの値の追加
call insert(list, newval)          " insert new value at start of list
call insert(list, newval, idx)     " insert new value before index idx
call    add(list, newval)          " append new value to end of list

値のリストを挿入する場合は、extend() を使用します。

リスト 12. リストへの値のセットの追加
call extend(list, newvals)         " append new values to end of list
call extend(list, newvals, idx)    " insert new values before index idx

指定した要素をリストから削除するには、以下の関数を使用します。

リスト 13. 要素の削除
call remove(list, idx)             " remove element at index idx
call remove(list, from, to)        " remove elements in range of indices

リストをソートする場合、またはリスト内の要素を逆順にする場合は、以下の関数を使用します。

リスト 14. リストのソートまたは順序の逆転
call sort(list)                    " re-order the elements of list alphabetically
                                       
call reverse(list)                 " reverse order of elements in list

リスト・プロシージャーでありがちな間違い

リスト関連のプロシージャーもすべて、変更後のリストを返すことに注意してください。例えば、以下のようなコードを作成するとします。

let sorted_list = reverse(sort(unsorted_list))

上記のようなコードを作成すると、必ずと言っていいほど大きな問題を引き起こします。このように戻り値を使用しても、リスト関連の関数は相変わらず元の引数を変更するため、上記の例では nsorted_list に含まれるリストがソートされて逆の順序になります。しかも、unsorted_listsorted_list の両方が、このソートされて逆順になったリストを参照するようになってしまいます (「リストの割り当ておよび別名参照」で説明)。

これはほとんどのプログラマーにとって、かなり直観に反したことです。プログラマーというものは、sortreverse のような関数は、元のデータの変更後のコピーを返し、元のデータを変更することはないと想定しているからです。

けれども Vim スクリプトのリストはそのようには機能しません。そのため、予期せぬ問題を避けるためには、適切なコーディングの習慣をつけることが重要です。そのような習慣の 1 つとして、sort()reverse() などは純粋な関数として呼び出し、常に変更対象のデータのコピーを渡すように徹底してください。その場合のコピーには、組み込み copy() 関数を使用することができます。

let sorted_list = reverse(sort(copy(unsorted_list)))

リストのフィルタリングおよび変換

特に役立つプロシージャー型のリスト関数として、filter()map() の 2 つが挙げられます。filter() 関数はリストを引数に取り、指定された基準を満たさない要素をリストから削除します。

let filtered_list = filter(copy(list), criterion_as_str)

filter() を呼び出すと、この関数に 2 番目の引数として渡した文字列がコードに変換されて、最初の引数として渡したリストの各要素に適用されます。つまり、2 番目の引数に対して eval() を繰り返し実行するということです。この関数は各要素を評価するたびに、v:val という特殊な変数によって、最初の引数として渡したリスト内の次の要素をコードに渡します。評価されたコードの結果がゼロ (つまり、false) であれば、該当する要素がリストから削除されます。

例えば負の値をリストから削除するには、以下を入力します。

let positive_only = filter(copy(list_of_numbers), 'v:val >= 0')

以下を入力した場合は、/.*nix/ というパターンが含まれる名前がリストから削除されます。

let non_starnix = filter(copy(list_of_systems), 'v:val !~ ".*nix"')

map() 関数

map() 関数は filter() と似ていますが、要素を削除する代わりに、要素の元の値をユーザーの指定に従って変換することで各要素を置換します。構文は以下のとおりです。

let transformed_list = map(copy(list), transformation_as_str)

filter() と同じく、map()v:val によって各リスト要素を渡しながら、2 番目の引数として渡された文字列を評価します。ただし filter() とは異なり、map() は常にリストに含まれるすべての要素を保持し、リストの各値を、その各値に対してコードを評価した結果に置き換えます。

例えば、リスト内の各数値に 10 を加算するには、以下を入力します。

let increased_numbers = map(copy(list_of_numbers), 'v:val + 10')

リストに含まれる各単語を大文字にするには、以下を入力します。

let LIST_OF_WORDS = map(copy(list_of_words), 'toupper(v:val)')

filter()map() の場合も同じく、渡された最初の引数を直接書き換えることに注意してください。これらの関数を使用するときに非常にありがちな誤りは、以下のようなコードを作成することです。

let squared_values = map(values, 'v:val * v:val')

上記のコードは、以下のように作成しなければなりません。

let squared_values = map(copy(values), 'v:val * v:val')

リストの連結

リストを連結するには、+ や += などの演算子を使用します。

リスト 15. リストの連結
let activities = ['sleep', 'eat'] + ['game', 'drink']

let activities += ['code']

演算子の両側は必ずリストでなければならないことに注意してください。+= を単なる要素の追加と考えてはいけません。この演算子を使って 1 つの値を直接リストの終わりに追加することはできないのです。

リスト 16. 連結には 2 つのリストを使用すること
let activities += 'code'    
" Error: Wrong variable type for +=

サブリスト

インデックス操作の角括弧のなかにコロンで区切った範囲を指定することで、リストの一部を抽出することができます。範囲を制限するには、定数、数値を使った変数、または任意の数式を使用することができます。

リスト 17. リストの部分的な抽出
let week = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']
let weekdays = week[1:5]
let freedays = week[firstfree : lastfree-2]

開始インデックスを省略すると、サブリストは自動的にゼロのインデックスから開始します。一方、終了インデックスを省略した場合、サブリストは最後の要素で終了します。以下は、リストを (ほぼ) 均等に 2 分割する場合の入力例です。

リスト 18. 2 つのサブリストへの分割
let middle = len(data)/2
let first_half  = data[: middle-1]    " same as: data[0 : middle-1]
let second_half = data[middle :]      " same as: data[middle : len(data)-1]

例 1: 自動位置合わせの復習

リストが持つ最大の威力と有用性は、実際の例を使うと最も明確に説明することができます。そこで、まずは既存のツールを改善する例から紹介します。

この連載の第 2 回では、代入演算子を簡潔に整列させる AlignAssignments() というユーザー定義関数を詳しく説明しました。リスト 19 に、この関数をもう一度記載します。

リスト 19. 元の AlignAssignments() 関数
function AlignAssignments ()
" Patterns needed to locate assignment operators...
    let ASSIGN_OP   = '[-+*/%|&]\?=\@<!=[=~]\@!'
    let ASSIGN_LINE = '^\(.\{-}\)\s*\(' . ASSIGN_OP . '\)'

" Locate block of code to be considered (same indentation, no blanks)
    let indent_pat = '^' . matchstr(getline('.'), '^\s*') . '\S'
    let firstline  = search('^\%('. indent_pat . '\)\@!','bnW') + 1
    let lastline   = search('^\%('. indent_pat . '\)\@!', 'nW') - 1
    if lastline < 0
        let lastline = line('$')
    endif

" Find the column at which the operators should be aligned...
    let max_align_col = 0
    let max_op_width  = 0
    for linetext in getline(firstline, lastline)
" Does this line have an assignment in it?
        let left_width = match(linetext, '\s*' . ASSIGN_OP)

" If so, track the maximal assignment column and operator width...
        if left_width >= 0
            let max_align_col = max([max_align_col, left_width])

            let op_width      = strlen(matchstr(linetext, ASSIGN_OP))
            let max_op_width  = max([max_op_width, op_width+1])
        endif
    endfor

" Code needed to reformat lines so as to align operators...
    let FORMATTER = '\=printf("%-*s%*s", max_align_col, submatch(1),
    \                                    max_op_width,  submatch(2))'

" Reformat lines with operators aligned in the appropriate column...
    for linenum in range(firstline, lastline)
        let oldline = getline(linenum)
        let newline = substitute(oldline, ASSIGN_LINE, FORMATTER, "")
        call setline(linenum, newline)
    endfor
endfunction

この関数には 1 つの欠点があります。それは、処理対象の行をそれぞれ 2 回取得しなければならないことです。(最初の for ループで) パラグラフの既存の構造に関する情報を収集するために取得した後、(最後の for ループで) 各行を新しい構造に合わせるためにもう 1 度取得しています。

このように重複した操作では、明らかに最適とは言えません。それよりも望ましいのは、行を何らかの内部データ構造に格納し、そこから直接、行を再利用することです。リストをどのように処理するかがわかっていれば、より効率的かつ簡潔に AlignAssignments() を作成し直すのは不可能な話ではありません。リスト 20 に、このユーザー定義関数の新しいバージョンを記載します。このバージョンでは、複数のリスト・データ構造と、前のセクションで説明したさまざまなリスト操作関数を利用しています。

リスト 20. 更新後の AlignAssignments() 関数
function! AlignAssignments ()
" Patterns needed to locate assignment operators...
    let ASSIGN_OP   = '[-+*/%|&]\?=\@<!=[=~]\@!'
    let ASSIGN_LINE = '^\(.\{-}\)\s*\(' . ASSIGN_OP . '\)\(.*\)$'

" Locate block of code to be considered (same indentation, no blanks)
    let indent_pat = '^' . matchstr(getline('.'), '^\s*') . '\S'
    let firstline  = search('^\%('. indent_pat . '\)\@!','bnW') + 1
    let lastline   = search('^\%('. indent_pat . '\)\@!', 'nW') - 1
    if lastline < 0
        let lastline = line('$')
    endif

" Decompose lines at assignment operators...
    let lines = []
    for linetext in getline(firstline, lastline)
        let fields = matchlist(linetext, ASSIGN_LINE)
        call add(lines, fields[1:3])
    endfor

" Determine maximal lengths of lvalue and operator...
    let op_lines = filter(copy(lines),'!empty(v:val)')
    let max_lval = max( map(copy(op_lines), 'strlen(v:val[0])') ) + 1
    let max_op   = max( map(copy(op_lines), 'strlen(v:val[1])'  ) )

" Recompose lines with operators at the maximum length...
    let linenum = firstline
    for line in lines
        if !empty(line)
            let newline
            \    = printf("%-*s%*s%s", max_lval, line[0], max_op, line[1], line[2])
            call setline(linenum, newline)
        endif
        let linenum += 1
    endfor
endfunction

更新後の関数に含まれる最初の 2 つのコード・ブロックは、元の関数と比べてほとんど変わっていないことに注意してください。以前と同じくこの 2 つのコード・ブロックが、テキストの現在のインデント設定に基づいて、どこからどこまでの行の代入演算子を位置合わせするのかを突き止めます。

関数の内容が変わってくるのは、3 番目のコード・ブロックからです。このコード・ブロックでは、2 つの引数を指定した組み込み getline() 関数を使って、位置合わせ範囲に含まれるすべての行のリストを取得します。

続いて for ループがリストの各行を繰り返し処理し、組み込み matchlist() 関数を使用して各行と ASSIGN_LINE 内の正規表現との突き合わせを行います。

let fields = matchlist(linetext, ASSIGN_LINE)

matchlist() を呼び出すことで返されるのは、正規表現によって捕捉したすべてのフィールドのリストです (つまり、\(...\) 区切り文字で囲まれたパターンの構成部分と一致したフィールドすべて)。この例では、突き合わせが成功した場合のフィールドは、代入演算子の行を分解して取り出された lvalue、演算子、および rvalue の各コンポーネントとなります。

具体的には、matchlist() の呼び出しが正常に完了すると、以下の要素を持つリストが返されます。

  • 行全体 (matchlist() は常に、一致した全体を最初の要素として返すため)
  • 代入演算子の左側にあるすべてのもの
  • 代入演算子自体
  • 代入演算子の右側にあるすべてのもの

上記のリストが返されると、add() の呼び出しによって、最後の 3 つのフィールドのサブリストが行のリストに追加されます。突き合わせが失敗した場合 (行に、代入演算子が含まれていなかった場合) には、matchlist() は空のリストを返すため、add() が追加するサブリスト (以下の fields[1:3]) も同じく空になります。空のサブリストは、リフォーマッターに対象外の行であることを示すために使用されます。

call add(lines, fields[1:3])

4 番目のコード・ブロックでは、代入演算子が含まれる行ごとの構造を分析するために filter() 関数と map() 関数が使われています。最初に filter() を使って行のリストを選別し、前のコード・ブロックによって正常に複数のコンポーネントに分解された行だけを保持します。

let op_lines = filter(copy(lines), '!empty(v:val)')

次の関数は、各代入演算子の lvalue の長さを決定するために、フィルタリング後の行のコピーに対して strlen() 関数による置き換えを行います。

map(copy(op_lines), 'strlen(v:val[0])')

続いて lvalue の長さのリストが組み込み max() 関数に渡されて、すべての代入演算子のなかで最長の lvalue が決定されます。最大長によって、すべての代入演算子を位置合わせする必要がある列 (最大の lvalue より幅が広い 1 つの列) が決まります。

let max_lval = max( map(copy(op_lines),'strlen(v:val[0])') ) + 1

同じように、4 番目のコード・ブロックの最後の行では、フィルタリング後の行のコピーに対して strlen() 関数による置き換えを行ってから各行に含まれる個々の文字列の長さを最大化し、それによって、検出されたさまざまな代入演算子を収容するために必要な列の最大数を決定します。

let max_op = max( map(copy(op_lines),'strlen(v:val[1])'  ) )

最後のコード・ブロックは、代入演算子の行を再フォーマットするため、元のバッファー行番号 (linenum) と行のリストに含まれる各行を並行して繰り返し処理します。

let linenum = firstline

for line in lines

ループの繰り返し処理ごとに、特定の行を再フォーマットする必要があるかどうかがチェックされます (つまり、代入演算子とその左右が正常に分解されているかどうかのチェック)。再フォーマットの必要がある場合、この関数は printf() を使用して行のコンポーネントを再フォーマットし、新しいバージョンの行を作成します。

if !empty(line)
    let newline = printf("%-*s%*s%s", max_lval, line[0], max_op, line[1], line[2])

新しく作成された行は setline() の呼び出しによってエディター・バッファーに再び書き込まれ、次の繰り返し処理に備えて行の追跡記録が更新されます。

    call setline(linenum, newline)
endif
let linenum += 1

すべての行の処理が完了すると、バッファーは完全に更新され、該当するすべての代入演算子が適切な列に位置合わせされます。Vim スクリプトの優れたリスト・サポートおよびリスト操作を利用できることから、この 2 番目のバージョンの AlignAssignments() コードは前のバージョンより約 15 パーセントコード長が短縮されています。しかしそれよりも遥かに重要なことは、関数のバッファー・アクセス回数がわずか 3分の1 に減り、コードが大幅に簡潔になって保守しやくなったという点です。


例 2: Vim の補完機能の拡張

Vim には高度なテキスト補完メカニズムが組み込まれています。この機能について調べるには、任意の Vim セッションで :help ins-completion と入力してください。

最もよく使われている補完モードの 1 つは、キーワード補完です。キーワード補完を使用するには、テキストの挿入中、任意の時点で CTRL-N キーを押します。すると、キーワード補完機能がさまざまなロケーション (「complete」オプションで指定されたロケーション) を検索し、カーソル直前の文字シーケンスで始まる単語を探します。デフォルトの検索ロケーションは、現在編集中のバッファー、現在作業しているセッションで編集したその他すべてのバッファー、ロードしたタグ・ファイル、そしてテキスト入力で (include オプションによって) インクルードされている全ファイルです。

例えば、バッファー内に上記の 2 つのパラグラフが入れられている場合に、挿入モードで以下を入力するとします。

My use of Vim is increasingly so<CTRL-N>

Vim はテキストを検索し、「so」で始まる単語は sophisticated のみであると判断し、すぐさま以下のように単語を補完します。

My use of Vim is increasingly sophisticated_

一方、以下を入力したとします。

My repertoire of editing skills is bu<CTRL-N>

Vim は考えられる補完として、built、buffer、buffers の 3 つを検出します。その場合、Vim はデフォルトで補完候補のメニューを表示します。

リスト 21. テキスト補完候補
My repertoire of editing skills is bu_
built
                                   buffer
                                   buffers

このメニューで CTRL-NCTRL-P (あるいは上矢印と下矢印) を使って補完候補の項目を移動し、目的の単語を選択します。

補完は、随時 CTRL-E キーを押すことでキャンセルすることができます。現在選択されている項目を承認して挿入するには、CTRL-Y キーを押します。これ以外の文字 (通常は空白または改行) を入力すると、現在選択されている単語が承認されて挿入されるとともに、入力した追加の文字が挿入されます。

より賢い補完機能の設計

Vim の組み込み補完メカニズムが極めて役立つことに疑いの余地はありませんが、それほど賢い動作をするわけではありません。デフォルトで突き合わせるのは「キーワード」文字 (英数字とアンダーバー) のシーケンスのみで、しかも突き合わせるのはカーソル左側の文字シーケンスだけです。それ以外のコンテキストは理解しません。

また、この補完メカニズムは人間工学の点でもあまり優れたものではありません。CTRL-N は最も入力しやすいキー・シーケンスでも、プログラマーが入力し慣れているシーケンスでもありません。大抵のコマンドラインのユーザーは、それよりも TABESC を補完キーとして使うのに慣れています。

幸い、Vim スクリプトではこれらの欠点を簡単に修正することができます。そこで、これから挿入モードの TAB キーを定義し直し、カーソル両側のテキストのパターンを認識して、コンテキストに応じた適切な補完を選択するように教え込みます。新しいメカニズムが現在の挿入コンテキストを認識しない場合には、Vim の組み込み CTRL-N 補完メカニズムに復帰するように調整します。そうする一方で、引き続き TAB キーを使ってタブ文字を入力できるようにしたいと思います。

より賢い補完機能の指定

このより賢い補完メカニズムを作成するには、補完リクエストに対する一連の「コンテキストに応じたレスポンス」を保管しなければなりません。そこで必要になるのがリストです。もっと厳密に言えば、コンテキストに応じたそれぞれのレスポンス自体が 4 つの要素で構成されることから、リストのリストが必要になります。リスト 22 に、これに該当するデータ構造を設定する方法を示します。

リスト 22. Vim スクリプトでのルックアップ・テーブルの設定
" Table of completion specifications (a list of lists)...
let s:completions = []
" Function to add user-defined completions...
function! AddCompletion (left, right, completion, restore)
    call insert(s:completions, [a:left, a:right, a:completion, a:restore])
endfunction
let s:NONE = ""
" Table of completions...
"                    Left   Right    Complete with...       Restore
"                    =====  =======  ====================   =======
call AddCompletion(  '{',   s:NONE,  "}",                      1    )
call AddCompletion(  '{',   '}',     "\<CR>\<C-D>\<ESC>O",     0    )
call AddCompletion(  '\[',  s:NONE,  "]",                      1    )
call AddCompletion(  '\[',  '\]',    "\<CR>\<ESC>O\<TAB>",     0    )
call AddCompletion(  '(',   s:NONE,  ")",                      1    )
call AddCompletion(  '(',   ')',     "\<CR>\<ESC>O\<TAB>",     0    )
call AddCompletion(  '<',   s:NONE,  ">",                      1    )
call AddCompletion(  '<',   '>',     "\<CR>\<ESC>O\<TAB>",     0    )
call AddCompletion(  '"',   s:NONE,  '"',                      1    )
call AddCompletion(  '"',   '"',     "\\n",                    1    )
call AddCompletion(  "'",   s:NONE,  "'",                      1    )
call AddCompletion(  "'",   "'",     s:NONE,                   0    )

作成するリストのリストは、コンテキストに応じたレスポンス仕様のテーブルとしての役割を果たすものです。このリストは、リスト変数 s:completions に格納します。リストに含まれる各エントリーは、それ自体が以下の 4 つの値を持つリストです。

  • カーソル左側のコンテキストと突き合わせる正規表現を指定する文字列
  • カーソル右側のコンテキストと突き合わせる正規表現を指定する文字列
  • 上記のコンテキストが両方検出された場合に挿入する文字列
  • 補完テキストが挿入された後にカーソルを補完前の位置に自動的に復帰させるかどうかを示すフラグ

このテーブルにデータを取り込むために、AddCompletion() という小さな関数を作成します。この関数では 4 つの引数を想定します。具体的には、左側のコンテキスト、右側のコンテキスト、置換テキスト、そして「カーソル復帰」フラグです。この一連の引数は、そのまま単純に 1 つのリストに集約されます。

[a:left, a:right, a:completion, a:restore]

上記のリストは組み込み insert() 関数によって、s:completions 変数の先頭に単一の要素として追加します。

call insert(s:completions, [a:left, a:right, a:completion, a:restore])

AddCompletion() を繰り返し呼び出すことによって、それぞれに 1 つの補完を指定するリストのリストを組み立てます。この処理を行うのが、リスト 22 のコードです。

以下は、AddCompletion() の最初の呼び出しです。

"                    Left   Right    Complete with...       Restore
"                    =====  =======  ====================   =======
call AddCompletion( '{',    s:NONE,  '}',                   1      )

上記では、新しいメカニズムがカーソルの左側に波括弧を検出し、右側には何も検出しなかった場合、括弧を閉じるための波括弧を挿入してからカーソルを補完前の位置に復帰させるように指定しています。例えば以下のコンテキストを補完するとします。

while (1) {_

(ここで、_ はカーソルを表します) この場合、補完メカニズムは以下のように補完を行います。

while (1) {_}

カーソルは便利なことに、新しく閉じられたブロックの中央に残されます。

以下は、2 番目の AddCompletion() の呼び出しです。

"                    Left   Right    Complete with...       Restore
"                    =====  =======  ====================   =======
call AddCompletion(  '{',   '}',     "\<CR>\<C-D>\<ESC>O",  0      )

この呼び出しによって、補完メカニズムはさらに賢くなります。ここで指定している内容は、補完メカニズムがカーソル左側に開始波括弧を検出し、カーソル右側に終了波括弧を検出した場合、改行を挿入して終了波括弧をアウトデントしてから (CTRL-D)、挿入モードからエスケープし (ESC)、終了波括弧の上に新しい行を開始する (O) というものです。

smartindent」オプションが有効に設定されているという前提で、例えば以下のコンテキストで TAB キーを押したとします。

while (1) {_}

すると、補完メカニズムによって以下のように補完されることになります。

while (1) {
    _
}

別の言葉に置き換えると、補完テーブルに追加された最初の 2 つの AddCompletion() 呼び出しによって、開始括弧の後の TAB 補完が同じ行で括弧を閉じ、それからすぐに 2 番目の TAB 補完が (正しいインデントを使って) ブロックを複数の行に「広げる」ということです。

残りの AddCompletion() 呼び出しは、この取り決めを他の 3 種類の括弧 (角括弧、丸括弧、不等号括弧) に対して繰り返すとともに、単一引用符と二重引用符に対して特殊な補完セマンティクスを指定しています。二重引用符の後の補完によって対応する終了二重引用符を追加し、2 つの二重引用符の間の補完によって \n (改行) メタ文字を追加します。単一引用符の後の補完では対応する単一引用符を追加しますが、2 番目の補完では何も行いません。

より賢い補完機能の実装

補完仕様のリストを設定し終わったら、後はテーブルから適切な補完を選択する関数を実装し、その関数を TAB キーにバインドすればよいだけです。リスト 23 にこの関数のコードを記載します。

リスト 23. 高度な補完関数
" Implement smart completion magic...
function! SmartComplete ()
" Remember where we parked...
    let cursorpos = getpos('.')
    let cursorcol = cursorpos[2]
    let curr_line = getline('.')

" Special subpattern to match only at cursor position...
    let curr_pos_pat = '\%' . cursorcol . 'c'

" Tab as usual at the left margin...
    if curr_line =~ '^\s*' . curr_pos_pat
        return "\<TAB>"
    endif

" How to restore the cursor position...
    let cursor_back = "\<C-O>:call setpos('.'," . string(cursorpos) . ")\<CR>"

" If a matching smart completion has been specified, use that...
    for [left, right, completion, restore] in s:completions
        let pattern = left . curr_pos_pat . right
        if curr_line =~ pattern
" Code around bug in setpos() when used at EOL...
            if cursorcol == strlen(curr_line)+1 && strlen(completion)==1 
                let cursor_back = "\<LEFT>"
            endif

" Return the completion...
            return completion . (restore ? cursor_back : "")
        endif
    endfor

" If no contextual match and after an identifier, do keyword completion...
    if curr_line =~ '\k' . curr_pos_pat
        return "\<C-N>"

" Otherwise, just be a <TAB>...
    else
        return "\<TAB>"
    endif
endfunction

" Remap <TAB> for smart completion on various characters...
inoremap <silent> <TAB>   <C-R>=SmartComplete()<CR>

SmartComplete() 関数はまず、引数に '.' (カーソル位置の取得) を指定した組み込み getpos() 関数を使用してカーソルの位置を見つけます。この呼び出しによって、4 つの要素のリストが返されます。4 つの要素とは、バッファー番号 (通常はゼロ)、行番号と列番号 (いずれも 1 から始まるインデックスが付けられます)、そして特殊な「仮想オフセット」(これも通常はゼロですが、ここでは関係ありません) です。ここで知りたいのはカーソルの位置なので、この位置を示す中央の 2 つの値が興味の対象となります。具体的に言うと、SmartComplete() には列番号が必要です。そのため以下のように、getpos() から返されたリストにインデックスを付けて、列番号を抽出します。

let cursorcol = cursorpos[2]

この関数は、カレント行のテキストも知らなければなりません。そこで、getline() を使用してテキストを取得し、curr_line に格納します。

SmartComplete()s:completions テーブルに含まれる各エントリーを、カレント行に対して突き合わせるパターンに変換します。カーソルの左右のコンテキストを正しく突き合わせるには、パターンを突き合わせる場所がカーソルの列のみであることを確実にしなければなりません。Vim にはそのための特殊なサブパターン \%Nc (ここで、N は必要な列番号) があるので、関数は検出されたカーソルの列の位置を挿入することによって、このパターンを作成します。

let curr_pos_pat = '\%' . cursorcol . 'c'

最終的にはこの関数を TAB キーにバインドすることになるため、可能な場合、そして特に行の先頭では関数に TAB を挿入させる必要があります。そのため、SmartComplete() は最初にカーソル位置の左側に空白文字しかないかどうかを確認し、空白文字しかない場合には単純なタブ・スペースを返します。

if curr_line =~ '^\s*' . curr_pos_pat
    return "\<TAB>"
endif

カーソルが行の先頭に位置していない場合には、SmartComplete() は補完テーブルのすべてのエントリーを調べて、該当するエントリーがある場合にどれを適用するかを決定しなければなりません。これらのエントリーのいくつかは、補完後にカーソルを前の位置に戻すように指定することになります。それには挿入モードでカスタム・コマンドを実行する必要があります。このカスタム・コマンドは単に、組み込み setpos() 関数に前の getpos() 呼び出しから取得した元の情報を値として渡して呼び出すだけにすぎません。この関数呼び出しを挿入モードから実行するには、CTRL-O エスケープ (任意の Vim セッションで :help i_CTRL-O と入力して表示される内容を参照) が必要です。そのため、SmartComplete() は必要な CTRL-O コマンドをストリングとして事前ビルドし、cursor_back に格納します。

let cursor_back = "\<C-O>:call setpos('.'," . string(cursorpos) . ")\<CR>"

より高度な for ループ

補完テーブルをウォークスルーするために、この関数は for 文の特殊なバージョンを使用します。Vim スクリプトでの標準的な for ループは 1 次元リストの要素を一度に 1 つずつ順に処理します。

リスト 24. 標準 for ループ
for name in list
    echo name
endfor

しかしリストが 2 次元の場合は (つまり、それぞれの要素がリストである場合は)、繰り返し処理する際に、ネストされたリストの中身を展開しなければならないことがよくあります。それには、以下のような方法が考えられます。

リスト 25. ネストされたリストの繰り返し処理
for nested_list in list_of_lists
    let name   = nested_list[0] 
    let rank   = nested_list[1] 
    let serial = nested_list[2] 
    
    echo rank . ' ' . name . '(' . serial . ')'
endfor

その一方、Vim スクリプトには遥かに簡潔な省略形があります。

リスト 26. ネストされたリストを繰り返し処理するための簡潔な省略形
for [name, rank, serial] in list_of_lists
    echo rank . ' ' . name . '(' . serial . ')'
endfor

繰り返し処理ごとに、ループは次のネストされたリストを list_of_lists から取得し、そのリストで最初にネストされている要素を name に、2 番目にネストされている要素を rank に、そして 3 番目にネストされている要素を serial に割り当てます。

この特殊な形の for ループを使用することで、SmartComplete() は補完テーブルの内容を順に処理し、補完ごとの各コンポーネントに論理名を指定するという作業を簡単に行えるというわけです。

for [left, right, completion, restore] in s:completions

補完コンテキストの認識

ループ内では、SmartComplete() がカーソル位置と一致する特殊なサブパターンの左右に、それぞれ左側のコンテキスト・パターン、右側のコンテキスト・パターンを配置して正規表現を組み立てます。

let pattern = left . curr_pos_pat . right

カレント行が正規表現と一致する場合、関数は正しい補完 (補完にすでに含まれているテキスト) を見つけたことになるので、その補完をそのまま返すことができます。当然のことながら、選択された補完でカーソルを元の位置に戻さなければならない場合には (restore が true の場合)、前に作成したカーソル復帰コマンドを追加します。

あいにく、setpos() ベースのカーソル復帰コマンドには 1 つの問題があります。Vim バージョン 7.2 以前の setpos() にははっきとりとしない特異性があり、カーソルの元の位置が行の終わりで、挿入される補完テキストが 1 文字だけの場合には、挿入モードでカーソルを正しく再配置しません。こうした特殊な場合、復帰コマンドを 1 つの左矢印に変更することによって、カーソルを新しく挿入された文字に戻す必要があります。

したがって、選択された補完が返される前に、以下のコードによってこの変更を行います。

リスト 27. 行の終わりに 1 文字挿入した後のカーソルの復帰
if cursorcol == strlen(curr_line)+1 && strlen(completion)==1 
    let cursor_back = "\<LEFT>"
endif

後は、カーソルの復帰が必要な場合には cursor_back コマンドを追加して、選択された補完を返すだけです。

return completion . (restore ? cursor_back : "")

補完テーブルに現在のコンテキストと一致するエントリーが 1 つもなければ、SmartComplete()for ループを終了してから 2 つの最終的な代替手段を試行します。まず、カーソルの直前にある文字が「キーワード」文字であった場合は、CTRL-N を返すことで通常のキーワード補完を呼び出します。

リスト 28. CTRL-N 動作への復帰
" If no contextual match and after an identifier, do keyword completion...
if curr_line =~ '\k' . curr_pos_pat
    return "\<C-N>"

それでも補完が可能でなければ、リテラルのタブ文字を返すことで通常の TAB キーのように動作します。

リスト 29. 通常の TAB キー動作への復帰
" Otherwise, just be a <TAB>...
else
    return "\<TAB>"
endif

新規メカニズムのデプロイメント

残る作業は、TAB キーに SmartComplete() を呼び出させ、何を挿入するべきなのかを判別させればよいだけです。それには、以下のように inoremap を使用します。

inoremap <silent> <TAB>   <C-R>=SmartComplete()<CR>

キー・マッピングはあらゆる挿入モードの TABCTRL-R= に変換し、SmartComplete() を呼び出して、それによって返された補完文字列を挿入します (このメカニズムについての詳細は、:help i_CTRL-R を実行するか、この連載の最初の記事を参照してください)。

ここでは inoremap という形の imap を使用していますが、その理由は、SmartComplete() が返す補完文字列のなかには、TAB 文字が含まれるものもあるためです。正規の imap を使用したとすると、関数から返された TAB を挿入すると同時に同じキー・マッピングが再び呼び出され、それによって SmartComplete() が再度呼び出されて、結果的に別の TAB が返される可能性があります。

inoremap を配置すると、TAB キーには以下の機能が備わります。

  • 特殊なユーザー定義の挿入コンテキストを認識し、そのコンテキストに応じて補完する
  • 識別子の後で通常の CTRL-N 補完に復帰する
  • 他のあらゆる場所では TAB として動作する

さらに、リスト 22 とリスト 23 のコードを .vimrc ファイルに配置すれば、補完テーブルに AddCompletion() 呼び出しを追加して拡張するだけで、コンテキストに応じた新しい補完を追加することができます。例えば、以下の呼び出しを追加すると、新しい Vim スクリプト関数を開始しやすくなります。

call AddCompletion( 'function!\?',  "",  "\<CR>endfunction", 1 )

このように関数キーワードの直後にタブを入れると、対応する endfunction キーワードが次の行に追加されます。

あるいは以下の呼び出しを追加して、C/C++ コメントを賢く自動補完することもできます (cindent オプションも設定されることが前提です)。

call AddCompletion( '/\*', "",    '*/',                        1 )
call AddCompletion( '/\*', '\*/', "\<CR>* \<CR>\<ESC>\<UP>A",  0 )

上記の呼び出しを以下に適用するとします。

/*_<TAB>

すると、コメント終了の区切り文字がカーソルの後に追加されます。

/*_*/

その位置での 2 つ目の TAB は、簡潔な複数行のコメントを挿入し、カーソルをその中央に配置します。

/*
 * _
 */

これからの展望

データのリストを保管して操作することができれば、Vim スクリプトで容易に実現できるタスクの範囲が大幅に広がります。しかし、必ずしもリストが情報を収集して保管するための理想的なソリューションであるとは限りません。例えば、リスト 20 で実装し直した AlignAssignments() には、以下のような printf() 呼び出しが含まれています。

printf("%-*s%*s%s", max_lval, line[0], max_op, line[1], line[2])

各種のコンポーネントの代わりに line[0]line[1]line[2] を使用したコードは、あまり読みやすいとは言えません。そのため、初期実装でエラーが起こりやすくなると同時に、その後の保守も必要以上に難しくなります。

関連するデータをまとめておく必要がある一方、データに固有の順序がない場合や、データの順序に特別な意味がない場合はよくあります。このような場合には大抵、各データを数値によるインデックスではなく、論理名で識別したほうが得策です。もちろん、以下のようにそれぞれの定数に「名前を付ける」ための一連の変数を作成することはできます。

let LVAL = 0
let OP   = 1
let RVAL = 2

" and later...

printf("%-*s%*s%s", max_lval, line[LVAL], max_op, line[OP], line[RVAL])

しかしこのソリューションはスマートさにも安定性にも欠け、行リスト内でコンポーネントの順序が変更されたとしても、その変更に合わせて変数が更新されなければ見つけにくいエラーが存在することになりがちです。

名前付きデータのコレクションはプログラミングでは一般的な要件であるため、ほとんどの動的言語では名前付きデータのコレクションを提供する共通の構成体があります。その構成体とはすなわち、連想配列、ハッシュ・テーブル、あるいは辞書です。結局のところ、Vim には辞書もあります。連載の次回の記事では、Vim スクリプトでのこの非常に便利なデータ構造の実装について検討します。

参考文献

学ぶために

製品や技術を入手するために

議論するために

  • My developerWorks コミュニティーに加わってください。ここでは他の developerWorks ユーザーとのつながりを持てる他、開発者が主導するブログ、フォーラム、グループ、ウィキを調べることができます。

コメント

developerWorks: サイン・イン

必須フィールドは(*)で示されます。


IBM ID が必要ですか?
IBM IDをお忘れですか?


パスワードをお忘れですか?
パスワードの変更

「送信する」をクリックすることにより、お客様は developerWorks のご使用条件に同意したことになります。 ご使用条件を読む

 


お客様が developerWorks に初めてサインインすると、お客様のプロフィールが作成されます。会社名を非表示とする選択を行わない限り、プロフィール内の情報(名前、国/地域や会社名)は公開され、投稿するコンテンツと一緒に表示されますが、いつでもこれらの情報を更新できます。

送信されたすべての情報は安全です。

ディスプレイ・ネームを選択してください



developerWorks に初めてサインインするとプロフィールが作成されますので、その際にディスプレイ・ネームを選択する必要があります。ディスプレイ・ネームは、お客様が developerWorks に投稿するコンテンツと一緒に表示されます。

ディスプレイ・ネームは、3文字から31文字の範囲で指定し、かつ developerWorks コミュニティーでユニークである必要があります。また、プライバシー上の理由でお客様の電子メール・アドレスは使用しないでください。

必須フィールドは(*)で示されます。

3文字から31文字の範囲で指定し

「送信する」をクリックすることにより、お客様は developerWorks のご使用条件に同意したことになります。 ご使用条件を読む

 


送信されたすべての情報は安全です。


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=60
Zone=Linux
ArticleID=468829
ArticleTitle=Vim エディターのスクリプトの作成: 第 3 回 組み込みリスト
publish-date=01272010