Search code examples
luaneovim

Using Lua to Create Conditional Key Mappings in Neovim


I'm trying to translate a specific vimscript code that moves visual blocks up and down using Ctrl + arrow keys to lua.

Here’s the Vimscript code in my .vimrc:

vnoremap ^[[1;5A  :m '<-2<CR>gv=gv
vnoremap ^[[1;5B  :m '>+1<CR>gv=gv

And here’s the Lua code in my init.lua:

-- Global Variable 
local opts = { noremap = true, silent = true }
-- Key Mappings
vim.api.nvim_set_keymap('v', '<C-Up>', ':m \'<-2<CR>gv=gv', opts)
vim.api.nvim_set_keymap('v', '<C-Down>', ':m \'>+1<CR>gv=gv', opts)

However, the issue is that when I reach the top or bottom of the file, I get an E16: Invalid range error and the visual selection gets deselected. To handle this in Vim, I added a check in my .vimrc as follows:

vnoremap <expr> ^[[1;5A (line("'<") == 1 ? "" : ":m '<-2<CR>gv=gv")
vnoremap <expr> ^[[1;5B (line("'>") == line("$") ? "" : ":m '>+1<CR>gv=gv")

Translating this to lua, I came up with the following:

-- Global Variable 
local opts = { noremap = true, silent = true }

-- Custom function to move visual block up
function _G.move_visual_block_up()
  if vim.fn.line("'<") > 1 then
    vim.cmd(":m '<-2<CR>gv=gv")
  end
end

-- Custom function to move visual block down
function _G.move_visual_block_down()
  if vim.fn.line("'>") < vim.fn.line("$") then
    vim.cmd(":m '>+1<CR>gv=gv")
  end
end

-- Key Mappings
vim.api.nvim_set_keymap('v', '<C-Up>',   '<Cmd>lua _G.move_visual_block_up()<CR>', opts)
vim.api.nvim_set_keymap('v', '<C-Down>', '<Cmd>lua _G.move_visual_block_down()<CR>', opts)

However, hitting ctrl + up has no effect; on the other hand, pressing ctrl + down results in an error…

E5108: Error executing lua vim/_editor.lua:0: nvim_exec2(): Vim(move):E20: Mark
not set
stack traceback:
        [C]: in function 'nvim_exec2'
        vim/_editor.lua: in function 'cmd'
        /etc/nvim/init.lua:206: in function 'move_visual_block_down'
        [string ":lua"]:1: in main chunk

The issue appears unclear to me… What exactly is this mark? I tried looking up for the error, but there aren't any leads available.

PS:

While this question appears alike, it doesn’t aid in resolving my problem.


Solution

  • There's deceptively a lot going on here and this is really tougher than it should be... in your original map, :m '<-2<CR>gv=gv, vim is going to basically simulate all these keystrokes similar to a macro. You can press all of them yourself and get the same result. What you will notice in particular is when you have a visual selection and you press :, you will get '<,'> inserted automatically and you will lose the active visual selection. But '<,'> is a range that acts from the '< mark to the '> mark. (These are the "marks" it's referring to in the error message, which we’ll get to later). From :h '< and :h '>:

    '<  <                   To the first line or character of the last selected
                            Visual area in the current buffer.  For block mode it
                            may also be the last character in the first line (to
                            be able to define the block).
    
                                                            '> `>
    '>  >                   To the last line or character of the last selected
                            Visual area in the current buffer.  For block mode it
                            may also be the first character of the last line (to
                            be able to define the block).  Note that 'selection'
                            applies, the position may be just after the Visual
                            area.
    

    So, we no longer have the visual selection active, but this is precisely what allows that range to work, because it refers to the previous visual selection that was active. Since the area we just had selected is now considered the previous area, it targets the right section. So the desired area will be moved to the line of end of that area plus one. gv=gv will activate the previous visual selection, format it, and then activate the selection again.

    Now that we've explained why the original works, we can move on to fixing your lua version.

    Part of the issue is that neither vim.cmd() nor the <Cmd> part of a keymap simulate the : keystroke in the same way that your regular vnoremap does, but rather they just execute the command. This is the same reason you don't have to manually enter the <CR>. As a result, we don’t get that range automatically added to our m command like we did before, meaning it will only act on the current line by default. This also means that the visual selection won’t get deactivated before the command is executed, so if this is the first selection you’ve made in the buffer, there will be no “previous” selection as you still just have the current one active. I strongly suspect this is why you get the “mark not set” error as the '</'> have no previous visual selection to hook to yet.

    So, to replicate this behavior using lua commands, we can either simulate the : keystroke properly, work around it by escaping visual mode and using the '< and '> markers manually or by just getting the lines of the visual area while it is still selected and working with those. I'll be showing you how to successfully use the second method in this list and I'll include the code I attempted to get working using the third method as well if you'd like to tinker with it and fix it.

    Simulating the escape keystroke:
    We'll write a helper function that will simulate the user pressing the escape key so as to leave visual mode.

    local function simulate_escape()
      vim.api.nvim_feedkeys(
        vim.api.nvim_replace_termcodes('<Esc>', true, false, true),
        -- It is essential that you include "x" in this string; without it, the '<
        -- and '> marks won't get updated correctly after <Esc> is simulated.
        'nx',
        false
      )
    end
    
    

    Next, we'll implement the actual moving:

    local function move_linewise_visual_area(move_up)
      simulate_escape()
    
      if move_up and vim.fn.line("'<") > 1 then
        vim.cmd("'<,'>m '<-2")
        vim.cmd("normal gv=gv")
      elseif not move_up and vim.fn.line("'>") < vim.fn.line("$") then
        vim.cmd("'<,'>m '>+1")
        vim.cmd("normal gv=gv")
      else
        vim.cmd('normal gv=gv')
      end
    end
    
    

    This is more or less just replicating the behavior of your original keymap but also checks for start and end of file.

    Now, onto the keymaps. vim.keymap.set should be your go-to function for lua keymaps. It provides a better experience than vim.api.nvim_set_keymap as it has some extras built into it. Once we make that change, then instead of calling '<Cmd>...' in your keymap, you can just replace that string with the actual lua function itself.

    So then your keymap would become

    vim.keymap.set('v', '<C-Up>', function() move_linewise_visual_area(true) end, opts)
    vim.keymap.set('v', '<C-Down>', function() move_linewise_visual_area(false) end, opts)
    

    Note that we have to define a function inline to call the function since we're passing the inner function an argument. Without wrapping it with a function definition, we would just call it once when the init.lua reads it, and it would return a nil to the actual keymap function. If we didn't require an argument to our inner command, we could just pass it without parentheses to achieve the same effect.

    Here's the final result, with the other method I attempted just for reference.

    local opts = { noremap = true, silent = true }
    
    local function simulate_escape()
      vim.api.nvim_feedkeys(
        vim.api.nvim_replace_termcodes('<Esc>', true, false, true),
        -- It is essential that you include "x" in this string; without it, the '<
        -- and '> marks won't get updated correctly after <Esc> is simulated.
        'nx',
        false
      )
    end
    
    local function move_linewise_visual_area(move_up)
      simulate_escape()
    
      if move_up and vim.fn.line("'<") > 1 then
        vim.cmd("'<,'>m '<-2")
        vim.cmd("normal gv=gv")
      elseif not move_up and vim.fn.line("'>") < vim.fn.line("$") then
        vim.cmd("'<,'>m '>+1")
        vim.cmd("normal gv=gv")
      else
        vim.cmd('normal gv=gv')
      end
    end
    
    -- Key Mappings
    vim.keymap.set('v', '<C-Up>', function() move_linewise_visual_area(true) end, opts)
    vim.keymap.set('v', '<C-Down>', function() move_linewise_visual_area(false) end, opts)
    
    -- Other attempt
    
    local function get_visual_area_line_range()
      -- Line that cursor is on (one end of the visual area)
      local cursor_line = vim.fn.line(".")
    
      -- Line opposite the cursor's end of the visual area
      local opposite_line = vim.fn.line("v")
    
      -- Return the start and end lines of the visual area in that order
      if cursor_line < opposite_line then
        return cursor_line, opposite_line
      else
        return opposite_line, cursor_line
      end
    end
    
    local function move_linewise_visual_area_2(move_up)
      local start_line, end_line = get_visual_area_line_range()
      if move_up and start_line > 1 then
    
        vim.cmd(start_line .. "," .. end_line .. "m " .. start_line - 2)
        vim.cmd("normal =gv")
      elseif not move_up and end_line < vim.fn.line("$") then
        vim.cmd(start_line .. "," .. end_line .. "m " .. end_line + 1)
        vim.cmd("normal =gv")
      end
    end
    
    
    -- vim.keymap.set('v', '<C-Up>', function() move_linewise_visual_area_2(true) end, opts)
    -- vim.keymap.set('v', '<C-Down>', function() move_linewise_visual_area_2(false) end, opts)