日本語を分かち書きして Word Motion で移動できるようにしました

概要

前置き

この記事はVim Advent Calendar 2024 の 16日目の記事です。本記事執筆時点で15日目の記事は登録されていないので、本記事の前の記事は、nil2 さんの Vimの:{range}!を通して任意の言語でテキストを処理する です。AWK でテキストを整形したりワンライナーのスクリプトのデバッグをするのに便利そうな手法ですね。

Vim/Neovim には単語単位で移動する Word Motion という機能があり、w, b, e, ge で単語単位の移動ができますが、スペースを単語の区切りとしていますので、日本語では使いにくい機能でした。

そんな中、Vim Advent Calendar 2024 の 2日目の記事tinysegmenter.nvim という日本語の分かち書きを実現する Neovim プラグインが紹介されていましたが、こちらを利用すると日本語の文章を単語単位で簡単に区切れますので、これを使って Word Motion を改造してみようと思いました。

いざやってみると結構苦労しましたが、何とか形になりましたので、その成果を公表します。

実際の動作

前提条件

1❯ nvim --version
2NVIM v0.10.1
3Build type: Release
4LuaJIT 2.1.1713773202
5Run "nvim -V1 -v" for more info

tinysegmenter.nvim で日本語を分かち書きしますので、事前にインストールします。私は 🚀 Getting Started | lazy.nvim でプラグインを管理していますので、以下のコードを設定ファイルに追記して Lazy コマンドからインストールします。

1return {
2  "sirasagi62/tinysegmenter.nvim",
3}

本記事執筆時点で使っている tinysegmenter.nvim のバージョンは次のとおりです。

1tinysegmenter.nvim -> commit 64ac8a1

実装

まず、tinysegmenter.nvim で文章を分かち書きした結果を示します。

元の文章 今回は、2020年のアドベントカレンダーの記事で「設計中です」としていたキーボードについて、やっと自分なりに満足できる形になってきましたので、このキーボードの設計の意図なんかを書いていきます。

変換後の文章 { "今回", "は", "、", "2", "0", "2", "0", "年", "の", "アドベントカレンダー", "の", "記事", "で", "「", "設計", "中", "です", "」", "と", "し", "て", "い", "た", "キーボード", "について", "、", "やっと", "自分", "なり", "に", "満足", "できる", "形", "に", "なっ", "て", "き", "まし", "た", "の", "で", "、", "この", "キーボード", "の", "設計", "の", "意図", "なんか", "を", "書い", "て", "いき", "ます", "。" }

この結果を見ながら Word Motion を改造します。改造は、以下の方針で実装しました。

  • ASCII 文字の上にカーソルがある場合は本来の Word Motion をそのまま実行し、カーソルが日本語の文字の上にある時だけ改造した Word Motion を実行する。
    • Word Motion が上手く動かないのは日本語の上にカーソルがある場合なので、ASCII 文字の上にカーソルがある場合は本来の動作を実行してもらう。
  • W, B, E, gE は改造しない。
    • これらは WORD 単位で動きますが、日本語における WORD 単位の挙動が思いつかなかったので、今回は改造を見送りました。
  • 2w のように回数を指定した場合、きちんと回数分だけモーションを繰り返す

また、実装を始めた時は、以下のようにコード中の変数と w, b, e, ge をタイプしたときに移動するべき場所を文字毎にまとめた表を作成して、正しく改造できているかすぐに確認できるようにしました。

2020
length_up_to_cursor2234567891020202020202020202020219393959596
cursor_position1234567891011121314151617181920219293949596
w     
b     
e     
ge     

ここまで準備してから実装を始めましたが、カーソル位置と分かち書きの結果を対応させる必要がありましたので、試行錯誤の末、分かち書きの結果を以下のような多次元配列に格納しました。この多次元配列を for 文で順番に処理しつつ、getcursorcharpos() 関数で取得したカーソル位置と比較することで、カーソル位置と分かち書きの結果を対応させることにしました。

 1{
 2    {
 3        ['text'] = '今回',
 4        ['start'] = 1,
 5        ['end'] = 2
 6    },
 7    {
 8        ['text'] = 'は',
 9        ['start'] = 3,
10        ['end'] = 3
11    },
12    {
13        ['text'] = '、',
14        ['start'] = 4,
15        ['end'] = 4
16    },
17}

