plugin.lua

Branches > main > d3c0a10 > files / nvim / lua / parinfer / plugin.lua

--- Modified version of https://github.com/gpanders/nvim-parinfer
--- See ./LICENSE

vim.g.parinfer_mode = "smart"
vim.g.parinfer_enabled = true
vim.g.parinfer_force_balance = false
vim.g.parinfer_comment_chars = {";"}

local api = vim.api
local parinfer = require("parinfer.parinfer")
local modes = {indent = parinfer.indentMode,
               paren = parinfer.parenMode,
               smart = parinfer.smartMode}

local ns = api.nvim_create_namespace("parinfer")
local group = api.nvim_create_augroup("parinfer", {clear = true})

--- Per buffer: the text and changedtick as of the last run, and the cursor before the current edit
local state = {}

-- Returns true if 'val' is truthy, and not 0, otherwise returns false
-- 0 and 1 are commonly used in vim functions for true/false, but 0 is a truthy value in Lua
local function normalize_bool(val)
  if type(val) == "boolean" or val == nil then
    return val
  else
    return val ~= 0
  end
end

-- Looks up and returns 'opt' in the buffer-local and global option tables vim.b and vim.g
local function get_option(bufnr, opt)
  local b = vim.b[bufnr][opt]
  if b == nil then
    return vim.g[opt]
  else
    return b
  end
end

--- Returns how many bytes 'old' and 'new' have in common at the start, and then at the end
local function common_ends(old, new)
  local max = math.min(#old, #new)
  local prefix = 0
  while prefix < max and old:byte(prefix + 1) == new:byte(prefix + 1) do
    prefix = prefix + 1
  end
  local suffix = 0
  while suffix < max - prefix and old:byte(#old - suffix) == new:byte(#new - suffix) do
    suffix = suffix + 1
  end
  return prefix, suffix
end

--- Collapses everything that happened since the last run into one change.
--- Linewise edits (V>, Vd + p, :m, ...) used to be passed as several on_bytes changes, each in the
--- coordinates of a buffer that had been changed again since. Smart mode then shifted the
--- indentation of the wrong lines (or the same lines more than once) and mangled the forms.
local function get_change(old, new)
  if old == nil or old == new then return nil end

  local prefix, suffix = common_ends(old, new)
  local before = old:sub(1, prefix)
  local _, line_breaks = before:gsub("\n", "")
  local line_start = before:find("[^\n]*$")
  return {lineNo = line_breaks + 1,
          x = prefix - line_start + 2,
          oldText = old:sub(prefix + 1, #old - suffix),
          newText = new:sub(prefix + 1, #new - suffix)}
end

--- Only replaces the bytes parinfer changed. Replacing whole lines deletes the extmarks on them, so
--- the LSP semantic token highlights of every fixed line blinked off until the server sent new ones.
local function update_buffer(bufnr, old_text, new_text)
  local old_lines = vim.split(old_text, "\n", {plain = true})
  local new_lines = vim.split(new_text, "\n", {plain = true})

  vim.cmd("silent! undojoin")
  --- Parinfer only moves parens and indentation, it shouldn't ever add or remove lines
  if #old_lines ~= #new_lines then
    api.nvim_buf_set_lines(bufnr, 0, -1, false, new_lines)
    return
  end
  for i, old in ipairs(old_lines) do
    local new = new_lines[i]
    if old ~= new then
      local prefix, suffix = common_ends(old, new)
      api.nvim_buf_set_text(bufnr, i - 1, prefix, i - 1, #old - suffix,
                            {new:sub(prefix + 1, #new - suffix)})
    end
  end
end

local function highlight_error(bufnr, err)
  api.nvim_buf_clear_namespace(bufnr, ns, 0, -1)
  if err then
    local lnum, col = err.lineNo - 1, err.x - 1
    vim.hl.range(bufnr, ns, "Error", {lnum, col}, {lnum, col + 1})
  end
end

local function is_undo_leaf()
  local undotree = vim.fn.undotree()
  return undotree.seq_cur == undotree.seq_last
end

local function should_run(bufnr)
  return normalize_bool(get_option(bufnr, "parinfer_enabled"))
    and not vim.o.paste
    and not vim.bo[bufnr].readonly
    and vim.bo[bufnr].modifiable
    --- Don't fight undo/redo, rewriting an undone state would throw away the redo branch
    and is_undo_leaf()
end

local function process_buffer(bufnr, mode)
  local s = state[bufnr]
  local tick = vim.b[bufnr].changedtick
  if not s or s.tick == tick then return end

  local text = table.concat(api.nvim_buf_get_lines(bufnr, 0, -1, true), "\n")
  local change = get_change(s.text, text)
  s.text, s.tick = text, tick
  if not should_run(bufnr) then return end

  local win = api.nvim_get_current_win()
  local lnum, col = unpack(api.nvim_win_get_cursor(win))
  local response = modes[mode or get_option(bufnr, "parinfer_mode")](text, {
    commentChars = get_option(bufnr, "parinfer_comment_chars"),
    prevCursorLine = s.cursor[1],
    prevCursorX = s.cursor[2],
    cursorLine = lnum,
    cursorX = col + 1,
    changes = change and {change},
    forceBalance = normalize_bool(get_option(bufnr, "parinfer_force_balance"))})

  if response.success and response.text ~= text then
    update_buffer(bufnr, text, response.text)
    api.nvim_win_set_cursor(win, {response.cursorLine, response.cursorX - 1})
    s.text, s.tick = response.text, vim.b[bufnr].changedtick
  end
  s.cursor = {response.cursorLine, response.cursorX}
  highlight_error(bufnr, response.error)
end

local function cursor_moved(bufnr)
  local s = state[bufnr]
  --- CursorMoved fires before TextChanged, keep the cursor from before the edit until it's processed
  if s.tick ~= vim.b[bufnr].changedtick then return end
  local lnum, col = unpack(api.nvim_win_get_cursor(0))
  s.cursor = {lnum, col + 1}
end

local function setup_buffer(bufnr)
  local lnum, col = unpack(api.nvim_win_get_cursor(0))
  state[bufnr] = {cursor = {lnum, col + 1}}

  api.nvim_clear_autocmds({group = group, buffer = bufnr})
  api.nvim_create_autocmd({"TextChanged", "TextChangedI", "TextChangedP"}, {buffer = bufnr, group = group, callback = function() process_buffer(bufnr) end})
  api.nvim_create_autocmd({"CursorMoved", "CursorMovedI"}, {buffer = bufnr, group = group, callback = function() cursor_moved(bufnr) end})
  api.nvim_create_autocmd("BufWipeout", {buffer = bufnr, group = group, callback = function() state[bufnr] = nil end})

  api.nvim_buf_create_user_command(bufnr, "ParinferToggle", function()
    vim.b[bufnr].parinfer_enabled = not normalize_bool(get_option(bufnr, "parinfer_enabled"))
    if not vim.b[bufnr].parinfer_enabled then api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) end
    vim.notify("Parinfer " .. (vim.b[bufnr].parinfer_enabled and "enabled" or "disabled"))
  end, {})

  --- Paren mode fixes up the indentation first, so smart mode doesn't restructure the file on the first edit
  process_buffer(bufnr, "paren")
end

local function should_enable()
  --- Skips prompts, scratch buffers, the conjure log, etc
  return not vim.wo.previewwindow and vim.bo.buftype == ""
end

--- Sets up parinfer in the current buffer
local function setup()
  if should_enable() then
    setup_buffer(api.nvim_get_current_buf())
  end
end

return {setup = setup}