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

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/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.