Surround the world #2

surround.vim の正確な動作仕様が知りたいのだけれど、それっぽい文書が見つからないのでソースと動作を見ながら目コピーせざるを得ない。

先立って用語がある。

  • 包囲領域: カーソル位置を含む選択範囲。前方に開始文字列、後方に終端文字列がある
  • 開始文字列: 包囲領域の先頭にある文字列。”(” “‘” のようにたいていは 1 文字だがタグの場合は複数の文字になりうる
  • 終端文字列: 包囲領域の末尾にある文字列。”)” “‘” のようにたいていは 1 文字だがタグの場合は複数の文字になりうる
  • 包囲識別子: 開始文字列、終端文字列へマッピングされる 1 文字の識別子:
    識別子 開始文字列 終端文字列 備考
    / /* */ C コメントを対象にする
    次のうちのいずれか: !#$%&*+,\-.:;=?@^_|~ 識別子そのもの
    次のうちのいずれか以外のテキストオブジェクトの末尾の1文字: pswW それぞれのテキストオブジェクトの先頭文字及び末尾文字。さらに、識別子が以下のいずれかである場合は開始文字列の末尾および終端文字列の先頭に U+0020 が付加される: ([{
    a < >
    r [ ]
  • 包囲識別文字列: 開始文字列、終端文字列へマッピングされる 1 文字以上の文字列。以下の例外を除き、包囲識別子と同じく 1 文字の入力をユーザーに求める。以下の場合はモードは line_input へ遷移する:
    • 最初の文字が [cci]tT^T<,[/cci] のいずれかであった場合、[cci]>[/cci] が入力されるまでは通常の line_input と同じ動作。初期値は [cci]<[/cci]。[cci]>[/cci] が入力されたら自動的に line_input モードを抜ける(これは一時的な cnomap により実現されている)
    • 最初の文字が [cci]l[/cci] または [cci]\[/cci] であった場合、…
    • 最初の文字が [cci]f[/cci] または [cci]F[/cci] であった場合、…
    • 最初の文字が [cci]^F[/cci] であった場合、…
    • 最初の文字が上記以外であった場合、その文字が入力されたら自動的に line_input モードを抜ける。
      以下の文字に関しては特別なマッピングが施される:

      文字 開始文字列 終端文字列
      b ( )
      B { および U+0020 U+0020 および }
      r [ ]
      a < >
      p \n \n\n
      s U+0020 (空文字)
      : : (空文字)
      上記以外の英字 (空文字) (空文字)

      またこのとき、U+0020 を前置することができる。U+0020 を前置した場合、包囲を新しく行う場合に開始文字列の直後、終端文字列の直前に U+0020 が追加される

* * *

surround.vimは大きく分けて normal モード、input モード、visual モードのそれぞれに特定のマップを定義する。まず normal モードから見ていく。normal モードに定義されるマップは

  • [cci]ds[/cci]: ds + 包囲識別子 を入力する。包囲領域から開始文字列・終端文字列を削除する
  • [cci]cs[/cci]: cs + 包囲識別子(from) + 包囲識別文字列(to) を入力する。領域を囲んでいる from を to に変更する
  • [cci]cS[/cci]: cs と似ているが、開始文字列の直後、終端文字列の直前に改行が挿入され、また包囲領域の内容は適切にインデントされる
  • [cci]ys[/cci]: ys + (モーション | テキストオブジェクト) + 包囲識別文字列を入力する。モーションによって生成された範囲の先頭に開始文字列、末尾に終端文字列を追加する
  • [cci]yS[/cci]: ys と似ているが、開始文字列の直後、終端文字列の直前に改行が挿入され、また包囲領域の内容は適切にインデントされる
  • [cci]yss[/cci]: これは ys の Operation Alias に相当する。つまり暗黙的に [cci]_[/cci] モーションが指定されたことになる。従ってカーソル行全体が対象になるのだが、開始文字列が追加されるのは対象文字列の最初の非空白文字の直前、終端文字列が追加されるのは対象文字列の最後の非空白文字の直後である。なお、この機能はカウント n を前置することができ、その場合はカーソル行から n 行分が操作対象になる
  • [cci]ySs[/cci]: yss と似ているが、開始文字列の直後、終端文字列の直前に改行が挿入され、また包囲領域の内容は適切にインデントされる
  • [cci]ySS[/cci]: ySs と同じ

visual/select モードに定義されるマップは

  • [cci]S[/cci]: (v|V|^V) + S + 包囲識別文字列を入力する。normal モードの ys に相当
  • [cci]gS[/cci]: (v|V|^V) + gS + 包囲識別文字列を入力する。normal モードの yS に相当

いくつか気になる点がある。

  • d/c オペレーションが認識する包囲領域は複数行に対応していないようだ。いいのかな?
  • [cci]ys[/cci] はちょっと動作とコマンドが乖離しすぎて違和感がある。なぜ y に割り当てたんだろう
  • [cci]ys[/cci] が包囲識別文字列を最後に入力させるのは実装もめんどうそう。ys + 包囲識別文字列 + (モーション | テキストオブジェクト) ならちょっとだけ楽だったんだけど
  • 言うまでもなく、vi コマンドのオペレーションはオペレータとモーションの組み合わせで構成され、そこには直交性がある。が、surround.vim の場合決め打ちで最低限のマップ定義しかしてないので直交性が崩れている。いいのかな? ただし、たとえば [cci]>s”[/cci] とかできてもあんまり意味はないのは確かではある

しかし surround.vim 自体がすでにほぼ10年の歴史のあるプラグインであるので、世の中のユーザーはこの仕様にすっかり馴染んでいると思われる。なのでできるだけ尊重してそのまんま移植するのが正解であろう。

Surround the world

issue の中に、surround.vim をサポートしてほしいというものがある。

最初に告白しておくと、個人的には surround.vim 以前に vim のこの手のプラグインはまったく使っていない(別に使わない主義とかそういうわけではない)。なので、便利であれば実装することにやぶさかではないのだけれど、プラグインってなに? というところから調査しないといけない。

vim のプラグインは、本質的には vimscript のソースで、これは indent や syntax や colors で使うファイルも同様だ。つまり結局のところ全部 vimscript なのだが、それぞれで何を行うかが暗黙の了解的に区分けられていたり読み込まれるタイミングが違ったりはする。

まあそれはそれとして、とりあえず surround.vim を読んでみる。このファイルを ~/.vim/plugin/ に置くと、テキストオブジェクトが結果的に拡張されて i なんちゃらとか a なんちゃらの他に s なんちゃらと S なんちゃら、および ss/Ss/SS なんちゃらが使えるようになる。また visual モードでもいくつかの拡張が行われる。

結果的にというのは、本来テキストオブジェクトを拡張するとしたらコマンドディスパッチャへのフックとかそういう話になると思うのだが、surround.vim はそれを単に map で処理しているからだ。これはこれでなるほどという感じはする。ただ、これを wasavi に組み込むとなると、やはりコマンドディスパッチャに融合させる形になると思う。map で処理してもいいけどタイムアウト処理とかを意識する必要が出てくる。

0.6.410 released

各エクステンションストアにリリースした。ただし例によって Firefox 版は beta 扱い。

それはさておき。

wasavi の設定って、エクステンションの localStorage もしくは SimpleStorage に保存してある。つまりローカルに持っている。一方で、例えば Chrome には、chrome.storage.sync のように自動的に複数のデバイス間で同期が行われるストレージもある。もしかして、wasavi の設定もそういうストレージに置いたほうが便利なのではなかろうか?

と思って実装してみようとしたのだが、やっぱりやめた。同期が行われるということは wasavi の設定が google 先生のサーバとやりとりされるということだ。もし exrc にムフフな情報を書いていた場合、それが漏れないという保証ができない。いやまあそうそう漏れることはないんでしょうけど。

実装するとしたら自動的ではなくてユーザーのアクションをトリガーにするような半自動的な物のほうがよいのかもしれない。よくないかもしれない。

Some essential changes

wasavi 0.6.387 を github に上げた。このビルドには、いくつかの重要な変更がある。

まず、Firefox 版が Github 上で起動しない問題。前述の通り、これは wasavi をページ上に出す際の iframe に割り当てる src のドメインの問題だ。Github の CSP が data: スキームを許可していない。

そこで、iframe の src へは [cci]about:blank?wasavi-frame-source[/cci] というアドレスを割り当てることにした。当然中身は空っぽなので PageMod を経由して中身を差し込む。ちなみに about: スキームも CSP は許可していないのだが、結局 CSP の記述を実際にどう利用するかはブラウザが判断することであり、いろいろな例外があるわけで、すなわち about: 系は許されるのであり。このへんは実際に試してみないとわからないのだった。

実は Github で起動しない理由は他に、Hotkey モジュールに定義したショートカットよりもページスクリプトが消費するキー入力イベントが優先されるというのもある。しかし Firefox 版で Hotkey モジュールを使った理由というのは、それがページスクリプトよりも優先されるはずだからなのであり、実際その当時はその通り動作した。しかし現在はそうではないのである。しかし、まあ、Firefox というものは、大体において、そういうものなのでしょう、きっと。これは単に Chrome や Presto Opera と同様、keydown のフック機構を経由してキー入力を監視する方式に切り替えればいい。このくらいでは泣かないぞ。

* * *

insert モードと replace モードを総称して input モードと呼ぶ。従来はそれを実現するために、カーソル位置に重ね合わせる形で透明な textarea を配置し、それが受けたイベントを wasavi へ転送するというわけのわからない複雑さを備えた謎の機構を経由していた。なんでこんなめんどくさいことをしているのかといえば、すなわち Presto Opera のためだ。あの苦労してこさえた擬似 composition イベントは、textarea がフォーカスを持っている状態じゃないと正しく生成できないのである。

しかし、まあ、Presto Opera でありますよ。そろそろ引退させたほうがいろいろな方面が幸せになるのですよ。

というわけで input モードでは div 要素の contenteditalbe 属性を利用するようにした。これにより結構な量のコードをばっさり削除できるのである。おまけに入力のオーバーヘッドも減り、Selenium のテストも速くなり、word wrapping なども CSS での指定をそのまま使用でき、悪いものはないのである。強いて言えば前述の通り Presto Opera で IME を通した入力ができなくなるが、Presto Opera 上で wasavi を使っている人間なんて地球上にただ数人いるかいないかであり特に問題ではない(とはいえ最低限おかしくならない程度の処理は入れたが、まあ完全ではない)。

Up and Down Quickly

github 上の wasavi の issue で、[cci]j[/cci] [cci]k[/cci] が重い というものがあるのだが、いくらなんでもそれらのキーの機能が重かったら即気が付くわけで、従ってまったく手元で再現せず手を付けられないでいた。

しかし、ふともしかして [cci]:set jkdenotative[/cci] してるんじゃないの? と聞いてみたところその通りであるらしい。なるほど。すべての謎が解けた。

本来 [cci]j[/cci] と [cci]k[/cci] は、改行までの 1 行(物理行)を単位としてカーソルを上下に移動させる。一方、[cci]gj[/cci] と [cci]gk[/cci] は、レンダリングされた結果における折り返し行を単位とする。ここで、もしユーザーが改行までの 1 行が十分に長いテキストを頻繁に編集するのなら、[cci]j[/cci] [cci]k[/cci] は折り返し行単位で移動したほうがカーソルの移動は圧倒的に楽である。

そういうわけで、[cci]jkdenotative[/cci] オプションは [cci]j[/cci] [cci]k[/cci] と [cci]gj[/cci] [cci]gk[/cci] の機能を交換するか否かを指定する。まあ [cci]:map[/cci] でもいいんだけど。

で。この折り返し行単位の移動が確かに重いのである。従って [cci]:set jkdenotative[/cci] とすると、[cci]j[/cci] [cci]k[/cci] でのカーソルの移動がめちゃ重くなるわけだ。

よしわかった。やってやろうじゃん。100 倍速くしてやる。

折り返し行単位でのカーソルの移動を行うためには、折り返し行のそれぞれの先頭位置が、物理行のどの位置に対応するかというマッピング情報が必要になる。wasavi における物理行はすなわち div 要素なのだけど、しかし DOM にはそのマッピング情報を得る手段がない。そういうわけで、かなり力技を使わざるを得ない。物理行のある位置 n が属する次の折り返し行の先頭の物理位置を得るには、n から物理行の末尾に向かってループする。ループ内では 1 文字ずつカーソル位置を span でくくり、getBoundingClientRect() を呼び出す。これが前回の呼び出し位置と top プロパティが異なっていればそれが折り返し行の区切りになる。従来のやり方はこれであり、そして、すんごく重い。getBoundingClientRect() が重い。そういうわけでできるだけこのメソッドの使用回数を抑えるか、あわよくばゼロにする必要がある。

getBoundingClientRect() が重いのは、それがページ全体における要素の位置を計算するからだと思う。display:static な要素の場合、その要素の前のすべてを再計算しないといけない(レンダリングの結果でそれぞれの要素がそれを保持していないんだろうか? という気もするが)。

それはそれとして、とにかく別の方法を使わなければならない。ループを用いるのは同じだが、先立ってカーソル位置をまず span でくくり、これを left とする。また、カーソル位置から物理行末尾までの文字列を別の span でくくり、これを right とする。

ループでは right の先頭を left の末尾へ追加するという処理を行い、その都度 left の offsetHeight をチェックし、ループ直前のそれよりも増えていれば、折り返し行の区切りに達したことになる。offsetHeight はその要素だけで再計算が完了するはずなので、getBoundingClientRect() よりは速い。

また、ループは 2 つのステージで構成するようにする。第 1 ステージでは、ループカウンタの増分は 1 ではなくループごとに 2 倍して、ガンガン先に進める。この場合、「次の」折り返し行を超えた先へ到達してしまう可能性があるので、そのようである場合(初期 offsetHeight + 1 行分の lineHeight よりも offsetHeight が大きければ)増分を 1/2 してやり直したりする微調整は必要。

いずれにしてもカーソル位置の次の折り返し行に達した場合は第 2 ステージへ移り、増分を 1 に固定して正確な折り返し行の区切り位置を見つけ出す。これによりオーダーは O(n) から O(log2 n) になる、はず。

やってみたところ、修正前は [cci]10j[/cci] するのに 1600ms ほどかかっていたものが、160ms 程度まで短くすることができた。うーんさすがに 100 倍速は無理だった。また、折り返し行の区切り位置を見つけ出すループに関しては、たとえば単純ループが 38 回かかるところを 14 回で済んでいる。これはちょっと多いが、第 1 ステージは 6 回程度で抜けているのでオーダーの計算としては合ってる。第 2 ステージは更に最適化できるかもしれない。

というわけで折り返し行単位のカーソル上下移動が実用的になったと思う。最初からそう組んどけよ! と言われればはいその通りですと言わざるを得ないのだが。

Input mode revised

キーボード周りで Qeema を利用することにしたが、それにより若干従来のキーボードマネージャからインターフェースが変わった部分がある。この際なので、input モード周りのキー入力のハンドリングのおさらいをしたい。

モードが insert / replace へ遷移することにより、カーソル位置にまず I ビームを表示させる必要がある。この I ビームは、textarea 要素のそれを流用する。ちなみに input モード時に textarea 要素を流用するのは I ビームの見た目がほしいからではなく、composition events は、activeElement が編集可能な要素じゃないと発生しないからである。

textarea 要素のその位置は、折り返し行単位のカーソル行の上にぴったり重ね合わせるようにする。また、ここでカーソル行の前後に 2 つの span を生成する。

1 つは、折り返し行の先頭から I ビームまでの文字列で、これを leading と呼んでいる。leading span は visibility:hidden にする。その代わり、textarea の value 要素を leading で初期化する。これにより、カーソル行のカーソル位置と input 用 textarea の I ビームの位置が(だいたい)一致する。だいたいというのは…理屈の上では、margin やら padding やらを合わせれば wasavi の行の div と textarea で揃えればぴったり重ね合わさるはずが、ブラウザによってはそうならないものもあるのである。ただまあ、ずれるとしても数ピクセルなので大したことではない。ただ I ビームの先頭位置を を textarea の内容で調節するのはもう 1 つの問題がある。つまり色分けできないのである。現状では wasavi は色分けしないのでいいのだけど、将来必ず困ってしまうのであるのであるが、それはさておき。

もう 1 つの span は I ビーム位置に挿入する空の span で、compositionupdate や compositionend イベントで送られてくるプリエディット文字列を入れる場所だ。このようにして入力用 textarea とプリエディットの状態を同期させることによりインライン入力っぽいような動作をさせている。実際に IME から一気に送出された文字列を挿入する処理は、IME 経由以外の方法で入力された文字と共通する。この仕掛けは qeema の恩恵による。

composition events を正しくリスンし正しく相応しい処理を行うのは、アジア圏の言語のためだけではない。Compose キーを併用した文字の入力は、たとえば Firefox では composition events を経由するので(それを踏まえて qeema でもそうしてある)、より広範囲の言語に対応するために必須の処理だ。

ところで Project Spartan なる新生 IE は、javascript ベースの拡張をサポートするそうだ。Presto Opera のサポートを漸次的に終了すると同時に、Spartan の動向をウォッチする必要があるかもしれない。

execution #3

ex コマンドの executor をごそっと書き直した。また、global コマンドの内容もそれに合わせた。

基本的には想定したコードそのままなのだが、ひとつ思いがけないことがあった。個々の ex コマンドはアドレスを前置することができる。このとき、絶対指定以外の行番号は現在のカレント行から相対的に決定される。それで、現在は ex コマンドのソース文字列をパースする段階でアドレスも評価し、実際の行番号を得ているのだが、一方で global コマンドの入れ子コマンドはいったん生成した中間形式を繰り返し再利用する形になっている。つまり、アドレスの情報が最初に評価した状態から変更されないのである。アドレス情報だけは実行ごとにダイナミックに生成しないといけない。

とりあえずあんまり綺麗ではない手法で何とかしたが(ex コマンドを実行した後にアドレス情報を破棄する。再度実行した時アドレス情報が無効なら再生成する)、これはパースの段階では単に文法的な誤りがないことだけを判断し、評価は実行直前まで遅延させたほうがいい。これはそのうち直す。

次は s コマンド。

execution #2

s コマンドについてもまとめておきたい。

s コマンドは global に似ていて、前段の処理と後段の処理が分かれている。前段で正規表現にマッチした箇所を覚えて、後段でそれらを置換する。したがって中間形式でも、ユーザがアクセスできる ex コマンドは前段、実際に仕事をするのは後段というように分けるのが自然だ。後段を latter-subst と名付けよう。

s コマンドは c オプションを付加するかどうかで動作がまるっきり変わる。すなわち、置換ごとにユーザに動作を確認するインタラクティブなモードと、一気に置換するモードだ。後者の場合は、latter-subst を生成する必要はない。前段の処理の最後に置換処理を呼び出して終わりである。したがって latter-subst は c オプションが付加されている場合のみ生成される。

c オプションが付加されると、モードが ex_s_prompt になる。また、置換に必要な情報は SubstituteWorker インスタンスに格納され、保持される。そのモードでのキー入力によって適切な動作を行う。現在は、modeHandler が SubstituteWorker を直接制御しているのだが、これは latter-subst に移譲したほうがよいだろう。つまり ExCommandExecutor のプロパティとして現在のプログラムカウンタが指す ex コマンドオブジェクト的なものを参照できないといけない。

なかなか固まってきた。そろそろ実装したい。

execution

ex コマンドの実行処理を再考したい。

実行処理の核となるループは単純だ。

  • ソース文字列は ex コマンド列である
  • ソース文字列から ex コマンドをひとつ取り出す。このとき、ex コマンドの区切りは改行だったり、[cci]|[/cci] だったりコマンドごとに異なる。この違いを吸収するのはパーサの仕事だ
  • 取り出した ex コマンドから引数を解析する
  • コマンド毎のハンドラを呼び出す
  • ハンドラがエラーを返すか、ソース文字列の最後まで実行するまで繰り返す

しかし単純ではないものもある。まず、動作が非同期に行われるコマンドがある。

  • クリップボードからの読み込み
  • dropbox などからのファイルの読み込み

これらのコマンドを実行した場合、いったん実行ループをそこで終了し、待機状態にする必要がある。ex コマンド実行中ではあるが、待機中ということだ。そして、それぞれの処理が完了した時点で実行ループを再開する。なお、待機状態のタイムアウトや、[cci]^C[/cci] を特別なシグナルとして扱う処理は必要だ。

もうひとつ面倒なのは、ex コマンドが入れ子に実行される場合がある。global、v、および edit コマンドなども該当する。

現在の ex コマンド実行クラスである ExCommandExecutor は、非同期処理の機構は基本的には実装済みであるものの、先の記事の通り global の入れ子としてのコマンド群の実行が非同期処理に対応していない。これをどうにかするのがこのミッションのステートメントだ。

ExCommandExecutor はまずソース文字列を解析し、ひとつの ex コマンドにひとつのオブジェクト(引数とハンドラを保持する)が対応するその配列を生成する。ソースそのものではなくいわば中間形式にして保持するのは、非同期の完了時に呼ばれる仮想的・擬似的な ex コマンドを動的に ex コマンド列中に挿入する必要があるためだ。ソース文字列のままだと挿入するポイントをつかみにくい。なお擬似というのは中間形式の状態では存在するが、あくまで内部的に使用するものであって、ユーザーが直接使用することはできないということだ。

それを踏まえて入れ子コマンドを考えるに、中間形式も入れ子の構造にする必要はない。たとえば配列の [n] 番目が edit でその引数に読み込み完了後に実行される ex コマンドを指定した場合、時系列的にはその入れ子のコマンド群は [n+1] 番目のコマンドの前に実行されるわけなので、単に一次元の配列のまま [n] の直後、[n+1] の直前に新しい中間形式を挿入すればよい。あくまで一次元の配列のまま考えばよい。

ただしそうは言っても、global の場合はもっと複雑になる。[n] 番目が global だったとき、[n] の直後に入れ子コマンドの中間形式を挿入するのは同じだが、先頭には擬似 ex コマンドとして global の処理の後段、つまり与えられた正規表現にマッチする全ての行に対して入れ子コマンドを実行するループを制御する処理を司る中間形式 latter-global-head が入る。また末尾にもまた擬似 ex コマンドとして、入れ子コマンドの実行後の分岐を行う処理を司る中間形式 latter-global-bottom が入る。latter-global-head は引数とハンドラの他に

  • 正規表現にマッチした全行への参照
  • 入れ子の ex コマンドの個数

も保持する必要がある。latter-global-bottom は

  • 入れ子の ex コマンドの個数

を持つ。

そして、実際にこの中間形式を実行する際は、

  1. global: 次に実行されるべき latter-global-head を挿入する
  2. バッファから正規表現にマッチする行を抜き出し、latter-global-head に保持させる
  3. 入れ子のコマンドの中間形式を latter-global-head の直後に挿入する
  4. 続けて latter-global-bottom を挿入する
  5. latter-global-head、latter-global-bottom に挿入した中間形式の個数を保持させる
  6. latter-global-head: マッチしている行が残っているなら、何もしない。すなわち直後の中間形式を実行させる
  7. マッチしている行が残っていないなら、中間形式のプログラムカウンタ的なものに入れ子の ex コマンドの個数を加算し、実行をスキップさせる
  8. latter-global-bottom: 入れ子のコマンドが実行されるとその最後にこの中間形式が実行される。プログラムカウンタを latter-global-head へ戻す

というような、逐次処理・分岐処理・繰り返し処理を備えたちょっとした構造化言語として振る舞わせる必要がある(これは一見、ex コマンドの実行処理部がそのままスクリプティングの実行エンジンに転用できそうな感じがするが、その予定はない)

それから、入れ子のコマンドでは特定のコマンドが使用を制限されることに気をつけなければならない。global の入れ子コマンドは global と v を使うことができない、と POSIX は定義している。これはわかる。しかし不思議なことに、edit コマンドに付加できるコマンドにはそういう制限はないように思える。つまり edit の入れ子に edit したりすることが許される。[cci]:edit +edit\ B A[/cci] としたとき、最終的に読み込まれるのは B だ。これにどういう意味があるのかはわからないが、まあそういうふうに動作するように組んでみよう。