From f0998e2e3414a5b18013b7458298cc674867c708 Mon Sep 17 00:00:00 2001 From: Mike Hartington Date: Tue, 24 Sep 2024 15:47:15 -0400 Subject: [PATCH] feat(utils): preseve folds and extmarks This adapts some code from conform to apply text edits in diffs instead of wiping the whole buffer. This will preserve folds and extmarks. --- .github/workflows/pr_check.yml | 20 +- lua/formatter/defaults/alejandra.lua | 2 +- lua/formatter/defaults/biome.lua | 8 +- lua/formatter/defaults/ktlint.lua | 20 +- lua/formatter/defaults/ocamlformat.lua | 2 +- lua/formatter/defaults/stylish_haskell.lua | 2 - lua/formatter/filetypes/java.lua | 29 +- lua/formatter/filetypes/kotlin.lua | 18 +- lua/formatter/filetypes/liquidsoap.lua | 4 +- lua/formatter/filetypes/lua.lua | 4 +- lua/formatter/filetypes/proto.lua | 2 +- lua/formatter/filetypes/python.lua | 18 +- lua/formatter/filetypes/toml.lua | 9 +- lua/formatter/format.lua | 11 +- lua/formatter/util.lua | 328 +++++++++++++++------ 15 files changed, 313 insertions(+), 164 deletions(-) diff --git a/.github/workflows/pr_check.yml b/.github/workflows/pr_check.yml index 5a82f41..a4fe05a 100644 --- a/.github/workflows/pr_check.yml +++ b/.github/workflows/pr_check.yml @@ -8,27 +8,13 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@master - - uses: JohnnyMorganz/stylua-action@v1.1.2 + - uses: JohnnyMorganz/stylua-action@v4 with: token: ${{ secrets.GITHUB_TOKEN }} + version: latest args: --check lua luacheck: runs-on: ubuntu-latest - steps: - - uses: actions/checkout@master - - - uses: leafo/gh-actions-lua@v8.0.0 - with: - luaVersion: 'luajit-2.1.0-beta3' - - - uses: leafo/gh-actions-luarocks@v4.0.0 - - - name: build - run: | - luarocks install luacheck - - - name: test - run: | - luacheck lua + - uses: lunarmodules/luacheck@v1.2.0 diff --git a/lua/formatter/defaults/alejandra.lua b/lua/formatter/defaults/alejandra.lua index 162c1a7..9f23214 100644 --- a/lua/formatter/defaults/alejandra.lua +++ b/lua/formatter/defaults/alejandra.lua @@ -2,6 +2,6 @@ return function() return { exe = "alejandra", stdin = true, - args = {"--quiet"}, + args = { "--quiet" }, } end diff --git a/lua/formatter/defaults/biome.lua b/lua/formatter/defaults/biome.lua index 3264ad2..33f0c6d 100644 --- a/lua/formatter/defaults/biome.lua +++ b/lua/formatter/defaults/biome.lua @@ -4,10 +4,10 @@ return function() return { exe = "biome", args = { - "format", - "--stdin-file-path", - util.escape_path(util.get_current_buffer_file_path()), + "format", + "--stdin-file-path", + util.escape_path(util.get_current_buffer_file_path()), }, stdin = true, } -end \ No newline at end of file +end diff --git a/lua/formatter/defaults/ktlint.lua b/lua/formatter/defaults/ktlint.lua index 922c98f..22eb2ff 100644 --- a/lua/formatter/defaults/ktlint.lua +++ b/lua/formatter/defaults/ktlint.lua @@ -1,13 +1,11 @@ -local util = require "formatter.util" - return function() - return { - exe = "ktlint", - args = { - "--stdin", - "--format", - "--log-level=none" - }, - stdin = true - } + return { + exe = "ktlint", + args = { + "--stdin", + "--format", + "--log-level=none", + }, + stdin = true, + } end diff --git a/lua/formatter/defaults/ocamlformat.lua b/lua/formatter/defaults/ocamlformat.lua index 05666d6..5dd0115 100644 --- a/lua/formatter/defaults/ocamlformat.lua +++ b/lua/formatter/defaults/ocamlformat.lua @@ -6,7 +6,7 @@ return function() "--enable-outside-detected-project", "--name", util.escape_path(util.get_current_buffer_file_name()), - "-" + "-", }, stdin = true, } diff --git a/lua/formatter/defaults/stylish_haskell.lua b/lua/formatter/defaults/stylish_haskell.lua index 15889a6..5d4f194 100644 --- a/lua/formatter/defaults/stylish_haskell.lua +++ b/lua/formatter/defaults/stylish_haskell.lua @@ -1,5 +1,3 @@ -local util = require "formatter.util" - return function() return { exe = "stylish-haskell", diff --git a/lua/formatter/filetypes/java.lua b/lua/formatter/filetypes/java.lua index 3d24276..93eccb4 100644 --- a/lua/formatter/filetypes/java.lua +++ b/lua/formatter/filetypes/java.lua @@ -3,11 +3,11 @@ local M = {} local util = require "formatter.util" function M.clangformat() - return { - exe = "clang-format", - args = {"--assume-filename=.java"}, - stdin = true - } + return { + exe = "clang-format", + args = { "--assume-filename=.java" }, + stdin = true, + } end function M.ideaformat() @@ -18,17 +18,16 @@ function M.ideaformat() } end - function M.google_java_format() - return { - exe = "google-java-format", - args = { - "--aosp", - util.escape_path(util.get_current_buffer_file_path()), - "--replace" - }, - stdin = true - } + return { + exe = "google-java-format", + args = { + "--aosp", + util.escape_path(util.get_current_buffer_file_path()), + "--replace", + }, + stdin = true, + } end return M diff --git a/lua/formatter/filetypes/kotlin.lua b/lua/formatter/filetypes/kotlin.lua index 2065dcd..49155ca 100644 --- a/lua/formatter/filetypes/kotlin.lua +++ b/lua/formatter/filetypes/kotlin.lua @@ -1,15 +1,15 @@ local M = {} function M.ktlint() - return { - exe = "ktlint", - args = { - "--stdin", - "--format", - "--log-level=none" - }, - stdin = true - } + return { + exe = "ktlint", + args = { + "--stdin", + "--format", + "--log-level=none", + }, + stdin = true, + } end return M diff --git a/lua/formatter/filetypes/liquidsoap.lua b/lua/formatter/filetypes/liquidsoap.lua index 7f21f9c..e8b6d71 100644 --- a/lua/formatter/filetypes/liquidsoap.lua +++ b/lua/formatter/filetypes/liquidsoap.lua @@ -3,8 +3,8 @@ local M = {} function M.liquidsoap_prettier() return { exe = "liquidsoap-prettier", - args = {"--write"}, - stdin = false + args = { "--write" }, + stdin = false, } end diff --git a/lua/formatter/filetypes/lua.lua b/lua/formatter/filetypes/lua.lua index 741c02c..a8eddef 100644 --- a/lua/formatter/filetypes/lua.lua +++ b/lua/formatter/filetypes/lua.lua @@ -19,8 +19,8 @@ end function M.luaformat() return { exe = "lua-format", - args = {util.escape_path(util.get_current_buffer_file_path())}, - stdin = true + args = { util.escape_path(util.get_current_buffer_file_path()) }, + stdin = true, } end diff --git a/lua/formatter/filetypes/proto.lua b/lua/formatter/filetypes/proto.lua index 4d7cb36..532fd16 100644 --- a/lua/formatter/filetypes/proto.lua +++ b/lua/formatter/filetypes/proto.lua @@ -4,7 +4,7 @@ function M.buf_format() return { exe = "buf format", args = { - '-w', + "-w", }, stdin = false, } diff --git a/lua/formatter/filetypes/python.lua b/lua/formatter/filetypes/python.lua index 577c1fb..1b359f0 100644 --- a/lua/formatter/filetypes/python.lua +++ b/lua/formatter/filetypes/python.lua @@ -16,10 +16,15 @@ function M.autopep8() end function M.isort() - local util = require("formatter.util") + local util = require "formatter.util" return { exe = "isort", - args = { "-q", "--filename", util.escape_path(util.get_current_buffer_file_path()), "-" }, + args = { + "-q", + "--filename", + util.escape_path(util.get_current_buffer_file_path()), + "-", + }, stdin = true, } end @@ -33,10 +38,15 @@ function M.docformatter() end function M.black() - local util = require("formatter.util") + local util = require "formatter.util" return { exe = "black", - args = { "-q", "--stdin-filename", util.escape_path(util.get_current_buffer_file_name()), "-" }, + args = { + "-q", + "--stdin-filename", + util.escape_path(util.get_current_buffer_file_name()), + "-", + }, stdin = true, } end diff --git a/lua/formatter/filetypes/toml.lua b/lua/formatter/filetypes/toml.lua index 4de29dd..a2a6f3a 100644 --- a/lua/formatter/filetypes/toml.lua +++ b/lua/formatter/filetypes/toml.lua @@ -1,10 +1,15 @@ local M = {} function M.taplo() - local util = require("formatter.util") + local util = require "formatter.util" return { exe = "taplo", - args = { "fmt", "--stdin-filepath", util.escape_path(util.get_current_buffer_file_path()),"-" }, + args = { + "fmt", + "--stdin-filepath", + util.escape_path(util.get_current_buffer_file_path()), + "-", + }, stdin = true, try_node_modules = true, } diff --git a/lua/formatter/format.lua b/lua/formatter/format.lua index 8dd7e0c..7d8dfc3 100644 --- a/lua/formatter/format.lua +++ b/lua/formatter/format.lua @@ -134,7 +134,8 @@ function M.start_task(configs, start_line, end_line, opts) end if opts.lock then - vim.api.nvim_buf_set_option(bufnr, "modifiable", false) + vim.api.nvim_set_option_value("modifiable", false, { buf = bufnr }) + -- vim.api.nvim_buf_set_option(bufnr, "modifiable", false) end name = current.name @@ -179,7 +180,7 @@ function M.start_task(configs, start_line, end_line, opts) if binpath then try_node_modules_path = binpath .. util.path_separator - .. vim.fn.getenv("PATH") + .. vim.fn.getenv "PATH" else try_node_modules_path = false end @@ -232,7 +233,8 @@ function M.start_task(configs, start_line, end_line, opts) end if opts.lock then - vim.api.nvim_buf_set_option(bufnr, "modifiable", true) + -- vim.api.nvim_buf_set_option(bufnr, "modifiable", true) + vim.api.nvim_set_option_value("modifiable", true, { buf = bufnr }) end if not util.is_same(input, output) then @@ -247,7 +249,8 @@ function M.start_task(configs, start_line, end_line, opts) ) return end - util.set_lines(bufnr, start_line, end_line, output) + util.update_lines(bufnr, input, output) + util.restore_view_per_window(window_to_view) if opts.write and bufnr == vim.api.nvim_get_current_buf() then diff --git a/lua/formatter/util.lua b/lua/formatter/util.lua index 910369b..8f2bc81 100644 --- a/lua/formatter/util.lua +++ b/lua/formatter/util.lua @@ -1,184 +1,334 @@ local M = {} - -local config = require("formatter.config") +local config = require "formatter.config" -- NOTE: to contributors -- NOTE: use these for "lua/formatter/defaults" and "lua/formatter/filetypes" function M.get_cwd() - return vim.fn.getcwd() + return vim.fn.getcwd() end function M.get_current_buffer_file_path() - return vim.api.nvim_buf_get_name(0) + return vim.api.nvim_buf_get_name(0) end function M.get_current_buffer_file_name() - return vim.fn.fnamemodify(M.get_current_buffer_file_path(), ":t") + return vim.fn.fnamemodify(M.get_current_buffer_file_path(), ":t") end function M.get_current_buffer_file_dir() - return vim.fn.fnamemodify(M.get_current_buffer_file_path(), ":h") + return vim.fn.fnamemodify(M.get_current_buffer_file_path(), ":h") end function M.get_current_buffer_file_extension() - return vim.fn.fnamemodify(M.get_current_buffer_file_path(), ":e") + return vim.fn.fnamemodify(M.get_current_buffer_file_path(), ":e") end function M.quote_cmd_arg(arg) - return string.format("'%s'", arg) + return string.format("'%s'", arg) end -- TODO: check fnameescape or shellescape? function M.escape_path(arg) - return vim.fn.shellescape(arg, true) + return vim.fn.shellescape(arg, true) end function M.wrap_sed_replace(pattern, replacement, flags) - return string.format("s/%s/%s/%s", pattern, replacement or "", flags or "") + return string.format("s/%s/%s/%s", pattern, replacement or "", flags or "") end -- TODO: check that this is okay for paths and ordinary strings function M.format_prettydiff_arg(name, value) - return string.format('%s:"%s"', name, value) + return string.format('%s:"%s"', name, value) end -- Returns a list of currently available formatters for the specified filetype. function M.get_available_formatters_for_ft(ft) - local available_formatters = {} - local user_defined_formatters = config.values.filetype - - for formatter_filetype, formatter_functions in pairs(user_defined_formatters) do - if ft == "*" or ft == formatter_filetype then - for _, formatter_function in ipairs(formatter_functions) do - local formatter_info = formatter_function() - table.insert(available_formatters, formatter_info) - end - end + local available_formatters = {} + local user_defined_formatters = config.values.filetype + + for formatter_filetype, formatter_functions in pairs(user_defined_formatters) do + if ft == "*" or ft == formatter_filetype then + for _, formatter_function in ipairs(formatter_functions) do + local formatter_info = formatter_function() + table.insert(available_formatters, formatter_info) + end end + end - return available_formatters + return available_formatters end -- NOTE: to contributors -- NOTE: use these for "lua/formatter/filetypes" function M.withl(f, ...) - local argsl = { ... } - return function(...) - local argsr = { ... } - return f(unpack(argsl), unpack(argsr)) - end + local argsl = { ... } + return function(...) + local argsr = { ... } + return f(unpack(argsl), unpack(argsr)) + end end function M.withr(f, ...) - local argsr = { ... } - return function(...) - local argsl = { ... } - return f(unpack(argsl), unpack(argsr)) - end + local argsr = { ... } + return function(...) + local argsl = { ... } + return f(unpack(argsl), unpack(argsr)) + end end function M.copyf(f) - return function(...) - return f(...) - end + return function(...) + return f(...) + end end ----------------------------------------------------------------------------- -- Tables function M.is_empty(s) - if type(s) == "table" then - for _, v in pairs(s) do - if not M.is_empty(v) then - return false - end - end - return true + if type(s) == "table" then + for _, v in pairs(s) do + if not M.is_empty(v) then + return false + end end - return s == nil or s == "" + return true + end + return s == nil or s == "" end function M.split(s, sep, plain) - if s ~= "" then - local t = {} - for c in vim.gsplit(s, sep, plain) do - t[c] = true - end - return t + if s ~= "" then + local t = {} + for c in vim.gsplit(s, sep, plain) do + t[c] = true end + return t + end end function M.is_same(a, b) - if type(a) ~= type(b) then - return false + if type(a) ~= type(b) then + return false + end + if type(a) == "table" then + if #a ~= #b then + return false end - if type(a) == "table" then - if #a ~= #b then - return false - end - for k, v in pairs(a) do - if not M.is_same(b[k], v) then - return false - end - end - return true - else - return a == b + for k, v in pairs(a) do + if not M.is_same(b[k], v) then + return false + end end + return true + else + return a == b + end +end + +function M.tbl_slice(tbl, start_idx, end_idx) + local ret = {} + if not start_idx then + start_idx = 1 + end + if not end_idx then + end_idx = #tbl + end + for i = start_idx, end_idx do + table.insert(ret, tbl[i]) + end + return ret end ----------------------------------------------------------------------------- -- Vim function M.set_lines(bufnr, startLine, endLine, lines) - return vim.api.nvim_buf_set_lines(bufnr, startLine, endLine, false, lines) + return vim.api.nvim_buf_set_lines(bufnr, startLine, endLine, false, lines) +end + +local function common_prefix_len(a, b) + if not a or not b then + return 0 + end + local min_len = math.min(#a, #b) + for i = 1, min_len do + if string.byte(a, i) ~= string.byte(b, i) then + return i - 1 + end + end + return min_len +end + +local function common_suffix_len(a, b) + local a_len = #a + local b_len = #b + local min_len = math.min(a_len, b_len) + for i = 0, min_len - 1 do + if string.byte(a, a_len - i) ~= string.byte(b, b_len - i) then + return i + end + end + return min_len +end + +local function create_text_edit( + original_lines, + replacement, + is_insert, + is_replace, + orig_line_start, + orig_line_end +) + local start_line, end_line = orig_line_start - 1, orig_line_end - 1 + local start_char, end_char = 0, 0 + if is_replace then + -- If we're replacing text, see if we can avoid replacing the entire line + start_char = + common_prefix_len(original_lines[orig_line_start], replacement[1]) + if start_char > 0 then + replacement[1] = replacement[1]:sub(start_char + 1) + end + + if original_lines[orig_line_end] then + local last_line = replacement[#replacement] + local suffix = common_suffix_len(original_lines[orig_line_end], last_line) + -- If we're only replacing one line, make sure the prefix/suffix calculations don't overlap + if orig_line_end == orig_line_start then + suffix = + math.min(suffix, original_lines[orig_line_end]:len() - start_char) + end + end_char = original_lines[orig_line_end]:len() - suffix + if suffix > 0 then + replacement[#replacement] = last_line:sub(1, last_line:len() - suffix) + end + end + end + -- If we're inserting text, make sure the text includes a newline at the end. + -- The one exception is if we're inserting at the end of the file, in which case the newline is + -- implicit + if is_insert and start_line < #original_lines then + table.insert(replacement, "") + end + local new_text = table.concat(replacement, "\n") + + return { + newText = new_text, + range = { + start = { + line = start_line, + character = start_char, + }, + ["end"] = { + line = end_line, + character = end_char, + }, + }, + } +end + +function M.update_lines(bufnr, original_lines, new_lines) + -- Update lines based on diffs. This is based (copied) from conform with some edits + table.insert(original_lines, "") + table.insert(new_lines, "") + local original_text = table.concat(original_lines, "\n") + local new_text = table.concat(new_lines, "\n") + table.remove(original_lines) + table.remove(new_lines) + + if new_text:match "^%s*$" and not original_text:match "^%s*$" then + return false + end + + ---@diagnostic disable-next-line: missing-fields + local indices = vim.diff(original_text, new_text, { + result_type = "indices", + algorithm = "histogram", + }) + assert(type(indices) == "table") + local text_edits = {} + for _, idx in ipairs(indices) do + local orig_line_start, orig_line_count, new_line_start, new_line_count = + unpack(idx) + local is_insert = orig_line_count == 0 + local is_delete = new_line_count == 0 + local is_replace = not is_insert and not is_delete + local orig_line_end = orig_line_start + orig_line_count + local new_line_end = new_line_start + new_line_count + + if is_insert then + orig_line_start = orig_line_start + 1 + orig_line_end = orig_line_end + 1 + end + + local replacement = M.tbl_slice(new_lines, new_line_start, new_line_end - 1) + + if is_replace then + orig_line_end = orig_line_end - 1 + end + local text_edit = create_text_edit( + original_lines, + replacement, + is_insert, + is_replace, + orig_line_start, + orig_line_end + ) + table.insert(text_edits, text_edit) + end + + vim.lsp.util.apply_text_edits(text_edits, bufnr, "utf-8") + + return not vim.tbl_isempty(text_edits) end function M.get_lines(bufnr, startLine, endLine) - return vim.api.nvim_buf_get_lines(bufnr, startLine, endLine, false) + return vim.api.nvim_buf_get_lines(bufnr, startLine, endLine, false) end function M.fire_event(event, silent) - local cmd = string.format( - "%s doautocmd User %s", - silent and "silent" or "", - event - ) - vim.api.nvim_command(cmd) + local cmd = string.format( + "%s doautocmd User %s", + silent and "silent" or "", + event + ) + vim.api.nvim_command(cmd) end function M.get_buffer_variable(buf, var) - local status, result = pcall(vim.api.nvim_buf_get_var, buf, var) - if status then - return result - end - return nil + local status, result = pcall(vim.api.nvim_buf_get_var, buf, var) + if status then + return result + end + return nil end --- get a table that maps a window to a view ---@see vim.fn.winsaveview() function M.get_views_for_this_buffer() - local windows_containing_this_buffer = vim.fn.win_findbuf(vim.fn.bufnr()) - local window_to_view = {} - for _, w in ipairs(windows_containing_this_buffer) do - vim.api.nvim_win_call(w, function() - window_to_view[w] = vim.fn.winsaveview() - end) - end - return window_to_view + local windows_containing_this_buffer = vim.fn.win_findbuf(vim.fn.bufnr()) + local window_to_view = {} + for _, w in ipairs(windows_containing_this_buffer) do + vim.api.nvim_win_call(w, function() + window_to_view[w] = vim.fn.winsaveview() + end) + end + return window_to_view end --- restore view for each window ---@param window_to_view table maps window to a view ---@see vim.fn.winrestview() function M.restore_view_per_window(window_to_view) - for w, view in pairs(window_to_view) do - if vim.api.nvim_win_is_valid(w) then - vim.api.nvim_win_call(w, function() vim.fn.winrestview(view) end) - end + for w, view in pairs(window_to_view) do + if vim.api.nvim_win_is_valid(w) then + vim.api.nvim_win_call(w, function() + vim.fn.winrestview(view) + end) end + end end --- Find the closest node_modules path @@ -203,7 +353,7 @@ function M.get_node_modules_bin_path(node_modules) return bin_path end -if vim.fn.has("win32") == 1 then +if vim.fn.has "win32" == 1 then M.path_separator = ";" else M.path_separator = ":"