Fix Tailwind CSS Canonical Classes in Neovim with One Keymap
TL;DR
- Tailwind CSS LSP suggests canonical class replacements but only one at a time
apply_all_tailwind_actions_buffer()fixes every non-canonical class in the buffer with one keypress- Reverse-sorting edits by position prevents offset corruption
- Map it to
<leader>ltand never manually fix a canonical class again
Requirements: Neovim 0.9+, Tailwind CSS v4, tailwindcss-language-server
Tailwind CSS v4 introduced canonical class suggestions — the LSP tells you when h-[72px] should be h-18, or when [display:_flex] should just be flex. The problem is, fixing them one by one through code actions is painfully slow.
I wrote two Lua functions that fix all canonical class diagnostics in a buffer with a single keypress.
The Problem
When Tailwind CSS LSP detects a non-canonical class, it shows a diagnostic:
The class `h-[72px]` can be written as `h-18`
The built-in fix is to hover each diagnostic, open the code action menu, and select "Replace with...". In a file with 30+ non-canonical classes, this takes forever.
I wanted <leader>lt to fix the entire buffer at once.
The Solution
There are two approaches, each solving a different scope:
1. Buffer-wide Fix via Diagnostics (Recommended)
This function reads all diagnostics in the current buffer, filters for suggestCanonicalClasses, parses the "old → new" class names from the diagnostic message, and applies all replacements in one pass.
function M.apply_all_tailwind_actions_buffer()
local bufnr = vim.api.nvim_get_current_buf()
local all_diagnostics = vim.diagnostic.get(bufnr)
if #all_diagnostics == 0 then
vim.notify("No diagnostics found in buffer", vim.log.levels.INFO)
return
end
-- Filter for suggestCanonicalClasses diagnostics
-- Message format: "The class `X` can be written as `Y`"
local edits = {}
for _, diag in ipairs(all_diagnostics) do
if diag.code == "suggestCanonicalClasses"
or (diag.message and diag.message:match("can be written as"))
then
local old_class, new_class =
diag.message:match("The class `([^`]+)` can be written as `([^`]+)`")
if old_class and new_class then
table.insert(edits, {
lnum = diag.lnum,
col = diag.col,
end_lnum = diag.end_lnum or diag.lnum,
end_col = diag.end_col or (diag.col + #old_class),
old_class = old_class,
new_class = new_class,
})
end
end
end
if #edits == 0 then
vim.notify("No canonical class fixes found", vim.log.levels.INFO)
return
end
-- Sort in reverse order so edits don't shift positions
table.sort(edits, function(a, b)
if a.lnum ~= b.lnum then
return a.lnum > b.lnum
end
return a.col > b.col
end)
vim.notify(
string.format("Applying %d Tailwind CSS fixes...", #edits),
vim.log.levels.INFO
)
for _, edit in ipairs(edits) do
vim.api.nvim_buf_set_text(
bufnr, edit.lnum, edit.col, edit.end_lnum, edit.end_col,
{ edit.new_class }
)
end
end2. Current Line Fix via LSP Code Actions
This function requests code actions from the Tailwind CSS LSP for the current line and applies all "Replace with" actions automatically:
local function collect_and_apply_tailwind_edits(bufnr, results)
local all_edits = {}
for client_id, result in pairs(results) do
local client = vim.lsp.get_client_by_id(client_id)
if client and client.name == "tailwindcss" and result.result then
for _, action in ipairs(result.result) do
if action.title and action.title:match("^Replace with") then
if action.edit then
if action.edit.changes then
for _, edits in pairs(action.edit.changes) do
for _, edit in ipairs(edits) do
table.insert(all_edits, edit)
end
end
end
if action.edit.documentChanges then
for _, change in ipairs(action.edit.documentChanges) do
if change.edits then
for _, edit in ipairs(change.edits) do
table.insert(all_edits, edit)
end
end
end
end
end
end
end
end
end
if #all_edits == 0 then
vim.notify("No 'Replace with' actions available", vim.log.levels.INFO)
return
end
-- Sort reverse to preserve positions
table.sort(all_edits, function(a, b)
local a_line = a.range.start.line
local b_line = b.range.start.line
if a_line ~= b_line then
return a_line > b_line
end
return a.range.start.character > b.range.start.character
end)
for _, edit in ipairs(all_edits) do
local start_line = edit.range.start.line
local start_col = edit.range.start.character
local end_line = edit.range["end"].line
local end_col = edit.range["end"].character
local new_text = vim.split(edit.newText, "\n", { plain = true })
vim.api.nvim_buf_set_text(bufnr, start_line, start_col, end_line, end_col, new_text)
end
end
function M.apply_all_tailwind_actions()
local bufnr = vim.api.nvim_get_current_buf()
local context = {
diagnostics = vim.lsp.diagnostic.get_line_diagnostics(bufnr),
triggerKind = vim.lsp.protocol.CodeActionTriggerKind.Invoked,
}
local params = vim.lsp.util.make_range_params(0, "utf-16")
params.context = context
vim.lsp.buf_request_all(bufnr, "textDocument/codeAction", params, function(results)
collect_and_apply_tailwind_edits(bufnr, results)
end)
endKeymaps
Add these to your Neovim config:
-- Fix all canonical classes in the current buffer
vim.keymap.set("n", "<leader>lt",
"<cmd>lua require('your.lsp.handlers').apply_all_tailwind_actions_buffer()<CR>",
{ noremap = true, silent = true, desc = "Fix all Tailwind canonical classes (buffer)" })
-- Fix Tailwind classes on the current line via code actions
vim.keymap.set("n", "<leader>lT",
"<cmd>lua require('your.lsp.handlers').apply_all_tailwind_actions()<CR>",
{ noremap = true, silent = true, desc = "Fix Tailwind canonical classes (line)" })How It Works
The key insight is that Tailwind CSS LSP diagnostics follow a predictable message format:
The class `h-[72px]` can be written as `h-18`
The buffer-wide function:
- Pulls all diagnostics from
vim.diagnostic.get(bufnr) - Filters for diagnostics with code
suggestCanonicalClasses(or matching the message pattern as a fallback) - Extracts the old and new class names using Lua pattern matching
- Sorts edits in reverse order (bottom-right to top-left) — this is critical because applying an edit changes byte offsets for everything after it
- Applies each replacement using
nvim_buf_set_text
The reverse-sort trick is the same technique used by LSP servers when applying workspace edits — it guarantees that earlier edits don't invalidate the positions of later ones.
Before & After
Before pressing <leader>lt:
<div class="h-[72px] w-[100%] m-[16px] p-[8px] [display:_flex]">After:
<div class="h-18 w-full m-4 p-2 flex">Every non-canonical class in the buffer is fixed instantly.
You can find my full Neovim config in my dotfiles on GitHub.