diff options
author | Florian Fischer <florian.fl.fischer@fau.de> | 2020-09-17 11:36:51 +0200 |
---|---|---|
committer | Florian Fischer <florian.fl.fischer@fau.de> | 2020-09-17 11:36:51 +0200 |
commit | aceaf94e4a3de99dd7d626e3eb22065d7bf58617 (patch) | |
tree | ad69976b0b81db36556af3fdc1fcf070d0532b08 | |
parent | 38b8c680cc43c123912b09fe5507aff363e34432 (diff) | |
parent | ca3342d1cb7e16501960f4f83b35f3778ec1d6cd (diff) | |
download | vis-spellcheck-aceaf94e4a3de99dd7d626e3eb22065d7bf58617.tar.gz |
Merge branch 'syntax_aware' into master
-rw-r--r-- | Readme.md | 13 | ||||
-rw-r--r-- | spellcheck.lua | 282 |
2 files changed, 264 insertions, 31 deletions
@@ -2,19 +2,20 @@ A spellchecking lua plugin for the [vis editor](https://github.com/martanne/vis). -## installation +## Installation 1. Download `spellcheck.lua` or clone this repository 2. Load the plugin in your `visrc.lua` with `require(path/to/plugin/spellcheck)` -## usage +## Usage + To enable highlighting of misspelled words press `<Ctrl-w>e` in normal mode. + To disable highlighting press `<Ctrl-w>d` in normal mode. ++ To toggle highlighting press `<F7>` in normal mode. + To correct the word under the cursor press `<Ctrl+w>w` in normal mode. + To ignore the word under the cursor press `<Ctrl+w>i` in normal mode. -## configuration +## Configuration The module table returned from `require(...)` has some configuration options: @@ -24,8 +25,12 @@ The module table returned from `require(...)` has some configuration options: * default: `enchant -l -d %s` * `lang`: The name of the used dictionary. `lang` is inserted in the cmd-strings at `%s`. * default: `$LANG` or `en_US` -* `typo_style`: The style string with which misspellings should be highlighted +* `typo_style`: The style string with which misspellings should be highlighted when using the _full viewport_ method * default: `fore:red` +* `check_tokens`: A table mapping all token names we consider for spellchecking to true + * default: `{[vis.lexers.STRING]=true, [vis.lexers.COMMENT]=true, [vis.lexers.DEFAULT]=true}` +* `disable_syntax_awareness`: Disable the syntax aware spellchecking and use always _full viewport_ + * default: `false` A possible configuration could look like this: diff --git a/spellcheck.lua b/spellcheck.lua index 9d8a6ed..90ba907 100644 --- a/spellcheck.lua +++ b/spellcheck.lua @@ -3,8 +3,10 @@ local spellcheck = {} spellcheck.lang = os.getenv("LANG"):sub(0,5) or "en_US" -local supress_output = ">/dev/null 2>/dev/null" -if os.execute("type enchant "..supress_output) then +local supress_stdout = " >/dev/null" +local supress_stderr = " 2>/dev/null" +local supress_output = supress_stdout .. supress_stderr +if os.execute("type enchant"..supress_output) then spellcheck.cmd = "enchant -d %s -a" spellcheck.list_cmd = "enchant -l -d %s" elseif os.execute("type enchant-2"..supress_output) then @@ -21,14 +23,131 @@ else end spellcheck.typo_style = "fore:red" -spellcheck.enabled = {} +spellcheck.check_full_viewport = {} +spellcheck.disable_syntax_awareness = false +spellcheck.check_tokens = { + [vis.lexers.STRING] = true, + [vis.lexers.COMMENT] = true, + [vis.lexers.DEFAULT] = true, +} + +-- Return nil or a string of misspelled word in a specific file range or text +-- by calling the spellchecker's list command. +-- If given a range we will use vis:pipe to get our typos from the spellchecker. +-- If a string was passed we call the spellchecker ourself and redirect its stdout +-- to a temporary file. See http://lua-users.org/lists/lua-l/2007-10/msg00189.html. +-- The returned string consists of each misspell followed by a newline. +local function get_typos(range_or_text) + local cmd = spellcheck.list_cmd:format(spellcheck.lang) + local typos = nil + if type(range_or_text) == "string" then + local text = range_or_text + local tmp_name = os.tmpname() + local full_cmd = cmd .. "> " .. tmp_name .. supress_stderr + local proc = assert(io.popen(full_cmd, "w")) + proc:write(text) + -- this error detection may need lua5.2 + local success, reason, exit_code = proc:close() + if not success then + vis:info("calling " .. cmd .. " failed ("..exit_code..")") + return nil + end + + local tmp_file = assert(io.open(tmp_name, "r")) + typos = tmp_file:read("*a") + tmp_file:close() + os.remove(tmp_name) + else + local range = range_or_text + local ret, so, se = vis:pipe(vis.win.file, range, cmd) + + if ret ~= 0 then + vis:info("calling " .. cmd .. " failed ("..ret..")") + return nil + end + typos = so + end + + return typos +end + +-- plugin global list of ignored typos local ignored = {} -local last_viewport, last_typos = nil, "" +-- Return an iterator over all not ignored typos and their positions in text. +-- The returned iterator is a self contained statefull iterator function closure. +-- Which will return the next typo and its start and finish in the text, starting by 1. +local function typo_iter(text, typos, ignored) + local index = 1 + local unfiltered_iterator, iter_state = typos:gmatch("(.-)\n") + +-- see https://stackoverflow.com/questions/6705872/how-to-escape-a-variable-in-lua + local escape_lua_pattern + do + local matches = + { + ["^"] = "%^"; + ["$"] = "%$"; + ["("] = "%("; + [")"] = "%)"; + ["%"] = "%%"; + ["."] = "%."; + ["["] = "%["; + ["]"] = "%]"; + ["*"] = "%*"; + ["+"] = "%+"; + ["-"] = "%-"; + ["?"] = "%?"; + } + + escape_lua_pattern = function(s) + return (s:gsub(".", matches)) + end + end + + return function(foo, bar) + repeat + typo = unfiltered_iterator(iter_state) + until(not typo or (typo ~= "" and not ignored[typo])) + + if typo then + -- to prevent typos from being found in correct words before them + -- ("stuff stuf", "broken ok", ...) + -- we match typos only when they are enclosed in non-letter characters. + local start, finish = text:find("[%A]" .. escape_lua_pattern(typo) .. "[%A]", index) + -- typo was not found by our pattern this means it must be either + -- the first or last word in the text + if not start then + -- check start of text + start = 1 + finish = #typo + -- typo is not the beginning must be the end of text + if text:sub(start, finish) ~= typo then + start = #text - #typo + 1 + finish = start + #typo - 1 + end + + if text:sub(start, finish) ~= typo then + vis:info(string.format("can't find typo %s after %d. Please report this bug.", + typo, index)) + end + -- our pettern [%A]typo[%A] found it + else + start = start + 1 -- ignore leading non letter char + finish = finish - 1 -- ignore trailing non letter char + end + index = finish + + return typo, start, finish + end + end +end + +local last_viewport, last_data, last_typos = nil, "", "" vis.events.subscribe(vis.events.WIN_HIGHLIGHT, function(win) - if not spellcheck.enabled[win] or not win:style_define(42, spellcheck.typo_style) then + if not spellcheck.check_full_viewport[win] or not win:style_define(42, spellcheck.typo_style) then return false end local viewport = win.viewport @@ -39,23 +158,14 @@ vis.events.subscribe(vis.events.WIN_HIGHLIGHT, function(win) if last_viewport == viewport_text then typos = last_typos else - local cmd = spellcheck.list_cmd:format(spellcheck.lang) - local ret, so, se = vis:pipe(win.file, viewport, cmd) - if ret ~= 0 then - vis:message("calling " .. cmd .. " failed ("..se..")") + typos = get_typos(viewport) or "" + if not typos then return false end - typos = so or "" end - local corrections_iter = typos:gmatch("(.-)\n") - local index = 1 - for typo in corrections_iter do - if not ignored[typo] then - local start, finish = viewport_text:find(typo, index, true) - win:style(42, viewport.start + start - 1, viewport.start + finish) - index = finish - end + for typo, start, finish in typo_iter(viewport_text, typos, ignored) do + win:style(42, viewport.start + start - 1, viewport.start + finish) end last_viewport = viewport_text @@ -63,26 +173,142 @@ vis.events.subscribe(vis.events.WIN_HIGHLIGHT, function(win) return true end) +local wrapped_lex_funcs = {} + +local wrap_lex_func = function(old_lex_func) + local old_new_tokens = {} + + return function(lexer, data, index, redrawtime_max) + local tokens, timedout = old_lex_func(lexer, data, index, redrawtime_max) + + -- quit early if the lexer already took to long + -- TODO: investigate further if timedout is actually set by the lexer. + -- As I understand lpeg.match used by lexer.lex timedout will always be nil + if timeout then + return tokens, timedout + end + + local new_tokens = {} + + local typos = "" + if last_data ~= data + then + typos = get_typos(data) + if not typos then + return tokens, timedout + end + last_data = data + else + return old_new_tokens + end + + local i = 1 + for typo, typo_start, typo_end in typo_iter(data, typos, ignored) do + repeat + -- no tokens left + if i > #tokens -1 then + break + end + + local token_type = tokens[i] + local token_start = (tokens[i-1] or 1) - 1 + local token_end = tokens[i+1] + + -- the current token ends before our typo -> append to new stream + -- or is not spellchecked + if token_end < typo_start or not spellcheck.check_tokens[token_type] then + table.insert(new_tokens, token_type) + table.insert(new_tokens, token_end) + + -- done with this token -> advance token stream + i = i + 2 + -- typo and checked token overlap + else + local pre_typo_end = typo_start - 1 + -- unchanged token part before typo + if pre_typo_end > token_start then + table.insert(new_tokens, token_type) + table.insert(new_tokens, pre_typo_end + 1) + end + + -- highlight typo + table.insert(new_tokens, vis.lexers.ERROR) + -- the typo spans multiple tokens + if token_end < typo_end then + table.insert(new_tokens, token_end + 1) + i = i + 2 + else + table.insert(new_tokens, typo_end + 1) + end + end + until(not token_end or token_end >= typo_end) + end + + -- add tokens left after we handled all typos + for i = i, #tokens, 1 do + table.insert(new_tokens, tokens[i]) + end + + old_new_tokens = new_tokens + return new_tokens, timedout + end +end + +local enable_spellcheck = function() + -- prevent wrapping the lex function multiple times + if wrapped_lex_funcs[vis.win] then + return + end + + if not spellcheck.disable_syntax_awareness and vis.win.syntax and vis.lexers.load then + local lexer = vis.lexers.load(vis.win.syntax, nil, true) + if lexer and lexer.lex then + local old_lex_func = lexer.lex + wrapped_lex_funcs[vis.win] = old_lex_func + lexer.lex = wrap_lex_func(old_lex_func) + -- reset last data to enforce new highlighting + last_data = "" + return + end + end + + -- fallback check spellcheck the full viewport + spellcheck.check_full_viewport[vis.win] = true +end + +local is_spellcheck_enabled = function() + return spellcheck.check_full_viewport[vis.win] or wrapped_lex_funcs[vis.win] +end + vis:map(vis.modes.NORMAL, "<C-w>e", function(keys) - spellcheck.enabled[vis.win] = true - return 0 + enable_spellcheck() end, "Enable spellchecking in the current window") +local disable_spellcheck = function() + local old_lex_func = wrapped_lex_funcs[vis.win] + if old_lex_func then + local lexer = vis.lexers.load(vis.win.syntax, nil, true) + lexer.lex = old_lex_func + wrapped_lex_funcs[vis.win] = nil + else + spellcheck.check_full_viewport[vis.win] = nil + end +end + vis:map(vis.modes.NORMAL, "<C-w>d", function(keys) - spellcheck.enabled[vis.win] = nil + disable_spellcheck() -- force new highlight vis.win:draw() - return 0 end, "Disable spellchecking in the current window") -- toggle spellchecking on <F7> -- <F7> is used by some word processors (LibreOffice) for spellchecking -- Thanks to @leorosa for the hint. vis:map(vis.modes.NORMAL, "<F7>", function(keys) - if not spellcheck.enabled[vis.win] then - spellcheck.enabled[vis.win] = true + if not is_spellcheck_enabled() then + enable_spellcheck() else - spellcheck.enabled[vis.win] = nil + disable_spellcheck() vis.win:draw() end return 0 @@ -122,7 +348,7 @@ vis:map(vis.modes.NORMAL, "<C-w>w", function(keys) -- select a correction local cmd = 'printf "' .. suggestions:gsub(", ", "\\n") .. '\\n" | vis-menu' - local f = io.popen(cmd) + local f = assert(io.popen(cmd)) local correction = f:read("*all") f:close() -- trim correction @@ -157,8 +383,10 @@ end, "Ignore misspelled word") vis:option_register("spelllang", "string", function(value, toggle) spellcheck.lang = value vis:info("Spellchecking language is now "..value) - -- force new highlight + -- force new highlight for full viewport last_viewport = nil + -- force new highlight for syntax aware + last_data = nil return true end, "The language used for spellchecking") |