stevearc / conform.nvim

Lightweight yet powerful formatter plugin for Neovim
MIT License
2.55k stars 135 forks source link

feature request: Call a loaded lua formatter directly #460

Closed nickpoorman closed 2 weeks ago

nickpoorman commented 2 weeks ago

Did you check existing requests?

Describe the feature

I did some searching and I'm sure if this is possible yet with conform... I'd like to require a lua file (once when nvim loads) and then execute a function that simply formats the current buffer.

Provide background

I have the following formatter that I wrote in lua that I'd like to run on ruby files (to format the comments before handing off to the rubocop formatter.

Something like this:

formatters_by_ft = {
      ruby = { "ruby_comment_formatter", "rubocop" },
    },

I'd like to load my formatter once when nvim loads conform, and then simply have it execute a format function on the current buffer. But it looks like the only way to do that right now is to have my formatter in it's own .lua file and then execute lua my_formatter.lua as a custom formatter each time. Seems kinda silly when this is all native lua that could be loaded and executed. Is there a way to do this with conform?

My formatter for reference:

local function trim(s)
  return s:match("^%s*(.-)%s*$")
end

local function is_comment_line(line, comment_prefix)
  return line:find("^%s*" .. comment_prefix)
end

local function determine_indentation(line)
  return #line:match("^%s*")
end

local function split_comment_block(comment_prefix, lines)
  local blocks = {}
  local block = {}
  for _, line in ipairs(lines) do
    if trim(line) == comment_prefix then
      if #block > 0 then
        table.insert(blocks, block)
        block = {}
      end
    else
      table.insert(block, line)
    end
  end
  if #block > 0 then
    table.insert(blocks, block)
  end
  return blocks
end

local function join_sub_blocks_with_empty_comment_lines(comment_prefix, indentation, sub_blocks)
  local joined_blocks = {}
  for i, block in ipairs(sub_blocks) do
    for _, line in ipairs(block) do
      table.insert(joined_blocks, line)
    end
    if i < #sub_blocks then
      table.insert(joined_blocks, string.rep(" ", indentation) .. comment_prefix)
    end
  end
  return joined_blocks
end

