From e0276bb32e9b33ece11fef2a5cfc8fb2108df0df Mon Sep 17 00:00:00 2001 From: Bronson Jordan <80419011+bpjordan@users.noreply.github.com> Date: Mon, 15 Jan 2024 21:48:26 -0600 Subject: feat: Add dry_run option and report if buffer was/would be changed by 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 --- lua/conform/init.lua | 28 +++++++++++++++++----------- lua/conform/lsp_format.lua | 41 +++++++++++++++++++++++++++++++++-------- lua/conform/runner.lua | 44 +++++++++++++++++++++++++++++++++----------- 3 files changed, 83 insertions(+), 30 deletions(-) (limited to 'lua') diff --git a/lua/conform/init.lua b/lua/conform/init.lua index 35ffb6e..db0e939 100644 --- a/lua/conform/init.lua +++ b/lua/conform/init.lua @@ -354,6 +354,7 @@ 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. @@ -361,14 +362,15 @@ end --- 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, }) @@ -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") @@ -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) @@ -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 @@ -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 diff --git a/lua/conform/lsp_format.lua b/lua/conform/lsp_format.lua index cd6e429..f49e15f 100644 --- a/lua/conform/lsp_format.lua +++ b/lua/conform/lsp_format.lua @@ -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 @@ -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 @@ -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 @@ -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", { @@ -112,21 +123,35 @@ 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) @@ -134,7 +159,7 @@ function M.format(options, callback) return callback(string.format("[LSP][%s] %s", client.name, err)) end end - callback() + callback(nil, did_edit) end end diff --git a/lua/conform/runner.lua b/lua/conform/runner.lua index 0f4604d..3d9df8b 100644 --- a/lua/conform/runner.lua +++ b/lua/conform/runner.lua @@ -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 @@ -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) @@ -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) @@ -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 @@ -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() @@ -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 = { @@ -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 @@ -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() @@ -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 -- cgit v1.2.3-70-g09d2