🍡

Neovim x null-ls x cspellでユーザー辞書に単語を追加する

2022/07/27に公開

こちらの記事でNeovimにcspellを設定し、スペルチェックを行えるようにしました。

https://zenn.dev/kawarimidoll/articles/ad35f3dc4a5009

しかし、現実的には「辞書にはないが間違いではない単語」がありえます。ユーザー名や地名などはそうなりやすいですね。
こういった単語をNeovimからユーザー辞書に登録する方法を紹介します。

辞書に単語を追加するコマンド

以下で:CSpellAppendコマンドを定義します。前出の記事のnull_ls.luaに追記してください。

  • :CSpellAppendでカーソル下の単語を辞書に登録します。
  • :CSpellAppend zennのように引数を渡すと、その文字列を辞書に登録します。
  • :CSpellAppend!で保存先を公開辞書にします。
null_ls.lua
local cspell_append = function(opts)
  local word = opts.args
  if not word or word == "" then
    -- 引数がなければcwordを取得
    word = vim.call('expand', '<cword>'):lower()
  end

  -- bangの有無で保存先を分岐
  local dictionary_name = opts.bang and 'dotfiles' or 'user'

  -- shellのechoコマンドで辞書ファイルに追記
  io.popen('echo ' .. word .. ' >> ' .. cspell_files[dictionary_name])

  -- 追加した単語および辞書を表示
  vim.notify(
    '"' .. word .. '" is appended to ' .. dictionary_name .. ' dictionary.',
    vim.log.levels.INFO,
    {}
  )

  -- cspellをリロードするため、現在行を更新してすぐ戻す
  if vim.api.nvim_get_option_value('modifiable',{}) then
    vim.api.nvim_set_current_line(vim.api.nvim_get_current_line())
    vim.api.nvim_command('silent! undo')
  end
end

vim.api.nvim_create_user_command(
  'CSpellAppend',
  cspell_append,
  { nargs = '?', bang = true }
)

解説

以下の部分では、コマンドの引数を引き出し、引数がなければcwordを取得しています。
lower()で小文字にしているのは、cspellがデフォルトで大文字小文字を区別しないためです。

local word = opts.args
if not word or word == "" then
  -- 引数がなければcwordを取得
  word = vim.call('expand', '<cword>'):lower()
end

https://cspell.org/docs/case-sensitive/

以下の部分が処理の本体です。
lua標準のio.popen()でシェルのechoを実行し、辞書ファイルへ単語を追記しています。
cspell_filesは辞書のパスが入っているテーブルです(前回の記事参照)。

-- shellのechoコマンドで辞書ファイルに追記
io.popen('echo ' .. word .. ' >> ' .. cspell_files[dictionary_name])

以下の部分では、現在の行を同じ内容で上書きし、即座にもとに戻しています。つまり何も変更していません。
何故こんなことをしているのかというと、cspellの警告表示は辞書を更新しただけでは消えず、何らかの編集を行ってリロードする必要があるためです。
表示へ即反映されなくても良い場合は、この処理は不要です。

-- cspellをリロードするため、現在行を更新してすぐ戻す
if vim.api.nvim_get_option_value('modifiable',{}) then
  vim.api.nvim_set_current_line(vim.api.nvim_get_current_line())
  vim.api.nvim_command('silent! undo')
end

辞書に単語を追加するCode Action

コマンドで直接単語を入力して辞書に追加できるようになりましたが、基本的に辞書に追加したいのは現在警告表示が出ている単語のはずです。
つまり、警告が出ている場所で、直接その単語を辞書に追加できると便利です。
ということで、前述のcspell_append関数をCode Actionを使って呼び出します。

null_ls.lua
-- cspell_appendの定義は省略

local null_ls = require('null-ls')

local cspell_custom_actions = {
  name = 'append-to-cspell-dictionary',
  method = null_ls.methods.CODE_ACTION,
  filetypes = {},
  generator = {
    fn = function(_)
      -- 現在のカーソル位置
      local lnum = vim.fn.getcurpos()[2] - 1
      local col = vim.fn.getcurpos()[3]

      -- 現在行のエラーメッセージ一覧
      local diagnostics = vim.diagnostic.get(0, { lnum = lnum })

      -- カーソル位置にcspellの警告が出ているか探索
      local word = ''
      local regex = '^Unknown word %((%w+)%)$'
      for _, v in pairs(diagnostics) do
        if v.source == "cspell" and
            v.col < col and col <= v.end_col and
            string.match(v.message, regex) then
	  -- 見つかった場合、単語を抽出
          word = string.gsub(v.message, regex, '%1'):lower()
          break
        end
      end

      -- 警告が見つからなければ終了
      if word == '' then
        return
      end

      -- cspell_appendを呼び出すactionのリストを返却
      return {
        {
          title = 'Append "' .. word .. '" to user dictionary',
          action = function()
            cspell_append({ args = word })
          end
        },
        {
          title = 'Append "' .. word .. '" to dotfiles dictionary',
          action = function()
            cspell_append({ args = word, bang = true })
          end
        }
      }
    end
  }
}

-- null_lsに登録
null_ls.register(cspell_custom_actions)

解説

method = null_ls.methods.CODE_ACTIONはCode action定義の宣言、filetypes = {}はファイルタイプを指定しない(すべてのファイルで動作)する設定です。
generator = { fn = function() ... end }に設定された関数が返すリストがCode Actionとして使われます。

以下の部分で現在のカーソル位置および現在の行エラーメッセージを取得します。
vim.fn.getcurpos()で取得できるlnumと、vim.diagnostic.get()のオプションになるlnumの値がずれていたので、1を引いています。

-- 現在のカーソル位置
local lnum = vim.fn.getcurpos()[2] - 1
local col = vim.fn.getcurpos()[3]

-- 現在行のエラーメッセージ一覧
local diagnostics = vim.diagnostic.get(0, { lnum = lnum })

diagnosticsは、要素としてbufnr, col, end_col, end_lnum, lnum, message, namespace, row, severity, sourceをもつテーブルのリストです。

以下の部分でdiagnosticsをループし、「cspellによる」「現在のカーソル位置に表示されている」「想定した形式に合致する」エラーメッセージを探し、スペルチェック対象になっている単語を抽出します。

-- カーソル位置にcspellの警告が出ているか探索
local word = ''
local regex = '^Unknown word %((%w+)%)$'
for _, v in pairs(diagnostics) do
  if v.source == "cspell" and
      v.col < col and col <= v.end_col and
      string.match(v.message, regex) then
    -- 見つかった場合、単語を抽出
    word = string.gsub(v.message, regex, '%1'):lower()
    break
  end
end

最後に、Code Actionを定義するテーブルのリストを返します。
titleがCode Actionとして表示する文字列、actionが実行される処理です。actionの内部で、見つけた単語を引数としてcspell_append関数を呼び出しています。

-- cspell_appendを呼び出すactionのリストを返却
return {
  {
    title = 'Append "' .. word .. '" to user dictionary',
    action = function()
      cspell_append({ args = word })
    end
  },
  {
    title = 'Append "' .. word .. '" to dotfiles dictionary',
    action = function()
      cspell_append({ args = word, bang = true })
    end
  }
}

これで、Code Actionから単語を辞書に追加できるようになります。

LspSaga code_actionで実行

参考

Code Actionの追加に関しては以下の記事を参考にしました。

https://blog.semanticart.com/2021/12/31/null-ls-nvim-custom-code-actions/

Discussion