DEV Community

Cover image for # Building a Robust Neovim Format Autocommand
Sérgio Araújo
Sérgio Araújo

Posted on • Edited on

# Building a Robust Neovim Format Autocommand

Introduction

This article explains the process of creating an efficient and clean BufWritePre autocommand in Neovim using Lua. It walks through implementing functions to trim trailing whitespace, squeeze multiple blank lines, and format the buffer using the 'conform.nvim' plugin, while preserving the user's view (cursor position, scroll, and folds). This is intended to help Neovim users maintain clean code effortlessly on save, using a modular and reusable approach.

This work was created in partnership with ChatGPT and is based on best practices from the Neovim community.


Utility Functions in text_manipulation.lua

1. trim_whitespace(bufnr)

Removes trailing spaces at the end of each line in the given buffer. It uses the Neovim Lua API to read all lines, trims trailing whitespace with a pattern, and rewrites the lines only if modifications were made. The cursor position and view are preserved using vim.fn.winsaveview() and vim.fn.winrestview().

M.trim_whitespace = function(bufnr)
  bufnr = bufnr or 0
  if vim.bo[bufnr].modifiable == false then return end
  local view = vim.fn.winsaveview()
  local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
  local modified = false

  for i = 1, #lines do
    local trimmed = lines[i]:gsub('%s+$', '')
    if trimmed ~= lines[i] then
      lines[i] = trimmed
      modified = true
    end
  end

  if modified then
    vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
    vim.fn.winrestview(view)
  end
end
Enter fullscreen mode Exit fullscreen mode

2. squeeze_blank_lines(bufnr)

Removes consecutive blank lines so that only one blank line remains where multiple existed. It also removes trailing blank lines at the end of the buffer. The function preserves cursor position intelligently even when blank lines are removed before it.

