♨️

Neovimでマルチバイトの文字を含んだ行のカーソル位置を取得しよう

2022/12/10に公開

この記事はVim Advent Calendar 2022 10日目の記事です。

はじめに

みなさんこんにちは、higashiです。
私はvim-denopsが出た直後くらいに練習でdps-kakkonanという括弧補完プラグインを作りました。
lexima.vimvim-sandwichをイメージして作っています。
そのため、括弧補完以外にも囲った範囲を指定した括弧で囲む、括弧を別の括弧に置き換えるなどの機能も持っています。
そんなdps-kakkonanですが、実はある程度完成してから1年ほどとあるバグを抱えていました。
それがこの記事を書くきっかけでもある「マルチバイト文字が入ると正しく動かない」です。

投稿後に発覚した話

vim-jpにてNeovimにもgetcharposが入っていることを教えていただきました。
今年の2月頃に導入されたようです。
この記事の草稿を書いていた7月ごろの私は全く気づいてませんでした。(一度getcharposをNeovimで実行してエラー吐かれた記憶はあるので単に古いNeovimを使っていたようです。)
この記事でやっていたことは以下の一行で終わるので皆さんはこちらを使いましょう。

echo getcharpos('.')

バグの原因

dps-kakkonanではカーソル位置を取得し、その前後の文字を見てゴニョゴニョするといった実装を行っています。(詳しい説明は端折ります)
カーソル位置を取得する際は getpos を使っていました。
getpos で実装する場合、その行がシングルバイト文字のみなら、特に問題ありません。
しかし、マルチバイト文字が入ってくると実際にNeovim上で自分が見ているカーソル位置と getpos で取得できるカーソル位置には違いが出てきます。
その影響でdps-kakkonanはマルチバイト文字が入ってくると括弧から出れない、正しく囲えないなどのバグを抱えていました。

解決しよう

解決方法を探してネット上を彷徨っていたところ、Teratail にてこのようなQ&Aを見つけました。

https://teratail.com/questions/350676?sort=2

このQ&Aによると charidx を使うことで取得できると書いてあり、実際に試したところ、簡単に取得ができました。
本当にありがとうございます。

そんなこんなで「解決!バグ修正!完!」と行きたかったのですが、僕のプラグインに組み込むにはもう一工夫必要でした。

実際に使ってみる

実際に取得するスクリプトは以下のようになります。(上記teratailよりお借りしています)

let pos = getpos('.')
echo charidx(getline('.'), pos[2])

このスクリプトをカーソルが の位置にある状態で実行すると 3 が出力されます。

あいうえお

先頭を0とした0-indexで数えられる点に注意です。(私はhelpをちゃんと読まずにこれにハマりました)

次にこのスクリプトを以下のようなアルファベットのみで構成されたテキストで実行します。

I love Vim.

この場合、charidx の2つ目の引数に与えた数と同じ値が返ってきます。
V にカーソルがある場合だと 8 です。

マルチバイト文字だけだと0-index、シングルバイト文字だけだと1-indexが返ってきますね。
ただ、charidx の2つ目の引数に与えた数と同じ値かどうかという違いがあるのでこの場合だと以下のような実装が成り立ちそうです。

  • charidxの2つ目の引数 == 返ってきた値 ならアルファベットとして判定し、処理を行う。
  • 違うなら日本語として処理を行う

では、半角文字とアルファベットが混ざった文章上で実行してみます。

私はVimが超very大好きです。

カーソルがの位置にある状態で実行すると 11 が返ってきます。
では、カーソルがVimのVの位置にある状態で実行するとどんな値が返ってくるのでしょうか?
答えは3です。そして、このときcharidxに渡した2番目の引数は 7 です。

これでは先ほどの条件を元にした実装の分岐は厳しそうです。
このままだとマルチバイト文字とシングルバイト文字が混ざった行で意図しない挙動をしてしまう可能性があります。
そこでこの問題を解決するために byteidx を使います。

byteidxを組み合わせ実装する

byteidx はカーソル下の文字のbyte文字としてのindexを返します。
以下は実際のコードの例です。

let pos = getpos('.')
let line = getline('.')
let col = charidx(line, pos[2])
let byteCol = byteidx(line, col)
echo byteCol

これを先ほどの例文、私はVimが超very大好きです。V の位置で実行すると出力は 7 になります。
これは charidx の第二引数に入っている値 (pos[2]) と同じです。
では y の位置で実行するとどうなるでしょう。
pos[2] の出力は 19 、byteidx の返り値は 19 でマルチバイト文字を途中に挟んでいるものの、同様の値を返してくれています。
では の位置で実行するとどうなるでしょう。
結果は pos[2] が 23 、byteidx が 22 になります。

これにより、以下の条件を元にコードを書くことができます。

  • byteidx から得られる値が pos[2] - 1 になら、カーソル下がマルチバイト文字
  • byteidx から得られる値が pos[2] と同じならカーソル下がシングルバイト文字

dps-kakkonanではこの条件を使い、バグを修正しました。
https://github.com/higashi000/dps-kakkonan/blob/main/denops/kakkonan/mod/surround.ts#L38

まとめ

Neovim上でマルチバイト文字を扱うことの多い日本人ならではの悩み(多分)ですが、このように解決できました。
これでNeovimを使ったブログ執筆も捗りそうです。
もしマルチバイト文字を含んだ行のカーソル位置取得に困ったら試してみてください。

参考

Discussion