local function format_block(comment_prefix, indentation, max_line_length, block)
  local block_single_line = ""
  for _, line in ipairs(block) do
    local trimmed_line = trim(line)
    block_single_line = block_single_line .. " " .. trimmed_line:gsub("^%s*" .. comment_prefix .. "%s*", "")
  end
  local words = vim.split(trim(block_single_line), "%s+")
  local formatted_lines = {}
  local line = string.rep(" ", indentation) .. comment_prefix
  for _, word in ipairs(words) do
    if (#line + #word + 1) > max_line_length then
      table.insert(formatted_lines, line)
      line = string.rep(" ", indentation) .. comment_prefix .. " " .. word
    else
      line = line .. " " .. word
    end
  end
  table.insert(formatted_lines, line)
  return formatted_lines
end

local function format_blocks(comment_prefix, indentation, max_line_length, blocks)
  local formatted_blocks = {}
  for _, block in ipairs(blocks) do
    table.insert(formatted_blocks, format_block(comment_prefix, indentation, max_line_length, block))
  end
  return formatted_blocks
end

local function replace_lines(lines, start, end_, replace_with)
  local head_lines = { unpack(lines, 1, start) }
  local tail_lines = { unpack(lines, end_ + 2) }
  local output = {}
  for _, line in ipairs(head_lines) do
    table.insert(output, line)
  end
  for _, line in ipairs(replace_with) do
    table.insert(output, line)
  end
  for _, line in ipairs(tail_lines) do
    table.insert(output, line)
  end
  return output
end

local function find_comment_block(comment_prefix, lines, line_number)
  local start, end_ = line_number, line_number
  while start > 1 and is_comment_line(lines[start - 1], comment_prefix) do
    start = start - 1
  end
  while end_ < #lines and is_comment_line(lines[end_ + 1], comment_prefix) do
    end_ = end_ + 1
  end
  return start, end_
end

local function format_comment_block(comment_prefix, lines, line_number, max_line_length)
  local indentation = determine_indentation(lines[line_number])
  local start, end_ = find_comment_block(comment_prefix, lines, line_number)
  local comment_block = { unpack(lines, start, end_) }
  local blocks = split_comment_block(comment_prefix, comment_block)
  local formatted_blocks = format_blocks(comment_prefix, indentation, max_line_length, blocks)
  local joined_blocks = join_sub_blocks_with_empty_comment_lines(comment_prefix, indentation, formatted_blocks)
  return replace_lines(lines, start - 1, end_ - 1, joined_blocks)
end

-- Main logic
local input = io.read("*all")
local lines = vim.split(input, "\n", { plain = true })
local comment_prefix = "#" -- specify your comment prefix
local max_line_length = 80 -- specify the max line length, or grab from `vim.api.nvim_get_option_value("textwidth", { buf = ctx.bufnr })`

local i = 1
while i <= #lines do
  if is_comment_line(lines[i], comment_prefix) then
    lines = format_comment_block(comment_prefix, lines, i, max_line_length)
    -- Update the line index to continue processing after the current block
    local _, end_ = find_comment_block(comment_prefix, lines, i)
    i = end_ + 1
  else
    i = i + 1
  end
end

print(table.concat(lines, "\n"))

What is the significance of this feature?

nice to have

Additional details

No response

stevearc commented 2 weeks ago

The most common way to define a formatter is to have a cmd and args, which will start a new process and do the formatting by piping stdin/stdout, but you can also define a formatter as an async lua function. The only built-in formatter currently doing this is the injected formatter (to format injected languages, using treesitter). You can see the function signature and how it is used here: https://github.com/stevearc/conform.nvim/blob/6e5d476e97dbd251cc2233d42fd238c810404701/lua/conform/formatters/injected.lua#L145

nickpoorman commented 2 weeks ago

Wow! Thanks for the quick response! That was actually super easy and it worked beautifully!

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

local ruby_comment_formatter = require("custom.ruby_comment_formatter")

return {
  "stevearc/conform.nvim",
  opts = {
    -- LazyVim will use these options when formatting with the conform.nvim formatter
    format = {
      timeout_ms = 3000,
      async = false, -- not recommended to change
      quiet = false, -- not recommended to change
      lsp_fallback = true, -- not recommended to change
    },
    formatters_by_ft = {
      ruby = { "ruby_comment_formatter", "rubocop" },
    },
    formatters = {
      ruby_comment_formatter = {
        format = function(_, ctx, lines, callback)
          ruby_comment_formatter(_, ctx, lines, callback)
        end,
      },
    },
  },
}

The updated formatter:

-- ~/.config/nvim/lua/customruby_comment_formatter.lua
local function trim(s)
  return s:match("^%s*(.-)%s*$")
end

local function is_comment_line(line, comment_prefix)
  return line:find("^%s*" .. comment_prefix)
end

local function determine_indentation(line)
  return #line:match("^%s*")
end

local function split_comment_block(comment_prefix, lines)
  local blocks = {}
  local block = {}
  for _, line in ipairs(lines) do
    if trim(line) == comment_prefix then
      if #block > 0 then
        table.insert(blocks, block)
        block = {}
      end
    else
      table.insert(block, line)
    end
  end
  if #block > 0 then
    table.insert(blocks, block)
  end
  return blocks
end

local function join_sub_blocks_with_empty_comment_lines(comment_prefix, indentation, sub_blocks)
  local joined_blocks = {}
  for i, block in ipairs(sub_blocks) do
    for _, line in ipairs(block) do
      table.insert(joined_blocks, line)
    end
    if i < #sub_blocks then
      table.insert(joined_blocks, string.rep(" ", indentation) .. comment_prefix)
    end
  end
  return joined_blocks
end

local function format_block(comment_prefix, indentation, max_line_length, block)
  local block_single_line = ""
  for _, line in ipairs(block) do
    local trimmed_line = trim(line)
    block_single_line = block_single_line .. " " .. trimmed_line:gsub("^%s*" .. comment_prefix .. "%s*", "")
  end
  local words = {}
  for word in block_single_line:gmatch("%S+") do
    table.insert(words, word)
  end
  local formatted_lines = {}
  local line = string.rep(" ", indentation) .. comment_prefix
  for _, word in ipairs(words) do
    if (#line + #word + 1) > max_line_length then
      table.insert(formatted_lines, line)
      line = string.rep(" ", indentation) .. comment_prefix .. " " .. word
    else
      line = line .. " " .. word
    end
  end
  table.insert(formatted_lines, line)
  return formatted_lines
end

local function format_blocks(comment_prefix, indentation, max_line_length, blocks)
  local formatted_blocks = {}
  for _, block in ipairs(blocks) do
    table.insert(formatted_blocks, format_block(comment_prefix, indentation, max_line_length, block))
  end
  return formatted_blocks
end

local function replace_lines(lines, start, end_, replace_with)
  local head_lines = { table.unpack(lines, 1, start) }
  local tail_lines = { table.unpack(lines, end_ + 2) }
  local output = {}
  for _, line in ipairs(head_lines) do
    table.insert(output, line)
  end
  for _, line in ipairs(replace_with) do
    table.insert(output, line)
  end
  for _, line in ipairs(tail_lines) do
    table.insert(output, line)
  end
  return output
end

local function find_comment_block(comment_prefix, lines, line_number)
  local start, end_ = line_number, line_number
  while start > 1 and is_comment_line(lines[start - 1], comment_prefix) do
    start = start - 1
  end
  while end_ < #lines and is_comment_line(lines[end_ + 1], comment_prefix) do
    end_ = end_ + 1
  end
  return start, end_
end

local function format_comment_block(comment_prefix, lines, line_number, max_line_length)
  local indentation = determine_indentation(lines[line_number])
  local start, end_ = find_comment_block(comment_prefix, lines, line_number)
  local comment_block = { table.unpack(lines, start, end_) }
  local blocks = split_comment_block(comment_prefix, comment_block)
  local formatted_blocks = format_blocks(comment_prefix, indentation, max_line_length, blocks)
  local joined_blocks = join_sub_blocks_with_empty_comment_lines(comment_prefix, indentation, formatted_blocks)
  return replace_lines(lines, start - 1, end_ - 1, joined_blocks)
end

return function(_, ctx, lines, callback)
  local comment_prefix = "#"
  local max_line_length = vim.api.nvim_get_option_value("textwidth", { buf = ctx.bufnr })
  if max_line_length == 0 then
    max_line_length = 80
  end

  local i = 1
  while i <= #lines do
    if is_comment_line(lines[i], comment_prefix) then
      lines = format_comment_block(comment_prefix, lines, i, max_line_length)
      -- Update the line index to continue processing after the current block
      local _, end_ = find_comment_block(comment_prefix, lines, i)
      i = end_ + 1
    else
      i = i + 1
    end
  end

  callback(nil, lines)
end