Luigit
repositories / dotfiles

dotfiles

bugabingas dorkfiles

owned by admin

neovim/lua/bugabinga/pivi/edit.lua

Raw
local activity = require 'bugabinga.pivi.activity'

local is_file_buffer = function( bufnr )
  return vim.api.nvim_buf_is_valid( bufnr )
    and vim.bo[bufnr].buftype == ''
    and vim.api.nvim_buf_get_name( bufnr ) ~= ''
end

--- Finds the loaded buffer for a path, if any.
local buffer_for = function( path )
  local wanted = vim.fs.normalize( vim.fn.fnamemodify( path, ':p' ) )
  for _, bufnr in ipairs( vim.api.nvim_list_bufs() ) do
    if vim.api.nvim_buf_is_loaded( bufnr ) then
      local name = vim.api.nvim_buf_get_name( bufnr )
      if name ~= '' and vim.fs.normalize( name ) == wanted then return bufnr end
    end
  end
  return nil
end

--- Reads authoritative content: the loaded buffer wins over disk.
local read = function( path )
  local bufnr = buffer_for( path )
  if bufnr then
    return table.concat( vim.api.nvim_buf_get_lines( bufnr, 0, -1, false ), '\n' ), 'buffer'
  end
  local ok, lines = pcall( vim.fn.readfile, path )
  if not ok then return nil, 'missing' end
  return table.concat( lines, '\n' ), 'disk'
end

--- Writes a loaded buffer to disk so later filesystem readers observe agent edits.
--- pivi starts no filesystem watcher; visibility is part of the edit itself.
local persist = function( bufnr )
  if not is_file_buffer( bufnr ) then return false end
  if not vim.bo[bufnr].modified then return true end
  local ok = pcall( vim.api.nvim_buf_call, bufnr, function()
    vim.cmd 'silent! noautocmd write'
  end )
  -- Writing stays part of the agent's operation, so the trail survives it.
  activity.touch( bufnr )
  return ok and not vim.bo[bufnr].modified
end

--- Applies line replacements as one coherent undoable change.
--- Replacements are zero based, end exclusive, and are applied bottom up.
--- When Neovim refuses to join undo blocks the edit still lands.
--- @return boolean applied
--- @return boolean joined whether the change stayed a single undo block
local apply = function( bufnr, replacements )
  vim.validate( 'bufnr', bufnr, 'number' )
  vim.validate( 'replacements', replacements, 'table' )
  if not vim.api.nvim_buf_is_valid( bufnr ) then return false, false end

  local ordered = vim.deepcopy( replacements )
  table.sort( ordered, function( left, right ) return left.first > right.first end )

  local joined = true
  local touched = {}

  for index, replacement in ipairs( ordered ) do
    if index > 1 then
      local ok = pcall( vim.api.nvim_buf_call, bufnr, function() vim.cmd.undojoin() end )
      if not ok then joined = false end
    end
    vim.api.nvim_buf_set_lines(
      bufnr,
      replacement.first,
      replacement.last,
      false,
      replacement.lines
    )
    table.insert( touched, {
      first = replacement.first,
      last = replacement.first + #replacement.lines,
    } )
  end

  activity.mark_edit( bufnr, touched )
  return true, joined
end

--- Brings a buffer to the given content by rewriting only what differs.
--- A whole-buffer replacement would mark every line as agent work and destroy
--- marks and folds for an edit that touched one line.
local set_content = function( bufnr, content )
  vim.validate( 'content', content, 'string' )
  if not vim.api.nvim_buf_is_valid( bufnr ) then return false, false end

  local target = vim.split( content, '\n', { plain = true, } )
  local current = table.concat( vim.api.nvim_buf_get_lines( bufnr, 0, -1, false ), '\n' )
  if current == content then return true, true end

  local whole = { { first = 0, last = -1, lines = target, }, }

  local ok, hunks = pcall( vim.text.diff, current .. '\n', content .. '\n', { result_type = 'indices', } )
  if not ok or type( hunks ) ~= 'table' or #hunks == 0 then
    return apply( bufnr, whole )
  end

  local replacements = {}
  for _, hunk in ipairs( hunks ) do
    local start_a, count_a, start_b, count_b = hunk[1], hunk[2], hunk[3], hunk[4]
    local first = count_a > 0 and start_a - 1 or start_a
    local lines = {}
    for index = start_b, start_b + count_b - 1 do
      table.insert( lines, target[index] )
    end
    table.insert( replacements, { first = first, last = first + count_a, lines = lines, } )
  end

  return apply( bufnr, replacements )
end

local find_text = function( content, needle )
  local first = content:find( needle, 1, true )
  if not first then return nil end
  local second = content:find( needle, first + 1, true )
  if second then return nil, 'text is not unique' end
  return first
end

--- Replaces one unique text occurrence in a loaded buffer.
--- Only the lines the replacement actually spans are rewritten, so undo,
--- marks, folds, and the activity trail stay proportional to the change.
local replace = function( bufnr, old_text, new_text )
  vim.validate( 'old_text', old_text, 'string' )
  vim.validate( 'new_text', new_text, 'string' )
  if not vim.api.nvim_buf_is_valid( bufnr ) then return false, 'invalid buffer' end

  local lines = vim.api.nvim_buf_get_lines( bufnr, 0, -1, false )
  local content = table.concat( lines, '\n' )
  local found, failure = find_text( content, old_text )
  if not found then return false, failure or 'text not found' end

  local first = #vim.split( content:sub( 1, found - 1 ), '\n', { plain = true, } )
  local last = first + #vim.split( old_text, '\n', { plain = true, } ) - 1

  local region = table.concat( vim.list_slice( lines, first, last ), '\n' )
  local offset = region:find( old_text, 1, true )
  if not offset then return false, 'text not found' end

  local replaced = region:sub( 1, offset - 1 ) .. new_text .. region:sub( offset + #old_text )

  return apply( bufnr, { {
    first = first - 1,
    last = last,
    lines = vim.split( replaced, '\n', { plain = true, } ),
  }, } )
end

return {
  buffer_for = buffer_for,
  is_file_buffer = is_file_buffer,
  read = read,
  persist = persist,
  apply = apply,
  set_content = set_content,
  replace = replace,
}