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