# 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 …


This content originally appeared on DEV Community and was authored by Sérgio Araújo

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

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

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

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

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,
})

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 and Nelvin communities. 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 spec:

-- File: ~/.config/nvim/lua/plugins/conform.lua

return {
  'stevearc/conform.nvim',
  event = 'BufWritePre',
  keys = {
    {
      '<leader>bf',
      function()
        require('conform').format({
          lsp_fallback = true,
          async = false,
          timeout_ms = 2000,
        })
      end,
      desc = 'Formatar buffer atual',
    },
  },
  config = function()
    local conform = require('conform')

    -- Define custom fallback only once
    local function fallback_format(bufnr)
      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 trim = require('core.utils.text_manipulation').trim_whitespace
      local squeeze = require('core.utils.text_manipulation').squeeze_blank_lines
      local with_preserved_view = require('core.utils.nvim_utils').with_preserved_view

      trim(bufnr)
      squeeze(bufnr)

      local clients = vim.lsp.get_active_clients({ bufnr = bufnr })
      if #clients > 0 then
        vim.lsp.buf.format({ bufnr = bufnr, async = false })
      else
        vim.api.nvim_buf_call(bufnr, function()
          with_preserved_view(function() vim.cmd('normal! gg=G') end)
        end)
      end
    end

    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,
}

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.


This content originally appeared on DEV Community and was authored by Sérgio Araújo


Print Share Comment Cite Upload Translate Updates
APA

Sérgio Araújo | Sciencx (2025-07-08T21:43:47+00:00) # Building a Robust Neovim Format Autocommand. Retrieved from https://www.scien.cx/2025/07/08/building-a-robust-neovim-format-autocommand/

MLA
" » # Building a Robust Neovim Format Autocommand." Sérgio Araújo | Sciencx - Tuesday July 8, 2025, https://www.scien.cx/2025/07/08/building-a-robust-neovim-format-autocommand/
HARVARD
Sérgio Araújo | Sciencx Tuesday July 8, 2025 » # Building a Robust Neovim Format Autocommand., viewed ,<https://www.scien.cx/2025/07/08/building-a-robust-neovim-format-autocommand/>
VANCOUVER
Sérgio Araújo | Sciencx - » # Building a Robust Neovim Format Autocommand. [Internet]. [Accessed ]. Available from: https://www.scien.cx/2025/07/08/building-a-robust-neovim-format-autocommand/
CHICAGO
" » # Building a Robust Neovim Format Autocommand." Sérgio Araújo | Sciencx - Accessed . https://www.scien.cx/2025/07/08/building-a-robust-neovim-format-autocommand/
IEEE
" » # Building a Robust Neovim Format Autocommand." Sérgio Araújo | Sciencx [Online]. Available: https://www.scien.cx/2025/07/08/building-a-robust-neovim-format-autocommand/. [Accessed: ]
rf:citation
» # Building a Robust Neovim Format Autocommand | Sérgio Araújo | Sciencx | https://www.scien.cx/2025/07/08/building-a-robust-neovim-format-autocommand/ |

Please log in to upload a file.




There are no updates yet.
Click the Upload button above to add an update.

You must be logged in to translate posts. Please log in or register.