Skip to content

Commit

Permalink
feat: Add dry_run option and report if buffer was/would be changed by…
Browse files Browse the repository at this point in the history
… formatters (#273)

* feat: add dry_run option and pass return values for if buffer would be modified

* fix: implement dry_run for blocking calls to lsp formatter

* refactor: change `changed` variable to `did_edit`

* docs: Update README

* fix: address PR comments

* fix: small cleanups

---------

Co-authored-by: Steven Arcangeli <stevearc@stevearc.com>
  • Loading branch information
bpjordan and stevearc authored Jan 16, 2024
1 parent 75e7c5c commit e0276bb
Show file tree
Hide file tree
Showing 6 changed files with 107 additions and 45 deletions.
29 changes: 15 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -502,20 +502,21 @@ require("conform").formatters.my_formatter = {
`format(opts, callback): boolean` \
Format a buffer

| Param | Type | Desc | |
| -------- | ---------------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| opts | `nil\|table` | | |
| | timeout_ms | `nil\|integer` | Time in milliseconds to block for formatting. Defaults to 1000. No effect if async = true. |
| | bufnr | `nil\|integer` | Format this buffer (default 0) |
| | async | `nil\|boolean` | If true the method won't block. Defaults to false. If the buffer is modified before the formatter completes, the formatting will be discarded. |
| | formatters | `nil\|string[]` | List of formatters to run. Defaults to all formatters for the buffer filetype. |
| | lsp_fallback | `nil\|boolean\|"always"` | Attempt LSP formatting if no formatters are available. Defaults to false. If "always", will attempt LSP formatting even if formatters are available. |
| | quiet | `nil\|boolean` | Don't show any notifications for warnings or failures. Defaults to false. |
| | range | `nil\|table` | Range to format. Table must contain `start` and `end` keys with {row, col} tuples using (1,0) indexing. Defaults to current selection in visual mode |
| | id | `nil\|integer` | Passed to vim.lsp.buf.format when lsp_fallback = true |
| | name | `nil\|string` | Passed to vim.lsp.buf.format when lsp_fallback = true |
| | filter | `nil\|fun(client: table): boolean` | Passed to vim.lsp.buf.format when lsp_fallback = true |
| callback | `nil\|fun(err: nil\|string)` | Called once formatting has completed | |
| Param | Type | Desc | |
| -------- | ---------------------------------------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| opts | `nil\|table` | | |
| | timeout_ms | `nil\|integer` | Time in milliseconds to block for formatting. Defaults to 1000. No effect if async = true. |
| | bufnr | `nil\|integer` | Format this buffer (default 0) |
| | async | `nil\|boolean` | If true the method won't block. Defaults to false. If the buffer is modified before the formatter completes, the formatting will be discarded. |
| | dry_run | `nil\|boolean` | If true don't apply formatting changes to the buffer |
| | formatters | `nil\|string[]` | List of formatters to run. Defaults to all formatters for the buffer filetype. |
| | lsp_fallback | `nil\|boolean\|"always"` | Attempt LSP formatting if no formatters are available. Defaults to false. If "always", will attempt LSP formatting even if formatters are available. |
| | quiet | `nil\|boolean` | Don't show any notifications for warnings or failures. Defaults to false. |
| | range | `nil\|table` | Range to format. Table must contain `start` and `end` keys with {row, col} tuples using (1,0) indexing. Defaults to current selection in visual mode |
| | id | `nil\|integer` | Passed to vim.lsp.buf.format when lsp_fallback = true |
| | name | `nil\|string` | Passed to vim.lsp.buf.format when lsp_fallback = true |
| | filter | `nil\|fun(client: table): boolean` | Passed to vim.lsp.buf.format when lsp_fallback = true |
| callback | `nil\|fun(err: nil\|string, did_edit: nil\|boolean)` | Called once formatting has completed | |

Returns:

Expand Down
5 changes: 4 additions & 1 deletion doc/conform.txt
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ format({opts}, {callback}): boolean *conform.forma
{async} `nil|boolean` If true the method won't block. Defaults
to false. If the buffer is modified before the
formatter completes, the formatting will be discarded.
{dry_run} `nil|boolean` If true don't apply formatting changes to
the buffer
{formatters} `nil|string[]` List of formatters to run. Defaults to
all formatters for the buffer filetype.
{lsp_fallback} `nil|boolean|"always"` Attempt LSP formatting if no
Expand All @@ -137,7 +139,8 @@ format({opts}, {callback}): boolean *conform.forma
lsp_fallback = true
{filter} `nil|fun(client: table): boolean` Passed to
|vim.lsp.buf.format| when lsp_fallback = true
{callback} `nil|fun(err: nil|string)` Called once formatting has completed
{callback} `nil|fun(err: nil|string, did_edit: nil|boolean)` Called once
formatting has completed
Returns:
`boolean` True if any formatters were attempted

Expand Down
28 changes: 17 additions & 11 deletions lua/conform/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -354,21 +354,23 @@ end
--- timeout_ms nil|integer Time in milliseconds to block for formatting. Defaults to 1000. No effect if async = true.
--- bufnr nil|integer Format this buffer (default 0)
--- async nil|boolean If true the method won't block. Defaults to false. If the buffer is modified before the formatter completes, the formatting will be discarded.
--- dry_run nil|boolean If true don't apply formatting changes to the buffer
--- formatters nil|string[] List of formatters to run. Defaults to all formatters for the buffer filetype.
--- lsp_fallback nil|boolean|"always" Attempt LSP formatting if no formatters are available. Defaults to false. If "always", will attempt LSP formatting even if formatters are available.
--- quiet nil|boolean Don't show any notifications for warnings or failures. Defaults to false.
--- range nil|table Range to format. Table must contain `start` and `end` keys with {row, col} tuples using (1,0) indexing. Defaults to current selection in visual mode
--- id nil|integer Passed to |vim.lsp.buf.format| when lsp_fallback = true
--- name nil|string Passed to |vim.lsp.buf.format| when lsp_fallback = true
--- filter nil|fun(client: table): boolean Passed to |vim.lsp.buf.format| when lsp_fallback = true
---@param callback? fun(err: nil|string) Called once formatting has completed
---@param callback? fun(err: nil|string, did_edit: nil|boolean) Called once formatting has completed
---@return boolean True if any formatters were attempted
M.format = function(opts, callback)
---@type {timeout_ms: integer, bufnr: integer, async: boolean, lsp_fallback: boolean|"always", quiet: boolean, formatters?: string[], range?: conform.Range}
---@type {timeout_ms: integer, bufnr: integer, async: boolean, dry_run: boolean, lsp_fallback: boolean|"always", quiet: boolean, formatters?: string[], range?: conform.Range}
opts = vim.tbl_extend("keep", opts or {}, {
timeout_ms = 1000,
bufnr = 0,
async = false,
dry_run = false,
lsp_fallback = false,
quiet = false,
})
Expand All @@ -379,7 +381,7 @@ M.format = function(opts, callback)
if not opts.range and mode == "v" or mode == "V" then
opts.range = range_from_selection(opts.bufnr, mode)
end
callback = callback or function(_err) end
callback = callback or function(_err, _did_edit) end
local errors = require("conform.errors")
local log = require("conform.log")
local lsp_format = require("conform.lsp_format")
Expand All @@ -403,7 +405,8 @@ M.format = function(opts, callback)

if any_formatters then
---@param err? conform.Error
local function handle_err(err)
---@param did_edit? boolean
local function handle_result(err, did_edit)
if err then
local level = errors.level_for_code(err.code)
log.log(level, err.message)
Expand All @@ -426,22 +429,25 @@ M.format = function(opts, callback)
return callback(err_message)
end

if
if opts.dry_run and did_edit then
callback(nil, true)
elseif
opts.lsp_fallback == "always" and not vim.tbl_isempty(lsp_format.get_format_clients(opts))
then
log.debug("Running LSP formatter on %s", vim.api.nvim_buf_get_name(opts.bufnr))
lsp_format.format(opts, callback)
else
callback()
callback(nil, did_edit)
end
end

local run_opts = { exclusive = true }
local run_opts = { exclusive = true, dry_run = opts.dry_run }
if opts.async then
runner.format_async(opts.bufnr, formatters, opts.range, run_opts, handle_err)
runner.format_async(opts.bufnr, formatters, opts.range, run_opts, handle_result)
else
local err = runner.format_sync(opts.bufnr, formatters, opts.timeout_ms, opts.range, run_opts)
handle_err(err)
local err, did_edit =
runner.format_sync(opts.bufnr, formatters, opts.timeout_ms, opts.range, run_opts)
handle_result(err, did_edit)
end
return true
elseif opts.lsp_fallback and not vim.tbl_isempty(lsp_format.get_format_clients(opts)) then
Expand Down Expand Up @@ -496,7 +502,7 @@ M.format_lines = function(formatter_names, lines, opts, callback)
callback(err, new_lines)
end

local run_opts = { exclusive = false }
local run_opts = { exclusive = false, dry_run = false }
if opts.async then
runner.format_lines_async(opts.bufnr, formatters, nil, lines, run_opts, handle_err)
else
Expand Down
41 changes: 33 additions & 8 deletions lua/conform/lsp_format.lua
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ local util = require("vim.lsp.util")

local M = {}

local function apply_text_edits(text_edits, bufnr, offset_encoding)
local function apply_text_edits(text_edits, bufnr, offset_encoding, dry_run)
if
#text_edits == 1
and text_edits[1].range.start.line == 0
Expand All @@ -19,9 +19,19 @@ local function apply_text_edits(text_edits, bufnr, offset_encoding)
table.remove(new_lines)
end
log.debug("Converting full-file LSP format to piecewise format")
require("conform.runner").apply_format(bufnr, original_lines, new_lines, nil, false)
return require("conform.runner").apply_format(
bufnr,
original_lines,
new_lines,
nil,
false,
dry_run
)
elseif dry_run then
return #text_edits > 0
else
vim.lsp.util.apply_text_edits(text_edits, bufnr, offset_encoding)
return #text_edits > 0
end
end

Expand Down Expand Up @@ -56,7 +66,7 @@ function M.get_format_clients(options)
end

---@param options table
---@param callback fun(err?: string)
---@param callback fun(err?: string, did_edit?: boolean)
function M.format(options, callback)
options = options or {}
if not options.bufnr or options.bufnr == 0 then
Expand Down Expand Up @@ -84,9 +94,10 @@ function M.format(options, callback)
if options.async then
local changedtick = vim.b[bufnr].changedtick
local do_format
local did_edit = false
do_format = function(idx, client)
if not client then
return callback()
return callback(nil, did_edit)
end
local params = set_range(client, util.make_formatting_params(options.formatting_options))
local auto_id = vim.api.nvim_create_autocmd("LspDetach", {
Expand All @@ -112,29 +123,43 @@ function M.format(options, callback)
)
)
else
apply_text_edits(result, ctx.bufnr, client.offset_encoding)
local this_did_edit =
apply_text_edits(result, ctx.bufnr, client.offset_encoding, options.dry_run)
changedtick = vim.b[bufnr].changedtick

do_format(next(clients, idx))
if options.dry_run and this_did_edit then
callback(nil, true)
else
did_edit = did_edit or this_did_edit
do_format(next(clients, idx))
end
end
end, bufnr)
end
do_format(next(clients))
else
local timeout_ms = options.timeout_ms or 1000
local did_edit = false
for _, client in pairs(clients) do
local params = set_range(client, util.make_formatting_params(options.formatting_options))
local result, err = client.request_sync(method, params, timeout_ms, bufnr)
if result and result.result then
apply_text_edits(result.result, bufnr, client.offset_encoding)
local this_did_edit =
apply_text_edits(result.result, bufnr, client.offset_encoding, options.dry_run)
did_edit = did_edit or this_did_edit

if options.dry_run and did_edit then
callback(nil, true)
return true
end
elseif err then
if not options.quiet then
vim.notify(string.format("[LSP][%s] %s", client.name, err), vim.log.levels.WARN)
end
return callback(string.format("[LSP][%s] %s", client.name, err))
end
end
callback()
callback(nil, did_edit)
end
end

Expand Down
44 changes: 33 additions & 11 deletions lua/conform/runner.lua
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ local M = {}

---@class (exact) conform.RunOpts
---@field exclusive boolean If true, ensure only a single formatter is running per buffer
---@field dry_run boolean If true, do not apply changes and stop after the first formatter attempts to do so

---@param formatter_name string
---@param ctx conform.Context
Expand Down Expand Up @@ -152,9 +153,10 @@ end
---@param new_lines string[]
---@param range? conform.Range
---@param only_apply_range boolean
M.apply_format = function(bufnr, original_lines, new_lines, range, only_apply_range)
---@return boolean any_changes
M.apply_format = function(bufnr, original_lines, new_lines, range, only_apply_range, dry_run)
if not vim.api.nvim_buf_is_valid(bufnr) then
return
return false
end
local bufname = vim.api.nvim_buf_get_name(bufnr)
log.trace("Applying formatting to %s", bufname)
Expand All @@ -173,7 +175,7 @@ M.apply_format = function(bufnr, original_lines, new_lines, range, only_apply_ra
-- This is to hack around oddly behaving formatters (e.g black outputs nothing for excluded files).
if new_text:match("^%s*$") and not original_text:match("^%s*$") then
log.warn("Aborting because a formatter returned empty output for buffer %s", bufname)
return
return false
end

log.trace("Comparing lines %s and %s", original_lines, new_lines)
Expand Down Expand Up @@ -228,9 +230,13 @@ M.apply_format = function(bufnr, original_lines, new_lines, range, only_apply_ra
end
end

log.trace("Applying text edits: %s", text_edits)
vim.lsp.util.apply_text_edits(text_edits, bufnr, "utf-8")
log.trace("Done formatting %s", bufname)
if not dry_run then
log.trace("Applying text edits: %s", text_edits)
vim.lsp.util.apply_text_edits(text_edits, bufnr, "utf-8")
log.trace("Done formatting %s", bufname)
end

return not vim.tbl_isempty(text_edits)
end

---Map of formatter name to if the last run of that formatter produced an error
Expand Down Expand Up @@ -452,7 +458,7 @@ end
---@param formatters conform.FormatterInfo[]
---@param range? conform.Range
---@param opts conform.RunOpts
---@param callback fun(err?: conform.Error)
---@param callback fun(err?: conform.Error, did_edit?: boolean)
M.format_async = function(bufnr, formatters, range, opts, callback)
if bufnr == 0 then
bufnr = vim.api.nvim_get_current_buf()
Expand All @@ -475,6 +481,7 @@ M.format_async = function(bufnr, formatters, range, opts, callback)
original_lines,
opts,
function(err, output_lines, all_support_range_formatting)
local did_edit = nil
-- discard formatting if buffer has changed
if not vim.api.nvim_buf_is_valid(bufnr) or changedtick ~= util.buf_get_changedtick(bufnr) then
err = {
Expand All @@ -485,9 +492,16 @@ M.format_async = function(bufnr, formatters, range, opts, callback)
),
}
else
M.apply_format(bufnr, original_lines, output_lines, range, not all_support_range_formatting)
did_edit = M.apply_format(
bufnr,
original_lines,
output_lines,
range,
not all_support_range_formatting,
opts.dry_run
)
end
callback(err)
callback(err, did_edit)
end
)
end
Expand Down Expand Up @@ -534,6 +548,7 @@ end
---@param range? conform.Range
---@param opts conform.RunOpts
---@return conform.Error? error
---@return boolean did_edit
M.format_sync = function(bufnr, formatters, timeout_ms, range, opts)
if bufnr == 0 then
bufnr = vim.api.nvim_get_current_buf()
Expand All @@ -551,8 +566,15 @@ M.format_sync = function(bufnr, formatters, timeout_ms, range, opts)
local err, final_result, all_support_range_formatting =
M.format_lines_sync(bufnr, formatters, timeout_ms, range, original_lines, opts)

M.apply_format(bufnr, original_lines, final_result, range, not all_support_range_formatting)
return err
local did_edit = M.apply_format(
bufnr,
original_lines,
final_result,
range,
not all_support_range_formatting,
opts.dry_run
)
return err, did_edit
end

---@param bufnr integer
Expand Down
Loading

0 comments on commit e0276bb

Please sign in to comment.