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
end
2. 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)
end
Keymaps
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.
Originally published at rielj.xyz.
Top comments (0)