typos を使った Neovim でのスペルチェック

2024/02/09に公開

typo との戦い

僕もよくやってしまうんですが、人のコードをレビューしている時に typo をよく見かけます。
エディターの auto completion や最近流行の GitHub Copilot などは typo した状態で補完してくれたりするので、ミスした状態のコードが量産されていってしまいます。
どこかで一括置換すれば直せますが、修正漏れがあるとただのスペルミス修正なのに動かなくなってしまうなんてこともあり得るため、早めに直しておきたいものです。

VSCode のスペルチェック

typo で悩みたくないなーと感じて数年。
VSCode で Code Spell Checker という Extension を発見しました。

https://marketplace.visualstudio.com/items?itemName=streetsidesoftware.code-spell-checker

Code Spell Checker

この Extension から派生して今では CSpell として独立したものとして CLI でも動作するようになっています。

https://cspell.org/

CSpell started out as an extension for VS Code: Code Spell Checker - Visual Studio Marketplace. When we started using VS Code, it did not have a spell checker. As a person that has trouble with spelling, I found this to be a great hinderance, thus the extension was born. At the suggestion of users, cspell was pulled out of the extension and into its own library and command line tools.

DeepLにて翻訳

CSpellはVS Codeの拡張機能としてスタートしました:Code Spell Checker - Visual Studio Marketplace。私たちがVS Codeを使い始めたとき、スペル・チェッカーがありませんでした。スペルチェックが苦手な私にとって、これは大きな障害でした。ユーザーの提案により、cspellは拡張機能から独立し、独自のライブラリーとコマンドラインツールになりました。

作者も VSCode さぞ辛かったことでしょう。

僕も愛用していて、すぐ typo に気付けるようになり、「あれ?なんでメソッドが動かないんだ?」みたいなことは減りました。

Neovim のスペルチェック

もっぱらここ2年くらいは Neovim を使用しています。

Neovim には公式で Spell Check に対応しています。

自分が使いこなせていないだけかもしれませんが、有効にしてみてもそこまで使い勝手がいいとは思えませんでした。

VSCode で使用していたような個別のスペルチェックプラグインは未だないようです。

スペルチェックを行う手段

では Neovim では簡易的なチェックしか出来ないのかってこともないようで。
何かしらのプラグイン経由でスペルチェック機能を搭載することはできます。
他にもあるかもしれませんが、手元で確認したのは次のプラグインです。

  • none-ls.nvim(旧 null-ls.nvim)
  • ALE
  • nvim-lspconfig

CSpell にはどれも対応していたようでしたが、実際導入するためにはドキュメントがそこまであるわけでもなく VSCode のようにインストールしたらすぐ使えるという感じでもありません。

そもそも、これらのプラグイン導入自体のハードルが高いように感じます。

none-ls.nvim

CSpell を使うためにはじめ null-ls.nvim を利用していました。
null-ls.nvim は LSP に対応していないライブラリ等を lua を使って LSP として扱えるようにうまいことギャップをなくしてくれるライブラリです。

null-ls.nvim で builtin されているライブラリは豊富で CSpell も対応していました。
しかし、長らくメンテナンスされてきたライブラリでしたが作者は Neovim をプライマリエディタとして使わなくなっており、メンテナンスを維持することが困難になったためアーカイブすることが告知されてしまったのです。

https://github.com/jose-elias-alvarez/null-ls.nvim/issues/1621

今後どうなるかと思いきや、気付いたらコミュニティによって維持されることになり none-ls.nvim として再出発していました。

https://github.com/nvimtools/none-ls.nvim

ただし、中身は null-ls.nvim のままであり作者の issue に書かれているように、問題を多く抱えたままです。
今後に期待ですが、別の実現方法を模索することにしました。

ALE

次にたどり着いたのは ALE です。

https://github.com/dense-analysis/ale

ALE は Neovim や Vim でテキストが変更されるとバッファの内容に対して linter を走らせエラーを返す仕組みで動いています。
また LSP Client として動作するため、定義ジャンプや Hover による情報表示等にも対応しています。

