Skip to content

Instantly share code, notes, and snippets.

@quolpr
Last active November 2, 2025 09:57
Show Gist options
  • Select an option

  • Save quolpr/2d9560c0ad5e77796a068061c8ea439c to your computer and use it in GitHub Desktop.

Select an option

Save quolpr/2d9560c0ad5e77796a068061c8ea439c to your computer and use it in GitHub Desktop.
Cspell integration to nvim
  1. Clone https://github.com/streetsidesoftware/vscode-spell-checker locally and compile it with npm i && npm run build-production. You can put js build to nvim config dir, so if you are store config in git .js will be always available on all machines.
  2. Create file lua/cspell-lsp/init.lua. Put main.cjs to lua/cspell-lsp.
local util = require 'lspconfig.util'

-- Function to decode a URI to a file path
local function decode_uri(uri)
  return string.gsub(uri, 'file://', '')
end

-- JSON Formatter implementation
local JsonFormatter = {}

function JsonFormatter:escape_chars(str)
  return str:gsub('[\\"\a\b\f\n\r\t\v]', {
    ['\\'] = '\\\\',
    ['"'] = '\\"',
    ['\a'] = '\\a',
    ['\b'] = '\\b',
    ['\f'] = '\\f',
    ['\n'] = '\\n',
    ['\r'] = '\\r',
    ['\t'] = '\\t',
    ['\v'] = '\\v',
  })
end

function JsonFormatter:format_string(value)
  local result = self.escape_special_chars and self:escape_chars(value) or value
  self:emit(([["%s"]]):format(result), true)
end

function JsonFormatter:format_table(value, add_indent)
  local tbl_count = vim.tbl_count(value)
  self:emit('{\n', add_indent)
  self.indent = self.indent + 2
  local prev_indent = self.indent
  local i = 1
  for k, v in self.pairs_by_keys(value, self.compare[self.indent / 2] or self.default_compare) do
    self:emit(('"%s": '):format(self.escape_special_chars and self:escape_chars(k) or k), true)
    if type(v) == 'string' then
      self.indent = 0
    end
    self:format_value(v)
    self.indent = prev_indent
    if i == tbl_count then
      self:emit '\n'
    else
      self:emit ',\n'
    end
    i = i + 1
  end
  self.indent = self.indent - 2
  self:emit('}', true)
end

function JsonFormatter:format_array(value)
  local array_count = #value
  self:emit '[\n'
  self.indent = self.indent + 2
  for i, item in ipairs(value) do
    self:format_value(item, true)
    if i == array_count then
      self:emit '\n'
    else
      self:emit ',\n'
    end
  end
  self.indent = self.indent - 2
  self:emit(']', true)
end

