├── LICENSE ├── README.md ├── lua ├── nvim-toggler.lua └── tests │ ├── run │ └── test.lua └── stylua.toml /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Khang 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nvim-toggler 2 | 3 | Invert text in vim, purely with lua. 4 | 5 | ![demo](https://user-images.githubusercontent.com/10664455/185724246-f7165f38-6058-46f3-809b-d55cf09255e3.gif) 6 | 7 | [Install](#install) 8 |  ·  9 | [Run](#run) 10 |  ·  11 | [Custom inverses](#custom-inverses) 12 |  ·  13 | [Custom keymaps](#custom-keymaps) 14 | 15 | ## Install 16 | 17 | Using [packer.nvim][packer] 18 | 19 | ```lua 20 | use { 'nguyenvukhang/nvim-toggler' } 21 | ``` 22 | 23 | Using [vim-plug][vim-plug] 24 | 25 | ```vim 26 | Plug 'nguyenvukhang/nvim-toggler' 27 | ``` 28 | 29 | ## Run 30 | 31 | ```lua 32 | -- init.lua 33 | require('nvim-toggler').setup() 34 | ``` 35 | 36 | ```vim 37 | " init.vim or .vimrc 38 | lua << EOF 39 | require('nvim-toggler').setup() 40 | EOF 41 | ``` 42 | 43 | Once that is set, the default binding is `i` to invert the 44 | word under your cursor. 45 | 46 | ## Custom inverses 47 | 48 | You can configure `nvim-toggler` with the `setup()` function: 49 | 50 | ```lua 51 | -- init.lua 52 | require('nvim-toggler').setup({ 53 | -- your own inverses 54 | inverses = { 55 | ['vim'] = 'emacs' 56 | }, 57 | -- removes the default i keymap 58 | remove_default_keybinds = true, 59 | -- removes the default set of inverses 60 | remove_default_inverses = true, 61 | -- auto-selects the longest match when there are multiple matches 62 | autoselect_longest_match = false 63 | }) 64 | ``` 65 | 66 | ## Custom keymaps 67 | 68 | To map toggling to something else like `cl`, simply do 69 | 70 | ```lua 71 | -- init.lua 72 | vim.keymap.set({ 'n', 'v' }, 'cl', require('nvim-toggler').toggle) 73 | ``` 74 | 75 | [packer]: https://github.com/wbthomason/packer.nvim 76 | [vim-plug]: https://github.com/junegunn/vim-plug 77 | -------------------------------------------------------------------------------- /lua/nvim-toggler.lua: -------------------------------------------------------------------------------- 1 | local log = {} 2 | local banner = function(msg) return '[nvim-toggler] ' .. msg end 3 | function log.warn(msg) vim.notify(banner(msg), vim.log.levels.WARN) end 4 | function log.once(msg) vim.notify_once(banner(msg), vim.log.levels.WARN) end 5 | function log.echo(msg) vim.api.nvim_echo({ { banner(msg), 'None' } }, false, {}) end 6 | 7 | local defaults = { 8 | inverses = { 9 | ['true'] = 'false', 10 | ['True'] = 'False', 11 | ['yes'] = 'no', 12 | ['on'] = 'off', 13 | ['left'] = 'right', 14 | ['up'] = 'down', 15 | ['enable'] = 'disable', 16 | ['!='] = '==', 17 | }, 18 | opts = { 19 | remove_default_keybinds = false, 20 | remove_default_inverses = false, 21 | autoselect_longest_match = false, 22 | }, 23 | } 24 | 25 | ---@param str string 26 | ---@param byte integer 27 | ---@return boolean 28 | local function contains_byte(str, byte) 29 | for i = 1, #str do 30 | if str:byte(i) == byte then return true end 31 | end 32 | return false 33 | end 34 | 35 | ---@param line string 36 | ---@param word string 37 | ---@param c_pos integer 38 | ---@return integer|nil, integer|nil 39 | local function surround(line, word, c_pos) 40 | local w, W = 0, #word 41 | local l, L = math.max(c_pos - #word, 0), math.min(c_pos + #word, #line) 42 | while w < W and l < L do 43 | local _w, _l = word:byte(w + 1), line:byte(l + 1) 44 | w, l = _w == _l and w + 1 or word:byte(1) == _l and 1 or 0, l + 1 45 | end 46 | if w == W then return l - w + 1, l end 47 | end 48 | 49 | local inv_tbl = { data = {}, hash = {} } 50 | 51 | function inv_tbl:reset() 52 | self.hash, self.data = {}, {} 53 | end 54 | 55 | -- Adds unique key-value pairs to the inv_tbl. 56 | -- 57 | -- If either the `key` or the `value` is found to be already in 58 | -- `inv_tbl`, then the `key`-`value` pair will not be added. 59 | function inv_tbl:add(tbl, verbose) 60 | for k, v in pairs(tbl or {}) do 61 | if not self.hash[k] and not self.hash[v] then 62 | self.data[k], self.data[v], self.hash[k], self.hash[v] = v, k, true, true 63 | elseif verbose then 64 | log.once('conflicts found in inverse config.') 65 | end 66 | end 67 | end 68 | 69 | local app = { inv_tbl = inv_tbl, opts = {} } 70 | 71 | function app:load_opts(opts) 72 | opts = opts or {} 73 | for k in pairs(defaults.opts) do 74 | if type(opts[k]) == type(defaults.opts[k]) then 75 | self.opts[k] = opts[k] 76 | elseif opts[k] ~= nil then 77 | log.once('incorrect type found in config.') 78 | end 79 | end 80 | end 81 | 82 | function app.sub(line, result) 83 | local lo, hi, inverse = result.lo, result.hi, result.inverse 84 | line = table.concat({ line:sub(1, lo - 1), inverse, line:sub(hi + 1) }, '') 85 | return vim.api.nvim_set_current_line(line) 86 | end 87 | 88 | -- `word` is the string to be replaced 89 | -- `inverse` is the string that will replace `word` 90 | -- 91 | -- Toggle is executed on the first keyword found such that 92 | -- 1. `word` contains the character under the cursor. 93 | -- 2. current line contains `word`. 94 | -- 3. cursor is on that `word` in the current line. 95 | function app:toggle() 96 | local line, cursor = vim.fn.getline('.'), vim.fn.col('.') 97 | local byte = line:byte(cursor) 98 | local results = {} 99 | for word, inverse in pairs(self.inv_tbl.data) do 100 | if contains_byte(word, byte) then 101 | local lo, hi = surround(line, word, cursor) 102 | if lo and lo <= cursor and cursor <= hi then 103 | table.insert( 104 | results, 105 | { lo = lo, hi = hi, inverse = inverse, word = word } 106 | ) 107 | end 108 | end 109 | end 110 | if #results == 0 then return log.warn('unsupported value.') end 111 | if #results == 1 then return self.sub(line, results[1]) end 112 | -- handle multiple results (`results` is guaranteed >= 2 entries from here) 113 | table.sort(results, function(a, b) return #a.word > #b.word end) 114 | if 115 | #results[1].word > #results[2].word and app.opts.autoselect_longest_match 116 | then 117 | return app.sub(line, results[1]) 118 | end 119 | local prompt, fmt = {}, '[%d] %s -> %s' 120 | for i, result in ipairs(results) do 121 | table.insert(prompt, fmt:format(i, result.word, result.inverse)) 122 | end 123 | table.insert(prompt, '[?] > ') 124 | local input = vim.fn.input(table.concat(prompt, '\n')) 125 | vim.cmd('redraw!') 126 | if input == nil or input == '' then return log.echo('nothing happened.') end 127 | local result = results[input:byte(1) - 48] 128 | if result then 129 | app.sub(line, result) 130 | log.echo(('%s -> %s'):format(result.word, result.inverse)) 131 | else 132 | log.echo('nothing happened.') 133 | end 134 | end 135 | 136 | function app:setup(opts) 137 | self:load_opts(defaults.opts) 138 | self:load_opts(opts) 139 | self.inv_tbl:reset() 140 | self.inv_tbl:add((opts or {}).inverses, true) 141 | if not self.opts.remove_default_inverses then 142 | self.inv_tbl:add(defaults.inverses) 143 | end 144 | if not self.opts.remove_default_keybinds then 145 | vim.keymap.set( 146 | { 'n', 'v' }, 147 | 'i', 148 | function() self:toggle() end, 149 | { silent = true } 150 | ) 151 | end 152 | end 153 | 154 | return { 155 | setup = function(opts) app:setup(opts) end, 156 | toggle = function() app:toggle() end, 157 | reset = function() 158 | app.inv_tbl:reset() 159 | app.opts = {} 160 | end, 161 | } 162 | -------------------------------------------------------------------------------- /lua/tests/run: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | if [[ $1 == '-i' ]]; then 4 | c() { 5 | rm -f tmp.txt 6 | } 7 | trap c EXIT 8 | nvim --clean \ 9 | -c "lua package.path = package.path .. ';./?.lua;../?.lua'" \ 10 | -c 'lua require("test")' -- tmp.txt 11 | fi 12 | nvim --headless --clean \ 13 | -c "lua package.path = package.path .. ';./?.lua;../?.lua'" \ 14 | -c 'lua require("test")' -c "q!" 15 | -------------------------------------------------------------------------------- /lua/tests/test.lua: -------------------------------------------------------------------------------- 1 | vim.g.mapleader = ' ' 2 | vim.keymap.set('n', '', '') 3 | 4 | local clear_package = function() 5 | local all_packages = vim.tbl_keys(package.loaded) 6 | local related_packages = vim.tbl_filter( 7 | function(p) return vim.startswith(p, 'nvim-toggler') end, 8 | all_packages 9 | ) 10 | vim.tbl_map(function(p) 11 | package.loaded[p] = nil -- unload it 12 | end, related_packages) 13 | end 14 | clear_package() 15 | require('nvim-toggler').setup({ 16 | inverses = { 17 | ['true'] = 'false', 18 | ['ru'] = 'fase', 19 | }, 20 | }) 21 | 22 | -- asserts that `received` == `expected`, and prints a pretty 23 | -- assertion message with titled with `desc`. 24 | local assert_eq = function(received, expected, desc) 25 | if received ~= expected then 26 | print(desc) 27 | print('received:', received) 28 | print('expected:', expected) 29 | end 30 | return received == expected 31 | end 32 | 33 | -- useful power-tool 34 | local P = function(x) print(vim.inspect(x)) end 35 | 36 | local test = { title = '', _config = {}, count = 0 } 37 | local summary = setmetatable({ 38 | ok = true, 39 | ran = false, 40 | update = function(self, result) 41 | self.ran = true 42 | self.ok = self.ok and result 43 | end, 44 | }, { 45 | __call = function(self) 46 | if self.ok then 47 | print('[test.lua] all ok!\n') 48 | else 49 | print('[test.lua] some tests failed.\n') 50 | end 51 | end, 52 | }) 53 | 54 | -- sets the inverses only (with override) 55 | test.inverses = function(self, inverses) 56 | self._config = { remove_default_inverses = true, inverses = inverses } 57 | end 58 | 59 | -- sets the entire config as if called with require('nvim-toggler').setup() 60 | test.setup = function(self, config) self._config = config end 61 | 62 | test.run = function(self, start_state, cursor_pos) 63 | vim.api.nvim_set_current_line(start_state or '') 64 | vim.cmd('norm! ' .. cursor_pos .. '|') 65 | local nt = require('nvim-toggler') 66 | nt.reset() 67 | nt.setup(self._config) 68 | nt.toggle() 69 | return vim.api.nvim_get_current_line() 70 | end 71 | 72 | test.assert_mirror = function(self, a, b) 73 | if self:assert(a, b, 1) then self:assert(b, a, 1) end 74 | end 75 | 76 | test.assert = function(self, start_state, expected, cursor_pos) 77 | self.count = self.count + 1 78 | local title = ('%s, %d'):format(self.title, self.count) 79 | local result = assert_eq(self:run(start_state, cursor_pos), expected, title) 80 | summary:update(result) 81 | return result 82 | end 83 | 84 | test = setmetatable(test, { 85 | __call = function(self, title) 86 | self:setup() 87 | self.title = title 88 | self.count = 0 89 | end, 90 | }) 91 | 92 | local function stable() 93 | -- ensures that a default actually exists 94 | test('Default true <-> false toggle exists') 95 | assert(test._config == nil) 96 | test:assert_mirror('true', 'false') 97 | 98 | test('Base override check') 99 | test:inverses({ ['vertical'] = 'horizontal' }) 100 | test:assert_mirror('vertical', 'horizontal') 101 | 102 | test('Override existing pair') 103 | test:setup({ inverses = { ['true'] = 'maybe' } }) 104 | test:assert_mirror('true', 'maybe') 105 | 106 | test('Override existing pair (inverted)') 107 | test:setup({ inverses = { ['maybe'] = 'true' } }) 108 | test:assert_mirror('true', 'maybe') 109 | 110 | test('Default clipboard not polluted') 111 | test:inverses({ ['true'] = 'false' }) 112 | vim.fn.setreg('"', 'clean') 113 | test:assert_mirror('true', 'false') 114 | assert_eq(vim.fn.getreg('"'), 'clean', test.title) 115 | 116 | test('Clustered inverses') 117 | test:inverses({ ['foo'] = 'bar' }) 118 | test:assert('foobarfoo', 'barbarfoo', 3) 119 | test:assert('foobarfoo', 'foofoofoo', 4) 120 | test:assert('foobarfoo', 'foofoofoo', 5) 121 | test:assert('foobarfoo', 'foofoofoo', 6) 122 | test:assert('foobarfoo', 'foobarbar', 7) 123 | 124 | test('Clustered inverses with symbols') 125 | test:inverses({ ['!='] = '==', ['true'] = 'false' }) 126 | test:assert('true!=false', 'false!=false', 4) 127 | test:assert('true!=false', 'true==false', 5) 128 | test:assert('true!=false', 'true==false', 6) 129 | test:assert('true!=false', 'true!=true', 7) 130 | 131 | test('Inverses with spaces') 132 | test:inverses({ ['check yes'] = 'juliet are' }) 133 | test:assert('check yes you', 'juliet are you', 4) 134 | 135 | test('Toggle checkboxes') 136 | test:inverses({ ['- [ ]'] = '- [x]' }) 137 | test:assert('- [ ] Buy milk', '- [x] Buy milk', 1) 138 | test:assert('- [ ] Buy milk', '- [x] Buy milk', 2) 139 | test:assert('- [ ] Buy milk', '- [x] Buy milk', 3) 140 | test:assert('- [ ] Buy milk', '- [x] Buy milk', 4) 141 | test:assert('- [ ] Buy milk', '- [x] Buy milk', 5) 142 | test:assert('- [ ] Buy milk', '- [ ] Buy milk', 6) 143 | 144 | test('LaTeX bug') 145 | test:inverses({ ['true'] = 'false' }) 146 | test:assert('\\iffalse', '\\iftrue', 4) 147 | test:assert('\\iftrue', '\\iffalse', 4) 148 | test:assert('\\iftrue', '\\iffalse', 7) 149 | test:assert('\\iffalse', '\\iftrue', 6) 150 | 151 | test('Substring of inverse') 152 | test:inverses({ ['shift'] = 'unshift' }) 153 | test:assert('\\shift', '\\unshift', 4) 154 | test:assert('\\unshift', '\\shift', 2) 155 | test:assert('\\unshift', '\\shift', 3) 156 | 157 | -- FAILING: this triggers a user interaction due to the ambiguity. 158 | -- test:assert('\\unshift', '\\shift', 4) 159 | end 160 | 161 | local function experimental() end 162 | 163 | -- check with manual testing 164 | -- test('Multiple matches') 165 | -- test:inverses({ ['true'] = 'false', ['ru'] = 'falslyfalse' }) 166 | -- test:assert('foo true bar', 'foo true bar', 6) 167 | 168 | stable() 169 | experimental() 170 | summary() 171 | -------------------------------------------------------------------------------- /stylua.toml: -------------------------------------------------------------------------------- 1 | column_width = 80 2 | line_endings = "Unix" 3 | indent_type = "Spaces" 4 | indent_width = 2 5 | quote_style = "AutoPreferSingle" 6 | call_parentheses = "Always" 7 | collapse_simple_statement = "Always" 8 | --------------------------------------------------------------------------------