Luigit
repositories / dotfiles

dotfiles

bugabingas dorkfiles

owned by admin

neovim/lua/std/dbg/viewer.lua

Raw
---Debug event viewer — special buffer UI for browsing debug events.
---
---## Public API
---
---| Function                | Description                                      |
---| ----------------------- | ------------------------------------------------ |
---| `open()`                | Open viewer (reuse existing window if open)      |
---| `close()`               | Close viewer and delete its buffer + watcher     |
---| `refresh()`             | Re-read all events from disk and re-render      |
---| `is_open()`             | Returns `true` if viewer window is valid        |
---| `jump_to_source(win_id)`| Jump to the source file:line of event at cursor |
---| `clear_filters()`       | Remove all active filters                        |
---| `filter_by_kind()`      | Prompt for kind filter (fk)                      |
---| `filter_by_message()`   | Prompt for message filter (fm)                  |
---| `filter_by_location()`  | Prompt for filename filter (fl)                 |
---| `filter_by_all()`       | Prompt for combined filter (ff)                 |
---| `get_winbar()`          | Returns winbar text (used by format string)     |
---
---## Keymaps (in viewer buffer)
---
---| Key   | Action                     |
---| ----- | -------------------------- |
---| `q`   | Close viewer               |
---| `<Esc>` | Close viewer             |
---| `r`   | Refresh events from disk   |
---| `<CR>` | Jump to source location   |
---| `ff`  | Filter combined (all)     |
---| `fc`  | Clear filters + notify    |
---| `fk`  | Filter by kind            |
---| `fm`  | Filter by message         |
---| `fl`  | Filter by location        |
---
---## Layout
---
---- Opens as a **bottom split** (`botright split`), respecting `splitbelow`
---- Height is **golden ratio** (~61.8% of screen height)
---- Each event renders as a **4-line block**: header, message, location, blank
---- Events sorted **newest-first** (by date descending)
---- Auto-refreshes via `vim.uv.fs_event` when debug file changes
---
---## Filtering
---
---- Filters use `vim.fn.matchfuzzy` for fuzzy matching
---- Multiple filters combine with **AND** logic
---- `ff` prompts for combined filter across kind + message + filename
---- `fc` clears all filters and shows `vim.notify`
---- Active filters displayed in **winbar**
---- Event count shown as `filtered/total` when filters active
---
---## Events Structure
---
---Events loaded from `std.dbg.file` (JSONL). Each event:
---```lua
---{
---  kind = "DEBUG" | "TRACE" | "ERROR",
---  date = os.time() integer,
---  message = formatted string,
---  data = { ... },  -- raw user data
---  filename = "@source.lua" or "[string ...]",
---  fn = "function_name",
---  line = 123,
---}
---```
---
---## Highlights
---
---| Highlight Group          | Links To         | Applied To          |
---| ------------------------ | ---------------- | ------------------- |
---| `DbgViewerKindIcon`      | `Title`         | Kind icon in header |
---| `DbgViewerKind`          | `Comment`       | Kind name in header |
---| `DbgViewerTime`          | `Normal`        | Timestamp in header |
---| `DbgViewerMessage`       | `Comment`       | Message lines       |
---| `DbgViewerLocation`      | `Comment`       | Location lines      |
---| (kind-specific)          | `DiagnosticInfo/Hint/Error` | Icon backgrounds |
---
---## Internal Architecture
---
---- `H.state` — module-local state:
---  ```lua
---  {
---    buf_id = number,
---    win_id = number,
---    events = event[],
---    filtered_indices = number[] | nil,
---    filters = { all = "", kind = "", message = "", filename = "" },
---    line_events = table,      -- line_idx (1-based) -> event_idx (1-based)
---    filtered_count = number, -- how many events currently displayed
---    event_count = number,    -- total events read from file
---    watcher = uv_fs_event | nil,
---    highlights = highlight[], -- all active highlights for re-apply on prepend
---  }
---  ```
---- `H.ns_id` — namespace for buffer highlights
---- `H.BUF_NAME_PREFIX` — buffer name pattern: `dbgviewer://<buf_id>/debug-events`
---- `H.FT` — filetype set on viewer buffer: `DbgViewer`
local file = require 'std.dbg.file'
local kinds = require 'std.dbg.kinds'
local timeago = require 'std.timeago'
local map = require 'std.map'

local start_watcher
local on_file_change

local H = {
  state = nil,
}

H.ns_id = vim.api.nvim_create_namespace 'std.dbg.viewer'

H.BUF_NAME_PREFIX = 'dbgviewer://'

