【Vim script】Vim9 script で画像検索プラグインを作成してみた

※ この記事は 2022年9月 に作成したものを一部改稿したものです。

**Vim script** はテキストエディタの Vim 上で動作するスクリプト言語で、主に Vim の設定ファイル (vimrc) やプラグインを記述するために用いられます。

2022年6月28日に6年ぶりのメジャーバージョンアップとなる Vim 9.0 がリリースされました。
Vim 9.0 の大きな目玉となったのが、新しい Vim script である **Vim9 script** です。

旧来の Vim script には、文法が曖昧だったり、各行を実行のたびに解析しているため動作が遅い、という問題点がありました。
そのため、Vim9 script では100%の後方互換性を諦めて文法を刷新し、スクリプトをバイトコードにコンパイルして実行する方式に変更されました。
逐次解析して実行するよりも遥かに効率的で、10倍から100倍もの高速化が期待できるようです。

本格的な Vim script を書いてみたいと思いながら Vim のヘルプを読んでいたところ、エスケープシーケンスを含む文字列を端末に出力できる `echoraw()` という関数の存在を知りました。
エスケープシーケンスを出力できるということは、[以前の記事](https://qiita.com/CRaLFa/items/fed50a2bd8cd6a64fd51) で紹介した Sixel と組み合わせれば Vim 上に画像を表示することができそうです。

そこで今回は、Vim9 script で画像検索プラグインを作成してみたいと思います。

使用する Vim のバージョンは以下の通りです。
```
$ vim --version
VIM - Vi IMproved 9.0 (2022 Jun 28, compiled May 10 2022 08:40:37)
適用済パッチ: 1-105
Modified by team+vim@tracker.debian.org
Compiled by team+vim@tracker.debian.org
Huge 版 without GUI.  機能の一覧 有効(+)/無効(-)
+acl               +file_in_path      +mouse_urxvt       -tag_any_white
+arabic            +find_in_path      +mouse_xterm       -tcl
+autocmd           +float             +multi_byte        +termguicolors
+autochdir         +folding           +multi_lang        +terminal
-autoservername    -footer            -mzscheme          +terminfo
-balloon_eval      +fork()            +netbeans_intg     +termresponse
+balloon_eval_term +gettext           +num64             +textobjects
-browse            -hangul_input      +packages          +textprop
++builtin_terms    +iconv             +path_extra        +timers
+byte_offset       +insert_expand     -perl              +title
+channel           +ipv6              +persistent_undo   -toolbar
+cindent           +job               +popupwin          +user_commands
-clientserver      +jumplist          +postscript        +vartabs
-clipboard         +keymap            +printer           +vertsplit
+cmdline_compl     +lambda            +profile           +vim9script
+cmdline_hist      +langmap           -python            +viminfo
+cmdline_info      +libcall           +python3           +virtualedit
+comments          +linebreak         +quickfix          +visual
+conceal           +lispindent        +reltime           +visualextra
+cryptv            +listcmds          +rightleft         +vreplace
+cscope            +localmap          -ruby              +wildignore
+cursorbind        -lua               +scrollbind        +wildmenu
+cursorshape       +menu              +signs             +windows
+dialog_con        +mksession         +smartindent       +writebackup
+diff              +modify_fname      +sodium            -X11
+digraphs          +mouse             -sound             -xfontset
-dnd               -mouseshape        +spell             -xim
-ebcdic            +mouse_dec         +startuptime       -xpm
+emacs_tags        +mouse_gpm         +statusline        -xsmp
+eval              -mouse_jsbterm     -sun_workshop      -xterm_clipboard
+ex_extra          +mouse_netterm     +syntax            -xterm_save
+extra_search      +mouse_sgr         +tag_binary
-farsi             -mouse_sysmouse    -tag_old_static
      システム vimrc: "$VIM/vimrc"
      ユーザー vimrc: "$HOME/.vimrc"
   第2ユーザー vimrc: "~/.vim/vimrc"
       ユーザー exrc: "$HOME/.exrc"
  デフォルトファイル: "$VIMRUNTIME/defaults.vim"
       省略時の $VIM: "/usr/share/vim"
コンパイル: gcc -c -I. -Iproto -DHAVE_CONFIG_H -Wdate-time -g -O2 -fdebug-prefix-map=/build/vim-FszxFd/vim-9.0.0105=. -fstack-protector-strong -Wformat -Werror=format-security -D_REENTRANT -U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=1
リンク: gcc -Wl,-Bsymbolic-functions -Wl,-z,relro -Wl,-z,now -Wl,--as-needed -o vim -lm -ltinfo -lselinux -lsodium -lrt -lacl -lattr -lgpm -ldl -L/usr/lib/python3.8/config-3.8-x86_64-linux-gnu -lpython3.8 -lcrypt -lpthread -ldl -lutil -lm -lm
```
Vim9 script を使用するには、`vim9script` 機能が有効 (+) になっている必要があります。

## プラグインの概要
まずは、プラグインを実際に使用している様子をご覧ください。

![img_search_demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3765253/bb63eed5-d832-d9d8-0107-ad35a692e75b.gif)

`Alt` - `i` (Windows キーボード) を押下時、ノーマルモードの場合はカーソルの下にある単語、ビジュアルモードの場合は選択範囲の文字列をキーワードとして Google 画像検索を実行し、画面下部にウィンドウを開いて検索結果の画像を表示します。
検索結果は最大10件で、`Alt` - `b` で前の画像を、`Alt` - `n` で次の画像を表示します。
表示中の画像のURLは無名レジスタにセットされているので、`p` や `P` で貼り付けることができます。
`Alt` - `j` を押下すると検索結果ウィンドウを閉じます。

画像検索は Google の [Custom Search API](https://developers.google.com/custom-search/v1/overview) を利用して検索結果を JSON で取得しています。
Custom Search API を利用するためには、[Google Cloud Platform](https://console.developers.google.com/) でプロジェクトを作成し、APIキーと自分用の検索エンジンを作成する必要があります。

## プラグインの構成
プラグインのディレクトリ構成は以下のようになります。
```
.
├── autoload
│   └── img_search.vim
└── plugin
    └── img_search.vim
```
`plugin` ディレクトリと `autoload` ディレクトリを作成し、その中にそれぞれ Vim script ファイルを配置します。

`plugin` 配下のスクリプトは Vim の起動時に読み込まれ、`autoload` 配下のスクリプトはコマンド実行時に1度だけ読み込まれます。
Vim の起動が遅くなるのを回避するため、`plugin` 配下のスクリプトでは Ex コマンドやキーバインドを定義するに留め、メインの処理は `autoload` 配下のスクリプトに記述します。

コマンドのヘルプを引けるようにしたい場合は、`doc` ディレクトリを作成しテキストファイルを配置します。

## スクリプトの内容
ここからは、スクリプトの内容について見ていきます。

### plugin/img_search.vim
plugin/img_search.vim の内容は以下のようになっています。
```vim
if !has('vim9script')
    echoerr 'Vim >= 9 is required'
    finish
endif

vim9script noclear
scriptencoding utf-8

if exists('g:loaded_img_search')
    finish
endif
g:loaded_img_search = true

import autoload 'img_search.vim' as is

nnoremap <silent> <Esc>i <ScriptCmd>is.SearchImage('normal')<CR>
nnoremap <silent> <Esc>j <ScriptCmd>is.ClearImage()<CR>
nnoremap <silent> <Esc>b <ScriptCmd>is.ShowPrevImage()<CR>
nnoremap <silent> <Esc>n <ScriptCmd>is.ShowNextImage()<CR>

xnoremap <silent> <Esc>i <ScriptCmd>is.SearchImage('visual')<CR>
```
まずスクリプトの先頭で、スクリプトを読み込む Vim で Vim9 script が有効になっているかを判定しています。
無効になっている場合は、エラーメッセージを表示し `finish` コマンドでスクリプトの読み込みを停止します。

次に、`vim9script` コマンドで以降のスクリプトが Vim9 script で記述されていることを示しています。
また、`scriptencoding` コマンドでファイルで使用する文字コードを UTF-8 として宣言しています。

続いて、スクリプトが二重に読み込まれるのを防ぐため、`g:loaded_img_search` という変数を定義しています。
変数名の頭に付けている `g:` はこの変数のスコープがグローバルスコープであることを表すプレフィックスです。

Vim9 script は、旧来の Vim script とは異なりデフォルトでは2回目に読み込んだ時はスクリプトローカルの関数や変数は削除されます。
そのままでは `finish` コマンドでスクリプトの読み込みを停止するとプラグインが動作しなくなってしまうため、`vim9script` コマンド の後ろに `noclear` を付加して削除しないようにしています。 

また、ユーザは vimrc 等で `g:loaded_img_search` 変数を定義しておくことで、このプラグインをロードしないようにすることができます。

次に、`import` コマンドを使用して `autoload` 配下の img_search.vim スクリプトを `is` という名前でインポートしています。
これは Vim9 script で利用できる機能で、JavaScript の ES Modules のように別のファイルでエクスポートした関数や変数をインポートすることができます。

最後に、`nnoremap` コマンドでノーマルモードの、`xnoremap` コマンドでビジュアルモードのキーマップを設定しています。
ノーマルモードで `Alt` - `i` を押下すると `is.SearchImage('normal')` が、
ビジュアルモードで `Alt` - `i` を押下すると `is.SearchImage('visual')` が実行されるという具合です。

### autoload/img_search.vim
autoload/img_search.vim の内容は以下のURLから参照できます。

https://github.com/CRaLFa/vim9-img-search/blob/master/autoload/img_search.vim

順を追って見ていきます。

まずは、以下の部分でスクリプト内で使用する定数と変数を宣言しています。
```vim
const TMP_DIR = expand('~/.vim/img-search')
const URL_FILE = TMP_DIR .. '/url.txt'
const REG_TMP = '"'

var window: dict<number>
var imgidx = 1
```
再代入可能な変数の宣言には `var`, 再代入不可な定数の宣言には `final` または `const` を使用します。
`const` で宣言したリストや辞書は、中の要素の変更も不可になります。
変数の宣言時に型が自明でない場合、`<変数名>: <型>` のようにして型を明示します。

Vim9 script では、変数と関数のスコープはプレフィックスで明示しない限りスクリプトローカルになります。
`..` は、旧来の `.` に代わって使用される文字列の連結を行う演算子です。

続いて、`SearchImage()` 関数です。
```vim
export def SearchImage(mode: string)
    if !exists('g:img_search_api_key') || !exists('g:img_search_engine_id')
        echo 'Both g:img_search_api_key and g:img_search_engine_id are required'
        return
    endif

    var searchword = ''
    if mode ==# 'normal'
        searchword = expand('<cword>')
    elseif mode ==# 'visual'
        searchword = GetSelectedWord()
    else
        echoerr 'Invalid mode'
    endif

    if empty(searchword)
        return
    endif

    final urls = GetImageUrls(searchword)
    SaveUrlFile(searchword, urls)

    imgidx = 1
    ShowImage()
enddef
```
旧来の Vim script では `function` を使用して関数を定義しますが、Vim9 script では Ruby や Python のように `def` を使用して定義します。
Vim9 script のユーザ定義関数はパスカルケース (大文字始まり) で命名するのが一般的なようです。
また、引数と戻り値の型を明示する必要があります。
`SearchImage()` 関数は `plugin` 配下のスクリプトでインポートして使用するため、`def` の前に `export` を付加します。

処理内容としては、まず画像検索に必要なAPIキーと検索エンジンIDが定義されているかをチェックしています。

続いて、引数として渡されたモードで場合分けをして検索ワードを取得しています。
ノーマルモードの場合は `expand('<cword>')` でカーソルの下にある単語を取得、
ビジュアルモードの場合は `GetSelectedWord()` 関数を呼び出しています。

`GetSelectedWord()` 関数では `execute` コマンドを利用して `y` で選択範囲をヤンク (コピー) して無名レジスタに保存し、保存した文字列から空白を除去・改行を置換して返却しています。
```vim
def GetSelectedWord(): string
    execute 'normal! "' .. REG_TMP .. 'y'
    return getreg(REG_TMP)->trim(" \t")->substitute('[\r\n]\+', ' ', 'g')
enddef
```
`->` はメソッド呼び出しの記法で、`fuga(hoge())` のような入れ子になった関数呼び出しを `hoge()->fuga()` のようにメソッドチェーンで記述することができます。

次に、`GetImageUrls()` 関数で画像検索を実行し画像URLのリストを取得しています。
```vim
def GetImageUrls(query: string): list<string>
    const encodedquery = system('jq -Rr @uri', query)->trim()
    const url = printf('https://www.googleapis.com/customsearch/v1?key=%s&cx=%s&searchType=image&q=%s',
        g:img_search_api_key, g:img_search_engine_id, encodedquery)

    try
        final res: dict<any> = printf("curl -s '%s'", url)->system()->json_decode()
        return res.items
            ->map((_, item) => item.link)
            ->filter((_, link) => link->tolower()->match('\.\(png\|jpg\|jpeg\)$') >= 0)
    catch
        echoerr v:exception
    endtry

    return []
enddef
```
`system()` 関数を利用して外部コマンドの `jq` を実行して検索ワードを URI エンコードし、`curl` コマンドで Custom Search API を叩いています。
APIから返却された JSON は `jq` ではなく `json_decode()` 関数でデコードし、ラムダ式を活用してURLのリストに変換しています。
Vim9 script のラムダ式は JavaScript のアロー関数とほぼ同じ感覚で使用することができます。

次に、`SaveUrlFile()` 関数で画像URLをファイルに保存しています。
```vim
def SaveUrlFile(searchword: string, urls: list<string>)
    if !isdirectory(TMP_DIR)
        mkdir(TMP_DIR, 'p')
    endif

    glob(TMP_DIR .. '/*.sixel')->split("\n")->map('delete(v:val)')

    urls->insert(searchword)
    writefile(urls, URL_FILE)
enddef
```
ディレクトリがなければ `mkdir()` 関数で作成し、前回の検索結果の sixel ファイルが残っていれば削除します。
`insert()` 関数でURLのリストの先頭に検索ワードを挿入することで、1行目に検索ワード、2行目以降にURLを記載したファイルを作成しています。

その後、`ShowImage()` 関数で画像を表示しています。
```vim
def ShowImage()
    if !filereadable(URL_FILE)
        return
    endif

    const urls = readfile(URL_FILE)
    const url = urls->get(imgidx, '')

    if empty(url)
        echo 'No image'
        return
    endif

    setreg(REG_TMP, url)

    const sixelfile = printf('%s/%d.sixel', TMP_DIR, imgidx)
    var sixel: string

    if filereadable(sixelfile)
        sixel = readfile(sixelfile)->join("\n")
    else
        const maxwidth = exists('g:img_search_max_width') ? g:img_search_max_width : 480
        const maxheight = exists('g:img_search_max_height') ? g:img_search_max_height : 270

        sixel = printf("set -o pipefail; curl -s '%s' | convert - -resize '%dx%d>' jpg:- | img2sixel",
            url, maxwidth, maxheight)->system()
        if v:shell_error
            echo 'Cannot show image'
            return
        endif

        writefile([sixel], sixelfile)
    endif

    const searchword = urls->get(0, '')->trim()
    const winname = printf('%s (%d／%d)', searchword, imgidx, urls->len() - 1)
    window = OpenWindow(winname)

    printf("\x1b[%d;%dH%s", window.row, window.col, sixel)->echoraw()
enddef
```
少し長い関数ですが、流れとしては以下のようになっています。
- `SaveUrlFile()` 関数で作成したファイルから表示する画像のURLを取得し、無名レジスタに保存
- sixel ファイルが保存されていればファイルを読み込み、保存されていなければ画像をダウンロード・リサイズして Sixel 形式に変換し保存
- `OpenWindow()` 関数を実行してウィンドウを開き、`echoraw()` 関数で sixel を出力して画像を表示
`system()` 関数で実行した外部コマンドの終了ステータスは `v:shell_error` にセットされます。
ダウンロード・リサイズ・変換をパイプラインで実行するため、Bash の `pipefail` オプションを有効にしダウンロードやリサイズでエラーになった場合にも終了ステータスがエラーになるようにしています。
また、画像をリサイズする際の縦横の長さの最大値はグローバル変数で設定可能にしています。

`OpenWindow()` 関数では、`new` コマンドを実行して画面下部に新しいウィンドウを開き、window-ID とウィンドウの左上隅の画面上の位置座標を辞書で返却しています。
```vim
def OpenWindow(winname: string): dict<number>
    execute 'silent new +set\ nonumber ' .. winname

    const winid = win_getid()
    const pos = screenpos(winid, 1, 1)

    silent! wincmd p
    redraw

    return {
        id: winid,
        row: pos.row,
        col: pos.col,
    }
enddef
```
`wincmd p` でカーソルを元のウィンドウに戻した後、`redraw` コマンドで画面を再描画しています。
通常は Vim の画面の描画はスクリプトの実行終了時に行われるため、実行中に反映したい場合は `redraw` コマンドを使用する必要があります。

最後に `ESC[n;mH` の ANSI エスケープシーケンスでカーソルを移動してから Sixel のエスケープシーケンスを出力し、検索結果ウィンドウに画像を表示しています。

`ClearImage()` 関数では、`OpenWindow()` 関数で返却した window-ID と位置座標をもとに画像と検索結果ウィンドウをクリアしています。
```vim
export def ClearImage()
    if empty(window)
        return
    endif

    printf("\x1b[%d;%dH\x1b[J", window.row, window.col)->echoraw()
    win_execute(window.id, 'close')
    redraw

    window = {}
enddef
```
画像のクリアは `ESC[J` のエスケープシーケンスで行っています。

`ShowPrevImage()` 関数、`ShowNextImage()` 関数は `imgidx` をデクリメント / インクリメントすることで表示画像を切り替えています。
```vim
export def ShowPrevImage()
    if imgidx <= 1
        echo 'No image'
        return
    endif

    imgidx -= 1

    ClearImage()
    ShowImage()
enddef

export def ShowNextImage()
    if imgidx > 10
        echo 'No image'
        return
    endif

    imgidx += 1

    ClearImage()
    ShowImage()
enddef
```

## 旧来の Vim script との比較
比較のため、旧来の Vim script でも同じプラグインを書いてみました。

### plugin/img_search.vim
plugin/img_search.vim の内容は以下のようになっています。
```vim
scriptencoding utf-8

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

nnoremap <silent> <Esc>i :call img_search#search_image('normal')<CR>
nnoremap <silent> <Esc>b :call img_search#show_prev_image()<CR>
nnoremap <silent> <Esc>n :call img_search#show_next_image()<CR>
nnoremap <silent> <Esc>j :call img_search#clear_image()<CR>

xnoremap <silent> <Esc>i :<C-u>call img_search#search_image('visual')<CR>
```

### autoload/img_search.vim
autoload/img_search.vim の内容は以下のURLから参照できます。

https://github.com/CRaLFa/vim-img-search/blob/master/autoload/img_search.vim

Vim9 script との文法上の相違点としては、以下のような点が挙げられます。
- 変数名や関数名の前にスコープを表すプレフィックスを付ける
- 関数は `function` で定義し、関数名は大文字始まりにはしない
- オートロードされる関数は `<ファイル名>#<関数名>` のように定義する
- 変数の型は宣言しない
- 変数に値を代入するには `let` を使用する
- 戻り値を使用しない関数呼び出しには `call` を使用する
- 行の途中で改行 (行継続) する際には先頭にバックスラッシュが必要

実行速度については、HTTPリクエストを投げているということもありそこまでの差は感じませんでした。
計算量の多い処理を実行すると、5倍程度の速度差があるという実証結果があるようです。

https://zenn.dev/mattn/articles/9c670e5ec661becb4d88

## 終わりに
本記事では、Vim9 script を用いて Vim 上で画像検索ができるプラグインを作成してみました。

旧来の Vim script は vimrc を記述する際に使用したことがあり取っ付きにくさを感じていたのですが、Vim9 script はモダンな言語に近い書き味で親しみやすく感じました。
ネックとしては、対応している Language Server が少ないことでしょうか。

Vim script のヘルプ (公式ドキュメント) である [usr_41.txt](https://vim-jp.org/vimdoc-ja/usr_41.html) も、現在は Vim9 script ベースになっています。
これから Vim script を書いてみようという方は、Vim9 script から始めてみてはいかがでしょうか。

今回作成したプラグインは端末や外部コマンドに依存する部分が大きく、あくまで自分の環境で使うために作ったものになりますが、
次は他の Vim ユーザにも使ってもらえるようなプラグインを作成してみたいと思いました。

## 参考文献
- vim9 - Vim日本語ドキュメント
    https://vim-jp.org/vimdoc-ja/vim9.html
- eval - Vim日本語ドキュメント
    https://vim-jp.org/vimdoc-ja/eval.html
- 「Vim」がスクリプト言語を一新 ～約6年ぶりのメジャーバージョンアップv9.0が公開 - 窓の杜
    https://forest.watch.impress.co.jp/docs/news/1421103.html