結論から言うとやりたいことの実現は出来たのですが、 null-ls.nvim より設定難易度はハードでした。
Neovim 用ライブラリではないため lua で書かれているわけでもありません。

ネットやら issue やらを dig って色々設定を見て回った結果です。

ale.lua
-- lazy.nvim での例
return {
  'dense-analysis/ale',
  config = function()
    vim.g.ale_linters = {
      ['*'] = { 'cspell' },
    }

    vim.g.ale_cspell_options = '--config ~/.config/nvim/spell/cspell.json'

    local function add_word_to_cspell()
      local word = vim.fn.expand('<cword>')
      local cspell_file = '~/.config/nvim/spell/cspell.json'

      -- JSON ファイルを読み込む
      local file, err = io.open(cspell_file, 'r')
      if not file then
        print('Failed to open ' .. cspell_file .. ': ' .. err)
        return
      end

      local data = file:read('*a')
      file:close()

      -- JSON データを解析する
      local status, json = pcall(vim.fn.json_decode, data)
      if not status then
        print('Failed to parse ' .. cspell_file .. ': ' .. json)
        return
      end

      -- 単語を追加する
      table.insert(json.words, word)

      -- JSON ファイルを更新する
      file, err = io.open(cspell_file, 'w')
      if not file then
        print('Failed to open ' .. cspell_file .. ': ' .. err)
        return
      end

      file:write(vim.fn.json_encode(json))
      file:close()

      print('Added "' .. word .. '" to ' .. cspell_file)

      -- ALE を再実行する
      vim.cmd('ALELint')
    end

    vim.api.nvim_create_user_command('AddWordToCSpell', add_word_to_cspell, {})
  end,
}

:AddWordToCSpell コマンドを打つことで、カーソル配下の単語が cspell.json へ保存され辞書登録されるようになっています。
正直、使い勝手がとても良いというわけでもなく、改善の余地はあります。

また、辞書にないものは当然警告が表示されまくります。
日本にしかない英単語なども全て警告が出てしまい、単語登録を毎回しなくてはならず、放置していると警告が鬱陶しいと感じるようになりました。

ここまで設定して使っていましたが CSpell である必要もないのではないか、もっと簡単に設定してできるものはないのかとも思い始めました。

要はスペルミスが見つかれば良いのです。
ただそれだけなのです。

ということで、また楽にできるものを探し始めることにしました。

nvim-lspconfig

そして、原点に返ってきました。我らが nvim-lspconfig。

https://github.com/neovim/nvim-lspconfig

Neovim で LSP を使うならば、避けては通れないくらいスタンダードなプラグインです。
様々な言語の LSP に対応しており、割と楽に(とはいえおまじない的な設定は必要ですが) LSP の設定が出来るプラグインです。

実はここにたどり着く前に none-ls.nvim の builtin されているプラグインで CSpell 以外に簡単に使えそうなものはないか物色していました。

https://github.com/nvimtools/none-ls.nvim/blob/main/doc/BUILTINS.md

builtin されているもの中でスペルチェックのものは見たところ次の通りです。

  • spell
  • codespell
  • misspel
  • textidote
  • typos

この中で typos のみが nvim-lspconfig の server_configurations に含まれていました。

https://github.com/neovim/nvim-lspconfig/blob/master/doc/server_configurations.md#typos_lsp

typos

typos は自然言語と言うよりソースコードに特化したスペルチェッカーのようです。
Rust 製であり、計測されてないので分かりませんが、十分な速度があると謳っています。

https://github.com/crate-ci/typos

通常のスペルチェッカーとは思想が違い、誤検知を防ぐようにできているようです。
未知の単語を指摘するのではなく、既知のスペルミスのみを指摘することで、辞書に登録されてないものが大量にスペルミスとして出力されるといったことを減らしてくれます。

実際に CSpell と typos で比較してみると PayPay のような辞書にはない単語は CSpell ではスペルチェックに検知されますが、 typos では検知されません。
更に recieve という単語は正しくは receive ですが、これは typos でも検知されます。

これによって辞書に載っていないような英単語を、都度単語登録する必要がなくなります。
とはいえ、検知してほしくない単語も少なからずあります。
それは辞書ファイルを作っておくことで除外することが可能です。

