← Back to blog

Fix Tailwind CSS Canonical Classes in Neovim with One Keymap

tailwindcssneovimtoolingtypescript

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>lt and 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:

  1. Pulls all diagnostics from vim.diagnostic.get(bufnr)
  2. Filters for diagnostics with code suggestCanonicalClasses (or matching the message pattern as a fallback)
  3. Extracts the old and new class names using Lua pattern matching
  4. Sorts edits in reverse order (bottom-right to top-left) — this is critical because applying an edit changes byte offsets for everything after it
  5. 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.

Related Posts