また、本来の w, b, e, ge は行を跨いだ移動もできますので、以下のようにアルファベットだけの文章での w, b, e, ge の動きをチェックしてから、日本語の文章でも同様の動きになるよう調整しました(|はカーソル位置を示しています)。

 1-- アルファベットだけの文章
 2if vim.fn.strcharlen(char) > 1 |then
 3  return false
 4end
 5
 6- `w` をタイプ
 7if vim.fn.strcharlen(char) > 1 then
 8  |return false
 9end
10
11-- `b` をタイプ
12if vim.fn.strcharlen(char) > |1 then
13  return false
14end
15
16-- `e` をタイプ
17if vim.fn.strcharlen(char) > 1 the|n
18  return false
19end
20
21-- `ge` をタイプ
22if vim.fn.strcharlen(char) > |1 then
23  return false
24end
25
26-- 日本語の文章
27今回は、2020年のアドベントカレンダーの|記事
28    で設計中ですとしていたキーボードについて、
29
30- `w` をタイプ
31今回は、2020年のアドベントカレンダーの記事
32    |で設計中ですとしていたキーボードについて、
33
34- `b` をタイプ
35今回は、2020年のアドベントカレンダー|の記事
36    で設計中ですとしていたキーボードについて、
37
38-- `e` をタイプ
39今回は、2020年のアドベントカレンダーの記|
40    で設計中ですとしていたキーボードについて、
41
42-- `ge` をタイプ
43今回は、2020年のアドベントカレンダー|の記事
44    で設計中ですとしていたキーボードについて、

実はこの行を跨いだ移動の実装が面倒だったところで、行頭・行末に空白文字がある場合の処理が難しかったです。試行錯誤の結果、分かち書きは行頭・行末の空白文字を除いた文章で実施し、同時に行頭で空白文字以外の文字が最初に登場する場所と、行末で空白文字以外の文字が最後に登場する場所を変数に格納して利用する形にしました。

