Neovim で setcellwidths() が利用可能になりました、または、パッチ移植の難しさについて

# `setcellwidths()` とは？

Vim には `setcellwidths()` という関数があります。マニアックな機能なのですが、[ヘルプ][]が簡潔で分かり易いので引用します。

[ヘルプ]: https://vim-jp.org/vimdoc-ja/builtin.html#setcellwidths()

> 指定した文字のレンジのセル幅を上書きする。これは、文字の幅が画面のセルで数えてどれだけになるかを Vim に教えるのに使う。これは `'ambiwidth'` を上書きする。

(Neo)Vim で表示される文字の幅は `1`（半角）、`2`（全角）のどちらかに決まっているのですが、これは基本的に Unicode によって定められたものを使っています。「基本的に」というのがミソで、一部の文字についてはこれが定められていないのです。

* [東アジアの文字幅 - Wikipedia](https://ja.wikipedia.org/wiki/東アジアの文字幅)

> * **A**（Ambiguous; 曖昧）- 文脈によって文字幅が異なる文字。東アジアの組版とそれ以外の組版の両方に出現し、東アジアの従来文字コードではいわゆる全角として扱われることがある。ギリシア文字やキリル文字など。

これですね。「一部」とは言い条、これが結構重要なものを含んでおりまして、[罫線素片][]、[丸数字][]、[絵文字][]などなど、日本語では良く利用されるものばかりでした。また、最近では (Neo)Vim の UI もだいぶリッチになりまして、補完候補などを罫線付きのウィンドウで表示することも出来るようになりました。これに限らず、UI の描画に様々な “Ambiguous” 文字が利用されています。

<img width="279" alt="スクリーンショット 0004-08-10 9.52.02.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/8880/1b336417-43d8-2126-d443-b8fc6d67b10e.png">

今まで（`setcellwidths()` 以前）の Vim ではこの “Ambiguous” な文字について一律 `1` または `2` の文字幅を与えることしか出来ませんでした（[ヘルプ - `'ambiwidth'`][ambiwidth] を参照）。これを上書きして、任意のコードポイントに対して任意の文字幅を与えるのが `setcellwidths()` の役割です。

[罫線素片]: https://ja.wikipedia.org/wiki/罫線素片
[丸数字]: https://ja.wikipedia.org/wiki/丸数字
[絵文字]: https://ja.wikipedia.org/wiki/携帯電話の絵文字
[ambiwidth]: https://vim-jp.org/vimdoc-ja/options.html#'ambiwidth'

# `setcellwidths()` の使い方

“Ambiguous” な文字には様々なものがあるのですが、ここでは卑近な例として「℃」（U+2103 Degree Celsius）を文字幅 `2` として取り扱うことを考えてみます。例として次のようなテキストファイルを用意します。

```sh
$ cat /tmp/do.txt
'℃'
```

これをそのまま Neovim で表示するとこうなります。

```sh
$ nvim /tmp/do.txt
```

<img width="482" alt="スクリーンショット 0004-08-10 10.04.04.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/8880/5d99e7a9-a99a-9874-b77f-a3cb61430959.png">

最後の `'` が `℃` に被っているのが分かるでしょうか？　これは `℃` の文字幅が `1` と認識されているため、3 文字目の `'` と被って描画されているのです。

:::note

これは (Neo)Vim だけではなく、ターミナルの設定にも依ります。このスクリーンショットは [iTerm2][] で撮っていますが、このアプリではデフォルトで “Ambiguous” な文字の幅を `1` と見做します。この設定は環境設定から変更可能です（Preferences → Profiles → Text → Ambiguous characters are double-width）。これは (Neo)Vim 側の `'ambiwidth'` と揃えていた方がいいでしょう。

[iTerm2]: https://iterm2.com

:::

次に、これを `setcellwidths()` で上書きしてみます。

```sh
$ nvim +'call setcellwidths([[0x2103, 0x2103, 2]])' /tmp/do.txt
```

<img width="482" alt="スクリーンショット 0004-08-10 10.04.28.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/8880/1572c1d0-814c-3955-b0e7-d88ecc2cd21c.png">

見事、`℃` が `'` に被らないように表示出来ましたね。今回は起動オプションで無理矢理指定していますが、上手く行ったら `.vimrc` や `init.lua` に書いておきましょう。

```vim:.vimrc
call setcellwidths([
  \ [ 0x2500, 0x257f, 2 ],  " 罫線素片
  \ [ 0x2100, 0x214d, 2 ],  " 文字様記号（℃を含む）
  \ " .... などなど
  \ ])
```

```lua:init.lua
vim.fn.setcellwidths {
  { 0x2500, 0x257f, 2 },
  { 0x2100, 0x214d, 2 },
  ...
}
```

# Vim のパッチを Neovim に移植することの難しさについて

`setcellwidths()` についての話はこのくらいなのですが、以下はこの関数が Neovim で使えるようになるまでの経緯を纏めておきます。

## そもそもの切っ掛け

Vim に `setcellwidths()` が実装されたのは [8.2.1535][] というバージョンでした。タイムスタンプが 2020-08-29 ですからほとんど 2 年前ですね。

[8.2.1535]: https://github.com/vim/vim/commit/08aac3c6192f0103cb87e280270a32b50e653be1

僕は Vim の開発状況を追っていなかったので、この便利な関数があることに気付いたのは随分後でした。

https://twitter.com/delphinus35/status/1353531462918656000

この呟きの後「自分でポーティング（移植）するのが早い」という尤もな指摘を受け、初めて Neovim のレポジトリーに PR したのが [2021-02-21 のこと][]でした。

[2021-02-21 のこと]: https://twitter.com/delphinus35/status/1357502669074489346

しかし、この後が全然上手く行かない。元々のパッチ（Vim 8.2.1535）はそれほど複雑な構造をしておらず、関数本体（`f_setcellwidths()`）と、実際に文字幅を計算する関数（`cw_value()`）のみから出来ておりました。この 2 つの関数さえ実装出来れば動くはず……と見て初ポーティングに挑戦したのですが、事はそう単純じゃなかったのです。

## パッチ移植の難しさとは

ポーティングには 2 種類の難しさがあります。

### 1. 移植自体が難しい

Neovim は Vim のフォークですからソースの構造が全く異なるわけではありません。しかし、フォークしてから随分時も経ち、お互いに新機能の追加とリファクタリングを行って来たために、ファイル名や関数の場所は色々動いています。それを加味してパッチを移植するのがまず難しいです。

また、機能によっては複数のパッチが必要です。今回の場合も、実装された `setcellwidths()` が何度か改修されており、最終的には 8.2.1535, [8.2.1537][], [8.2.3545][] という 3 つ + αを移植することになりました。最新の master のソースとにらめっこしながらどのパッチが他に必要か調べる必要があるのですが、ポーティングに時間が掛かればそれだけ調べるパッチも増えていくことになるのです。

[8.2.1537]: https://github.com/vim/vim/commit/b06a6d59d12dbd67d55b3c46f6e5547e9103c931
[8.2.3545]: https://github.com/vim/vim/commit/94358a1e6e640ca5ebeb295efdddd4e92b700673

### 2. 移植しても動かない場合がある

8.2.1535 のパッチを移植自体は僕の手でもなんとか終えることが出来ました。コンパイルも通り、`setcellwidths()` 関数のテストにも問題は無かったので一安心。ですが、起動した Neovim では設定が全く有効にならないのです（`℃` と `'` が被ったままでした）。

これの原因が全く分かりませんでした。どうやら他のパッチも必要らしい、ということで 8.2.1537 を移植してみたのですがこれでも全く動かない。ここで手詰まり。本業が忙しくなってきたことで僕自身もこれ以上続けることが出来ませんでした。

ここで `setcellwidths()` の「マニアックさ」が問題になります。この関数、用途から言って東アジア語圏のユーザー以外には必要性が分からないものでした（そうでも無かった事が後で分かるのですが後述）。そのため Neovim のコアメンバーにも見逃され、これから 1 年以上放置されることになるのです。

## 重い腰を上げて再挑戦

ここまでの流れとは関係無いのですが、勤務先で「vimrc 勉強会」なるものが始まりました。

https://twitter.com/DeNAxTech/status/1544154099028856832

勉強会あるあるなのですが、毎週やってるとネタが無くなるんですよね。そこで色々ひねり出しているうちに `setcellwidths()` の事を自分でも思い出しまして、「うーん、今だったらあの頃よりは Neovim の事分かったし解決出来るかな」という思いで作業を開始しました。実際に[再開したコミット][saikai]を見ると 2022-08-02 というタイムスタンプです。

[saikai]: https://github.com/neovim/neovim/pull/13883/commits/1540aacc853b170c704f339d30ce3c635e965897

あの頃気付かなかったもう一つのパッチ（8.2.3545）を移植したり、C の構文的におかしい所をちまちま直しながらやってたのですが、丁度暇だった（？）コアメンバーの [@zeertzjq 氏][]が度々コメントしてくれて徐々に作業は進みました。

[@zeertzjq 氏]: https://github.com/zeertzjq

## 突然のPRクローズ

それがある日、この @zeertzjq 氏によって PR がクローズされてしまうのです。

<img width="413" alt="スクリーンショット 0004-08-10 18.27.40.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/8880/f8c9c8df-33a9-59d4-4f24-8a0fe4f00158.png">

> こりゃとても rebase 出来ん。別に PR 作るね。

えー。rebase 出来ないからクローズ？　そんなにツリーを綺麗にしたいのかよー。と思ってたらこれはまあ邪推で、[実際出来た PR][] はそれはもう綺麗で、次にポーティングすることあれば是非参考にしたいほどでした。

[実際出来た PR]: https://github.com/neovim/neovim/pull/19674

ただ、これでもまだ `setcellwidths()` は動きませんでした。どうやら Vim と Neovim の内部構造の差によるみたいです。

> I think this is likely because Nvim's TUI is line-based, while Vim's TUI is char-based.
> 「Neovim の TUI（Text User Interface）は行指向、それに対して Vim は文字ベースだからだね、多分」

ははあ、なるほど？アレがソレだから動かないんだね（全然分かってない）。この辺がポーティングの難しい所です。今回のパッチは偶然 Neovim の奥深い所まで改修しないと実現出来ないものでした。内部構造まで深く知らないとポーティングは上手く行かない（こともある）のです。

:::note

Neovim はエディタの本質部分から画面の描画周りを疎結合にしています。そのため Vim のそれよりリファクタリングも進んで実装は全く異なったものになっているようです。これにより[様々な GUI][] が有志によって作られているのが面白いですね。

[様々な GUI]: https://github.com/topics/neovim-guis

:::

この時点で `setcellwidths()` はまともに動かない代物でしたが、「ポーティング自体はこれで終わり」とばかりにこの PR 自体はマージされてしまいます。

## 最終解決

え？マージしちゃうの？動かないのに？と思ったのも束の間、[次の PR][]が立ちます。この問題の抜本的な解決に向けて怒涛のコミットが続いた上であっと言う間にこれもマージ（この間約 13 時間）。晴れて `setcellwidths()` が動くようになったのです。

[次の PR]: https://github.com/neovim/neovim/pull/19686

https://twitter.com/delphinus35/status/1557160609955069952

PR に付いている被リンクを読むと分かるのですが、この PR、引いては `setcellwidths()` は「東アジア語圏のユーザーのためのもの」では無かったのです。

* [Emoji Country Flags · Issue #19258 · neovim/neovim](https://github.com/neovim/neovim/issues/19258)
* [Flag emojis are 2 characters and break cursor positioning · Issue #16447 · neovim/neovim](https://github.com/neovim/neovim/issues/16447)
* [Changing colorscheme causes jumbled text in the buffer · Issue #19573 · neovim/neovim](https://github.com/neovim/neovim/issues/19573)

なるほど～。国旗の絵文字など、複数のコードポイントで一つの文字を表す際に、今までは余分な空白が出たりしていたのですね。これを解決するのに `setcellwidths()` が使えると。これは気付きませんでした。日々レポジトリーに上がってくる issue を見続けているコアメンバーだからこそ気付けた用途です。@zeertzjq 氏もその予感があったからこそ、僕の PR に目を付けてくれたのでしょう。

# まとめ

最後にまとめです。

* `setcellwidths()` が実装されたので使おう！
* Vim → Neovim のポーティングは難しい（多分逆も）。

完全なポーティングは、コアメンバーのように Neovim のことを奥底まで分かっていないと難しいです。ただ、今回のようにマニアックな機能でも、全く関係無さそうな issue で困っている人を助けることになるかも知れません。それに気付く事が出来れば色んな人が助けてくれるはずです。

今回は最初の PR の作成から 2 年近く経ってしまいましたが、僕の作業の再開（2022-08-02）からは一週間（2022-08-09）で解決してしまいました。コアメンバースゲーってことでもありますし、僕が上記の事に気付ければもっと早く解決したでしょう。反省です。

<img width="499" alt="スクリーンショット 0004-08-10 19.16.01.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/8880/411d76d9-437b-3af0-93ba-e3254ced5e11.png">

これで Neovim のコミットツリーにも半分だけ顔が載ることになりました！