plugin.lua

Branches > main > ada9461 > 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 diff = vim.text.diff or vim.diff
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

--- 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 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

  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

local function update_buffer(bufnr, old_text, new_text)
  local new_lines = vim.split(new_text, "\n", {plain = true})
  local hunks = diff(old_text .. "\n", new_text .. "\n", {result_type = "indices"})

  vim.cmd("silent! undojoin")
  --- Bottom up, so applying a hunk doesn't move the lines of the ones after it
  for i = #hunks, 1, -1 do
    local start_a, count_a, start_b, count_b = unpack(hunks[i])
    --- A pure insertion goes after start_a
    if count_a == 0 then start_a = start_a + 1 end
    api.nvim_buf_set_lines(bufnr, start_a - 1, start_a - 1 + count_a, false,
                           {unpack(new_lines, start_b, start_b + count_b - 1)})
  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}