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 = {}
  local stored = of_buffer( bufnr )
  for _, mark in ipairs( vim.api.nvim_buf_get_extmarks( bufnr, namespace(), 0, -1, { details = true, } ) ) do
    local record = stored[mark[1]]
    if record then
      found[#found + 1] = {
        id = record.id, kind = record.kind, meta = record.meta,
        first = mark[2] + 1, last = ( mark[4].end_row or mark[2] ) + 1,
      }
    end
  end
  return found
end

--- The entry covering a 1-based line, if any.
local at = function( bufnr, line_number )
  if not vim.api.nvim_buf_is_valid( bufnr ) or line_number < 1 then return nil end
  local row = math.min( line_number, vim.api.nvim_buf_line_count( bufnr ) ) - 1
  local marks = vim.api.nvim_buf_get_extmarks( bufnr, namespace(), { row, -1, }, 0, { limit = 1, details = true, } )
  local mark = marks[1]
  if not mark or line_number > ( mark[4].end_row or mark[2] ) + 1 then return nil end
  local record = of_buffer( bufnr )[mark[1]]
  if not record then return nil end
  return {
    id = record.id, kind = record.kind, meta = record.meta,
    first = mark[2] + 1, last = ( mark[4].end_row or mark[2] ) + 1,
  }
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,
  get = function( bufnr, id )
    local record = of_buffer( bufnr )[id]
    return record and describe( bufnr, record ) or nil
  end,
  list = list,
  at = at,
  meta = meta,
  annotate = annotate,
  clear = clear,
}