作成の経緯とか動機とか目標とか
- テキトーに入れた補完をしたい
- Word Fuzzy Completion が便利だった
- でも外部の処理系に依存したくなかった
- ついでに、色々と機能をつけたかった
- バッファをまたいだ候補の検索
- 速度低下を防ぐ
🙁「ミスタイピングをすると、Backspace キーを押すのが面倒。
そこら辺をうまいこと補完してくれるものは無いだろうか。」
🙁「関数名とか変数名とか、パッと思い出せない。
部分的に覚えている名前から検索したい。」
😊 「お、Word Fuzzy Completion 便利~
だけど、先頭一致なのがちょっと残念」
😀 「よし、作ろう」
Vim script VS Python
Word Fuzzy Completion では、補完のコアとなる処理を Python で実装していました。
ですが、できれば Vim で完結させたい。
依存性
個人的に Python のランタイムが入っていない環境で作業することが多いので、自作にあたっては Vim script オンリーの実装としました。
Vim script だけでどこまで実用的な速度になるか体験したかったというのもあります。
文字の取り扱い
今回の自作にあたり、日本語も補完の対象にしようと思っていました。
そうなると、アルファベットや数字、ひらがな、カタカナ、漢字などの文字種の取り扱いを、エディタと同様にする必要があります。
Vim で w
を押して移動する単位で単語を区切りたいということなのですが、Python で、というか外部処理系でこの分割を行うとすると、連携の部分が大変そうです。
一方で、Vim script の正規表現で擬似的にせよ区切ることはできそうでした。
速度
上の事柄が念頭があったので、速度は「がんばる」ことにしました。
どういうものを作ったのか - AmbiCompletion
編集中のバッファ内にある単語の中から
中間一致で、かつ似たような単語を候補として
補完します
例えば、bakpace
を Backspace
に補完することができます。
少しのミスタイピングであれば、補完されます。
また、例えば stat
を getStatus
に補完することができます。
全バッファの単語を候補とする
getbufinfo()
を使うとバッファの情報がリストで取得できます。
あとは、getbufline(b, 1, "$")
でバッファ内の行を取得し、単語単位に分割していきます。
単語の分割は、試行錯誤した結果なのでうまく説明できませんが \V\>\zs\ze\<\|\<\|\>\|\s
で split
することにしています。w
で移動するのに似せたかっただけなので、もっと単純にできるような気がしてるんですが…
全バッファから単語を収集した場合、体感時間が結構あります。
例えば Vim マニュアルのうち eval.txt
を読み込んでいると、そこからの単語収集の部分だけで 1 ~ 2 秒かかるため、何らか高速化の必要性があります。
補完対象の単語と候補が似ているかを基準とする
AmbiCompletion では、通常の補完とは異なり、単語の先頭が一致していない場合でも候補となります。
これには、レーベンシュタイン距離の簡易版(というか、評価方法もほぼ逆転している)を使いました。
単語間の文字を比べて、一致していれば加算する、という評価をします。
- 一致する文字がある場合は+1
- その際、前の文字同士も一致していた場合は、更に+1
- 文字が異なる場合や、片方の文字列長がオーバーしていても減点はしない
- ↑ 減点しても良いと思いますが、現時点では未着手。
ここで、「neat」と「neet」の一致度を計算する過程を見てみましょう。
結果は n(+1), e(+1+1), e(+0), t(+1)の 4 点です。
- 1. 初期状態
n | e | a | t | |
---|---|---|---|---|
n | ||||
e | ||||
e | ||||
t |
- 2. n 同士
n | e | a | t | |
---|---|---|---|---|
n | +1 | |||
e | ||||
e | ||||
t |
- 3. n と e
n | e | a | t | |
---|---|---|---|---|
n | 1 | ←の1を引き継ぐ | ||
e | ||||
e | ||||
t |
- 4. n の行を最後まで
n | e | a | t | |
---|---|---|---|---|
n | 1 | 1 | 1 | 1 |
e | ||||
e | ||||
t |
- 5. e と n
n | e | a | t | |
---|---|---|---|---|
n | 1 | 1 | 1 | 1 |
e | ↑の1を引き継ぐ | |||
e | ||||
t |
現在のセルの評価スコアは、max(左の値, 上の値, 左上の値+{一致したら1})
で求めます。
一致していた場合で、左上でも一致していた場合は、max(左の値, 上の値, 左上の値+{一致したら1}+{左上一致で+1})
で求めます。
- 6. e 同士
n | e | a | t | |
---|---|---|---|---|
n | 1 | 1 | 1 | 1 |
e | 1 | 1 +1 +1(左上と連続) | ||
e | ||||
t |
- 7. e の行を最後まで
n | e | a | t | |
---|---|---|---|---|
n | 1 | 1 | 1 | 1 |
e | 1 | 3 | 3 | 3 |
e | ||||
t |
- 8. その下の e の行を最後まで(加点するのは左から引き継いだ値についてのみ)
n | e | a | t | |
---|---|---|---|---|
n | 1 | 1 | 1 | 1 |
e | 1 | 3 | 3 | 3 |
e | 1 | max(3, 1+1)=3 | 3 | 3 |
t |
- 9. 最終
n | e | a | t | |
---|---|---|---|---|
n | 1 | 1 | 1 | 1 |
e | 1 | 3 | 3 | 3 |
e | 1 | 3 | 3 | 3 |
t | 1 | 3 | 3 | 3 +1 =4 |
こういった評価計算を候補となる単語に対して行いつつ、上位の3スコア分の単語を Vim の補完リストに出すようにしています。
なお、同時に参照・更新するのは、現在注目している横1行とその上の横一行です。
そのため、計算にあたって必要なのは「neat」に相当する横並びの要素数をもつ配列2つです。
意外と省メモリなんですね。
で、評価の仕方は上記のとおりですが、これがまた遅いんです。
前述のように eval.txt
(単語数: 約4500)を読み込んだ状態で補完をかけると、評価の部分だけで 3秒弱かかります。
どうでもいいですが、私が上の例を解くのに、だいたい30秒かかりました。eval.txt
を評価する場合…
4500 * 30s = 135000s ( = 37.5h …マジか )
135000s(私) / 3s(Vim script) = 45000
ですので、Vim script は私の 4万5千倍強いです。すごいです。どうでもいいです。
高速化のための工夫
単語を収集する部分とそれらを評価する部分、それぞれが遅いことがわかりました。
ここからは、どの様に高速化していったかを解説していきます。
単語の収集→キャッシュ
補完するたびに全バッファの単語を収集しているようでは遅いので、キャッシュするようにしています。
AmbiCompletion では「全バッファの全単語」を1つのキャッシュ単位としています。
単語からその単語が最初に見つかったバッファ番号へのマップと言う形で表現しています。
{word : first_bufnr}
バッファ毎に「自分のバッファ内の全単語」というキャッシュを持つ方法もあったのですが、補完の都度これらをマージする必要がでるため、そのオーバーヘッドを嫌って採用しませんでした。(ちょっと偏見あり、本来はきちんと見積もりして判断すべき)
上記の構造にしていますので、とあるバッファ内のとある単語がなくなった際には、キャッシュから消されないことがあります。
また、バッファ自体が存在しなくなっても、最後の収集結果が残ったままになります。
この部分は改良の余地がありそうですが、まあ、収集のやり直しもできるので、そのままにしています。
安直かもしれませんが、有効期限を設けるのが簡単でしょうか。(時刻や補完実行回数)
キャッシュの更新タイミング
バッファの情報は getbufinfo()
で取得することができます。
これで取得できる情報のうち、バッファの変更時に更新される値 changedtick
というのが使えそうでした。
この chengedtick
といのがヘルプドキュメントではあまり言及がないため、どういった変更でどの程度値が増加するのかは不明です。
AmbiCompletion ではこの値が 50 増加する度に再収集を行うようにしています。(デフォルト動作)
候補の評価→簡易評価とフィルタリング
評価スコアでみた上位3位まで(実際はもう少し曖昧な基準)を補完リストに出すようにしています。
そのため、先んじて、低い計算コストで、最大限の評価スコア(上振れしても良い)を求めることができれば、
どう足掻いても上位3位になるスコアに達しないとわかった候補を除外することができます。
ちなみに、ベストスコアは被補完単語それ自身です。
このようなフィルタリングをした後でより厳選された候補を対象に評価計算を行えば、ずっと短い処理時間で済むことになります。
AmbiCompletion では、以下の条件で除外するようにしています。
①共通する文字が少ないものは除外
②共通する文字を全て1種類にした場合のスコアが上位3位に満たないものは除外
例として、被補完語「neat」を元に、候補「neet」、「netbeans」、「ezezeze」を絞り込んでみます。
①については、簡単に言えば「neat」を正規表現「[neat]」にして、それにマッチした文字列長を簡易スコアとしてフィルタリングしています。
候補 | マッチ判断 | スコア |
---|---|---|
neet | @ @+ @+ @+ | 7 |
netbeans | @ @+ @+ b @+ @+ @+ s | 11 |
ezezeze | @ z @+ z @+ z @+ | 7 |
上の表にあるように、「neet」では「@ @+ @+ @+」(@はマッチしたことを意味、@+ は連続マッチとみなし+1加点)となり、この@の文字数は7、つまり簡易スコアは7です。
「neat」同士の正式な評価(ベストスコア)は 7 です。
この時点では、評価の上振れがありますので、被補完語同士のスコアよりも高いものが出てきます。
実際には、ベストスコアの6割程度で足切りします。
② では、「一致が連続した場合に更に加点する」部分をより厳密に評価します。
間に無関係の文字が挟まった場合は、連続マッチが途切れます。
候補 | マッチ判断 | スコア |
---|---|---|
neet | @ @+ @+ @+ | 7 |
netbeans | @ @+ @+ b @ @+ @+ s | 10 |
ezezeze | @ z @ z @ z @ | 4 |
足切りをベストスコアの6割とした場合、この時点で「eeee」は足切りスコアに満たないため、除外されます。
最終的に、「neet」と「netbeans」が候補として残りました。
これらに対して正式な評価関数を適用します。
一例ですが、eval.txt
の単語を候補にした場合、4500単語が上記のフィルタリングで 300弱に減ります。
その他の工夫
ログを取る
ログを取るようにしています。
その際、前のログ出力からの経過時間も出すようにして、計測が可能なようにしています。
切り分けできる範囲でログを取る
色々な処理が行われた結果として補完リストに出力されるわけですが、
どの部分に時間がかかったか、どの部分を高速化すべきか、切り分けられるようにできるのが好ましいです。
やりたいことによっては、並列処理が入る、複数の処理の中に計測したい処理が入り込む、など、構造が難しくなると思います。
が、できれば、計測可能な単位に切り出して、高速化するべきか判断できる単位にします。