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 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,
}
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
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:
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:
Top comments (1)
In this part of the code:
If is the first blank of a sequence of blank lines or is a line with text add to the cleaned table.