From 181350247fd5cc5abeb8a0d5a0dc4e0b96aac883 Mon Sep 17 00:00:00 2001 From: ravibrock <66334356+ravibrock@users.noreply.github.com> Date: Sat, 8 Jun 2024 12:21:53 -0500 Subject: [PATCH 1/7] feat: add first iteration of api-based spellcheck --- lua/spellwarn/diagnostics.lua | 18 ++++++-- lua/spellwarn/spelling.lua | 84 ++++++++++++++++++++++++++++++----- 2 files changed, 88 insertions(+), 14 deletions(-) diff --git a/lua/spellwarn/diagnostics.lua b/lua/spellwarn/diagnostics.lua index f26b181..012e298 100644 --- a/lua/spellwarn/diagnostics.lua +++ b/lua/spellwarn/diagnostics.lua @@ -11,8 +11,7 @@ local function get_bufs_loaded() return bufs_loaded end --- PERF: try wrapping this with a function to make it run asynchronously? -function M.update_diagnostics(opts, bufnr) +function M.update_diagnostics_main(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 @@ -20,7 +19,14 @@ function M.update_diagnostics(opts, bufnr) return end - local errors = require("spellwarn.spelling").get_spelling_errors(bufnr) + -- TODO: Add check for treesitter + -- TODO: Test to ensure this method gets the different types of spelling errors correct + local errors + if not vim.o.spell or string.find(vim.fn.getline(1), "spellwarn:disable", 1, true) ~= nil then + errors = {} + else + errors = require("spellwarn.spelling").get_spelling_errors_ts(bufnr) + end local diags = {} for _, error in pairs(errors) do if error.word ~= "" and error.word ~= "spellwarn" then @@ -40,6 +46,12 @@ function M.update_diagnostics(opts, bufnr) vim.diagnostic.set(namespace, bufnr, diags, { severity_sort = true }) end +-- PERF: This is supposed to be async but it's blocking the UI +-- PERF: Schedule helps but it still causes jitters +function M.update_diagnostics(opts, bufnr) + M.update_diagnostics_main(opts, bufnr) +end + function M.enable(opts) vim.api.nvim_create_augroup("Spellwarn", {}) vim.api.nvim_create_autocmd(opts.event, { diff --git a/lua/spellwarn/spelling.lua b/lua/spellwarn/spelling.lua index 0b4dfe1..6611a4c 100644 --- a/lua/spellwarn/spelling.lua +++ b/lua/spellwarn/spelling.lua @@ -8,13 +8,20 @@ function M.get_error_type(word, bufnr) end) end -function M.get_spelling_errors(bufnr) +function M.check_spellwarn_comment(bufnr, linenr) -- Check for spellwarn:disable* comments + local above = (linenr > 1 and vim.api.nvim_buf_get_lines(bufnr, linenr - 2, linenr - 1, false)[1]) or "" + local above_val = string.find(above, "spellwarn:disable-next-line", 1, true) ~= nil + local cur = vim.api.nvim_buf_get_lines(bufnr, linenr - 1, linenr, false)[1] + local cur_val = string.find(cur, "spellwarn:disable-line", 1, true) ~= nil + return above_val or cur_val +end + +function M.get_spelling_errors_cursor(bufnr) -- Save current window view and create table to store errors local window = vim.fn.winsaveview() local foldstatus = vim.o.foldenable local concealstatus = vim.o.conceallevel local errors = {} - if not vim.o.spell or string.find(vim.fn.getline(1), "spellwarn:disable", 1, true) ~= nil then return errors end -- Get location of first spelling error to start while loop vim.o.foldenable = false @@ -24,16 +31,8 @@ function M.get_spelling_errors(bufnr) vim.cmd("silent normal! ]s") local location = vim.fn.getpos(".") - local function check_spellwarn_comment() -- Check for spellwarn:disable* comments - local current_line_number = vim.fn.line(".") - local above = (current_line_number > 1 and vim.fn.getline(current_line_number - 1)) or "" - local above_val = string.find(above, "spellwarn:disable-next-line", 1, true) ~= nil - local cur = vim.fn.getline(current_line_number) - local cur_val = string.find(cur, "spellwarn:disable-line", 1, true) ~= nil - return above_val or cur_val - end local function adjust_table() -- Add error to table - if check_spellwarn_comment() then return end + if M.check_spellwarn_comment(bufnr, vim.fn.line(".")) then return end local word = vim.fn.expand("") table.insert(errors, { col = location[3], @@ -63,4 +62,67 @@ function M.get_spelling_errors(bufnr) return errors end +function M.get_spelling_errors_iter(bufnr, start_row, start_col, end_row, end_col) + if start_row == nil then start_row = 0 end + if start_col == nil then start_col = 0 end + if end_row == nil then end_row = #(vim.api.nvim_buf_get_lines(bufnr, 1, -1, false)) + 1 end + if end_col == nil then end_col = string.len(vim.api.nvim_buf_get_lines(bufnr, end_row - 1, end_row, false)[1]) end + local lines = vim.api.nvim_buf_get_lines(bufnr, start_row, end_row + 1, false) + lines[1] = string.sub(lines[1], start_col + 1) + lines[#lines] = string.sub(lines[#lines], 1, end_col + 1) + local errors = {} + for n, line in ipairs(lines) do + local errs = vim.spell.check(line) + for _, err in ipairs(errs) do + local i = start_row + n + local offset = (n == 1 and start_col) or 0 + local key = i .. (err[3] + offset) -- By inserting based on location, we avoid duplicates + if not M.check_spellwarn_comment(bufnr, i) then + errors[key] = { + lnum = i, + col = err[3] + offset, + word = err[1], + type = "spell" .. err[2], + } + end + end + end + return errors +end + +function M.get_spelling_errors_ts(bufnr) + local errors = {} + vim.treesitter.get_parser(bufnr):parse(true) + local node = vim.treesitter.get_node({ bufnr = 0, pos = { 0, 0 } }) + + ---@diagnostic disable-next-line: redefined-local + local function parserec(node) + local start_row, start_col = node:start() + local end_row, end_col = node:end_() + for i = 0, node:child_count() - 1 do + parserec(node:child(i)) + end + -- TODO: This seems to be the bottleneck + local spell = false + for _, capture in pairs(vim.treesitter.get_captures_at_pos(bufnr, start_row, start_col)) do + if capture.capture == "spell" then + spell = true + break + end + end + if spell then + for k, v in pairs(M.get_spelling_errors_iter(bufnr, start_row, start_col, end_row, end_col)) do + errors[k] = v + end + end + end + + while node do + parserec(node) + node = node:next_sibling() + end + + return errors +end + return M From 4cefec0bb68aa8c0e089a20bafb33d7798750fb9 Mon Sep 17 00:00:00 2001 From: ravibrock <66334356+ravibrock@users.noreply.github.com> Date: Sat, 8 Jun 2024 13:50:23 -0500 Subject: [PATCH 2/7] perf: drastically speed up treesitter checks --- lua/spellwarn/spelling.lua | 48 +++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 27 deletions(-) diff --git a/lua/spellwarn/spelling.lua b/lua/spellwarn/spelling.lua index 6611a4c..132e7c9 100644 --- a/lua/spellwarn/spelling.lua +++ b/lua/spellwarn/spelling.lua @@ -92,36 +92,30 @@ end function M.get_spelling_errors_ts(bufnr) local errors = {} - vim.treesitter.get_parser(bufnr):parse(true) - local node = vim.treesitter.get_node({ bufnr = 0, pos = { 0, 0 } }) + local buf_highlighter = vim.treesitter.highlighter.active[bufnr] - ---@diagnostic disable-next-line: redefined-local - local function parserec(node) - local start_row, start_col = node:start() - local end_row, end_col = node:end_() - for i = 0, node:child_count() - 1 do - parserec(node:child(i)) - end - -- TODO: This seems to be the bottleneck - local spell = false - for _, capture in pairs(vim.treesitter.get_captures_at_pos(bufnr, start_row, start_col)) do - if capture.capture == "spell" then - spell = true - break - end - end - if spell then - for k, v in pairs(M.get_spelling_errors_iter(bufnr, start_row, start_col, end_row, end_col)) do - errors[k] = v - end - end - end + if not buf_highlighter then return errors end + buf_highlighter.tree:for_each_tree(function(tstree, tree) + ---@diagnostic disable: invisible + if not tstree then return end + local root = tstree:root() - while node do - parserec(node) - node = node:next_sibling() - end + local q = buf_highlighter:get_query(tree:lang()) + + -- Some injected languages may not have highlight queries. + if not q:query() then return end + for capture, node in q:query():iter_captures(root, bufnr, 0, -1) do + local c = q._query.captures[capture] -- Name of the capture in the query + if c == "spell" then + local start_row, start_col, end_row, end_col = node:range() + for k, v in pairs(M.get_spelling_errors_iter(bufnr, start_row, start_col, end_row, end_col)) do + errors[k] = v + end + end + end + ---@diagnostic enable: invisible + end) return errors end From 4bcc0bc288b6e1b474dece5342d469da2dc5e850 Mon Sep 17 00:00:00 2001 From: ravibrock <66334356+ravibrock@users.noreply.github.com> Date: Sat, 8 Jun 2024 14:37:33 -0500 Subject: [PATCH 3/7] fix: parse out inline comments properly --- lua/spellwarn/spelling.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/spellwarn/spelling.lua b/lua/spellwarn/spelling.lua index 132e7c9..f6609c4 100644 --- a/lua/spellwarn/spelling.lua +++ b/lua/spellwarn/spelling.lua @@ -68,8 +68,8 @@ function M.get_spelling_errors_iter(bufnr, start_row, start_col, end_row, end_co if end_row == nil then end_row = #(vim.api.nvim_buf_get_lines(bufnr, 1, -1, false)) + 1 end if end_col == nil then end_col = string.len(vim.api.nvim_buf_get_lines(bufnr, end_row - 1, end_row, false)[1]) end local lines = vim.api.nvim_buf_get_lines(bufnr, start_row, end_row + 1, false) - lines[1] = string.sub(lines[1], start_col + 1) lines[#lines] = string.sub(lines[#lines], 1, end_col + 1) + lines[1] = string.sub(lines[1], start_col + 1) local errors = {} for n, line in ipairs(lines) do local errs = vim.spell.check(line) From 0085df7e1fd52f818b88b7fc1fffa04e29472306 Mon Sep 17 00:00:00 2001 From: ravibrock <66334356+ravibrock@users.noreply.github.com> Date: Sun, 9 Jun 2024 10:15:07 -0500 Subject: [PATCH 4/7] refactor: add function to select spellcheck method update --- lua/spellwarn/diagnostics.lua | 18 ++---------------- lua/spellwarn/init.lua | 2 +- lua/spellwarn/spelling.lua | 22 ++++++++++++++++++++-- 3 files changed, 23 insertions(+), 19 deletions(-) diff --git a/lua/spellwarn/diagnostics.lua b/lua/spellwarn/diagnostics.lua index 012e298..5d67ffc 100644 --- a/lua/spellwarn/diagnostics.lua +++ b/lua/spellwarn/diagnostics.lua @@ -11,7 +11,7 @@ local function get_bufs_loaded() return bufs_loaded end -function M.update_diagnostics_main(opts, bufnr) +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 @@ -19,16 +19,8 @@ function M.update_diagnostics_main(opts, bufnr) return end - -- TODO: Add check for treesitter - -- TODO: Test to ensure this method gets the different types of spelling errors correct - local errors - if not vim.o.spell or string.find(vim.fn.getline(1), "spellwarn:disable", 1, true) ~= nil then - errors = {} - else - errors = require("spellwarn.spelling").get_spelling_errors_ts(bufnr) - end local diags = {} - for _, error in pairs(errors) do + for _, error in pairs(require("spellwarn.spelling").get_spelling_errors_main(opts, bufnr) or {}) do if error.word ~= "" and error.word ~= "spellwarn" then if opts.severity[error.type] then diags[#diags + 1] = { @@ -46,12 +38,6 @@ function M.update_diagnostics_main(opts, bufnr) vim.diagnostic.set(namespace, bufnr, diags, { severity_sort = true }) end --- PERF: This is supposed to be async but it's blocking the UI --- PERF: Schedule helps but it still causes jitters -function M.update_diagnostics(opts, bufnr) - M.update_diagnostics_main(opts, bufnr) -end - function M.enable(opts) vim.api.nvim_create_augroup("Spellwarn", {}) vim.api.nvim_create_autocmd(opts.event, { diff --git a/lua/spellwarn/init.lua b/lua/spellwarn/init.lua index 997fa02..2bacf7b 100644 --- a/lua/spellwarn/init.lua +++ b/lua/spellwarn/init.lua @@ -8,7 +8,7 @@ local defaults = { "TextChangedP", "TextChangedT", }, - ft_config = { -- filetypes to override ft_default for + ft_config = { -- spellcheck method: "cursor", "iter", "treesitter", or boolean alpha = false, help = false, lazy = false, diff --git a/lua/spellwarn/spelling.lua b/lua/spellwarn/spelling.lua index f6609c4..e9eff2d 100644 --- a/lua/spellwarn/spelling.lua +++ b/lua/spellwarn/spelling.lua @@ -16,6 +16,23 @@ function M.check_spellwarn_comment(bufnr, linenr) -- Check for spellwarn:disable return above_val or cur_val end +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), "spellwarn:disable", 1, true) ~= nil + + if disable_comment or not bufopts then + return {} + elseif bufopts == true or bufopts == "cursor" then + return M.get_spelling_errors_cursor(bufnr) + elseif bufopts == "iter" then + return M.get_spelling_errors_iter(bufnr) + elseif bufopts == "treesitter" then + return M.get_spelling_errors_ts(bufnr) + else + error("Invalid value for ft_config: " .. bufopts) + end +end + function M.get_spelling_errors_cursor(bufnr) -- Save current window view and create table to store errors local window = vim.fn.winsaveview() @@ -92,9 +109,10 @@ end function M.get_spelling_errors_ts(bufnr) local errors = {} - local buf_highlighter = vim.treesitter.highlighter.active[bufnr] + local ts_enabled = pcall(require, "nvim-treesitter") + local buf_highlighter = ts_enabled and vim.treesitter.highlighter.active[bufnr] - if not buf_highlighter then return errors end + if not buf_highlighter then return M.get_spelling_errors_iter(bufnr) end buf_highlighter.tree:for_each_tree(function(tstree, tree) ---@diagnostic disable: invisible if not tstree then return end From 0002cb1e907526afa59b98fc15736decdb61dd41 Mon Sep 17 00:00:00 2001 From: ravibrock <66334356+ravibrock@users.noreply.github.com> Date: Sun, 9 Jun 2024 10:20:28 -0500 Subject: [PATCH 5/7] perf: don't update diagnostics in insert mode --- lua/spellwarn/init.lua | 1 + lua/spellwarn/spelling.lua | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/lua/spellwarn/init.lua b/lua/spellwarn/init.lua index 2bacf7b..a2240c6 100644 --- a/lua/spellwarn/init.lua +++ b/lua/spellwarn/init.lua @@ -3,6 +3,7 @@ local defaults = { -- FIX: Trouble.nvim jump to diagnostic is slightly buggy with `TextChanged` event; no good workaround though AFAICT event = { -- event(s) to refresh diagnostics on "CursorHold", + "InsertLeave", "TextChanged", "TextChangedI", "TextChangedP", diff --git a/lua/spellwarn/spelling.lua b/lua/spellwarn/spelling.lua index e9eff2d..b7c6c79 100644 --- a/lua/spellwarn/spelling.lua +++ b/lua/spellwarn/spelling.lua @@ -20,7 +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), "spellwarn:disable", 1, true) ~= nil - if disable_comment or not bufopts then + if vim.api.nvim_get_mode().mode == "i" or disable_comment or not bufopts then return {} elseif bufopts == true or bufopts == "cursor" then return M.get_spelling_errors_cursor(bufnr) From d4bacaa980b0cadfebd89d0874697a6670d6806b Mon Sep 17 00:00:00 2001 From: ravibrock <66334356+ravibrock@users.noreply.github.com> Date: Sun, 9 Jun 2024 10:35:37 -0500 Subject: [PATCH 6/7] docs: update documentation for new methods --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9ede5a0..ea79f81 100644 --- a/README.md +++ b/README.md @@ -28,12 +28,13 @@ Pass any of the following options to `require("spellwarn").setup()`: { event = { -- event(s) to refresh diagnostics on "CursorHold", + "InsertLeave", "TextChanged", "TextChangedI", "TextChangedP", "TextChangedT", }, - ft_config = { -- filetypes to override ft_default for + ft_config = { -- spellcheck method: "cursor", "iter", "treesitter", or boolean alpha = false, help = false, lazy = false, @@ -42,7 +43,7 @@ Pass any of the following options to `require("spellwarn").setup()`: }, ft_default = true, -- whether to enable or disable for all filetypes by default max_file_size = nil, -- maximum file size to check in lines (nil for no limit) - severity = { -- severity for each spelling error type (false to disable) + severity = { -- severity for each spelling error type (false to disable diagnostics for that type) spellbad = "WARN", spellcap = "HINT", spelllocal = "HINT", @@ -51,7 +52,7 @@ Pass any of the following options to `require("spellwarn").setup()`: prefix = "possible misspelling(s): ", -- prefix for each diagnostic message } ``` -Note that most options are overwritten (e.g. passing `ft_config = { python = false }` will mean that `alpha`, `mason`, etc. are set to true) but that `severity` is merged, so that passing `spellbad = "HINT"` won't cause `spellcap` to be nil. +Note that most options are overwritten (e.g. passing `ft_config = { python = false }` will mean that `alpha`, `mason`, etc. are set to true) but that `severity` is merged, so that 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 the Lua API, and `treesitter`, which uses the Lua API and Treesitter (and falls back on `iter` if Treesitter is unavailable). Finally, `false` disables Spellwarn for that filetype and `true` uses the default (`cursor`). ## Usage The plugin should be good to go after installation with the provided snippet. It has sensible defaults. Run `:Spellwarn enable` or `:Spellwarn disable` to enable/disable during runtime (though this will *not* override `max_file_size`, `ft_config`, or `ft_default`). 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* line of the file. From 677e92bd140cc521e7719a40fea32db19b9f7de5 Mon Sep 17 00:00:00 2001 From: ravibrock <66334356+ravibrock@users.noreply.github.com> Date: Sun, 9 Jun 2024 10:38:22 -0500 Subject: [PATCH 7/7] docs: add comment about possible performance boost --- lua/spellwarn/diagnostics.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/lua/spellwarn/diagnostics.lua b/lua/spellwarn/diagnostics.lua index 5d67ffc..a045203 100644 --- a/lua/spellwarn/diagnostics.lua +++ b/lua/spellwarn/diagnostics.lua @@ -11,6 +11,7 @@ local function get_bufs_loaded() return bufs_loaded end +-- PERF: try wrapping this with a function to make it run asynchronously? 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")