.typos.toml
[default.extend-words]
recieve = "recieve"

typos_lsp を nvim-lspconfig に設定する

server_configurations を参考に設定に組み込んでいきます。
先ほどの .typos.toml を読み込ませてあげるように設定してあげる必要があります。

設定は以下の通りです。

lsp.lua
require('lspconfig').typos_lsp.setup({
  init_options = {
    config = '~/.config/nvim/spell/.typos.toml',
  },
})
lsp.lua 全体の設定

mason.nvimnvim-cmp などの設定も含まれています。

lsp.lua
return {
  'neovim/nvim-lspconfig',
  dependencies = {
    { 'williamboman/mason.nvim', config = true },
    'williamboman/mason-lspconfig.nvim',
    'folke/neodev.nvim',
  },
  config = function()
    local on_attach = function(_, bufnr)
      local nmap = function(keys, func, desc)
        if desc then
          desc = 'LSP: ' .. desc
        end

        vim.keymap.set('n', keys, func, { buffer = bufnr, desc = desc })
      end

      nmap('gd', vim.lsp.buf.definition, '[G]oto [D]efinition')
      nmap('gr', require('telescope.builtin').lsp_references, '[G]oto [R]eferences')
      nmap('gI', vim.lsp.buf.implementation, '[G]oto [I]mplementation')
      nmap('<leader>D', vim.lsp.buf.type_definition, 'Type [D]efinition')

      nmap('K', vim.lsp.buf.hover, 'Hover Documentation')
      nmap('<C-k>', vim.lsp.buf.signature_help, 'Signature Documentation')

      nmap('gD', vim.lsp.buf.declaration, '[G]oto [D]eclaration')
      nmap('<leader>wa', vim.lsp.buf.add_workspace_folder, '[W]orkspace [A]dd Folder')
      nmap('<leader>wr', vim.lsp.buf.remove_workspace_folder, '[W]orkspace [R]emove Folder')
      nmap('<leader>wl', function()
        print(vim.inspect(vim.lsp.buf.list_workspace_folders()))
      end, '[W]orkspace [L]ist Folders')

      vim.api.nvim_buf_create_user_command(bufnr, 'Format', function(_)
        vim.lsp.buf.format()
      end, { desc = 'Format current buffer with LSP' })
    end

    require('neodev').setup()

    local servers = {
      lua_ls = {
        Lua = {
          workspace = { checkThirdParty = false },
          telemetry = { enable = false },
        },
      },
    }

    -- nvim-cmp supports additional completion capabilities, so broadcast that to servers
    local capabilities = vim.lsp.protocol.make_client_capabilities()
    capabilities = require('cmp_nvim_lsp').default_capabilities(capabilities)

    -- Ensure the servers above are installed
    local mason_lspconfig = require 'mason-lspconfig'

    mason_lspconfig.setup {
      ensure_installed = vim.tbl_keys(servers),
    }

    mason_lspconfig.setup_handlers {
      function(server_name)
        require('lspconfig')[server_name].setup {
          capabilities = capabilities,
          on_attach = on_attach,
          settings = servers[server_name],
          filetypes = (servers[server_name] or {}).filetypes,
        }
      end
    }

    require('lspconfig').typos_lsp.setup({
      init_options = {
        config = '~/.config/nvim/spell/.typos.toml',
      },
    })
  end
}

設定方法はこれだけですが、個人的に今までで一番楽にスペルチェックを Neovim に組み込むことが出来ました。

まとめ

他にも簡単に設定できるものはあるのかもしれませんが typos は自分のやりたかったことに近いですし、何より単語登録を何回もする必要がない点は大きなメリットになり得ます。

まだ現在は単語登録するために .typos.toml ファイルに直接書き込んでいますが、そのうちコマンドで出来るようにしたいと思います。
その時は結局 ALE と同じくらいのコード量になっていることでしょう。

最後に dotfiles を置いておきます。

https://github.com/yakiimo23/dotfiles

まだまだ自分にとって最高のエディターに育てる道は果てしなく続きそうです。

Discussion