実際のコード

  1local tinysegmenter = require("tinysegmenter")
  2
  3-- @desc: 引数で渡された文字が ASCII 文字かそうでないか判断する関数
  4-- @param - string
  5function IsASCIIChar(char)
  6  if vim.fn.strcharlen(char) > 1 then
  7    return false
  8  end
  9  local char_byte_count = string.len(char)
 10  if char_byte_count == 1 then
 11    return true
 12  else
 13    return false
 14  end
 15end
 16
 17function OverrideWordMotion(arg)
 18  if IsASCIIChar(arg.under_cursor_char) then
 19    -- `bang = true` とすると `normal!` と同じことになる
 20    -- カーソルがASCII文字の上にあるときは、通常の `w`, `b`, `e`, `ge` を実行する。
 21    vim.cmd.normal({ arg.motion, bang = true })
 22  else
 23    local parsed_text_with_position = {}
 24    local text_start_position = 1
 25    for i, text in ipairs(arg.parsed_text) do
 26      parsed_text_with_position[i] = {}
 27      parsed_text_with_position[i]['text'] = text
 28      parsed_text_with_position[i]['start'] = text_start_position
 29      parsed_text_with_position[i]['end'] = text_start_position + vim.fn.strcharlen(text) - 1
 30      text_start_position = text_start_position + vim.fn.strcharlen(text)
 31    end
 32    for i, text_with_position in ipairs(parsed_text_with_position) do
 33      if arg.cursor_position[3] >= text_with_position['start'] + arg.first_char_position - 1 and
 34         arg.cursor_position[3] <= text_with_position['end']   + arg.first_char_position - 1 then
 35        if arg.motion == 'w' then
 36          -- カーソルが非空白文字の末尾 or 分かち書きした文字列の最後のノードにある
 37          if arg.cursor_position[3] == arg.last_char_position or
 38            text_with_position['end'] + arg.first_char_position - 1 == arg.last_char_position then
 39            local below_line_text = vim.fn.getline(arg.cursor_position[2] + 1)
 40            local first_char_position = vim.fn.matchstrpos(below_line_text, '^\\s\\+')[3] + 1
 41            arg.cursor_position[3] = first_char_position
 42            arg.cursor_position[2] = arg.cursor_position[2] + 1
 43          elseif i ~= #parsed_text_with_position then
 44            arg.cursor_position[3] = parsed_text_with_position[i + 1]['start'] + arg.first_char_position - 1
 45          end
 46        end
 47        if arg.motion == 'ge' then
 48          -- カーソルが非空白文字の始め or 分かち書きした文字列の最初のノードにある
 49          if arg.cursor_position[3] == arg.first_char_position or
 50            text_with_position['start'] + arg.first_char_position - 1 == arg.first_char_position then
 51            local above_line_text = vim.fn.getline(arg.cursor_position[2] - 1)
 52            local last_char_position = vim.fn.strcharlen(vim.fn.substitute(above_line_text, '\\s\\+\\_$', '', 'g'))
 53            arg.cursor_position[3] = last_char_position
 54            arg.cursor_position[2] = arg.cursor_position[2] - 1
 55          elseif i ~= 1 then
 56            arg.cursor_position[3] = parsed_text_with_position[i - 1]['end'] + arg.first_char_position - 1
 57          end
 58        end
 59        if arg.motion == 'b' then
 60          -- カーソルが非空白文字の始めにある
 61          if arg.cursor_position[3] == arg.first_char_position then
 62            local above_line_text = vim.fn.getline(arg.cursor_position[2] - 1)
 63            local above_line_text_without_space = vim.fn.substitute(above_line_text, '\\s\\+\\_$', '', 'g')
 64            local above_line_text_without_space_length = vim.fn.strcharlen(above_line_text_without_space)
 65            local parsed_above_line_text = tinysegmenter.segment(above_line_text_without_space)
 66            arg.cursor_position[3] = above_line_text_without_space_length - vim.fnstrcharlen(parsed_above_line_text[#parsed_above_line_text]) + 1
 67            arg.cursor_position[2] = arg.cursor_position[2] - 1
 68          -- カーソルが分かち書きした文字列の最初のノードにある
 69          elseif text_with_position['start'] + arg.first_char_position - 1 == arg.first_char_position then
 70            arg.cursor_position[3] = text_with_position['start'] + arg.first_char_position - 1
 71          elseif i ~= 1 then
 72            -- カーソルが分かち書きした各ノードの1文字目にある
 73            if arg.cursor_position[3] == text_with_position['start'] + arg.first_char_position - 1 then
 74              arg.cursor_position[3] = parsed_text_with_position[i - 1]['start'] + arg.first_char_position - 1
 75            else
 76              arg.cursor_position[3] = text_with_position['start'] + arg.first_char_position - 1
 77            end
 78          end
 79        end
 80        if arg.motion == 'e' then
 81          -- カーソルが非空白文字の末尾にある
 82          if arg.cursor_position[3] == arg.last_char_position then
 83            local below_line_text = vim.fn.getline(arg.cursor_position[2] + 1)
 84            local below_line_text_without_space = vim.fn.substitute(below_line_text, '^\\s\\+', '', 'g')
 85            local first_char_position = vim.fn.matchstrpos(below_line_text, '^\\s\\+')[3] + 1
 86            -- 行頭に空白が無いと first_char_position が 0 になるので、強制的に値を 1 にする
 87            if first_char_position == 0 then
 88              first_char_position = first_char_position + 1
 89            end
 90            local parsed_below_line_text = tinysegmenter.segment(below_line_text_without_space)
 91            arg.cursor_position[3] = vim.fn.strcharlen(parsed_below_line_text[1]) + first_char_position - 1
 92            arg.cursor_position[2] = arg.cursor_position[2] + 1
 93            -- カーソルが分かち書きした文字列の最後のノードにある
 94          elseif text_with_position['end'] + arg.first_char_position - 1 == arg.last_char_position then
 95            arg.cursor_position[3] = text_with_position['end'] + arg.first_char_position - 1
 96          elseif i ~= #parsed_text_with_position then
 97            -- カーソルが分かち書きした各ノードの最後の文字にある
 98            if arg.cursor_position[3] == text_with_position['end'] + arg.first_char_position - 1 then
 99              arg.cursor_position[3] = parsed_text_with_position[i + 1]['end'] + arg.first_char_position - 1
100            else
101              arg.cursor_position[3] = text_with_position['end'] + arg.first_char_position - 1
102            end
103          end
104        end
105        vim.fn.setcursorcharpos(arg.cursor_position[2], arg.cursor_position[3])
106        break
107      end
108    end
109  end
110end
111
112for _, motion in ipairs({'w', 'b', 'e', 'ge'}) do
113  vim.keymap.set('n', motion, function ()
114    -- コマンドの指定回数を取得する。回数が指定されていない場合の値は 1 である。
115    local count1 = vim.v.count1
116    while count1 > 0 do
117      local cursor_line_text = vim.fn.getline('.')
118      -- 行末の空白文字を残して分かち書き処理すると後処理が面倒なので削除する
119      local cursor_line_text_without_eol_space = vim.fn.substitute(cursor_line_text, '\\s\\+\\_$', '', 'g')
120      -- 行頭の空白文字も残すと後処理が面倒なので削除する
121      local cursor_line_text_without_space = vim.fn.substitute(cursor_line_text_without_eol_space, '^\\s\\+', '', 'g')
122      -- `b`, `ge` はカーソルが非空白文字の始めにあれば処理を分岐するので、非空白文字の始めの位置を取得しておく。
123      -- matchstrpos はパターンが1文字目に見つかったらインデックスを `0` `と返す
124      local first_char_position = vim.fn.matchstrpos(cursor_line_text, '^\\s\\+')[3] + 1
125      -- 行頭に空白が無いと first_char_position が 0 になるので、強制的に値を 1 にする
126      if first_char_position == 0 then
127        first_char_position = first_char_position + 1
128      end
129      -- `w`, `e` はカーソルが非空白文字の末尾にあれば処理を分岐するので、非空白文字の末尾の位置を取得しておく。
130      local last_char_position = vim.fn.strcharlen(cursor_line_text_without_eol_space)
131      local parsed_text = tinysegmenter.segment(cursor_line_text_without_space)
132      local under_cursor_char = vim.fn.matchstr(cursor_line_text, '.', vim.fn.col('.')-1)
133      local cursor_position = vim.fn.getcursorcharpos()
134      OverrideWordMotion({
135        motion = motion,
136        cursor_line_text = cursor_line_text,
137        parsed_text = parsed_text,
138        cursor_position = cursor_position,
139        under_cursor_char = under_cursor_char,
140        first_char_position = first_char_position,
141        last_char_position = last_char_position
142      })
143      count1 = count1 - 1
144    end
145  end)
146end

実装中に知った機能など

vim.cmd.normalnormal! と同じ動作を実現する方法

vim.cmd.normal の引数に bang = true を渡すと、normal ではなく normal! と同じ意味になります。

これを活用することで、vim.cmd[[normal! w]] としていた部分を vim.cmd.normal({motion_type_variable, bang = true}) に変更できました。

2w のような回数指定の回数を取得する方法

回数を指定していたらその回数だけ処理を繰り返すという機能を実現したかったのですが、vim.v.count1 に指定された回数が保存されていることが分かりましたので、この変数を使いました。回数が指定されていない場合のデフォルト値は 1 です。

テーブル型変数の最後の要素にアクセスする方法

Lua にはテーブルの末尾にアクセスするメソッドはないようですが、#変数名 とすればテーブルの配列数を取得できますので、hoge_table というテーブル型の変数の最後の要素にアクセスするには hoge_table[#hoge_table] とすれば良いことが分かりました。

文字列の置き換え

カーソル行の行末にある空白文字を削除する必要がありましたが、Lua の文字列置き換え関数では UTF-8 の文字列を適切に扱えないため、代わりに Vim/Neovim のビルトイン関数の substitute() を使うことにしました。

行末の空白文字を削除するには vim.fn.substitute(vim.fn.getline('.'), '\\s\\+\\_$', 'g') とすればOKです。

なお、行頭の空白文字を削除するには vim.fn.substitute(vim.fn.getline('.'), '^\\s\\+', '', 'g') とすればOKです。

未実装の機能

行を跨ぐ「アルファベットから日本語」、「日本語からアルファベット」への移動について、前者は通常の w, b, e, ge で処理し、後者は tinysegment で分かち書きした結果に基づいて移動するため、あるべき移動からずれた移動になることがあります。

これに対応するのは難しそうなので、現時点では対応していません。