C-Spell setup in Neovim: A comprehensive guide

Dung Huynh DucDung Huynh Duc
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.

0
Subscribe to my newsletter

Read articles from Dung Huynh Duc directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Dung Huynh Duc
Dung Huynh Duc

Hi ๐Ÿ‘‹, I'm Dung Huynh Duc A passionate engineer from Singapore ๐Ÿ”ญ Iโ€™m currently working in AirCarbon ๐Ÿ‘จโ€๐Ÿ’ป All of my projects are available at https://productsway.com ๐Ÿ“ I regularly write articles on https://productsway.com ๐Ÿ“ซ How to reach me dung@productsway.com ๐Ÿ“น I often publish my video every Sunday on IT Man Channel