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