M.squeeze_blank_lines = function(bufnr)
  bufnr = bufnr or 0
  if vim.bo[bufnr].binary or vim.bo[bufnr].filetype == 'diff' then return end

  local cursor_line, cursor_col = unpack(vim.api.nvim_win_get_cursor(0))
  local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)

  local cleaned = {}
  local excess_blank_lines = 0
  local blank_run = 0

  for i, line in ipairs(lines) do
    local is_blank = line:match('^%s*$') ~= nil

    if is_blank then
      blank_run = blank_run + 1
    else
      if blank_run >= 2 and i <= cursor_line then
        excess_blank_lines = excess_blank_lines + (blank_run - 1)
      end
      blank_run = 0
    end

    if not is_blank or (is_blank and blank_run == 1) then
      table.insert(cleaned, is_blank and '' or line)
    end
  end

  -- Remove trailing blank lines
  for i = #cleaned, 1, -1 do
    if cleaned[i]:match('^%s*$') then
      table.remove(cleaned, i)
    else
      break
    end
  end

  with_preserved_view(function() vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, cleaned) end)

  if excess_blank_lines > 0 then
    local final_line = math.max(1, cursor_line - excess_blank_lines)
    final_line = math.min(final_line, #cleaned)
    vim.api.nvim_win_set_cursor(bufnr, { final_line, cursor_col })
  end
end
Enter fullscreen mode Exit fullscreen mode

3. with_preserved_view(op) (from nvim_utils.lua)

Utility function that executes a given operation (either a Vim command string or a Lua function) while preserving the user's view—cursor position, scroll, folds, and so forth.

M.with_preserved_view = function(op)
  local view = vim.fn.winsaveview()
  local ok, err = pcall(function()
    if type(op) == 'function' then
      op()
    else
      vim.cmd(('keepjumps keeppatterns %s'):format(op))
    end
  end)
  vim.fn.winrestview(view)
  if not ok then vim.notify(err, vim.log.levels.ERROR) end
end
Enter fullscreen mode Exit fullscreen mode

4. format_all(bufnr)

Combines the trimming of whitespace, squeezing blank lines, and formatting using the external plugin conform.nvim. If no LSP clients are attached to the buffer, it falls back to manual indentation (gg=G) while preserving the view. It also skips non-modifiable buffers, buffers with a buftype set (e.g., help, terminal), or buffers with an empty filetype.

--- Formata buffer com conform.nvim e fallback manual para reindentar via comando Vim.
--- Depende do conform e da função with_preserved_view para preservar cursor, folds etc.
--- @param bufnr number Buffer number (default 0)
M.format_all = function(bufnr)
  bufnr = bufnr or 0

  if
    not vim.api.nvim_buf_is_loaded(bufnr)
    or not vim.api.nvim_buf_get_option(bufnr, 'modifiable')
    or vim.api.nvim_buf_get_option(bufnr, 'buftype') ~= ''
    or vim.api.nvim_buf_get_option(bufnr, 'filetype') == ''
  then
    return
  end

  local conform = require('conform')
  local utils = require('core.utils')

  utils.text_manipulation.trim_whitespace(bufnr)
  utils.text_manipulation.squeeze_blank_lines(bufnr)

  local ok, err = pcall(function()
    conform.format({
      bufnr = bufnr,
      async = false,
      lsp_fallback = true,
      timeout_ms = 2000,
    })
  end)

  if not ok then
    -- fallback manual: reindenta buffer com preservação de view
    vim.api.nvim_buf_call(bufnr, function()
      M.with_preserved_view(function()
        vim.cmd('normal! gg=G')
      end)
    end)
  end
end
Enter fullscreen mode Exit fullscreen mode

Autocommand Implementation

To hook this formatting flow into your Neovim workflow, create a BufWritePre autocommand that calls format_all for the buffer being saved, respecting exceptions such as quickfix or help buffers.

-- Cache augroups to avoid recreating them repeatedly
local augroups = {}
local function augroup(name)
  if not augroups[name] then
    augroups[name] = vim.api.nvim_create_augroup('sergio-lazyvim_' .. name, { clear = true })
  end
  return augroups[name]
end

local autocmd = vim.api.nvim_create_autocmd
local utils = require('core.utils')

autocmd('BufWritePre', {
  group = augroup('format_on_save'),
  buffer = 0,
  callback = function(args)
    local bufnr = args.buf

    -- Ignore buffers like quickfix, terminal, help, etc.
    local buftype = vim.api.nvim_buf_get_option(bufnr, 'buftype')
    if buftype ~= '' then return end

    utils.format_all(bufnr)
  end,
})
Enter fullscreen mode Exit fullscreen mode

Explanation of args.buf

In the callback function, the args table contains information about the autocommand event. args.buf is the buffer number related to the event—in this case, the buffer that is about to be written. This ensures the function formats the correct buffer regardless of which buffer triggers the event.


Conclusion

This modular approach enables you to keep your code clean automatically on save, handling trimming, blank line squeezing, and formatting with fallback mechanisms. Leveraging Lua APIs directly helps maintain performance and user experience (like preserving cursor position).

This article was developed in collaboration with ChatGPT and draws from insights and best practices from the Neovim community. Your feedback and contributions are most welcome!

📌 Update: Using conform.nvim with a Custom Fallback

Since publishing this article, I’ve adopted conform.nvim not only for its support of external formatters and LSP, but also for the ability to inject custom logic in its fallback layer.

With a bit of Lua, I now run trim_whitespace, squeeze_blank_lines, and fallback to normal! gg=G with with_preserved_view, directly inside the lsp_fallback. This makes the separate autocommand unnecessary.

If you already have your formatting utilities, here’s how you can set it up inside your Conform plugin

-- File: ~/.config/nvim/lua/plugins/conform.lua
-- Last Change: Fri, 27 Jun 2025 - 13:24:25
-- Author: Sergio Araujo
-- vim: ts=2 sw=2 tw=78 et fenc=utf-8 ft=lua nospell:

local function fallback_format(bufnr)
    bufnr = bufnr or 0

    -- Check buffer valid state
    if not vim.api.nvim_buf_is_loaded(bufnr) then
        return
    end
    if not vim.bo[bufnr].modifiable then
        return
    end
    if vim.bo[bufnr].buftype ~= "" then
        return
    end
    if vim.bo[bufnr].readonly then
        return
    end
    if vim.bo[bufnr].filetype == "" then
        return
    end
    if vim.bo[bufnr].binary then
        return
    end

    -- Now do trimming and squeezing blank lines, etc
    local with_preserved_view = require("core.utils.nvim_utils").with_preserved_view
    local utils = require("core.utils.text_manipulation")

    utils.trim_whitespace(bufnr)
    utils.squeeze_blank_lines(bufnr)

    -- Try LSP formatting if any client attached
    local clients = vim.lsp.get_clients({ bufnr = bufnr })
    if #clients > 0 then
        vim.lsp.buf.format({ bufnr = bufnr, async = false })
    else
        -- fallback manual indent reformat
        vim.api.nvim_buf_call(bufnr, function()
            with_preserved_view(function()
                vim.cmd("normal! gg=G")
            end)
        end)
    end
end

return {
    "stevearc/conform.nvim",
    event = "BufWritePre",
    keys = {
        {
            "<leader>bf",
            function()
                require("conform").format({
                    lsp_fallback = fallback_format,
                    async = false,
                    timeout_ms = 2000,
                })
            end,
            desc = "Formatar buffer atual",
        },
    },
    config = function()
        local conform = require("conform")
        conform.setup({
            formatters_by_ft = {
                javascript = { "prettier" },
                typescript = { "prettier" },
                javascriptreact = { "prettier" },
                typescriptreact = { "prettier" },
                svelte = { "prettier" },
                css = { "prettier" },
                html = { "prettier" },
                json = { "prettier" },
                yaml = { "prettier" },
                markdown = { "prettier" },
                graphql = { "prettier" },
                lua = { "stylua" },
                python = { "isort", "black" },
                sh = { "shfmt" },
            },
            format_on_save = {
                lsp_fallback = fallback_format,
                async = false,
                timeout_ms = 2500,
            },
        })
    end,
}

Enter fullscreen mode Exit fullscreen mode

I ended up combining the trim function with squeeze_blank_lines

-- Trims trailing whitespace and collapses consecutive blank lines.
-- Preserves viewport and cursor position if the buffer is visible.
-- Leaves cursor and marks untouched for hidden buffers
M.clean_buffer = function(bufnr)
  bufnr = bufnr or 0

  -- Ignora buffers não carregados, binários ou diffs
  if
    not vim.api.nvim_buf_is_loaded(bufnr)
    or vim.bo[bufnr].binary
    or vim.bo[bufnr].filetype == 'diff'
  then
    return
  end

  local is_visible = false
  local win_cursor_line, win_cursor_col

  -- Tenta preservar cursor se o buffer estiver visível em alguma janela
  for _, win in ipairs(vim.api.nvim_list_wins()) do
    if vim.api.nvim_win_get_buf(win) == bufnr then
      local cursor = vim.api.nvim_win_get_cursor(win)
      win_cursor_line, win_cursor_col = cursor[1], cursor[2]
      is_visible = true
      break
    end
  end

  local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
  local cleaned = {}
  local excess_blank_lines = 0
  local blank_run = 0

  for i, line in ipairs(lines) do
    local trimmed = line:gsub('%s+$', '')
    local is_blank = trimmed:match('^%s*$') ~= nil

    if is_blank then
      blank_run = blank_run + 1
    else
      if blank_run >= 2 and is_visible and win_cursor_line and i <= win_cursor_line then
        excess_blank_lines = excess_blank_lines + (blank_run - 1)
      end
      blank_run = 0
    end

    if not is_blank or blank_run == 1 then table.insert(cleaned, is_blank and '' or trimmed) end
  end

  -- Remove excesso de linhas em branco no final, mantendo no máximo uma (se já existir)
  local last = #cleaned
  local blank_count = 0

  for i = last, 1, -1 do
    if cleaned[i]:match('^%s*$') then
      blank_count = blank_count + 1
      if blank_count >= 2 then table.remove(cleaned, i) end
    else
      break
    end
  end

  -- Aplica alterações com preservação de view se possível
  local function apply_changes() vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, cleaned) end

  if is_visible then
    local view = vim.fn.winsaveview()
    apply_changes()
    vim.fn.winrestview(view)
  else
    apply_changes()
  end

  -- Restaura cursor apenas se o buffer visível continua na janela ativa
  if is_visible and win_cursor_line and win_cursor_col then
    local final_line = math.max(1, win_cursor_line - excess_blank_lines)
    final_line = math.min(final_line, #cleaned)
    if vim.api.nvim_win_is_valid(0) and vim.api.nvim_win_get_buf(0) == bufnr then
      vim.api.nvim_win_set_cursor(0, { final_line, win_cursor_col })
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

You also use the unified function in your code.

With this configuration, you retain all the power of Conform, and gain precise control over what happens when external formatters or LSP aren’t available.

Thanks again to the awesome Neovim community and to ChatGPT for helping refine this solution.

My nvim repo is here

New update:

I ended up noticing that the LSP does not sends any error when it does not have a server attached, as a result the fallback does not run. So I decided to swich to none-ls:

-- File: ~/.config/nvim/lua/plugins/none-ls.lua
-- Last Change: Sun, Jul 2025/07/20
-- Author: Sergio Araujo

return {
  'nvimtools/none-ls.nvim',
  enabled = true,
  event = { 'BufReadPre', 'BufNewFile' },
  dependencies = { 'nvim-lua/plenary.nvim' },
  keys = {
    {
      '<leader>bf',
      function()
        local bufnr = vim.api.nvim_get_current_buf()
        require('core.utils.format_fallback').run(bufnr)
      end,
      desc = 'Formatar buffer atual (fallback + LSP)',
    },
  },
  config = function()
    local null_ls = require('null-ls')
    local formatting = null_ls.builtins.formatting

    local augroup = vim.api.nvim_create_augroup('AutoFormatGroup', { clear = true })

    null_ls.setup({
      sources = {
        formatting.stylua,
        formatting.prettierd,
        formatting.black,
        formatting.isort,
        formatting.shfmt,
      },
    })

    -- Autocomando sempre registrado, mesmo sem cliente LSP
    vim.api.nvim_create_autocmd('BufWritePre', {
      group = augroup,
      callback = function(args)
        local bufnr = args.buf
        require('core.utils.format_fallback').run(bufnr)
      end,
    })
  end,
}
-- vim: set ts=2 sw=2 tw=78 et fenc=utf-8 ft=lua nospell:
Enter fullscreen mode Exit fullscreen mode

The module used in none-ls

-- File: ~/.config/nvim/lua/core/utils/format_fallback.lua
-- Last Change: Sun, Jul 2025/07/20 - 07:35:52
-- Author: Sergio Araujo

local M = {}

function M.run(bufnr)
  bufnr = bufnr or 0
  local api = vim.api

  -- Validações básicas do buffer
  if not api.nvim_buf_is_loaded(bufnr) then return end
  if not api.nvim_buf_get_option(bufnr, 'modifiable') then return end
  if api.nvim_buf_get_option(bufnr, 'buftype') ~= '' then return end
  if api.nvim_buf_get_option(bufnr, 'readonly') then return end
  if api.nvim_buf_get_option(bufnr, 'filetype') == '' then return end
  if api.nvim_buf_get_option(bufnr, 'binary') then return end

  local with_preserved_view = require('core.utils.nvim_utils').with_preserved_view
  local utils = require('core.utils.text_manipulation')

  -- 1. Limpeza: trim whitespace + squeeze blank lines
  utils.clean_buffer(bufnr)

  -- 2. Formatação clássica: indentação gg=G
  vim.api.nvim_buf_call(bufnr, function()
    with_preserved_view(function() vim.cmd('normal! gg=G') end)
  end)

  -- 3. Formatação via LSP se cliente anexado
  local clients = vim.lsp.get_active_clients({ bufnr = bufnr })
  if #clients > 0 then
    pcall(function() vim.lsp.buf.format({ bufnr = bufnr, async = false }) end)
  end
end

return M
-- vim: set ts=2 sw=2 tw=78 et fenc=utf-8 ft=lua nospell:

Enter fullscreen mode Exit fullscreen mode

Top comments (1)

Collapse
 
voyeg3r profile image
Sérgio Araújo • Edited

In this part of the code:

if not is_blank or (is_blank and blank_run == 1) then
  table.insert(cleaned, is_blank and '' or trimmed)
end
Enter fullscreen mode Exit fullscreen mode

If is the first blank of a sequence of blank lines or is a line with text add to the cleaned table.