Luigit
repositories / dotfiles

dotfiles

bugabingas dorkfiles

owned by admin

neovim/lua/bugabinga/pivi/entry.lua

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

local NAMESPACE = 'pivi-entry'

local KINDS = {
  prompt = true,
  answer = true,
  tool = true,
  error = true,
}

local records = {}
local versions = {}

--- Bumped whenever the entries of a buffer change shape.
--- Buffer text alone is not enough to know that: re-anchoring an entry moves
--- its bounds without touching a single character.
local version = function( bufnr )
  return versions[bufnr] or 0
end

local touch = function( bufnr )
  versions[bufnr] = version( bufnr ) + 1
end

local namespace = function()
  return vim.api.nvim_create_namespace( NAMESPACE )
end

local of_buffer = function( bufnr )
  records[bufnr] = records[bufnr] or {}
  return records[bufnr]
end

local last_row = function( bufnr )
  return vim.api.nvim_buf_line_count( bufnr ) - 1
end

local text_of = function( bufnr, row )
  return vim.api.nvim_buf_get_lines( bufnr, row, row + 1, false )[1] or ''
end

--- Anchors an entry's range to the buffer itself.
--- Fixed line numbers would drift as later entries are appended, so bounds
--- are extmark-backed and re-anchored whenever the entry grows.
local anchor = function( bufnr, id, first, last )
  touch( bufnr )
  vim.api.nvim_buf_set_extmark( bufnr, namespace(), first, 0, {
    id = id,
    end_row = last,
    end_col = #text_of( bufnr, last ),
    right_gravity = false,
    end_right_gravity = true,
  } )
end

--- Starts an entry at the end of the transcript.
--- An entry always begins on its own line, so its bounds never share a line
--- with the entry before it.
--- @param bufnr number
--- @param kind string prompt, answer, tool, or error
--- @param meta table? entry metadata such as tool, path, line, args
--- @return number? id
local open = function( bufnr, kind, meta )
  vim.validate( 'bufnr', bufnr, 'number' )
  vim.validate( 'kind', kind, 'string' )
  if not KINDS[kind] then error( 'unknown entry kind: ' .. kind ) end
  if not vim.api.nvim_buf_is_valid( bufnr ) then return nil end

  -- Virtual lines above the very first buffer line are not shown once that
  -- line is at the top of a window, so an entry never starts on line one.
  if last_row( bufnr ) == 0 or text_of( bufnr, last_row( bufnr ) ) ~= '' then
    transcript.line( bufnr, '' )
  end

  local row = last_row( bufnr )
  local id = vim.api.nvim_buf_set_extmark( bufnr, namespace(), row, 0, {
    end_row = row,
    end_col = 0,
    right_gravity = false,
    end_right_gravity = true,
  } )

  of_buffer( bufnr )[id] = { id = id, kind = kind, meta = meta or {}, }
  touch( bufnr )
  return id
end

local start_row = function( bufnr, id )
  local mark = vim.api.nvim_buf_get_extmark_by_id( bufnr, namespace(), id, {} )
  if not mark or #mark == 0 then return nil end
  return mark[1]
end

--- The last row an entry actually shows.
--- Appending a complete line leaves an empty row behind it, and that row is
--- where the next entry starts rather than part of this one.
local content_row = function( bufnr, first )
  local row = last_row( bufnr )
  while row > first and text_of( bufnr, row ) == '' do row = row - 1 end
  return row
end

--- Re-anchors an entry to everything written since it opened.
local extend = function( bufnr, id )
  if not id or not vim.api.nvim_buf_is_valid( bufnr ) then return end
  if not of_buffer( bufnr )[id] then return end
  local first = start_row( bufnr, id )
  if not first then return end
  anchor( bufnr, id, first, content_row( bufnr, first ) )
end

--- Appends text to an open entry, continuing its final line.
local write = function( bufnr, id, text )
  if not id then return end
  transcript.append( bufnr, text )
  extend( bufnr, id )
end

--- Appends one complete line to an open entry.
local line = function( bufnr, id, text )
  if not id then return end
  transcript.line( bufnr, text )
  extend( bufnr, id )
end

--- Replaces everything an entry shows below its opening line.
--- A tool that reports progress rewrites its own tail, so the entry keeps its
--- identity and its bounds while its content changes.
local rewrite = function( bufnr, id, text )
  if not id or not vim.api.nvim_buf_is_valid( bufnr ) then return end
  if not of_buffer( bufnr )[id] then return end

  local first = start_row( bufnr, id )
  if not first then return end

  transcript.replace( bufnr, first + 1, content_row( bufnr, first ) + 1, text )
  extend( bufnr, id )
end

local close = function( bufnr, id )
  extend( bufnr, id )
  return nil
end

--- @return number? first 1-based first line
--- @return number? last 1-based last line
local range = function( bufnr, id )
  if not vim.api.nvim_buf_is_valid( bufnr ) then return nil end
  local mark = vim.api.nvim_buf_get_extmark_by_id( bufnr, namespace(), id, { details = true, } )
  if not mark or #mark == 0 then return nil end
  local details = mark[3] or {}
  return mark[1] + 1, ( details.end_row or mark[1] ) + 1
end

local describe = function( bufnr, record )
  local first, last = range( bufnr, record.id )
  if not first then return nil end
  return {
    id = record.id,
    kind = record.kind,
    meta = record.meta,
    first = first,
    last = last,
  }
end

--- Every entry of a transcript, in buffer order.
local list = function( bufnr )
  if not vim.api.nvim_buf_is_valid( bufnr ) then return {} end
  local found = {}
  for _, record in pairs( of_buffer( bufnr ) ) do
    local described = describe( bufnr, record )
    if described then table.insert( found, described ) end
  end
  table.sort( found, function( left, right ) return left.first < right.first end )
  return found
end

--- The entry covering a 1-based line, if any.
local at = function( bufnr, line_number )
  local found
  for _, entry in ipairs( list( bufnr ) ) do
    if entry.first <= line_number and line_number <= entry.last then found = entry end
  end
  return found
end

local meta = function( bufnr, id )
  local record = of_buffer( bufnr )[id]
  return record and record.meta or nil
end

--- Replaces an entry's metadata fields, leaving its text untouched.
local annotate = function( bufnr, id, fields )
  local record = of_buffer( bufnr )[id]
  if not record then return end
  record.meta = vim.tbl_extend( 'force', record.meta, fields or {} )
end

local clear = function( bufnr )
  records[bufnr] = nil
  versions[bufnr] = nil
  if vim.api.nvim_buf_is_valid( bufnr ) then
    vim.api.nvim_buf_clear_namespace( bufnr, namespace(), 0, -1 )
  end
end

transcript.on_reset( clear )

return {
  namespace = namespace,
  version = version,
  kinds = KINDS,
  open = open,
  write = write,
  line = line,
  rewrite = rewrite,
  extend = extend,
  close = close,
  range = range,
  list = list,
  at = at,
  meta = meta,
  annotate = annotate,
  clear = clear,
}