Banner of Automation, Not Just Shortcuts: Custom Functions that Think Like an IDE

Automation, Not Just Shortcuts: Custom Functions that Think Like an IDE


Category: vim

📅 May 15, 2026   |   👁️ Views: 1

Author:   mosaid

Beyond Keymaps: Functions as First‑Class Tools

Most Neovim users start by remapping keys. That’s fine, but it stops short of turning the editor into a programmable environment. The real power comes from custom Lua functions that do things no plugin can anticipate: running code asynchronously, replacing visual selections with computed results, exploring your command history like a file, or inspecting registers at a glance.

In this article we open lua/config/functions.lua – the heart of the automation layer – and walk through every major utility. You’ll see the full source for each function, understand why it was built, and learn how to wire it into your own workflow.

Architecture Overview

The file lua/config/functions.lua exports a table of functions. These are called from keymaps (defined in keymaps.lua) and user commands (defined in commands.lua). The module is loaded once during startup and reused everywhere.

The directory structure relevant to this article:


lua/config/
├── functions.lua     <-- all custom logic
├── keymaps.lua       <-- maps keys to functions
└── commands.lua      <-- creates :UserCommands

    

1. Async Python and LaTeX Runners

Two of the most frequently used functions are run_python_selection() and run_python_and_replace(). The first executes the visually selected Python code in a non‑blocking job, appending its output to a scratch buffer. The second does the same, but replaces the selection with the output – ideal for quick calculations directly in your code or prose.

