LazyVim and zprint

Zprint is a Clojure formatter. By default, it fully reformats a top-level form, replacing all the existing indentation and newlines with its own. This works well if you don’t mind the disruption.

I often work in a codebase where such heavy-handed reformatting would be unwelcome, so I use zprint in :respect-nl mode. This usually results in less code movement, but sometimes takes more vertical space than optimal, since zprint isn’t allowed to fold existing newlines.

Easy way

The easy way is to configure zprint per project.

;; ~/.zprintrc
{:search-config? true}
;; src/project/.zprintrc
{:style :respect-nl}

And then configure LazyVim to use zprint for Clojure:

-- ~/.config/nvim/lua/plugins/zprint.lua
return {
  'stevearc/conform.nvim',
  opts = {
    formatters_by_ft = {
      clojure = { 'zprint' },
    },
  }
}

This works but there’s no easy way to toggle to the other mode on demand, except for changing the config.

Hard way

I use multiple key mappings to run zprint different ways. The main mapping uses whatever is configured in .zprintrc. The alternate mappings choose explicitly between :respect-nl and :respect-nl-off.

Here are the default mappings provided by LazyVim:

  • <space>cf reformats the current file.
  • gq is the format operator, so it expects a visual selection or motion. Normally, gqq would format the current line, but for zprint this means to reformat the current top-level form.

I augment these with an additional mapping:

  • = is usually the indent operator, but I map it to do the same as gq because I don’t need separate indent and format operations. Therefore, with zprint, == reformats the current top-level form.

And then, to run zprint with an explicit style:

  • <space>=1 uses :respect-nl-off.
  • <space>=2 uses :respect-nl.
  • <space>=3 uses :indent-only.

Like the other mappings, I can repeat the last key to reformat the current top-level form. For example, <space>=11 has zprint fully reformat the current function.

What’s hard?

The hard part is mapping an operator. This means that the new mappings should work with a following motion, and they should work equally well with a visual selection.

You should read the doc behind that link, then this utility function will make sense:

-- ~/.config/nvim/lua/config/keymaps.lua

---@class (exact) OperatorRegisterOpts
---@field setup function
---@field execute function | string
---@field cleanup function

---@param name string
---@param opts OperatorRegisterOpts
function operator_register(name, opts)
  _G[name] = function(motion_type)
    if motion_type == nil then
      vim.opt.opfunc = 'v:lua.' .. name
      return 'g@' -- calls back to this function
    end

    -- boilerplate save, see :help g@
    local sel_save = vim.opt.selection
    local reg_save = vim.fn.getreginfo('"')
    local cb_save = vim.opt.clipboard
    local visual_marks_save = { vim.fn.getpos("'<"), vim.fn.getpos("'>") }

    -- boilerplate setup
    vim.opt.clipboard = ''
    vim.opt.selection = 'inclusive'

    -- custom setup
    local status, result = pcall(opts.setup, motion_type)
    local saved = status and result or nil
    local err = not status and result or nil

    if status then
      -- convert motion to visual
      local commands = {
        char = '`[v`]',
        line = '`[V`]',
        block = '`[\\<c-v>`]',
      }

      -- execute
      if type(opts.execute) == 'string' then
        vim.cmd('noautocmd keepjumps normal! ' .. commands[motion_type] .. opts.execute)
      else
        status, result = pcall(opts.execute --[[@as function]], motion_type)
        if not status then
          err = result
        end
      end

      -- custom cleanup
      status, result = pcall(opts.cleanup, saved)
      if not status then
        err = result
      end
    end

    -- boilerplate cleanup
    vim.fn.setreg('"', reg_save)
    vim.fn.setpos("'<", visual_marks_save[0])
    vim.fn.setpos("'>", visual_marks_save[1])
    vim.opt.clipboard = cb_save
    vim.opt.selection = sel_save

    -- if setup/execute/cleanup failed, raise error
    if err then
      error(err)
    end
  end
end

Reformatting prose

Did you know you can reformat prose at the current textwidth with gw? It’s a handy alternative to gq.

To demonstrate operator_register, here’s a mapping that I use for reformatting prose at 80 columns, regardless of the current textwidth:

-- ~/.config/nvim/lua/config/keymaps.lua

