C-Spell setup in Neovim: A comprehensive guide

A detailed tutorial for implementing spell check in your Neovim workflow

ยท

5 min read

Introduction

After switching to Neovim as my main IDE two years ago, I immediately noticed the absence of a tool I had relied on in Visual Studio Code: streetsidesoftware/vscode-spell-checker. This powerful extension was invaluable for catching typos in my code. Consequently, I wanted to develop a similar tool for my Neovim setup.

Cspell-tool

https://github.com/jellydn/cspell-tool

This side project has helped me streamline the setup of cspell in any project. It scans the folder and lists all the unknown words in a text file. Naturally, you would review the results and remove the invalid words. I believe there is a demand for this among other Neovim users, so the README serves not only as documentation for the open-source tool but also as a guide for anyone using the null-ls.nvim plugin.

What is the new setup?

As null-ls has been deprecated, I have migrated my setup to nvim-lintwhich is an asynchronous linter plugin.

null-ls is now archived and will no longer receive updates. Please see this issue for details.

Initialize Config with cspell-tool

Run the following command in your working project:

npx cspell-tool@latest

Image from Gyazo

Install cspell with mason and linting with nvim-lint

I'm using lazy.nvim, so my configuration looks like this:

--- plugins/cspell.lua
return {
  --- Install cspell with mason
  {
    "williamboman/mason.nvim",
    opts = {
      ensure_installed = {
        "cspell",
      },
    },
  },
  -- Lint file
  {
    "mfussenegger/nvim-lint",
    event = "VeryLazy",
    opts = {
      linters_by_ft = {
        ["*"] = { "cspell", "codespell" }, -- Install with: pip install codespell
      },
    },
    config = function(_, opts)
      local lint = require("lint")
      lint.linters_by_ft = opts.linters_by_ft

      vim.api.nvim_create_autocmd({ "BufWritePost", "BufReadPost", "InsertLeave" }, {
        callback = function()
          local names = lint._resolve_linter_by_ft(vim.bo.filetype)

          -- Create a copy of the names table to avoid modifying the original.
          names = vim.list_extend({}, names)

          -- Add fallback linters.
          if #names == 0 then
            vim.list_extend(names, lint.linters_by_ft["_"] or {})
          end

          -- Add global linters.
          vim.list_extend(names, lint.linters_by_ft["*"] or {})

          -- Run linters.
          if #names > 0 then
            -- Check if the linter is available, otherwise it will throw an error.
            for _, name in ipairs(names) do
              local cmd = vim.fn.executable(name)
              if cmd == 0 then
                vim.notify("Linter " .. name .. " is not available", vim.log.levels.INFO)
                return
              else
                -- Run the linter
                lint.try_lint(name)
              end
            end
          end
        end,
      })
    end,
  },
}

Code Action

We will create small helpers to detect the project root and initialize the cspell.json file if it does not exist.

utils/path.lua

local M = {}

--- Check if the current directory is a git repo
---@return boolean
function M.is_git_repo()
  vim.fn.system("git rev-parse --is-inside-work-tree")
  return vim.v.shell_error == 0
end

--- Get the root directory of the git project
---@return string|nil
function M.get_git_root()
  return vim.fn.systemlist("git rev-parse --show-toplevel")[1]
end

--- Get the root directory of the git project or fallback to the current directory
---@return string|nil
function M.get_root_directory()
  if M.is_git_repo() then
    return M.get_git_root()
  end

  return vim.fn.getcwd()
end

return M

utils/cspell.lua

local Path = require("utils.path")

local M = {}

