diff --git a/README.md b/README.md index a92da13..116f28e 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,9 @@ This is a plugin to display spelling errors as diagnostics. Some language server A simpler solution, therefore, is to use Neovim's existing spellchecking and diagnostics features. This is done by iterating through the flagged words in a buffer and passing them to `vim.diagnostic.set()`. Neovim is fast enough that this is ~instantaneous for most files. See below for installation and configuration instructions. ## Installation + I recommend using [Lazy.nvim](https://github.com/folke/lazy.nvim): + ```lua { "ravibrock/spellwarn.nvim", @@ -23,7 +25,9 @@ I recommend using [Lazy.nvim](https://github.com/folke/lazy.nvim): Note that this uses Neovim's built-in spellchecking. This requires putting `vim.opt.spell = true` and `vim.opt.spelllang = [YOUR LANGUAGE HERE]` somewhere in your Neovim config if you haven't already. You may also want to add the word "spellwarn" to your Neovim dictionary. This can be done by putting the cursor onto "spellwarn" and hitting `zg`. ## Configuration + Pass any of the following options to `require("spellwarn").setup()`: + ```lua { event = { -- event(s) to refresh diagnostics on @@ -33,40 +37,60 @@ Pass any of the following options to `require("spellwarn").setup()`: "TextChangedI", "TextChangedP", }, + enable = true, -- enable diagnostics on startup + + max_file_size = nil, -- maximum file size to check in lines (nil for no limit) + + suggest = false, -- show spelling suggestions in diagnostic message + num_suggest = 3, -- number of suggestions shown in diagnostic message + + -- function to do any custom processing of the diagnostics table before passing it to vim.diagnostic.set + func_preprocess = function(bufnr, diag_tbl) + return diag_tbl + end, + + bt_config = { -- buffer types to run on + [""] = true, + }, + bt_default = false, -- default for types not in bt_config. + ft_config = { -- spellcheck method: "cursor", "iter", or boolean - alpha = false, - help = false, - lazy = false, + alpha = false, + help = false, + lazy = false, lspinfo = false, - mason = false, + mason = false, }, ft_default = true, -- default option for unspecified filetypes - max_file_size = nil, -- maximum file size to check in lines (nil for no limit) + + diagnostic_opts = { severity_sort = true }, -- options for diagnostic display severity = { -- severity for each spelling error type (false to disable diagnostics for that type) - spellbad = "WARN", - spellcap = "HINT", - spelllocal = "HINT", - spellrare = "INFO", + spellbad = { level = "WARN", prefix = "Unknown Word: ", suffix = "" }, + spellcap = { level = "HINT", prefix = "Missing capital: ", suffix = "" }, + spelllocal = { level = "HINT", prefix = "Word Localization: ", suffix = "" }, + spellrare = { level = "INFO", prefix = "Rare Word: ", suffix = "" }, }, - suggest = false, -- show spelling suggestions in diagnostic message (works best with window-style message) - num_suggest = 3, -- number of spelling suggestions shown in diagnostic message - prefix = "possible misspelling(s): ", -- prefix for each diagnostic message - diagnostic_opts = { severity_sort = true }, -- options for diagnostic display -} ``` + Most options are overwritten (e.g. passing `ft_config = { python = false }` will mean that `alpha`, `mason`, etc. are set to true) but `severity` and `diagnostic_opts` are merged, so that (for example) passing `{ spellbad = "HINT" }` won't cause `spellcap` to be nil. You can pass any of `cursor`, `iter`, `treesitter`, `false`, or `true` as options to `ft_config`. The default method is `cursor`, which iterates through the buffer with `]s`. There is also `iter`, which uses Treesitter (if available) and the Lua API. Finally, `false` disables Spellwarn for that filetype and `true` uses the default (`cursor`). The `suggest` option adds spelling suggestions to the diagnostic message, it does not allow auto-complete. `num_suggest` specifies the number of suggestions to show in the diagnostic message. **If you have `suggest` set to `true`, you need to also have `num_suggest >= 1` or else you will have an error.** *Note: `iter` doesn't show `spellcap` errors, but works well other than that. I recommend it.* ## Usage + The plugin should be good to go after installation with the provided snippet. It has sensible defaults. Run `:Spellwarn enable`, `:Spellwarn disable`, or `:Spellwarn toggle` to enable/disable/toggle during runtime (though this will *not* override `max_file_size`, `ft_config`, or `ft_default`). You can also add keybindings for any of these (one possible usecase would be disabling by default with the `enable` key of the configuration table and then only enabling when needed). To disable diagnostics on a specific line, add `spellwarn:disable-next-line` to the line immediately above or `spellwarn:disable-line` to a comment at the end of the line. To disable diagnostics in a file, add a comment with `spellwarn:disable` to the *first or second* line of the file. +`Spellwarn qflist` will show the quickfix window, populated with the list of spelling mistakes in the current buffer. + ## Lua API + The Lua API matches the arguments for the `Spellwarn` command: - `require("spellwarn").disable()` to disable - `require("spellwarn").enable()` to enable - `require("spellwarn").toggle()` to toggle +- `require("spellwarn").qflist()` to show the quickfix window ## Contributing + PRs and issues welcome! Please consult `CONTRIBUTING.md` for style guidelines. diff --git a/doc/spellwarn.txt b/doc/spellwarn.txt index 42c1043..f862dd1 100644 --- a/doc/spellwarn.txt +++ b/doc/spellwarn.txt @@ -1,4 +1,4 @@ -*spellwarn.txt* For NVIM v0.8.0 Last change: 2025 September 03 +*spellwarn.txt* For NVIM v0.8.0 Last change: 2026 March 13 ============================================================================== Table of Contents *spellwarn-table-of-contents* @@ -31,7 +31,7 @@ configuration instructions. INSTALLATION *spellwarn-spellwarn.nvim-installation* -I recommend using Lazy.nvim +I recommend using Lazy.nvim : >lua { @@ -41,7 +41,7 @@ I recommend using Lazy.nvim } < -Notethat this uses Neovim’s built-in spellchecking. This requires putting +Note that this uses Neovim’s built-in spellchecking. This requires putting `vim.opt.spell = true` and `vim.opt.spelllang = [YOUR LANGUAGE HERE]` somewhere in your Neovim config if you haven’t already. You may also want to add the word "spellwarn" to your Neovim dictionary. This can be done by putting the @@ -50,7 +50,7 @@ cursor onto "spellwarn" and hitting `zg`. CONFIGURATION *spellwarn-spellwarn.nvim-configuration* -Pass any of the following options to `require("spellwarn").setup()` +Pass any of the following options to `require("spellwarn").setup()`: >lua { @@ -61,30 +61,43 @@ Pass any of the following options to `require("spellwarn").setup()` "TextChangedI", "TextChangedP", }, + enable = true, -- enable diagnostics on startup + + max_file_size = nil, -- maximum file size to check in lines (nil for no limit) + + suggest = false, -- show spelling suggestions in diagnostic message + num_suggest = 3, -- number of suggestions shown in diagnostic message + + -- function to do any custom processing of the diagnostics table before passing it to vim.diagnostic.set + func_preprocess = function(bufnr, diag_tbl) + return diag_tbl + end, + + bt_config = { -- buffer types to run on + [""] = true, + }, + bt_default = false, -- default for types not in bt_config. + ft_config = { -- spellcheck method: "cursor", "iter", or boolean - alpha = false, - help = false, - lazy = false, + alpha = false, + help = false, + lazy = false, lspinfo = false, - mason = false, + mason = false, }, ft_default = true, -- default option for unspecified filetypes - max_file_size = nil, -- maximum file size to check in lines (nil for no limit) + + diagnostic_opts = { severity_sort = true }, -- options for diagnostic display severity = { -- severity for each spelling error type (false to disable diagnostics for that type) - spellbad = "WARN", - spellcap = "HINT", - spelllocal = "HINT", - spellrare = "INFO", + spellbad = { level = "WARN", prefix = "Unknown Word: ", suffix = "" }, + spellcap = { level = "HINT", prefix = "Missing capital: ", suffix = "" }, + spelllocal = { level = "HINT", prefix = "Word Localization: ", suffix = "" }, + spellrare = { level = "INFO", prefix = "Rare Word: ", suffix = "" }, }, - suggest = false, -- show spelling suggestions in diagnostic message (works best with window-style message) - num_suggest = 3, -- number of spelling suggestions shown in diagnostic message - prefix = "possible misspelling(s): ", -- prefix for each diagnostic message - diagnostic_opts = { severity_sort = true }, -- options for diagnostic display - } < -Mostoptions are overwritten (e.g. passing `ft_config = { python = false }` +Most options are overwritten (e.g. passing `ft_config = { python = false }` will mean that `alpha`, `mason`, etc. are set to true) but `severity` and `diagnostic_opts` are merged, so that (for example) passing `{ spellbad = "HINT" }` won’t cause `spellcap` to be nil. You can pass any of `cursor`, @@ -115,12 +128,16 @@ when needed). To disable diagnostics on a specific line, add diagnostics in a file, add a comment with `spellwarn:disable` to the _first or second_ line of the file. +`Spellwarn qflist` will show the quickfix window, populated with the list of +spelling mistakes in the current buffer. + LUA API *spellwarn-spellwarn.nvim-lua-api* The Lua API matches the arguments for the `Spellwarn` command: - `require("spellwarn").disable()` to disable - `require("spellwarn").enable()` -to enable - `require("spellwarn").toggle()` to toggle +to enable - `require("spellwarn").toggle()` to toggle - +`require("spellwarn").qflist()` to show the quickfix window CONTRIBUTING *spellwarn-spellwarn.nvim-contributing* diff --git a/lua/spellwarn/diagnostics.lua b/lua/spellwarn/diagnostics.lua index 0a9f0dd..a6601f0 100644 --- a/lua/spellwarn/diagnostics.lua +++ b/lua/spellwarn/diagnostics.lua @@ -1,6 +1,8 @@ local M = {} local namespace = vim.api.nvim_create_namespace("Spellwarn") +local flag_text_changed = true -- Prevent updating when nothing was changed (CursorHold navigation, etc) + local function get_bufs_loaded() local bufs_loaded = {} for i, buf_hndl in ipairs(vim.api.nvim_list_bufs()) do @@ -16,14 +18,9 @@ function M.update_diagnostics(opts, bufnr) if opts.max_file_size and vim.api.nvim_buf_line_count(bufnr) > opts.max_file_size then return end - local ft = vim.fn.getbufvar(bufnr, "&filetype") - if opts.ft_config[ft] == false or (opts.ft_config[ft] == nil and opts.ft_default == false) then - vim.diagnostic.reset(namespace, bufnr) - return - end local diags = {} for _, error in pairs(require("spellwarn.spelling").get_spelling_errors_main(opts, bufnr) or {}) do - local msg = opts.prefix .. error.word + local msg = error.word if opts.suggest and opts.num_suggest > 0 then local suggestions = vim.fn.spellsuggest(error.word, opts.num_suggest) local addition = "\nSuggestions:\n" @@ -43,33 +40,81 @@ function M.update_diagnostics(opts, bufnr) diags[#diags + 1] = { col = error.col - 1, -- 0-indexed lnum = error.lnum - 1, -- 0-indexed - message = msg, - severity = vim.diagnostic.severity[opts.severity[error.type]], - source = "spellwarn", + message = opts.severity[error.type].prefix .. msg .. opts.severity[error.type].suffix, + severity = vim.diagnostic.severity[opts.severity[error.type].level], + source = "SpellWarn", } end end end - -- TODO: Add suffix diagnostics with type of spelling error the way that LSP diagnostics do + + -- Pre-process, if a function is set in opts to do anything. + diags = opts.func_preprocess(bufnr, diags) or {} + vim.diagnostic.set(namespace, bufnr, diags, opts.diagnostic_opts) end +local function can_update(opts, bufnr) + local winid = vim.api.nvim_get_current_win() + if winid then + if not vim.wo[winid].spell then + return false + end + end + + -- Allow the buffer type, or file type, to cancel the attempt to process the buffer. + local is_ok = true + + -- Buffer type check. + local buftype = vim.api.nvim_get_option_value("buftype", { buf = bufnr }) + if opts.bt_config[buftype] ~= nil then + is_ok = opts.bt_config[buftype] + else + is_ok = opts.bt_default + end + + if not is_ok then + return false + end + -- Still OK to proceed. + + -- File type check. + local ft = vim.fn.getbufvar(bufnr, "&filetype") + if opts.ft_config[ft] ~= nil then + is_ok = opts.ft_config[ft] + else + is_ok = opts.ft_default + end + + return is_ok +end + function M.setup(opts) + local group_name = "Spellwarn" function M.enable() - vim.api.nvim_create_augroup("Spellwarn", {}) + vim.api.nvim_create_augroup(group_name, {}) + -- BufEnter to trigger an initial pass through. + vim.api.nvim_create_autocmd({ "BufEnter", "TextChanged", "TextChangedI" }, { + group = group_name, + callback = function() + flag_text_changed = true + end, + }) + vim.api.nvim_create_autocmd(opts.event, { - group = "Spellwarn", + group = group_name, callback = function() - local winid = vim.api.nvim_get_current_win() - local bufnr = vim.fn.bufnr("%") - if winid then - if not vim.wo[winid].spell then - vim.diagnostic.reset(namespace, bufnr) -- ensure old are cleared if spell is toggled to off. - return - end + if not flag_text_changed then + return false end - M.update_diagnostics(opts, bufnr) + local bufnr = vim.fn.bufnr("%") + if can_update(opts, bufnr) then + flag_text_changed = false + M.update_diagnostics(opts, bufnr) + else + vim.diagnostic.reset(namespace, bufnr) + end end, desc = "Update Spellwarn diagnostics", }) @@ -103,13 +148,15 @@ function M.setup(opts) M.disable() elseif arg == "toggle" then M.toggle() + elseif arg == "qflist" then + require("spellwarn.qflist").qflist(opts) else vim.api.nvim_echo({ { "Invalid argument: " .. arg .. "\n" } }, true, { err = true }) end end, { nargs = 1, complete = function() - return { "disable", "enable", "toggle" } + return { "disable", "enable", "toggle", "qflist" } end, }) diff --git a/lua/spellwarn/init.lua b/lua/spellwarn/init.lua index aa51d0d..41b501a 100644 --- a/lua/spellwarn/init.lua +++ b/lua/spellwarn/init.lua @@ -8,7 +8,24 @@ local defaults = { "TextChangedI", "TextChangedP", }, + enable = true, -- enable diagnostics on startup + + max_file_size = nil, -- maximum file size to check in lines (nil for no limit) + + suggest = false, -- show spelling suggestions in diagnostic message + num_suggest = 3, -- number of suggestions shown in diagnostic message + + -- function to do any custom processing of the diagnostics table before passing it to vim.diagnostic.set + func_preprocess = function(bufnr, diag_tbl) + return diag_tbl + end, + + bt_config = { -- buffer types to run on + [""] = true, + }, + bt_default = false, -- default for types not in bt_config. + ft_config = { -- spellcheck method: "cursor", "iter", or boolean alpha = false, help = false, @@ -17,17 +34,14 @@ local defaults = { mason = false, }, ft_default = true, -- default option for unspecified filetypes - max_file_size = nil, -- maximum file size to check in lines (nil for no limit) + + diagnostic_opts = { severity_sort = true }, -- options for diagnostic display severity = { -- severity for each spelling error type (false to disable diagnostics for that type) - spellbad = "WARN", - spellcap = "HINT", - spelllocal = "HINT", - spellrare = "INFO", + spellbad = { level = "WARN", prefix = "Unknown Word: ", suffix = "" }, + spellcap = { level = "HINT", prefix = "Missing capital: ", suffix = "" }, + spelllocal = { level = "HINT", prefix = "Word Localization: ", suffix = "" }, + spellrare = { level = "INFO", prefix = "Rare Word: ", suffix = "" }, }, - suggest = false, -- show spelling suggestions in diagnostic message - num_suggest = 3, -- number of suggestions shown in diagnostic message - prefix = "possible misspelling(s): ", -- prefix for each diagnostic message - diagnostic_opts = { severity_sort = true }, -- options for diagnostic display } function M.setup(opts) @@ -44,6 +58,9 @@ function M.setup(opts) M.enable = require("spellwarn.diagnostics").enable M.disable = require("spellwarn.diagnostics").disable M.toggle = require("spellwarn.diagnostics").toggle + M.qflist = function() + require("spellwarn.qflist").qflist(defaults) + end end return M diff --git a/lua/spellwarn/qflist.lua b/lua/spellwarn/qflist.lua new file mode 100644 index 0000000..e49e155 --- /dev/null +++ b/lua/spellwarn/qflist.lua @@ -0,0 +1,20 @@ +local M = {} + +function M.qflist(opts) + local spelling = require("spellwarn.spelling").get_spelling_errors_main(opts, 0) + local list = {} + + for _, mistake in ipairs(spelling) do + local qf_item = { + bufnr = vim.api.nvim_get_current_buf(), + col = mistake.col, + lnum = mistake.lnum, + text = opts.severity[mistake.type].prefix .. mistake.word .. opts.severity[mistake.type].suffix, + } + list[#list + 1] = qf_item + end + vim.fn.setqflist(list, "r") + vim.cmd("copen") +end + +return M diff --git a/lua/spellwarn/spelling.lua b/lua/spellwarn/spelling.lua index 2f6256d..2cf44dd 100644 --- a/lua/spellwarn/spelling.lua +++ b/lua/spellwarn/spelling.lua @@ -20,6 +20,7 @@ function M.get_spelling_errors_main(opts, bufnr) local bufopts = opts.ft_config[vim.o.filetype] or opts.ft_default local disable_comment = string.find(vim.fn.getline(1) .. vim.fn.getline(2), "spellwarn:disable", 1, true) ~= nil + -- TODO: Is this test block necessary anymore? if vim.api.nvim_get_mode().mode == "i" or disable_comment