diff options
Diffstat (limited to 'lua/conform/runner.lua')
-rw-r--r-- | lua/conform/runner.lua | 283 |
1 files changed, 283 insertions, 0 deletions
diff --git a/lua/conform/runner.lua b/lua/conform/runner.lua new file mode 100644 index 0000000..fd806ce --- /dev/null +++ b/lua/conform/runner.lua @@ -0,0 +1,283 @@ +local fs = require("conform.fs") +local log = require("conform.log") +local util = require("conform.util") +local uv = vim.uv or vim.loop +local M = {} + +---@param ctx conform.Context +---@param config conform.FormatterConfig +local function build_cmd(ctx, config) + local command = config.command + if type(command) == "function" then + command = command(ctx) + end + local cmd = { command } + if config.args then + local args = config.args + if type(config.args) == "function" then + args = config.args(ctx) + end + ---@cast args string[] + for _, v in ipairs(args) do + if v == "$FILENAME" then + v = ctx.filename + elseif v == "$DIRNAME" then + v = vim.fs.dirname(ctx.filename) + end + table.insert(cmd, v) + end + end + return cmd +end + +---@param bufnr integer +---@param original_lines string[] +---@param new_lines string[] +local function apply_format(bufnr, original_lines, new_lines) + local restore = util.save_win_positions(bufnr) + + local original_text = table.concat(original_lines, "\n") + local new_text = table.concat(new_lines, "\n") + local indices = vim.diff(original_text, new_text, { + result_type = "indices", + algorithm = "minimal", + }) + assert(indices) + for i = #indices, 1, -1 do + local start_a, count_a, start_b, count_b = unpack(indices[i]) + -- When count_a is 0, the diff is an insert after the line + if count_a == 0 then + start_a = start_a + 1 + end + local replacement = util.tbl_slice(new_lines, start_b, start_b + count_b - 1) + vim.api.nvim_buf_set_lines(bufnr, start_a - 1, start_a - 1 + count_a, true, replacement) + end + + restore() +end + +---@param bufnr integer +---@param formatter conform.FormatterInfo +---@param input_lines string[] +---@param callback fun(err?: string, output?: string[]) +---@return integer +local function run_formatter(bufnr, formatter, input_lines, callback) + local config = assert(require("conform").get_formatter_config(formatter.name)) + local ctx = M.build_context(bufnr, config) + local cmd = build_cmd(ctx, config) + local cwd = nil + if config.cwd then + cwd = config.cwd(ctx) + end + log.info("Running formatter %s on buffer %d", formatter.name, bufnr) + if not config.stdin then + log.debug("Creating temp file %s", ctx.filename) + local fd = assert(uv.fs_open(ctx.filename, "w", 448)) -- 0700 + uv.fs_write(fd, table.concat(input_lines, "\n")) + uv.fs_close(fd) + local final_cb = callback + callback = function(...) + log.debug("Cleaning up temp file %s", ctx.filename) + uv.fs_unlink(ctx.filename) + final_cb(...) + end + end + log.debug("Running command: %s", cmd) + if cwd then + log.debug("Running in CWD: %s", cwd) + end + local stdout + local stderr + local exit_codes = config.exit_codes or { 0 } + local jid = vim.fn.jobstart(cmd, { + cwd = cwd, + stdout_buffered = true, + stderr_buffered = true, + stdin = config.stdin and "pipe" or "null", + on_stdout = function(_, data) + if config.stdin then + stdout = data + end + end, + on_stderr = function(_, data) + stderr = data + end, + on_exit = function(_, code) + local output + if not config.stdin then + local fd = assert(uv.fs_open(ctx.filename, "r", 448)) -- 0700 + local stat = assert(uv.fs_fstat(fd)) + local content = assert(uv.fs_read(fd, stat.size)) + uv.fs_close(fd) + output = vim.split(content, "\n", { plain = true }) + else + output = stdout + end + if vim.tbl_contains(exit_codes, code) then + log.debug("Formatter %s exited with code %d", formatter.name, code) + callback(nil, output) + else + log.error("Formatter %s exited with code %d", formatter.name, code) + log.warn("Formatter %s stdout:", formatter.name, stdout) + log.warn("Formatter %s stderr:", formatter.name, stderr) + callback( + string.format("Formatter '%s' error: %s", formatter.name, table.concat(stderr, "\n")) + ) + end + end, + }) + if jid == 0 then + callback(string.format("Formatter '%s' invalid arguments", formatter.name)) + elseif jid == -1 then + callback(string.format("Formatter '%s' command is not executable", formatter.name)) + elseif config.stdin then + local text = table.concat(input_lines, "\n") + vim.api.nvim_chan_send(jid, text) + vim.fn.chanclose(jid, "stdin") + end + vim.b[bufnr].conform_jid = jid + + return jid +end + +---@param bufnr integer +---@param config conform.FormatterConfig +---@return conform.Context +M.build_context = function(bufnr, config) + if bufnr == 0 then + bufnr = vim.api.nvim_get_current_buf() + end + local filename = vim.api.nvim_buf_get_name(bufnr) + + -- Hack around checkhealth. For buffers that are not files, we need to fabricate a filename + if vim.bo[bufnr].buftype ~= "" then + filename = "" + end + local dirname + if filename == "" then + dirname = vim.fn.getcwd() + filename = fs.join(dirname, "unnamed_temp") + local ft = vim.bo[bufnr].filetype + if ft and ft ~= "" then + filename = filename .. "." .. ft + end + else + dirname = vim.fs.dirname(filename) + end + + if not config.stdin then + local basename = vim.fs.basename(filename) + local tmpname = string.format(".conform.%d.%s", math.random(1000000, 9999999), basename) + local parent = vim.fs.dirname(filename) + filename = fs.join(parent, tmpname) + end + return { + buf = bufnr, + filename = filename, + dirname = dirname, + } +end + +---@param bufnr integer +---@param formatters conform.FormatterInfo[] +---@param callback? fun(err?: string) +M.format_async = function(bufnr, formatters, callback) + local idx = 1 + local changedtick = vim.b[bufnr].changedtick + local original_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local input_lines = original_lines + + -- kill previous jobs for buffer + local prev_jid = vim.b[bufnr].conform_jid + if prev_jid then + if vim.fn.jobstop(prev_jid) == 1 then + log.info("Canceled previous format job for buffer %d", bufnr) + end + end + + local function run_next_formatter() + local formatter = formatters[idx] + if not formatter then + -- discard formatting if buffer has changed + if vim.b[bufnr].changedtick == changedtick then + apply_format(bufnr, original_lines, input_lines) + else + log.warn("Async formatter discarding changes for buffer %d: concurrent modification", bufnr) + end + if callback then + callback() + end + return + end + idx = idx + 1 + + local jid + jid = run_formatter(bufnr, formatter, input_lines, function(err, output) + if err then + -- Only display the error if the job wasn't canceled + if jid == vim.b[bufnr].conform_jid then + vim.notify(err, vim.log.levels.ERROR) + end + if callback then + callback(err) + end + return + end + input_lines = output + run_next_formatter() + end) + end + run_next_formatter() +end + +---@param bufnr integer +---@param formatters conform.FormatterInfo[] +---@param timeout_ms integer +M.format_sync = function(bufnr, formatters, timeout_ms) + local start = uv.hrtime() / 1e6 + local original_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local input_lines = original_lines + + -- kill previous jobs for buffer + local prev_jid = vim.b[bufnr].conform_jid + if prev_jid then + if vim.fn.jobstop(prev_jid) == 1 then + log.info("Canceled previous format job for buffer %d", bufnr) + end + end + + for _, formatter in ipairs(formatters) do + local remaining = timeout_ms - (uv.hrtime() / 1e6 - start) + local done = false + local result = nil + run_formatter(bufnr, formatter, input_lines, function(err, output) + if err then + vim.notify(err, vim.log.levels.ERROR) + end + done = true + result = output + end) + + local wait_result, wait_reason = vim.wait(remaining, function() + return done + end, 5) + + if not wait_result then + if wait_reason == -1 then + vim.notify(string.format("Formatter '%s' timed out", formatter.name), vim.log.levels.WARN) + end + return + end + + if not result then + return + end + + input_lines = result + end + + local final_result = input_lines + apply_format(bufnr, original_lines, final_result) +end + +return M |