H.FT = 'DbgViewer'

local NS_HL = {
  kind_icon = 'DbgViewerKindIcon',
  kind_name = 'DbgViewerKind',
  time = 'DbgViewerTime',
  message = 'DbgViewerMessage',
  location = 'DbgViewerLocation',
}

local KIND_TO_HL = {
  DEBUG = 'DiagnosticInfo',
  TRACE = 'DiagnosticHint',
  ERROR = 'DiagnosticError',
}

local WINBAR_FORMAT = '%%#%s#%%{v:lua.require("std.dbg.viewer").get_winbar()}%%#Normal#'

---@type { key: string, prefix: string, suffix: string }[]
local FILTER_WINBAR_SPECS = {
  { key = 'all',     prefix = ' all:"',   suffix = '"' },
  { key = 'kind',    prefix = ' kind:',  suffix = '' },
  { key = 'message', prefix = ' msg:"',   suffix = '"' },
  { key = 'filename',prefix = ' file:',  suffix = '' },
}

local function sanitize_src(src)
  if not src or src == '' then return 'unknown' end
  return (src:gsub('^@', ''):gsub('^%[string .+%]$', 'unknown'))
end

local FILTER_SPECS = {
  {
    key = 'all',
    extract = function(ev) return ev.kind .. ' ' .. ev.message .. ' ' .. sanitize_src(ev.filename) end,
  },
  {
    key = 'kind',
    extract = function(ev) return ev.kind end,
  },
  {
    key = 'message',
    extract = function(ev) return ev.message end,
  },
  {
    key = 'filename',
    extract = function(ev) return sanitize_src(ev.filename) end,
  },
}

local function get_kind_hl_group(kind_name)
  return KIND_TO_HL[kind_name] or 'DiagnosticInfo'
end

local function make_winbar()
  return string.format(WINBAR_FORMAT, NS_HL.kind_icon)
end

