Luigit
repositories / dotfiles

dotfiles

bugabingas dorkfiles

owned by admin

neovim/lua/bugabinga/pivi/activity.lua

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

local TRAIL_NAMESPACE = 'pivi-trail'
local GHOST_NAMESPACE = 'pivi-ghost'
local DIAGNOSTIC_NAMESPACE = 'pivi-diagnostic'
local SIGN_GROUP = 'pivi'

local namespaces = {}

local namespace = function( name )
  if not namespaces[name] then
    namespaces[name] = vim.api.nvim_create_namespace( name )
  end
  return namespaces[name]
end

local diagnostic_namespace = function() return namespace( DIAGNOSTIC_NAMESPACE ) end

local trail = {}
local revisions = {}
local groups = {}

local clear_trail

local unwatch = function( bufnr )
  revisions[bufnr] = nil
  if groups[bufnr] then
    pcall( vim.api.nvim_del_augroup_by_id, groups[bufnr] )
    groups[bufnr] = nil
  end
end

--- Records the current revision as agent-owned.
--- Whatever the edit triggers in the same loop iteration, such as a formatter
--- on write, still belongs to the agent's operation rather than to the user.
local touch = function( bufnr )
  if not vim.api.nvim_buf_is_valid( bufnr ) then return end
  revisions[bufnr] = vim.api.nvim_buf_get_changedtick( bufnr )
  vim.schedule( function()
    if revisions[bufnr] and vim.api.nvim_buf_is_valid( bufnr ) then
      revisions[bufnr] = vim.api.nvim_buf_get_changedtick( bufnr )
    end
  end )
end

--- Clears a buffer's trail as soon as the user changes that buffer themselves.
--- Agent edits record their own revision, so only a foreign change clears it.
local watch = function( bufnr )
  touch( bufnr )
  if groups[bufnr] then return end

  groups[bufnr] = vim.api.nvim_create_augroup( 'pivi_trail_' .. tostring( bufnr ), { clear = true, } )

  vim.api.nvim_create_autocmd( { 'TextChanged', 'TextChangedI', }, {
    group = groups[bufnr],
    buffer = bufnr,
    desc = 'Clear the pivi edit trail once the user changes the buffer.',
    callback = function()
      if not vim.api.nvim_buf_is_valid( bufnr ) then
        unwatch( bufnr )
        return true
      end
      if vim.api.nvim_buf_get_changedtick( bufnr ) == revisions[bufnr] then return end
      clear_trail( bufnr )
      return true
    end,
  } )
end

--- Marks changed lines so the agent's work stays navigable.
--- Marks live in pivi's own namespace and never touch diagnostic or
--- language server namespaces.
local mark_edit = function( bufnr, ranges )
  if not vim.api.nvim_buf_is_valid( bufnr ) then return end
  local ns = namespace( TRAIL_NAMESPACE )
  for _, range in ipairs( ranges or {} ) do
    local first = math.max( 0, range.first )
    local last = math.max( first + 1, range.last )
    local count = vim.api.nvim_buf_line_count( bufnr )
    for line = first, math.min( last, count ) - 1 do
      pcall( vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, {
        line_hl_group = 'DiffChange',
        sign_text = '│',
        sign_hl_group = 'DiffChange',
        priority = 90,
      } )
    end
    table.insert( trail, { buffer = bufnr, first = first + 1, last = math.min( last, count ), } )
  end
  watch( bufnr )
  publish.activity { kind = 'edit', buffer = bufnr, ranges = ranges, }
end

local marks = function( bufnr )
  if not vim.api.nvim_buf_is_valid( bufnr ) then return {} end
  return vim.api.nvim_buf_get_extmarks( bufnr, namespace( TRAIL_NAMESPACE ), 0, -1, {} )
end

clear_trail = function( bufnr )
  if bufnr and vim.api.nvim_buf_is_valid( bufnr ) then
    vim.api.nvim_buf_clear_namespace( bufnr, namespace( TRAIL_NAMESPACE ), 0, -1 )
  end
  if bufnr then
    unwatch( bufnr )
  else
    for buffer in pairs( groups ) do unwatch( buffer ) end
  end
  trail = {}
end

local trail_entries = function() return vim.deepcopy( trail ) end