-- Reformat current paragraph with 80 textwidth.
operator_register('op_reformat_prose', {
  setup = function()
    local saved = {
      textwidth = vim.opt.textwidth,
      autoindent = vim.opt.autoindent,
      indentexpr = vim.opt.indentexpr,
    }
    vim.opt.textwidth = 80
    vim.opt.autoindent = true
    vim.opt.indentexpr = 'indent()'
    return saved
  end,
  execute = 'gw',
  cleanup = function(saved)
    vim.opt.textwidth = saved.textwidth
    vim.opt.autoindent = saved.autoindent
    vim.opt.indentexpr = saved.indentexpr
  end,
})

-- Bind to gW since the default prose-reformatting is gw.
vim.keymap.set(
  {'n', 'x'},
  'gW',
  'v:lua.op_reformat_prose()',
  { desc = 'Reformat prose (80 columns)', expr = true, silent = true }
)

-- For completeness, operate immediately on the current line with gWgW.
-- This is hardly ergonomic. Most of the time I want to reformat an entire
-- paragraph, which is gWap.
vim.keymap.set(
  'n',
  'gWgW',
  "v:lua.op_reformat_prose() .. '_'",
  { desc = 'Reformat prose (80 columns)', expr = true, silent = true }
)

Zprint mappings

With the operator_register function in hand, here’s the code for the zprint mappings.

-- ~/.config/nvim/lua/plugins/zprint.lua

---@param style string
local function zprinter(style)
  return {
    meta = {
      url = 'https://github.com/kkinnear/zprint',
      description = string.format('Zprint with %s', style),
    },
    command = 'zprint',
    args = { string.format('{:style %s}', style) },
    range_args = function(self, ctx)
      return {
        string.format(
          '{:style %s :input {:range {:start %d :end %d :use-previous-!zprint? true :continue-after-!zprint-error? true}}}',
          style,
          ctx.range.start[1] - 1,
          ctx.range['end'][1] - 1
        ),
      }
    end,
  }
end

return {
  'stevearc/conform.nvim',
  opts = {
    formatters = {
      zprint_respect = zprinter(':respect-nl'),
      zprint_disrespect = zprinter(':respect-nl-off'),
      zprint_indent = zprinter(':indent-only'),
    },

    formatters_by_ft = {
      -- This is the formatter that will be used by <leader>cf, gq operator, and
      -- also the = operator via keymaps.lua. See
      -- https://github.com/stevearc/conform.nvim/blob/master/lua/conform/formatters/zprint.lua
      clojure = { 'zprint' },
    },

    -- Custom opts entry, not consumed by conform. See keymaps.lua
    formatters_by_ft_alt = {
      clojure = {
        -- First alt, available as <leader>=1
        -- or conform.format({opts: {formatters: {'zprint_disrespect'}}})
        { 'zprint_disrespect' },

        -- Second alt, available as <leader>=2
        -- or conform.format({opts: {formatters: {'zprint_respect'}}})
        { 'zprint_respect' },

        -- Third alt, available as <leader>=3
        -- or conform.format({opts: {formatters: {'zprint_indent'}}})
        { 'zprint_indent' },
      },
    },
  },
}
-- ~/.config/nvim/lua/config/keymaps.lua

-- Format code with =
vim.keymap.set({ 'n', 'x' }, '=', 'gq', { desc = 'Format code', remap = true })
vim.keymap.set('n', '==', 'gqq', { desc = 'Format code', remap = true })

-- Reformat code with alternate formatter with <leader>=1, <leader>=2, etc.
for i = 1, 9 do
  operator_register('op_reformat_code_alt_' .. i, {
    setup = function()
      local conform = require('conform')
      local saved = {
        formatters_by_ft = conform.formatters_by_ft,
      }
      -- Our alternate formatters are stored in the opts for conform.nvim, and we
      -- have to fetch them from there because they don't carry into conform.
      local alt_formatters = LazyVim.opts('conform.nvim').formatters_by_ft_alt
      conform.formatters_by_ft = {}
      for k, v in pairs(alt_formatters) do
        conform.formatters_by_ft[k] = v[i]
      end
      return saved
    end,
    execute = 'gq',
    cleanup = function(saved)
      local conform = require('conform')
      conform.formatters_by_ft = saved.formatters_by_ft
    end,
  })
  vim.keymap.set(
    { 'n', 'x' },
    '<leader>=' .. i,
    'v:lua.op_reformat_code_alt_' .. i .. '()',
    { desc = 'Format code (alt ' .. i .. ')', expr = true, silent = true }
  )
  vim.keymap.set(
    'n',
    '<leader>=' .. i .. i,
    'v:lua.op_reformat_code_alt_' .. i .. "() .. '_'",
    { desc = 'Format code (alt ' .. i .. ')', expr = true, silent = true }
  )
end