function JsonFormatter:emit(value, add_indent)
  if add_indent then
    self.out[#self.out + 1] = (' '):rep(self.indent)
  end
  self.out[#self.out + 1] = value
end

function JsonFormatter:format_value(value, add_indent)
  if value == nil then
    self:emit 'null'
  end
  local _type = type(value)
  if _type == 'string' then
    self:format_string(value)
  elseif _type == 'number' then
    self:emit(tostring(value), add_indent)
  elseif _type == 'boolean' then
    self:emit(value == true and 'true' or 'false', add_indent)
  elseif _type == 'table' then
    local count = vim.tbl_count(value)
    if count == 0 then
      self:emit '{}'
    elseif #value > 0 then
      self:format_array(value)
    else
      self:format_table(value, add_indent)
    end
  end
end

function JsonFormatter:pretty_print(data, keys_orders, escape_special_chars)
  self.compare = {}
  if keys_orders then
    for indentation_level, keys_order in pairs(keys_orders) do
      local order = {}
      for i, key in ipairs(keys_order) do
        order[key] = i
      end
      local max_pos = #keys_order + 1
      self.compare[indentation_level] = function(a, b)
        return (order[a] or max_pos) - (order[b] or max_pos) < 0
      end
    end
  end
  self.default_compare = function(a, b)
    return a:lower() < b:lower()
  end
  self.escape_special_chars = escape_special_chars
  self.indent = 0
  self.out = {}
  self:format_value(data, false)
  return table.concat(self.out)
end

-- Helper for sorting pairs by keys
JsonFormatter.pairs_by_keys = function(tbl, compare)
  local keys = {}
  for key, _ in pairs(tbl) do
    table.insert(keys, key)
  end
  compare = compare or function(a, b)
    return a:lower() < b:lower()
  end
  table.sort(keys, compare)
  local i = 0
  return function()
    i = i + 1
    if keys[i] then
      return keys[i], tbl[keys[i]]
    end
  end
end

-- Function to read and parse JSON from a file
local function read_json_file(path)
  local file = io.open(path, 'r')
  if not file then
    error('Failed to open file: ' .. path)
  end
  local data = file:read '*a'
  file:close()

  local decoded = vim.json.decode(data)
  return decoded
end

-- Function to write formatted JSON data to a file
local function write_json_file(path, table)
  local formatted = JsonFormatter:pretty_print(table, nil, true)

  local file = io.open(path, 'w')
  if not file then
    error('Failed to open file for writing: ' .. path)
  end
  file:write(formatted)
  file:close()
end

local function line_byte_from_position(lines, lnum, col, offset_encoding)
  if not lines or offset_encoding == 'utf-8' then
    return col
  end

  local line = lines[lnum + 1]
  local ok, result = pcall(vim.str_byteindex, line, col, offset_encoding == 'utf-16')
  if ok then
    return result --- @type integer
  end

  return col
end

---@param bufnr integer
---@return string[]?
local function get_buf_lines(bufnr)
  if vim.api.nvim_buf_is_loaded(bufnr) then
    return vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
  end

  local filename = vim.api.nvim_buf_get_name(bufnr)
  local f = io.open(filename)
  if not f then
    return
  end

  local content = f:read '*a'
  if not content then
    -- Some LSP servers report diagnostics at a directory level, in which case
    -- io.read() returns nil
    f:close()
    return
  end

  local lines = vim.split(content, '\n')
  f:close()
  return lines
end
-- Get the directory of the current Lua file
local current_script_path = debug.getinfo(1).source:match '@?(.*/)'

return {
  default_config = {
    cmd = {
      'bash',
      '-c',
      'cd ~ && NODE_PATH=$(npm root -g) node ' .. current_script_path .. 'main.cjs --stdio',
    },
    filetypes = { '*' },
    root_dir = util.root_pattern '.git',
    single_file_support = true,
    settings = {
      cSpell = {
        enabled = true,
        trustedWorkspace = true,
        import = { vim.fn.stdpath 'config' .. '/cspell.json' },
        checkOnlyEnabledFileTypes = false,
        doNotUseCustomDecorationForScheme = true,
        useCustomDecorations = false,
      },
    },
    handlers = {
      ['_onDiagnostics'] = function(err, result, ctx, config)
        vim.lsp.handlers['textDocument/publishDiagnostics'](err, result[1][1], ctx, config)
        vim.lsp.diagnostic.on_publish_diagnostics(err, result[1][1], ctx, config)
      end,
      ['_onWorkspaceConfigForDocumentRequest'] = function()
        return {
          ['uri'] = nil,
          ['workspaceFile'] = nil,
          ['workspaceFolder'] = nil,
          ['words'] = {},
          ['ignoreWords'] = {},
        }
      end,
    },
    on_init = function()
      vim.lsp.commands['cSpell.editText'] = function(command, scope)
        local buf_lines = get_buf_lines(scope.bufnr)

        local range = command.arguments[3][1].range
        local new_text = command.arguments[3][1].newText

        local start_line = range.start.line
        local start_ch = line_byte_from_position(buf_lines, range.start.line, range.start.character, 'utf-16')
        local end_line = range['end'].line
        local end_ch = line_byte_from_position(buf_lines, range['end'].line, range['end'].character, 'utf-16')

        local lines = vim.api.nvim_buf_get_lines(scope.bufnr, start_line, end_line + 1, false)

        local start_line_content = lines[1]
        local end_line_content = lines[#lines]

        local before_range = start_line_content:sub(1, start_ch)
        local after_range = end_line_content:sub(end_ch + 1)

        lines[1] = before_range .. new_text .. after_range

        if #lines > 1 then
          for i = 2, #lines do
            lines[i] = nil
          end
        end

        vim.api.nvim_buf_set_lines(scope.bufnr, start_line, start_line + 1, false, lines)
      end

      vim.lsp.commands['cSpell.addWordsToConfigFileFromServer'] = function(command)
        local words = command.arguments[1]
        local json_file_uri = command.arguments[3].uri
        local json_file_path = decode_uri(json_file_uri)

        local json_data = read_json_file(json_file_path)
        vim.list_extend(json_data.words, words)
        write_json_file(json_file_path, json_data)
      end

      vim.lsp.commands['cSpell.addWordsToDictionaryFileFromServer'] = function()
        vim.notify 'Not supported'
      end

      vim.lsp.commands['cSpell.addWordsToVSCodeSettingsFromServer'] = function()
        vim.notify 'Not supported'
      end
    end,
  },
  docs = {
    description = [[LSP configuration using NODE_PATH for global package resolution and relative paths]],
  },
}
  1. Don't forget to install neovim/nvim-lspconfig. Add to your config:
local capabilities = vim.lsp.protocol.make_client_capabilities()
local configs = require 'lspconfig.configs'
configs['cSpell'] = require 'cspell-lsp'
local lspServer = {}
lspServer.capabilities = vim.tbl_deep_extend('force', {}, capabilities)
require('lspconfig')['cSpell'].setup(lspServer)
  1. You will also need to use modified code action version due to this two bugs: neovim/neovim#29500 + neovim/neovim#21985
local function get_diagnostic_at_cursor()
  local cur_buf = vim.api.nvim_get_current_buf()
  local line, col = unpack(vim.api.nvim_win_get_cursor(0))
  local entries = vim.diagnostic.get(cur_buf, { lnum = line - 1 })
  local res = {}
  for _, v in pairs(entries) do
    if v.col <= col and v.end_col >= col then
      table.insert(res, {
        code = v.code,
        message = v.message,
        range = {
          ['start'] = {
            character = vim.lsp.util.character_offset(cur_buf, v.lnum, v.col, 'utf-16'),
            line = v.lnum,
          },
          ['end'] = {
            character = vim.lsp.util.character_offset(cur_buf, v.end_lnum, v.end_col, 'utf-16'),
            line = v.end_lnum,
          },
        },
        severity = v.severity,
        source = v.source or nil,
      })
    end
  end
  return res
end
-- Execute a code action, usually your cursor needs to be on top of an error
-- or a suggestion from your LSP for this to activate.
map('<leader>ca', function()
  require('fzf-lua').lsp_code_actions {
    -- vim.lsp.buf.code_action {
    -- once = get_diagnostic_at_cursor(),
    context = {
      diagnostics = get_diagnostic_at_cursor(),
    },
    filter = function(action)
      if string.find(action.title, 'to user settings') then
        return false
      end

      return true
    end,
    -- query='!tousersettings '
  }
end, 'Code Action')
@MarcelRobitaille
Copy link

What is map? vim.keymap.set?

@quolpr
Copy link
Author

quolpr commented Oct 17, 2024

@MarcelRobitaille yep. Actually, I plan to put nvim plug instead of this snippet :)

@MarcelRobitaille
Copy link

Thanks. I have another question: when you add a word to the dictionary and save the file, do the diagnostics go away for you? It is flaky for me.

@quolpr
Copy link
Author

quolpr commented Oct 21, 2024

@MarcelRobitaille yep. It works for me perfectly fine. Maybe other clients(from none-ls for example) are also running?

@MarcelRobitaille
Copy link

@quolpr Thanks for your answer. I was able to reproduce it again. I also have copilot lsp attached. I'll try turning it off.

@quolpr
Copy link
Author

quolpr commented Oct 23, 2024

@MarcelRobitaille then super weird. Can't reproduce on my setup unfortunately

@MarcelRobitaille
Copy link

@quolpr I'm also able to reproduce it in a markdown file with no other LSPs running. It seems like the first time I save the cspell.json file it updates, but subsequently it does not. My config is .vscode/cspell.json.

@quolpr
Copy link
Author

quolpr commented Oct 25, 2024

@MarcelRobitaille that sad! Right now, I don't have idea how to fix it 🤔

@MarcelRobitaille
Copy link

@quolpr Thanks for all your help! Can I give you a tip for helping me to get it working?

@quolpr
Copy link
Author

quolpr commented Oct 26, 2024

@MarcelRobitaille thank you, no tip is needed 😅 Also, I updated the gist:

  1. I had errors when I was opening projects with package.json on root. And cspell was not working at all
  2. Also, now you can put main.cjs near the lua file

@quolpr
Copy link
Author

quolpr commented Oct 26, 2024

@MarcelRobitaille hmm, now I also added json formatter, so you can easily diff and resolve conflicts on cspell dictionary adds.

And I also noticed that with this formatter adding words to dict become stable (not sure why). But actually, not sure, I need to use it more.

@giggio
Copy link

giggio commented Jun 17, 2025

@quolpr thanks for sharing that.
I have it work on Nix and Neovim, adapting your work.
To help someone that might be interested, my dotfiles with the nix derivations are:
giggio/dotfiles@0c99e58
And the neovim commit is: giggio/vimfiles@f863b88

@giggio
Copy link

giggio commented Nov 1, 2025

I noticed that this implementation had a problem when working with the plugin tiny-code-action, which can end up changing the buffer that the code action sees. To solve that I updated the code to search for the correct buffer, starting at the alternate buffer, which will probably be the correct one.
For anyone having problems, this is the commit, I just added a new function:
giggio/vimfiles@470a378

I don't know if this is a problem with cSpell itself, I don't have issues with other LSPs.

@MarcelRobitaille
Copy link

I noticed that lspconfig now has instructions for cspell. I'm using that now, and it's working more reliably

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment