-- mod-version:3 local core = require "core" local style = require "core.style" local config = require "core.config" local command = require "core.command" local common = require "core.common" local DocView = require "core.docview" local Highlighter = require "core.doc.highlighter" local Doc = require "core.doc" local platform_dictionary_file if PLATFORM == "Windows" then platform_dictionary_file = EXEDIR .. "/words.txt" else platform_dictionary_file = "/usr/share/dict/words" end config.plugins.spellcheck = common.merge({ enabled = true, files = { "%.txt$", "%.md$", "%.markdown$" }, dictionary_file = platform_dictionary_file }, config.plugins.spellcheck) local last_input_time = 0 local word_pattern = "%a+" local words local spell_cache = setmetatable({}, { __mode = "k" }) local font_canary local font_size_canary -- Move cache to make space for new lines local prev_insert_notify = Highlighter.insert_notify function Highlighter:insert_notify(line, n, ...) prev_insert_notify(self, line, n, ...) local blanks = { } if not spell_cache[self] then spell_cache[self] = {} end for i = 1, n do blanks[i] = false end common.splice(spell_cache[self], line, 0, blanks) end -- Close the cache gap created by removed lines local prev_remove_notify = Highlighter.remove_notify function Highlighter:remove_notify(line, n, ...) prev_remove_notify(self, line, n, ...) if not spell_cache[self] then spell_cache[self] = {} end common.splice(spell_cache[self], line, n) end -- Remove changed lines from the cache local prev_tokenize_line = Highlighter.tokenize_line function Highlighter:tokenize_line(idx, state, ...) local res = prev_tokenize_line(self, idx, state, ...) if not spell_cache[self] then spell_cache[self] = {} end spell_cache[self][idx] = false return res end local function reset_cache() for i=1,#spell_cache do local cache = spell_cache[i] for j=1,#cache do cache[j] = false end end end local function load_dictionary() core.add_thread(function() local t = {} local i = 0 for line in io.lines(config.plugins.spellcheck.dictionary_file) do for word in line:gmatch(word_pattern) do t[word:lower()] = true end i = i + 1 if i % 1000 == 0 then coroutine.yield() end end words = t core.redraw = true core.log_quiet( "Finished loading dictionary file: \"%s\"", config.plugins.spellcheck.dictionary_file ) end) end local function matches_any(filename, ptns) for _, ptn in ipairs(ptns) do if filename:find(ptn) then return true end end end local function active_word(doc, line, tail) local l, c = doc:get_selection() return l == line and c == tail and doc == core.active_view.doc and system.get_time() - last_input_time < 0.5 end local text_input = Doc.text_input function Doc:text_input(...) text_input(self, ...) last_input_time = system.get_time() end local function compare_arrays(a, b) if b == a then return true end if not a or not b then return false end if #b ~= #a then return false end for i=1,#a do if b[i] ~= a[i] then return false end end return true end local draw_line_text = DocView.draw_line_text function DocView:draw_line_text(idx, x, y) local lh = draw_line_text(self, idx, x, y) if not config.plugins.spellcheck.enabled or not words or not matches_any(self.doc.filename or "", config.plugins.spellcheck.files) then return lh end if font_canary ~= style.code_font or font_size_canary ~= style.code_font:get_size() or not compare_arrays(self.wrapped_lines, self.old_wrapped_lines) then spell_cache[self.doc.highlighter] = {} font_canary = style.code_font font_size_canary = style.code_font:get_size() self.old_wrapped_lines = self.wrapped_lines reset_cache() end if not spell_cache[self.doc.highlighter][idx] then local calculated = {} local s, e = 0, 0 local text = self.doc.lines[idx] while true do s, e = text:find(word_pattern, e + 1) if not s then break end local word = text:sub(s, e):lower() if not words[word] and not active_word(self.doc, idx, e + 1) then local x,y = self:get_line_screen_position(idx, s) table.insert(calculated, x + self.scroll.x) table.insert(calculated, y + self.scroll.y) x,y = self:get_line_screen_position(idx, e + 1) table.insert(calculated, x + self.scroll.x) table.insert(calculated, y + self.scroll.y) end end spell_cache[self.doc.highlighter][idx] = calculated end local color = style.spellcheck_error or style.syntax.keyword2 local h = math.ceil(1 * SCALE) local slh = self:get_line_height() local calculated = spell_cache[self.doc.highlighter][idx] for i=1,#calculated,4 do local x1, y1, x2, y2 = calculated[i], calculated[i+1], calculated[i+2], calculated[i+3] renderer.draw_rect(x1 - self.scroll.x, y1 + slh - self.scroll.y, x2 - x1, h, color) end return lh end local function get_word_at_caret() local doc = core.active_view.doc local l, c = doc:get_selection() local s, e = 0, 0 local text = doc.lines[l] while true do s, e = text:find(word_pattern, e + 1) if c >= s and c <= e + 1 then return text:sub(s, e):lower(), s, e end end end local function compare_words(word1, word2) local res = 0 for i = 1, math.max(#word1, #word2) do if word1:byte(i) ~= word2:byte(i) then res = res + 1 end end return res end -- The config specification used by the settings gui config.plugins.spellcheck.config_spec = { name = "Spell Check", { label = "Enabled", description = "Disable or enable spell checking.", path = "enabled", type = "toggle", default = true }, { label = "Files", description = "List of Lua patterns matching files to spell check.", path = "files", type = "list_strings", default = { "%.txt$", "%.md$", "%.markdown$" } }, { label = "Dictionary File", description = "Path to a text file that contains a list of dictionary words.", path = "dictionary_file", type = "file", exists = true, default = platform_dictionary_file, on_apply = function() load_dictionary() end } } load_dictionary() command.add("core.docview", { ["spell-check:toggle"] = function() config.plugins.spellcheck.enabled = not config.plugins.spellcheck.enabled end, ["spell-check:add-to-dictionary"] = function() local word = get_word_at_caret() if words[word] then core.error("\"%s\" already exists in the dictionary", word) return end if word then local fp = assert(io.open(config.plugins.spellcheck.dictionary_file, "a")) fp:write("\n" .. word .. "\n") fp:close() words[word] = true core.log("Added \"%s\" to dictionary", word) end end, ["spell-check:replace"] = function(dv) local word, s, e = get_word_at_caret() -- find suggestions local suggestions = {} local word_len = #word for w in pairs(words) do if math.abs(#w - word_len) <= 2 then local diff = compare_words(word, w) if diff < word_len * 0.5 then table.insert(suggestions, { diff = diff, text = w }) end end end if #suggestions == 0 then core.error("Could not find any suggestions for \"%s\"", word) return end -- sort suggestions table and convert to properly-capitalized text table.sort(suggestions, function(a, b) return a.diff < b.diff end) local doc = dv.doc local line = doc:get_selection() local has_upper = doc.lines[line]:sub(s, s):match("[A-Z]") for k, v in pairs(suggestions) do if has_upper then v.text = v.text:gsub("^.", string.upper) end suggestions[k] = v.text end -- select word and init replacement selector local label = string.format("Replace \"%s\" With", word) doc:set_selection(line, e + 1, line, s) core.command_view:enter(label, { submit = function(text, item) text = item and item.text or text doc:replace(function() return text end) end, suggest = function(text) local t = {} for _, w in ipairs(suggestions) do if w:lower():find(text:lower(), 1, true) then table.insert(t, w) end end return t end }) end, }) local contextmenu = require "plugins.contextmenu" contextmenu:register("core.docview", { contextmenu.DIVIDER, { text = "View Suggestions", command = "spell-check:replace" }, { text = "Add to Dictionary", command = "spell-check:add-to-dictionary" } })