--- Shows streaming edit intent without touching buffer contents.
local ghost = function( bufnr, line, text )
  if not vim.api.nvim_buf_is_valid( bufnr ) then return end
  if type( text ) ~= 'string' or text == '' then return end
  local ns = namespace( GHOST_NAMESPACE )
  local count = vim.api.nvim_buf_line_count( bufnr )
  local target = math.min( math.max( 0, line ), math.max( 0, count - 1 ) )
  vim.api.nvim_buf_clear_namespace( bufnr, ns, 0, -1 )
  local chunks = {}
  for _, piece in ipairs( vim.split( text, '\n', { plain = true, } ) ) do
    table.insert( chunks, { { piece, 'Comment', }, } )
  end
  pcall( vim.api.nvim_buf_set_extmark, bufnr, ns, target, 0, {
    virt_lines = chunks,
    virt_lines_above = false,
    priority = 100,
  } )
end

local ghost_marks = function( bufnr )
  if not vim.api.nvim_buf_is_valid( bufnr ) then return {} end
  return vim.api.nvim_buf_get_extmarks( bufnr, namespace( GHOST_NAMESPACE ), 0, -1, {} )
end

local ghost_clear = function( bufnr )
  if not vim.api.nvim_buf_is_valid( bufnr ) then return end
  vim.api.nvim_buf_clear_namespace( bufnr, namespace( GHOST_NAMESPACE ), 0, -1 )
end

local to_quickfix_item = function( item )
  return {
    filename = item.path,
    bufnr = item.path and nil or item.buffer,
    lnum = item.line or 1,
    col = ( item.column or 0 ) + 1,
    text = item.text or '',
    type = item.severity == 'error' and 'E' or item.severity == 'warning' and 'W' or 'I',
  }
end

--- Cross-file findings use the quickfix list.
local findings = function( items, title )
  local entries = vim.tbl_map( to_quickfix_item, items or {} )
  vim.fn.setqflist( {}, ' ', { title = title or 'pivi', items = entries, } )
  publish.activity { kind = 'findings', count = #entries, }
  return entries
end

--- Window associated findings use the location list.
local window_findings = function( win, items, title )
  local entries = vim.tbl_map( to_quickfix_item, items or {} )
  vim.fn.setloclist( win or 0, {}, ' ', { title = title or 'pivi', items = entries, } )
  return entries
end

local severity_of = function( value )
  if value == 'error' then return vim.diagnostic.severity.ERROR end
  if value == 'warning' then return vim.diagnostic.severity.WARN end
  if value == 'hint' then return vim.diagnostic.severity.HINT end
  return vim.diagnostic.severity.INFO
end

--- Agent diagnostics stay distinguishable from compiler and language server output.
local diagnose = function( bufnr, items )
  if not vim.api.nvim_buf_is_valid( bufnr ) then return end
  local diagnostics = {}
  for _, item in ipairs( items or {} ) do
    table.insert( diagnostics, {
      lnum = math.max( 0, ( item.line or 1 ) - 1 ),
      col = item.column or 0,
      message = item.text or '',
      severity = severity_of( item.severity ),
      source = 'pi',
    } )
  end
  vim.diagnostic.set( diagnostic_namespace(), bufnr, diagnostics )
end

local diagnostics = function( bufnr )
  return vim.diagnostic.get( bufnr, { namespace = diagnostic_namespace(), } )
end

local clear_diagnostics = function( bufnr )
  vim.diagnostic.reset( diagnostic_namespace(), bufnr )
end

local clear_all = function( bufnr )
  ghost_clear( bufnr )
  clear_trail( bufnr )
  clear_diagnostics( bufnr )
end

return {
  sign_group = SIGN_GROUP,
  namespace = namespace,
  touch = touch,
  trail_namespace = TRAIL_NAMESPACE,
  ghost_namespace = GHOST_NAMESPACE,
  diagnostic_namespace = diagnostic_namespace,
  mark_edit = mark_edit,
  marks = marks,
  trail = trail_entries,
  clear_trail = clear_trail,
  ghost = ghost,
  ghost_marks = ghost_marks,
  ghost_clear = ghost_clear,
  findings = findings,
  window_findings = window_findings,
  diagnose = diagnose,
  diagnostics = diagnostics,
  clear_diagnostics = clear_diagnostics,
  clear_all = clear_all,
}