local function get_winbar()
  if not H.state or not H.state.win_id then return '' end
  local s = assert(H.state)
  local parts = {}
  local has_filter = false
  local filters = s.filters or {}

  for _, spec in ipairs(FILTER_WINBAR_SPECS) do
    local val = filters[spec.key]
    if val and val ~= '' then
      parts[#parts + 1] = spec.prefix .. val .. spec.suffix
      has_filter = true
    end
  end

  local filter_str = has_filter and table.concat(parts, '') or ''
  local count = s.filtered_count or 0
  local total = #(s.events or {})
  local count_str = count == total and tostring(total) or count .. '/' .. total

  return ' ' .. kinds.get('DEBUG').icon .. ' Debug [' .. count_str .. ' events]' .. filter_str
end

local function format_event_for_display(event)
  local kind_info = kinds.get(event.kind)
  local icon = kind_info.icon
  local rel_time = timeago.format(event.date)
  local src = sanitize_src(event.filename)
  local loc = src .. ':' .. (event.line or '?')
  local msg = event.message or '(no message)'

  return {
    icon = icon,
    kind_name = event.kind,
    time = rel_time,
    message = msg,
    location = loc,
  }
end

local function build_lines_and_hl(events)
  local lines = {}
  local line_events = {}
  local highlights = {}

  if #events == 0 then
    lines[1] = '  (no debug events)'
    line_events[1] = nil
    return lines, line_events, highlights
  end

  for ei, event in ipairs(events) do
    local d = format_event_for_display(event)

    local header = string.format(' %s %s · %s', d.icon, d.kind_name, d.time)
    local msg_line = '  ' .. d.message
    local loc_line = '    ' .. d.location

    local header_line_idx = #lines + 1
    lines[#lines + 1] = header
    line_events[header_line_idx] = ei
    highlights[#highlights + 1] = {
      line = header_line_idx,
      kind = event.kind,
      icon_start = 1,
      icon_end = #d.icon + 1,
      kind_name_start = #d.icon + 2,
      kind_name_end = #d.icon + 2 + #d.kind_name,
      time_start = #header - #d.time,
      time_end = #header,
    }

    local msg_line_idx = #lines + 1
    lines[#lines + 1] = msg_line
    line_events[msg_line_idx] = ei

    local loc_line_idx = #lines + 1
    lines[#lines + 1] = loc_line
    line_events[loc_line_idx] = ei
    highlights[#highlights + 1] = {
      line = loc_line_idx,
      location = true,
    }

    local blank_idx = #lines + 1
    lines[#lines + 1] = ''
    line_events[blank_idx] = nil
  end

  return lines, line_events, highlights
end

local function apply_highlight(buf_id, group, lnum, start_col, end_col)
  if end_col == -1 then
    end_col = #(vim.api.nvim_buf_get_lines(buf_id, lnum, lnum + 1, false)[1] or '')
  end
  vim.hl.range(buf_id, H.ns_id, group, { lnum, start_col }, { lnum, end_col })
end

local function apply_highlights(buf_id, highlights)
  vim.api.nvim_buf_clear_namespace(buf_id, H.ns_id, 0, -1)

  for _, hl in ipairs(highlights) do
    local lnum = hl.line - 1

    if hl.location then
      apply_highlight(buf_id, NS_HL.location, lnum, 0, -1)
    elseif hl.kind then
      apply_highlight(buf_id, get_kind_hl_group(hl.kind), lnum, hl.icon_start - 1, hl.icon_end - 1)
      apply_highlight(buf_id, NS_HL.kind_name, lnum, hl.kind_name_start - 1, hl.kind_name_end - 1)
      apply_highlight(buf_id, NS_HL.time, lnum, hl.time_start - 1, hl.time_end - 1)
    else
      apply_highlight(buf_id, NS_HL.message, lnum, 0, -1)
    end
  end
end

local function create_buf()
  local buf_id = vim.api.nvim_create_buf(false, true)
  vim.api.nvim_buf_set_name(buf_id, H.BUF_NAME_PREFIX .. buf_id .. '/debug-events')
  vim.bo[buf_id].buftype = 'nofile'
  vim.bo[buf_id].bufhidden = 'wipe'
  vim.bo[buf_id].buflisted = false
  vim.bo[buf_id].modifiable = false
  vim.bo[buf_id].swapfile = false
  vim.bo[buf_id].filetype = H.FT
  vim.bo[buf_id].modeline = false
  return buf_id
end

local function open_win(buf_id)
  local height = math.max(5, math.floor(0.618 * vim.o.lines))
  vim.cmd('botright split')
  local win_id = vim.api.nvim_get_current_win()
  vim.api.nvim_win_set_buf(win_id, buf_id)
  vim.wo[win_id].wrap = true
  vim.wo[win_id].scrolloff = 0
  vim.wo[win_id].cursorline = true
  vim.wo[win_id].winbar = make_winbar()
  vim.api.nvim_win_set_height(win_id, height)
  return win_id
end

local function event_passes_filters(event, filters)
  for _, spec in ipairs(FILTER_SPECS) do
    local fval = filters[spec.key]
    if fval and fval ~= '' then
      local ftext = spec.extract(event)
      if #vim.fn.matchfuzzy({ ftext }, fval) == 0 then
        return false
      end
    end
  end
  return true
end

local function compute_filtered_indices(events, filters)
  if not filters then return nil end

  local has_any_filter = false
  for _, spec in ipairs(FILTER_SPECS) do
    local fval = filters[spec.key]
    if fval and fval ~= '' then
      has_any_filter = true
      break
    end
  end

  if not has_any_filter then return nil end

  local result = {}
  for i, ev in ipairs(events) do
    if event_passes_filters(ev, filters) then
      result[#result + 1] = i
    end
  end
  return result
end

local function render()
  if not H.state or not H.state.buf_id then return end

  local events = H.state.events or {}
  local filtered_indices = H.state.filtered_indices
  local display_events = filtered_indices and (function()
    local r = {}
    for _, idx in ipairs(filtered_indices) do r[#r+1] = events[idx] end
    return r
  end)() or events

  local lines, line_events, highlights = build_lines_and_hl(display_events)
  H.state.line_events = line_events
  H.state.highlights = highlights
  H.state.filtered_count = #display_events

  local was_modifiable = vim.bo[H.state.buf_id].modifiable
  vim.bo[H.state.buf_id].modifiable = true
  vim.api.nvim_buf_set_lines(H.state.buf_id, 0, -1, false, lines)
  vim.bo[H.state.buf_id].modifiable = was_modifiable

  apply_highlights(H.state.buf_id, highlights)

  if H.state.win_id and vim.api.nvim_win_is_valid(H.state.win_id) then
    vim.wo[H.state.win_id].winbar = make_winbar()
  end
end

local function prepend_new_events(new_events)
  if not H.state or not H.state.buf_id then return end
  if #new_events == 0 then return end

  local new_display_events = {}
  for _, ev in ipairs(new_events) do
    if event_passes_filters(ev, H.state.filters) then
      new_display_events[#new_display_events + 1] = ev
    end
  end

  if #new_display_events == 0 then return end

  local new_lines = {}
  local new_highlights = {}
  local new_line_events = {}

  local base_idx = 1
  for ei, event in ipairs(new_display_events) do
    local d = format_event_for_display(event)

    local header = string.format(' %s %s · %s', d.icon, d.kind_name, d.time)
    local msg_line = '  ' .. d.message
    local loc_line = '    ' .. d.location

    local header_line_idx = #new_lines + 1
    new_lines[#new_lines + 1] = header
    new_line_events[header_line_idx] = base_idx + ei - 1
    new_highlights[#new_highlights + 1] = {
      line = header_line_idx,
      kind = event.kind,
      icon_start = 1,
      icon_end = #d.icon + 1,
      kind_name_start = #d.icon + 2,
      kind_name_end = #d.icon + 2 + #d.kind_name,
      time_start = #header - #d.time,
      time_end = #header,
    }

    local msg_line_idx = #new_lines + 1
    new_lines[#new_lines + 1] = msg_line
    new_line_events[msg_line_idx] = base_idx + ei - 1

    local loc_line_idx = #new_lines + 1
    new_lines[#new_lines + 1] = loc_line
    new_line_events[loc_line_idx] = base_idx + ei - 1
    new_highlights[#new_highlights + 1] = {
      line = loc_line_idx,
      location = true,
    }

    local blank_idx = #new_lines + 1
    new_lines[#new_lines + 1] = ''
    new_line_events[blank_idx] = nil
  end

  local was_modifiable = vim.bo[H.state.buf_id].modifiable
  vim.bo[H.state.buf_id].modifiable = true
  vim.api.nvim_buf_set_lines(H.state.buf_id, 0, 0, false, new_lines)
  vim.bo[H.state.buf_id].modifiable = was_modifiable

  local shift = #new_lines
  local shifted_line_events = {}
  for line_idx, event_idx in pairs(H.state.line_events) do
    shifted_line_events[line_idx + shift] = event_idx + #new_display_events
  end
  for line_idx, event_idx in pairs(new_line_events) do
    shifted_line_events[line_idx] = event_idx
  end
  H.state.line_events = shifted_line_events

  local all_highlights = {}
  for _, hl in ipairs(H.state.highlights or {}) do
    all_highlights[#all_highlights + 1] = {
      line = hl.line + shift,
      kind = hl.kind,
      location = hl.location,
      icon_start = hl.icon_start,
      icon_end = hl.icon_end,
      kind_name_start = hl.kind_name_start,
      kind_name_end = hl.kind_name_end,
      time_start = hl.time_start,
      time_end = hl.time_end,
    }
  end
  for _, hl in ipairs(new_highlights) do
    all_highlights[#all_highlights + 1] = hl
  end
  H.state.highlights = all_highlights

  apply_highlights(H.state.buf_id, all_highlights)

  H.state.filtered_count = H.state.filtered_count + #new_display_events

  if H.state.win_id and vim.api.nvim_win_is_valid(H.state.win_id) then
    vim.wo[H.state.win_id].winbar = make_winbar()
  end
end

local function on_file_change()
  if not H.state then return end

  local watcher = H.state.watcher
  if watcher then
    watcher:stop()
    H.state.watcher = nil
  end

  local new_events, new_total = file.read_new(H.state.event_count)
  if #new_events == 0 and new_total == H.state.event_count then
    H.state.watcher = start_watcher()
    return
  end

  local reversed = {}
  for i = #new_events, 1, -1 do
    reversed[#reversed + 1] = new_events[i]
  end
  new_events = reversed

  for i = #new_events, 1, -1 do
    table.insert(H.state.events, 1, new_events[i])
  end
  H.state.event_count = new_total

  prepend_new_events(new_events)

  H.state.watcher = start_watcher()
end

start_watcher = function()
  local path = file.get_path()
  local callback = function(err)
    if err then return end
    on_file_change()
  end
  local watcher = assert(vim.uv.new_fs_event())
  watcher:start(path, {}, callback)
  return watcher
end

local function setup_keymaps(buf_id, win_id)
  map.normal { keys = 'q', command = close, buffer = buf_id, desc = 'Close debug viewer' }
  map.normal { keys = '<Esc>', command = close, buffer = buf_id, desc = 'Close debug viewer' }
  map.normal { keys = 'r', command = refresh, buffer = buf_id, desc = 'Refresh events from disk' }
  map.normal { keys = '<CR>', command = function() jump_to_source(win_id) end, buffer = buf_id, desc = 'Jump to source location' }
  map.normal { keys = 'ff', command = function() filter_by_all() end, buffer = buf_id, desc = 'Filter combined (all)' }
  map.normal { keys = 'fc', command = function() clear_filters() vim.notify('Filters cleared') end, buffer = buf_id, desc = 'Clear filters + notify' }
  map.normal { keys = 'fk', command = function() filter_by_kind() end, buffer = buf_id, desc = 'Filter by kind' }
  map.normal { keys = 'fm', command = function() filter_by_message() end, buffer = buf_id, desc = 'Filter by message' }
  map.normal { keys = 'fl', command = function() filter_by_location() end, buffer = buf_id, desc = 'Filter by location' }
end

function open()
  if H.state and H.state.win_id and vim.api.nvim_win_is_valid(H.state.win_id) then
    vim.api.nvim_set_current_win(H.state.win_id)
    return
  end

  local events = file.read_all()
  for i = 1, math.floor(#events / 2) do
    events[i], events[#events - i + 1] = events[#events - i + 1], events[i]
  end
  local buf_id = create_buf()
  local win_id = open_win(buf_id)
  setup_keymaps(buf_id, win_id)

  local watcher = start_watcher()

  H.state = {
    buf_id = buf_id,
    win_id = win_id,
    events = events,
    filtered_indices = nil,
    filters = { all = '', kind = '', message = '', filename = '' },
    line_events = {},
    filtered_count = #events,
    event_count = #events,
    watcher = watcher,
  }

  render()
end

function close()
  if not H.state then return end

  if H.state.watcher then
    H.state.watcher:stop()
    H.state.watcher = nil
  end

  if H.state.win_id and vim.api.nvim_win_is_valid(H.state.win_id) then
    pcall(vim.api.nvim_win_close, H.state.win_id, true)
  end
  if H.state.buf_id and vim.api.nvim_buf_is_valid(H.state.buf_id) then
    pcall(vim.api.nvim_buf_delete, H.state.buf_id, { force = true })
  end

  H.state = nil
end

function refresh()
  if not H.state then return end

  local events = file.read_all()
  H.state.events = events
  H.state.event_count = #events
  H.state.filtered_indices = compute_filtered_indices(events, H.state.filters)
  render()
end

function is_open()
  return H.state ~= nil
    and H.state.win_id ~= nil
    and vim.api.nvim_win_is_valid(H.state.win_id)
end

function jump_to_source(win_id)
  if not H.state or not H.state.buf_id then return end

  local cursor = vim.api.nvim_win_get_cursor(win_id or H.state.win_id)
  local line_idx = cursor[1]

  local event_idx = H.state.line_events[line_idx]
  if not event_idx then
    event_idx = H.state.line_events[line_idx - 1]
  end
  if not event_idx then
    event_idx = H.state.line_events[line_idx + 1]
  end
  if not event_idx then return end

  local event = H.state.events[event_idx]
  if not event then return end

  local path = sanitize_src(event.filename)
  if path == 'unknown' or path == '' then return end

  close()
  vim.cmd.edit(path)
  if event.line and event.line > 0 then
    pcall(vim.api.nvim_win_set_cursor, 0, { event.line, 0 })
  end
end

function clear_filters()
  if not H.state then return end
  H.state.filters = { all = '', kind = '', message = '', filename = '' }
  H.state.filtered_indices = nil
  H.state.filtered_count = #H.state.events
  render()
end

function filter_by_all()
  vim.ui.input({ prompt = 'Filter (all): ' }, function(value)
    if value == nil then return end
    if not H.state then return end
    H.state.filters.all = value or ''
    H.state.filtered_indices = compute_filtered_indices(H.state.events, H.state.filters)
    render()
  end)
end

local function do_filter(field, prompt_msg)
  vim.ui.input({ prompt = prompt_msg .. ': ' }, function(value)
    if value == nil then return end
    if not H.state then return end
    H.state.filters[field] = value or ''
    H.state.filtered_indices = compute_filtered_indices(H.state.events, H.state.filters)
    render()
  end)
end

function filter_by_kind()
  do_filter('kind', 'Filter kind')
end

function filter_by_message()
  do_filter('message', 'Filter message')
end

function filter_by_location()
  do_filter('filename', 'Filter location')
end

return {
  open = open,
  close = close,
  refresh = refresh,
  is_open = is_open,
  jump_to_source = jump_to_source,
  clear_filters = clear_filters,
  filter_by_all = filter_by_all,
  filter_by_kind = filter_by_kind,
  filter_by_message = filter_by_message,
  filter_by_location = filter_by_location,
  get_winbar = get_winbar,
  _on_file_change = on_file_change,
  _get_state = function() return H.state end,
}