function M.create_cspell_json_if_not_exist()
  local cspell_json_path = Path.get_root_directory() .. "/cspell.json"

  if vim.fn.filereadable(cspell_json_path) == 0 then
    local file = io.open(cspell_json_path, "w")
    if file then
      local default_content = [[
{
  "$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json",
  "version": "0.2",
  "language": "en",
  "globRoot": ".",
  "dictionaryDefinitions": [
    {
      "name": "cspell-tool",
      "path": "./cspell-tool.txt",
      "addWords": true
    }
  ],
  "dictionaries": [
    "cspell-tool"
  ],
  "ignorePaths": [
    "node_modules",
    "dist",
    "build",
    "/cspell-tool.txt"
  ]
}
]]
      file:write(default_content)
      file:close()
    else
      vim.notify("Could not create cSpell.json", vim.log.levels.WARN, { title = "cSpell" })
    end
  end
end

-- Add unknown word under cursor to dictionary
function M.add_word_to_c_spell_dictionary()
  local word = vim.fn.expand("<cword>")

  -- Show popup to confirm the action
  local confirm = vim.fn.confirm("Add '" .. word .. "' to cSpell dictionary?", "&Yes\n&No", 2)
  if confirm ~= 1 then
    M.add_word_from_diagnostics_to_c_spell_dictionary()
    return
  end

  M.create_cspell_json_if_not_exist()
  local dictionary_path = Path.get_root_directory() .. "/cspell-tool.txt"

  -- Append the word to the dictionary file
  local file = io.open(dictionary_path, "a")
  if file then
    -- Detect new line at the end of the file or not
    local last_char = file:seek("end", -1)
    if last_char ~= nil and last_char ~= "\n" then
      word = "\n" .. word
    end

    file:write(word .. "")
    file:close()
    -- Reload buffer to update the dictionary
    vim.cmd("e!")
  else
    vim.notify("Could not open cSpell dictionary", vim.log.levels.WARN, { title = "cSpell" })
  end
end

-- Add unknown word from cspell diagnostics source to dictionary
function M.add_word_from_diagnostics_to_c_spell_dictionary()
  -- Get diagnostics source and only get from cspell
  local bufnr = vim.api.nvim_get_current_buf()
  local winnr = vim.api.nvim_get_current_win()
  local cursor = vim.api.nvim_win_get_cursor(winnr)
  local diagnostics = vim.lsp.diagnostic.get_line_diagnostics(bufnr, cursor[1] - 1)
  local cspell_diagnostics = {}
  for _, diagnostic in ipairs(diagnostics) do
    if diagnostic.source == "cspell" then
      table.insert(cspell_diagnostics, diagnostic)
    end
  end

  -- Get the first word from the first cspell diagnostic
  -- E.g. "Unknown word ( word )"
  local word = cspell_diagnostics[1].message:match("%((.+)%)")
  if word == nil then
    vim.notify("Could not find unknown word", vim.log.levels.WARN, { title = "cSpell" })
    return
  end

  -- Show popup to confirm the action
  local confirm = vim.fn.confirm("Add '" .. word .. "' to cSpell dictionary?", "&Yes\n&No", 2)
  if confirm ~= 1 then
    return
  end

  M.create_cspell_json_if_not_exist()
  local dictionary_path = Path.get_root_directory() .. "/cspell-tool.txt"

  -- Append the word to the dictionary file
  local file = io.open(dictionary_path, "a")
  if file then
    -- Detect new line at the end of the file or not
    local last_char = file:seek("end", -1)
    if last_char ~= nil and last_char ~= "\n" then
      word = "\n" .. word
    end

    file:write(word .. "")
    file:close()
    -- Reload buffer to update the dictionary
    vim.cmd("e!")
  else
    vim.notify("Could not open cSpell dictionary", vim.log.levels.WARN, { title = "cSpell" })
  end
end

return M

Define a keymap to add unknown words to the dictionary:

vim.keymap.set(
  "n",
  "<leader>us",
  "<cmd>lua require('utils.cspell').add_word_to_c_spell_dictionary()<CR>",
  { noremap = true, silent = true, desc = "Add unknown word to cspell dictionary" }
)

Demo

Image from Gyazo

Conclusion

Hope you enjoy this setup. You can find all the code in my Neovim config: https://github.com/jellydn/my-nvim-ide. Let me know if you have any comments or thoughts on my approach.

ย