Both functions use vim.fn.jobstart to avoid freezing the UI, and they automatically handle buffer cleanup when the job finishes or the user presses Enter. A similar async pattern is used in the LaTeX ftplugin (covered in Article #2), but the core helper functions are general enough to be reused for any language.


function M.run_python_selection()
  -- Check if in visual mode
  local mode = vim.fn.mode()
  if mode ~= 'v' and mode ~= 'V' then
    print("No visual selection")
    return
  end

  -- Save current register
  local save_reg = vim.fn.getreg('"')
  local save_regtype = vim.fn.getregtype('"')

  -- Yank selection into default register
  vim.cmd('normal! gvy')
  local code = vim.fn.getreg('"')

  -- Restore original register
  vim.fn.setreg('"', save_reg, save_regtype)

  -- Trim whitespace and prepend math import
  code = code:gsub('^%s*', ''):gsub('%s*$', '')
  code = "from math import *\n" .. code

  -- Execute Python code safely
  local ok, output = pcall(vim.fn.system, {'python3', '-c', code})
  if not ok then
    print("Error executing Python code")
    return
  end

  -- Open scratch buffer
  vim.cmd('enew')
  vim.api.nvim_buf_set_lines(0, 0, -1, false, vim.split(output, "\n"))

  -- Configure buffer
  vim.bo.buftype = 'nofile'
  vim.bo.bufhidden = 'wipe'
  vim.bo.swapfile = false
  vim.wo.wrap = false
end

    

The run_python_and_replace() variant handles single‑line selections specially: it wraps the expression in a print() so that the output replaces the expression inline. This is useful for evaluating math expressions inside Markdown or LaTeX documents.


function M.run_python_and_replace()
  local save_reg = vim.fn.getreg('"')
  local save_regtype = vim.fn.getregtype('"')
  vim.cmd('normal! ""gvy')
  local code = vim.fn.getreg('"')
  vim.fn.setreg('"', save_reg, save_regtype)

  code = code:gsub('^%s*', ''):gsub('%s*$', '')

  local start_line = vim.fn.line("'<")
  local end_line = vim.fn.line("'>")
  if start_line == end_line then
    code = 'print(' .. code .. ')'
  end

  code = "from math import *\n" .. code
  local output = vim.fn.system('python3 -c ' .. vim.fn.shellescape(code))
  output = output:gsub('\n+$', '')

  vim.cmd('normal! gv"_c' .. output)
end

    

2. Command History Explorer

The command‑line history in Vim is powerful but invisible. My command_history_explorer() function dumps the entire history (newest first) into a temporary buffer. You can navigate it with j/k or arrow keys, and pressing Enter executes the command under the cursor. Pressing q or Tab closes the buffer.

This effectively gives you a “command palette” with zero dependencies. Because the buffer is non‑modifiable and immediately searchable with /, you can filter through hundreds of past commands in seconds.


function M.command_history_explorer()
  local history = {}
  local count = vim.fn.histnr(':') -- number of commands in history

  -- Collect commands from newest to oldest
  for i = count, 1, -1 do
    local cmd = vim.fn.histget(':', i)
    if cmd ~= "" then
      table.insert(history, cmd)
    end
  end

  -- Create new buffer
  vim.cmd('enew')
  local buf = vim.api.nvim_get_current_buf()

  vim.api.nvim_buf_set_lines(buf, 0, -1, false, history)

  -- Buffer options
  vim.bo[buf].buftype = 'nofile'
  vim.bo[buf].bufhidden = 'wipe'
  vim.bo[buf].swapfile = false
  vim.bo[buf].modifiable = false
  vim.wo.wrap = false

  local opts = { buffer = buf, noremap = true, silent = true }

  -- Map arrow keys (raw sequences)
  vim.keymap.set('n', "\27[B", 'j', opts) -- Down
  vim.keymap.set('n', "\27[A", 'k', opts) -- Up

  -- Also allow hjkl
  vim.keymap.set('n', 'j', 'j', opts)
  vim.keymap.set('n', 'k', 'k', opts)

  -- Quit buffer quickly
  vim.keymap.set('n', 'q', function() vim.cmd('bd! ' .. buf) end, opts)

  -- Execute the command under cursor
  vim.keymap.set('n', '', function()
    local cmd = vim.fn.getline('.')
    vim.cmd('bd! ' .. buf) -- close history buffer
    vim.cmd(cmd)           -- execute command
  end, opts)

  -- Start at the top
  vim.cmd('normal! gg')
end

    

A variant, search_command_history(), opens a modifiable buffer so that incsearch can highlight matches. This is useful when you want to interactively search without closing the buffer.

3. Register Inspector

Registers are one of Vim’s most powerful features, yet most users never know what’s stored in them. The output_regs() function prints the contents of registers a through z into a scratch buffer, labelled clearly. It’s a simple idea, but it turns registers into a visible, reviewable tool.


function M.output_regs()
  vim.cmd('enew')
  vim.bo.buftype = 'nofile'
  vim.bo.bufhidden = 'wipe'
  vim.bo.swapfile = false
  vim.wo.wrap = false

  local content = {}
  for reg = string.byte('a'), string.byte('z') do
    local reg_char = string.char(reg)
    local reg_content = vim.fn.getreg(reg_char)
    if reg_content ~= '' then
      table.insert(content, reg_char .. ":")
      table.insert(content, reg_content)
      table.insert(content, "")
    end
  end

  vim.api.nvim_buf_set_lines(0, 0, -1, false, content)
  vim.cmd('normal! gg')
  print("Contents of registers a to z")
end

    

4. Select Inside Any Pair

Vi’s i{, i(, etc. are great, but they require you to think about which delimiter you’re inside. select_inside_any_pair() tries every common pair ({}, [], (), $...$, quotes) and selects the innermost one that surrounds the cursor. If nothing matches, it falls back to selecting the current line.


function M.select_inside_any_pair()
  local savepos = vim.fn.getpos('.')
  local pairs = {
    {'{', '}'},
    {'[', ']'},
    {'(', ')'},
    {'$', '$'},
    {'"', '"'},
    {"'", "'"}
  }

  for _, pair in ipairs(pairs) do
    local open, close = pair[1], pair[2]
    local start, finish

    if open == close then
      start = vim.fn.searchpos('\\V' .. open, 'bnW')
      finish = vim.fn.searchpos('\\V' .. close, 'nW')
    else
      start = vim.fn.searchpairpos('\\V' .. open, '', '\\V' .. close, 'bnW')
      finish = vim.fn.searchpairpos('\\V' .. open, '', '\\V' .. close, 'nW')
    end

    if start[1] > 0 and finish[1] > 0 then
      local cur = vim.fn.getpos('.')
      local start_before_cursor = (start[1] < cur[2]) or (start[1] == cur[2] and start[2] <= cur[3])
      local finish_after_cursor = (finish[1] > cur[2]) or (finish[1] == cur[2] and finish[2] >= cur[3])

      if start_before_cursor and finish_after_cursor then
        vim.fn.setpos('.', {0, start[1], start[2] + 1, 0})
        vim.cmd('normal! v')
        vim.fn.setpos('.', {0, finish[1], finish[2], 0})
        vim.cmd('normal! h')
        return
      end
    end
  end

  -- Fallback: select current line
  local lnum = vim.fn.line('.')
  local line = vim.fn.getline(lnum)
  local end_col = #line
  if end_col > 0 and line:sub(-1) == '$' then
    end_col = end_col - 1
  end

  vim.fn.setpos('.', {0, lnum, 1, 0})
  vim.cmd('normal! v')
  vim.fn.setpos('.', {0, lnum, end_col, 0})
end

    

5. Old Files Browser and Ranger Chooser

Vim stores recently opened files in vim.v.oldfiles. output_old_files() dumps them into a buffer, making each entry a link you can press Enter on to open. This is a lightweight alternative to Telescope for quickly revisiting files without fuzzy matching.

The ranger_chooser() function integrates the ranger file manager directly into the editor: it runs ranger --choosefiles in a terminal (or XTerm if a GUI is detected) and opens all selected files as arguments. This fits the philosophy of using external, best‑in‑class tools while keeping the editor as the central hub.


function M.output_old_files()
  vim.cmd('enew')
  vim.api.nvim_buf_set_lines(0, 0, -1, false, vim.v.oldfiles)
  vim.bo.buftype = 'nofile'
  vim.bo.bufhidden = 'wipe'
  vim.bo.swapfile = false
  vim.wo.wrap = false

  vim.keymap.set('n', '', function()
    local line = vim.fn.getline('.')
    vim.cmd('e ' .. line)
  end, { buffer = true })

  local buf = vim.api.nvim_get_current_buf()
  vim.keymap.set('n', 'q', function() vim.cmd('bd! ' .. buf) end, { buffer = buf, noremap = true, silent = true })
  vim.keymap.set('n', '', function() vim.cmd('bd! ' .. buf) end, { buffer = buf, noremap = true, silent = true })
  vim.cmd('normal! gg')
end

    

6. Word Count, URL Maker, and Directory Prefix

Several smaller utilities fill in the gaps:

  • word_count() – runs detex on a LaTeX file and pipes it through wc -w to count plain‑text words (ignoring markup).
  • make_url(word) – replaces every occurrence of a word with an HTML link pointing to https://word.com. Useful for quickly linking to domains in blog articles.
  • get_dir_number() – extracts the leading numeric prefix of the current working directory, used in LaTeX snippet auto‑numbering.

7. Save Bindings and Functions

save_vim_bindings_and_functions() captures all current key mappings and function definitions into a buffer. This is invaluable for debugging or documenting your setup. It uses Vim’s :redir mechanism to capture the output of :map and :function and writes them into a scratch buffer.

Integration: Keymaps and Commands

These functions are exposed through two files:


-- keymaps.lua (excerpt)
vim.keymap.set("v", "c", functions.run_python_selection, { noremap = true })
vim.keymap.set("v", "r", functions.run_python_and_replace, { noremap = true })
vim.keymap.set("n", "wc", functions.word_count, opts)
vim.keymap.set("n", "mm", functions.output_old_files, opts)
vim.keymap.set("n", "cc", functions.command_history_explorer, opts)
vim.keymap.set("n", "rr", functions.output_regs, opts)
vim.keymap.set("n", "ranger", functions.ranger_chooser, opts)

    

-- commands.lua (excerpt)
vim.api.nvim_create_user_command("WC", functions.word_count, {})
vim.api.nvim_create_user_command("Mm", functions.output_old_files, {})
vim.api.nvim_create_user_command("Cc", functions.command_history_explorer, {})
vim.api.nvim_create_user_command("Ss", functions.search_command_history, {})
vim.api.nvim_create_user_command("Rr", functions.output_regs, {})
vim.api.nvim_create_user_command("HH", functions.wrap_code_in_buffer, {})
vim.api.nvim_create_user_command("RangerChooser", functions.ranger_chooser, {})
vim.api.nvim_create_user_command("EngType", functions.eng_type, {})
vim.api.nvim_create_user_command("FrType", functions.fr_type, {})

    

User commands (e.g., :WC, :Cc) are intentionally uppercase and short to minimise typing. Keymaps are mnemonic: <leader>cc for command history, <leader>rr for registers, etc.

Design Philosophy: Functions as Infrastructure

The unifying principle behind these utilities is that they treat Neovim as a programmable platform, not just a text editor. Each function solves a concrete, recurring need in the developer’s daily workflow:

  • Async execution – never block the UI.
  • Scratch buffers – temporary, non‑file buffers that clean up after themselves.
  • Explicit buffer management – every function that opens a buffer also defines how to close it (q or bd!).
  • Pure Lua, no plugin dependencies – everything runs on the built‑in API, making the config portable.

The result is an editor that behaves like an IDE, but without the weight. In the next article, we’ll see how snippets push this automation even further, turning tiny triggers into large, context‑aware code blocks.


← Engineering a Developer‑Grade Neovim Environment: The Foundation