C-Spell setup in Neovim: A comprehensive guide
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-lint
which 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
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
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.
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