feat(snacks): added Snacks.profiler

This commit is contained in:
Folke Lemaitre 2024-12-01 13:35:37 +01:00
parent 8f6719a368
commit 808879951f
No known key found for this signature in database
GPG key ID: 41F8B1FBACAE2040
7 changed files with 1503 additions and 0 deletions

View file

@ -0,0 +1,236 @@
---@class snacks.profiler.core
local M = {}
local hrtime = (vim.uv or vim.loop).hrtime
local nvim_create_autocmd = vim.api.nvim_create_autocmd
M._require = _G.require
M.attached = {} ---@type table<unknown, boolean>
M.events = {} ---@type snacks.profiler.Event[]
M.filter_fn = error ---@type fun(str:string):boolean
M.filter_mod = error ---@type fun(str:string):boolean
M.id = 0
M.me = debug.getinfo(1, "S").source:sub(2)
M.pids = {} ---@type table<string, number>
M.running = false
M.skips = { -- these modules are always be skipped
["_G"] = true,
["bit"] = true,
["coroutine"] = true,
["debug"] = true,
["ffi"] = true,
["io"] = true,
["jit"] = true,
["jit.opt"] = true,
["jit.profile"] = true,
["lpeg"] = true,
["luv"] = true,
["math"] = true,
["mpack"] = true,
["os"] = true,
["package"] = true,
["snacks.debug"] = true,
["snacks.profiler"] = true,
["snacks.profiler.core"] = true,
["snacks.profiler.loc"] = true,
["snacks.profiler.picker"] = true,
["snacks.profiler.tracer"] = true,
["snacks.profiler.ui"] = true,
["string"] = true,
["table"] = true,
}
function M.skip(it)
M.attached[it] = true
end
---@param spec table<string, boolean>
---@return fun(str:string):boolean
function M.filter(spec)
local filters = {} ---@type {pattern:string, want:boolean, exact:boolean}[]
local default = spec.default
default = default == nil and true or default
for pattern, want in pairs(spec) do
if pattern ~= "default" then
table.insert(filters, { pattern = pattern, want = want, exact = pattern:sub(1, 1) ~= "^" })
end
end
-- sort by longest pattern first
table.sort(filters, function(a, b)
return #a.pattern > #b.pattern
end)
return function(str)
for _, filter in ipairs(filters) do
if filter.exact then
if str == filter.pattern then
return filter.want
end
elseif str:find(filter.pattern) then
return filter.want
end
end
return default
end
end
---@param opts snacks.profiler.Trace.opts
---@param caller? snacks.profiler.Loc
---@return ...
function M.trace(opts, caller, ...)
local start = hrtime()
local thread = tostring(coroutine.running() or "main")
local pid = M.pids[thread] or 0
M.id = M.id + 1
M.pids[thread] = M.id
---@type snacks.profiler.Event
local entry = { id = M.id, start = start, pid = pid, ref = caller, opts = opts }
M.events[#M.events + 1] = entry
local ret = { pcall(opts.fn, ...) }
M.pids[thread] = pid
entry.stop = hrtime()
if not ret[1] then
error(ret[2])
end
return select(2, unpack(ret))
end
---@param depth? number
---@param max_depth? number
---@return snacks.profiler.Loc?
function M.caller(depth, max_depth)
for i = depth or 3, max_depth or 10 do
local info = debug.getinfo(i, "Sl")
if not info then
return
end
local source = info.source:sub(2)
if info.what ~= "C" and source ~= M.me then
return { file = source, line = info.currentline }
end
end
end
---@param opts snacks.profiler.Trace.opts
function M.attach_fn(opts)
if M.attached[opts.fn] then
return opts.fn
end
M.attached[opts.fn] = true
local ret = function(...)
if not M.running then
return opts.fn(...)
end
return M.trace(opts, M.caller() or nil, ...)
end
M.attached[ret] = true
return ret
end
---@param modname string
---@param mod table<string, function>
---@param opts? {force?:boolean}
function M.attach_mod(modname, mod, opts)
if type(mod) ~= "table" or M.attached[mod] then
return
end
opts = opts or {}
if (M.skips[modname] or not M.filter_mod(modname)) and opts.force ~= true then
return
end
M.attached[mod] = true
for k, v in pairs(mod) do
if type(k) == "string" and type(v) == "function" and not M.attached[v] then
local name = modname .. "." .. k
if M.filter_fn(name) then
mod[k] = M.attach_fn({ modname = modname, fname = k, name = name, fn = v })
end
end
end
end
function M.require(modname)
if not M.running or package.loaded[modname] or M.skips[modname] then
return M._require(modname)
end
local ret = {
M.trace({
fname = "require",
name = "require:" .. modname,
require = modname,
fn = M._require,
}, M.caller(), modname),
}
if type(ret[1]) == "table" then
M.attach_mod(modname, ret[1])
end
return unpack(ret)
end
---@param event any (string|array) Event(s) that will trigger the handler (`callback` or `command`).
---@param opts vim.api.keyset.create_autocmd Options dict:
function M.autocmd(event, opts)
if opts and type(opts.callback) == "function" then
local name = { type(event) == "string" and event or table.concat(event, "|") }
if opts.pattern then
name[#name + 1] = type(opts.pattern) == "string" and opts.pattern or table.concat(opts.pattern, "|")
end
local autocmd = table.concat(name, ":")
local trace = { name = "autocmd:" .. autocmd, fn = opts.callback, autocmd = autocmd }
opts.callback = function(...)
if not M.running then
return trace.fn(...)
end
return M.trace(trace, M.caller(), ...)
end
end
return nvim_create_autocmd(event, opts)
end
---@param opts snacks.profiler.Config
function M.start(opts)
assert(not M.running, "Profiler is already enabled")
-- Clear events
M.events = {}
-- Setup filters and include globals
local filter_mod = vim.deepcopy(opts.filter_mod)
for _, global in ipairs(opts.globals) do
filter_mod[global] = true
end
M.filter_mod = M.filter(filter_mod)
M.filter_fn = M.filter(opts.filter_fn)
-- Attach to require
_G.require = M.require
-- Attach to autocmds
if opts.autocmds then
vim.api.nvim_create_autocmd = M.autocmd
end
-- Attach to globals
for _, name in ipairs(opts.globals) do
M.attach_mod(name, vim.tbl_get(_G, unpack(vim.split(name, ".", { plain = true }))))
end
-- Attach to loaded modules
---@diagnostic disable-next-line: no-unknown
for modname, mod in pairs(package.loaded) do
M.attach_mod(modname, mod)
end
-- Enable the profiler
M.running = true
vim.api.nvim_exec_autocmds("User", { pattern = "SnacksProfilerStarted", modeline = false })
end
function M.stop()
assert(M.running, "Profiler is not enabled")
_G.require = M._require
vim.api.nvim_create_autocmd = nvim_create_autocmd
M.running = false
vim.api.nvim_exec_autocmds("User", { pattern = "SnacksProfilerStopped", modeline = false })
end
return M

View file

@ -0,0 +1,382 @@
require("snacks")
-- ### Traces
--
---@class snacks.profiler.Trace
---@field name string fully qualified name of the function
---@field time number time in nanoseconds
---@field depth number stack depth
---@field [number] snacks.profiler.Trace child traces
---@field fname string function name
---@field fn function function reference
---@field modname? string module name
---@field require? string special case for require
---@field autocmd? string special case for autocmd
---@field count? number number of calls
---@field def? snacks.profiler.Loc location of the definition
---@field ref? snacks.profiler.Loc location of the reference (caller)
---@field loc? snacks.profiler.Loc normalized location
---@class snacks.profiler.Loc
---@field file string path to the file
---@field line number line number
---@field loc? string normalized location
---@field modname? string module name
---@field plugin? string plugin name
-- ### Pick: grouping, filtering and sorting
--
---@class snacks.profiler.Find
---@field structure? boolean show traces as a tree or flat list
---@field sort? "time"|"count"|false sort by time or count, or keep original order
---@field loc? "def"|"ref" what location to show in the preview
---@field group? boolean|snacks.profiler.Field group traces by field
---@field filter? snacks.profiler.Filter filter traces by field(s)
---@field min_time? number only show grouped traces with `time >= min_time`
---@class snacks.profiler.Pick: snacks.profiler.Find
---@field picker? snacks.profiler.Picker
---@alias snacks.profiler.Picker "auto"|"fzf-lua"|"telescope"|"trouble"
---@alias snacks.profiler.Pick.spec snacks.profiler.Pick|{preset?:string}|fun():snacks.profiler.Pick
---@alias snacks.profiler.Field
---| "name" fully qualified name of the function
---| "def" definition
---| "ref" reference (caller)
---| "require" require
---| "autocmd" autocmd
---| "modname" module name of the called function
---| "def_file" file of the definition
---| "def_modname" module name of the definition
---| "def_plugin" plugin that defines the function
---| "ref_file" file of the reference
---| "ref_modname" module name of the reference
---| "ref_plugin" plugin that references the function
---@class snacks.profiler.Filter
---@field name? string|boolean fully qualified name of the function
---@field def? string|boolean location of the definition
---@field ref? string|boolean location of the reference (caller)
---@field require? string|boolean special case for require
---@field autocmd? string|boolean special case for autocmd
---@field modname? string|boolean module name
---@field def_file? string|boolean file of the definition
---@field def_modname? string|boolean module name of the definition
---@field def_plugin? string|boolean plugin that defines the function
---@field ref_file? string|boolean file of the reference
---@field ref_modname? string|boolean module name of the reference
---@field ref_plugin? string|boolean plugin that references the function
-- ### UI
--
---@alias snacks.profiler.Badge {icon:string, text:string, padding?:boolean, level?:string}
---@alias snacks.profiler.Badge.type "time"|"pct"|"count"|"name"|"trace"
---@class snacks.profiler.Highlights
---@field min_time? number only highlight entries with time >= min_time
---@field max_shade? number -- time in ms for the darkest shade
---@field badges? snacks.profiler.Badge.type[] badges to show
---@field align? "right"|"left"|number align the badges right, left or at a specific column
-- ### Other
--
---@class snacks.profiler.Startup
---@field event? string
---@field pattern? string|string[] pattern to match for the autocmd
---@alias snacks.profiler.GroupFn fun(entry:snacks.profiler.Trace):{key:string, name?:string}?
---@class snacks.profiler
---@field core snacks.profiler.core
---@field loc snacks.profiler.loc
---@field tracer snacks.profiler.tracer
---@field ui snacks.profiler.ui
---@field picker snacks.profiler.picker
local M = {}
local mods = { core = true, loc = true, tracer = true, ui = true, picker = true }
setmetatable(M, {
__index = function(t, k)
if mods[k] then
---@diagnostic disable-next-line: no-unknown
t[k] = require("snacks.profiler." .. k)
end
return rawget(t, k)
end,
})
---@class snacks.profiler.Config
local defaults = {
autocmds = true,
runtime = vim.env.VIMRUNTIME, ---@type string
-- thresholds for buttons to be shown as info, warn or error
-- value is a tuple of [warn, error]
thresholds = {
time = { 2, 10 },
pct = { 10, 20 },
count = { 10, 100 },
},
on_stop = {
highlights = true, -- highlight entries after stopping the profiler
pick = true, -- show a picker after stopping the profiler (uses the `on_stop` preset)
},
---@type snacks.profiler.Highlights
highlights = {
min_time = 0, -- only highlight entries with time > min_time (in ms)
max_shade = 20, -- time in ms for the darkest shade
badges = { "time", "pct", "count", "trace" },
align = 80,
},
pick = {
picker = "auto", ---@type snacks.profiler.Picker
---@type snacks.profiler.Badge.type[]
badges = { "time", "count", "name" },
---@type snacks.profiler.Highlights
preview = {
badges = { "time", "pct", "count" },
align = "right",
},
},
startup = {
event = "VimEnter", -- stop profiler on this event. Defaults to `VimEnter`
after = true, -- stop the profiler **after** the event. When false it stops **at** the event
pattern = nil, -- pattern to match for the autocmd
pick = true, -- show a picker after starting the profiler (uses the `startup` preset)
},
---@type table<string, snacks.profiler.Pick|fun():snacks.profiler.Pick>
presets = {
startup = { min_time = 1, sort = false },
on_stop = {},
filter_by_plugin = function()
return { filter = { def_plugin = vim.fn.input("Filter by plugin: ") } }
end,
},
---@type string[]
globals = {
-- "vim",
-- "vim.api",
-- "vim.keymap",
-- "Snacks.dashboard.Dashboard",
},
-- filter modules by pattern.
-- longest patterns are matched first
filter_mod = {
default = true, -- default value for unmatched patterns
["^vim%."] = false,
["mason-core.functional"] = false,
["mason-core.functional.data"] = false,
["mason-core.optional"] = false,
["which-key.state"] = false,
},
filter_fn = {
default = true,
["^.*%._[^%.]*$"] = false,
["trouble.filter.is"] = false,
["trouble.item.__index"] = false,
["which-key.node.__index"] = false,
["smear_cursor.draw.wo"] = false,
["^ibl%.utils%."] = false,
},
-- stylua: ignore
icons = {
time = "",
pct = "",
count = "",
require = "󰋺 ",
modname = "󰆼 ",
plugin = "",
autocmd = "",
file = "",
fn = "󰊕 ",
status = "󰈸 ",
},
debug = false,
}
M.config = Snacks.config.get("profiler", defaults)
local attached_debug = false
local loaded = false
-- Toggle the profiler
function M.toggle()
if M.core.running then
M.stop()
else
M.start()
end
return M.core.running
end
-- Statusline component
function M.status()
return {
function()
return ("%s %d events"):format(M.config.icons.status, #M.core.events)
end,
color = "DiagnosticError",
cond = function()
return M.core.running
end,
}
end
-- Start the profiler
---@param opts? snacks.profiler.Config
function M.start(opts)
if M.core.running then
return Snacks.notify.warn("Profiler is already enabled")
end
M.config = Snacks.config.get("profiler", defaults, opts)
M.highlight(false)
M.core.start(M.config)
end
local function load()
if loaded then
return
end
loaded = true
M.tracer.load() -- load traces
M.loc.load() -- add and normalize locations
M.ui.load() -- load highlights
vim.api.nvim_exec_autocmds("User", { pattern = "SnacksProfilerLoaded", modeline = false })
end
-- Stop the profiler
---@param opts? {highlights?:boolean, pick?:snacks.profiler.Pick.spec}
function M.stop(opts)
if not M.core.running then
return Snacks.notify.warn("Profiler is not enabled")
end
M.core.stop()
opts = vim.tbl_extend("force", {}, M.config.on_stop, opts or {})
if opts.pick == true then
opts.pick = M.config.presets.on_stop or {}
elseif opts.pick == false then
opts.pick = nil
end
loaded = false
vim.schedule(function()
load()
if opts.highlights then
M.highlight(true)
end
if opts.pick then
M.pick(opts.pick)
end
end)
end
-- Check if the profiler is running
function M.running()
return M.core.running
end
-- Profile the profiler
---@private
function M.debug()
if not M.core.running then
return Snacks.notify.warn("Profiler is not enabled")
end
if loaded then
return Snacks.notify.warn("Profiler is already loaded")
end
if not attached_debug then
attached_debug = true
M.core.skip(M.core.caller)
M.core.skip(M.core.trace)
M.core.skip(M.loc.loc)
M.core.skip(M.loc.norm)
M.core.skip(M.loc.realpath)
M.core.attach_mod("vim.fs", vim.fs, { force = true })
M.core.attach_mod("snacks.profiler", M, { force = true })
for mod in pairs(mods) do
M.core.attach_mod("snacks.profiler." .. mod, M[mod], { force = true })
end
end
local event_count = #M.core.events
local me = M.core.me
M.core.me = "__ignore__"
load()
M.pick({ picker = "foo", group = "name", structure = true })
M.core.events = vim.list_slice(M.core.events, event_count)
loaded = false
M.stop()
M.core.me = me
end
-- Group and filter traces
---@param opts snacks.profiler.Find
function M.find(opts)
load()
return M.tracer.find(opts)
end
-- Group and filter traces and open a picker
---@param opts? snacks.profiler.Pick.spec
function M.pick(opts)
load()
opts = type(opts) == "function" and opts() or opts or {}
if opts.preset then
local preset = M.config.presets[opts.preset]
preset = type(preset) == "function" and preset() or preset
opts = vim.tbl_deep_extend("force", {}, preset, opts)
end
---@cast opts snacks.profiler.Pick
return M.picker.open(opts)
end
--- Open a scratch buffer with the profiler picker options
function M.scratch()
return Snacks.scratch({
ft = "lua",
icon = "",
name = "Profiler Picker Options",
template = ("---@module 'snacks'\n\nSnacks.profiler.pick(%s)"):format(vim.inspect({
structure = true,
group = "name",
sort = "time",
min_time = 1,
})),
})
end
-- Start the profiler on startup, and stop it after the event has been triggered.
---@param opts snacks.profiler.Config
function M.startup(opts)
local event, pattern = M.config.startup.event or "VimEnter", M.config.startup.pattern
if event == "VeryLazy" then
event, pattern = "User", event
end
local cb = function()
local pick = M.config.startup.pick and M.config.presets.startup
Snacks.profiler.stop({ pick = pick })
end
if M.config.startup.after then
cb = vim.schedule_wrap(cb)
end
vim.api.nvim_create_autocmd(event, { pattern = pattern, once = true, callback = cb })
M.start(opts)
end
-- Toggle the profiler highlights
---@param enable? boolean
function M.highlight(enable)
if enable == nil then
enable = not M.ui.enabled
end
if enable == M.ui.enabled then
return
end
if enable then
load()
M.ui.show()
else
M.ui.hide()
end
end
return M

139
lua/snacks/profiler/loc.lua Normal file
View file

@ -0,0 +1,139 @@
---@class snacks.profiler.loc
---@field vim_runtime string
---@field user_runtime string
---@field user_config string
local M = {}
local fun_cache = {} ---@type table<function, snacks.profiler.Loc|false>
local norm_cache = {} ---@type table<string, table<number,snacks.profiler.Loc>>
local path_cache = {} ---@type table<string, string>
local ts_cache = {} ---@type table<string, table<string, snacks.profiler.Loc>>
local ts_query ---@type vim.treesitter.Query?
-- add and normalize locations
function M.load()
local opts = Snacks.profiler.config
M.vim_runtime = M.realpath(vim.env.VIMRUNTIME)
M.user_runtime = M.realpath(opts.runtime or M.vim_runtime)
M.user_config = M.realpath(vim.fn.stdpath("config") .. "")
Snacks.profiler.tracer.walk(function(entry)
entry.def = M.loc(entry)
entry.ref = entry.ref and M.norm(entry.ref) or nil
end)
end
-- Get the location at the cursor
function M.current()
local cursor = vim.api.nvim_win_get_cursor(0)
return M.norm({ file = vim.api.nvim_buf_get_name(0), line = cursor[1] })
end
--- Get the real path of a file
---@param path string
function M.realpath(path)
if path_cache[path] then
return path_cache[path]
end
path = vim.fs.normalize(path, { expand_env = false })
path_cache[path] = vim.fs.normalize(vim.uv.fs_realpath(path) or path, { expand_env = false, _fast = true })
return path_cache[path]
end
---@param loc snacks.profiler.Loc
function M.norm(loc)
local file, line = loc.file, loc.line
local ret = norm_cache[file] and norm_cache[file][line]
if not ret then
ret = M._norm(loc)
norm_cache[file] = norm_cache[file] or {}
norm_cache[file][line] = ret
end
return ret
end
---@param loc snacks.profiler.Loc
function M._norm(loc)
if loc.file:sub(1, 4) == "vim/" then
loc.file = M.user_runtime .. "/lua/" .. loc.file
elseif loc.file:find("runtime", 1, true) then
if loc.file:sub(1, #M.vim_runtime) == M.vim_runtime then
loc.file = M.user_runtime .. "/" .. loc.file:sub(#M.vim_runtime + 2)
end
end
loc.file = M.realpath(loc.file)
loc.line = loc.line == 0 and 1 or loc.line
loc.loc = ("%s:%d"):format(loc.file, loc.line)
if loc.file:find(M.user_config, 1, true) == 1 then
local relpath = loc.file:sub(#M.user_config + 2)
local modpath = relpath:match("^lua/(.*)%.lua$")
loc.modname = modpath and modpath:gsub("/", "."):gsub("%.init$", "") or "vimrc"
loc.plugin = "user"
else
local plugin, modpath = loc.file:match("/([^/]+)/lua/(.*)%.lua$")
if plugin and modpath then
plugin = plugin == "runtime" and "nvim" or plugin
loc.plugin = plugin
loc.modname = modpath:gsub("/", "."):gsub("%.init$", "")
end
end
return loc
end
---@param entry snacks.profiler.Trace
---@return snacks.profiler.Loc?
function M.loc(entry)
local ret = fun_cache[entry.fn]
if ret == nil then
local info = debug.getinfo(entry.fn, "S")
if info and info.what ~= "C" then
ret = { file = info.source:sub(2), line = info.linedefined }
if entry.fname and ret.file:sub(1, 4) == "vim/" then
ret.file = M.user_runtime .. "/lua/" .. ret.file
local ts_loc = M.ts_locs(ret.file)[entry.fname]
if ts_loc then
ret.file, ret.line = ts_loc.file, ts_loc.line
end
end
ret = M.norm(ret)
end
fun_cache[entry.fn] = ret or false
end
return ret or nil
end
---@param file string
function M.ts_locs(file)
if ts_cache[file] then
return ts_cache[file]
end
ts_query = ts_query
or vim.treesitter.query.parse(
"lua",
[[((function_declaration name: (_) @fun_name) @fun
(#has-parent? @fun chunk))
((return_statement (expression_list (identifier) @ret_name)) @ret
(#has-parent? @ret chunk))]]
)
local source = table.concat(vim.fn.readfile(file), "\n")
local parser = vim.treesitter.get_string_parser(source, "lua")
parser:parse()
local ret, ret_name = {}, nil ---@type table<string, snacks.profiler.Loc>, string?
local funs = {} ---@type table<string, number>
for id, node in ts_query:iter_captures(parser:trees()[1]:root(), source) do
local name = ts_query.captures[id]
if name == "fun_name" then
funs[vim.treesitter.get_node_text(node, source)] = node:start() + 1
elseif name == "ret_name" then
ret_name = vim.treesitter.get_node_text(node, source)
end
end
for fname, line in pairs(funs) do
fname = ret_name and fname:gsub("^" .. ret_name .. "%.", "") or fname
ret[fname] = { file = file, line = line }
end
ts_cache[file] = ret
return ret
end
return M

View file

@ -0,0 +1,178 @@
---@class snacks.profiler.picker
local M = {}
---@param opts? snacks.profiler.Pick
function M.open(opts)
opts = opts or {}
local picker = opts and opts.picker or Snacks.profiler.config.pick.picker
if picker == "auto" then
if pcall(require, "fzf-lua") then
picker = "fzf-lua"
elseif pcall(require, "telescope") then
picker = "telescope"
elseif pcall(require, "trouble") then
picker = "trouble"
else
return Snacks.notify.error("No picker found")
end
end
-- special case for trouble, since it does its own thing
if picker == "trouble" then
return require("trouble").open({ mode = "profiler", params = opts })
end
local traces, _, fopts = Snacks.profiler.tracer.find(opts)
---@alias snacks.profiler.Pick.entry {badges:snacks.profiler.Badge[], path:string, line:number, col:number, text:string[][]}|snacks.profiler.Trace
---@type snacks.profiler.Pick.entry[]
local entries = {}
local widths = {} ---@type number[]
for _, trace in ipairs(traces) do
local badges = Snacks.profiler.ui.badges(trace, {
badges = Snacks.profiler.config.pick.badges,
indent = fopts.group == false or fopts.structure,
})
for b, badge in ipairs(badges) do
widths[b] = math.max(widths[b] or 0, vim.api.nvim_strwidth(badge.text))
end
local loc = trace.loc
table.insert(
entries,
setmetatable({ badges = badges, path = loc and loc.file, line = loc and loc.line, col = 1 }, { __index = trace })
)
end
for _, entry in ipairs(entries) do
entry.text = Snacks.profiler.ui.format(entry.badges, { widths = widths })
for _, text in ipairs(entry.text) do
if text[2] == "Normal" or text[2] == "SnacksProfilerBadgeTrace" then
text[2] = nil
end
end
end
if #entries == 0 then
return Snacks.notify.warn("No traces found")
end
if picker == "telescope" then
M.telescope(entries)
elseif picker == "fzf-lua" then
M.fzf_lua(entries)
else
return Snacks.notify.error("Not a valid picker `" .. picker .. "`")
end
end
---@param entries snacks.profiler.Pick.entry[]
function M.telescope(entries)
local finder = require("telescope.finders").new_table({
results = entries,
---@param entry snacks.profiler.Pick.entry
entry_maker = function(entry)
local text, hl = {}, {} ---@type string[], string[][]
local col = 0
for _, t in ipairs(entry.text) do
text[#text + 1] = t[1]
if t[2] then
table.insert(hl, { { col, col + #t[1] }, t[2] })
end
col = col + #t[1]
end
return vim.tbl_extend("force", entry, {
lnum = entry.line,
ordinal = entry.name,
display = function()
return table.concat(text), hl
end,
})
end,
})
local conf = require("telescope.config").values
local topts = {}
local previewer = require("telescope.previewers").new_buffer_previewer({
title = "File Preview",
define_preview = function(self, entry, _status)
conf.buffer_previewer_maker(entry.path, self.state.bufnr, {
bufname = self.state.bufname,
winid = self.state.winid,
callback = function(bufnr)
Snacks.util.wo(self.state.winid, { cursorline = true })
Snacks.profiler.ui.highlight(
self.state.bufnr,
vim.tbl_extend("force", {}, Snacks.profiler.config.pick.preview, { file = entry.path })
)
pcall(vim.api.nvim_win_set_cursor, self.state.winid, { entry.lnum, 0 })
vim.api.nvim_buf_call(bufnr, function()
vim.cmd("norm! zz")
end)
end,
})
end,
})
require("telescope.pickers")
.new(topts, {
results_title = "Snacks Profiler",
prompt_title = "Filter",
finder = finder,
previewer = previewer,
sorter = conf.generic_sorter(topts),
})
:find()
end
---@param entries snacks.profiler.Pick.entry[]
function M.fzf_lua(entries)
local fzf = require("fzf-lua")
local builtin = require("fzf-lua.previewer.builtin")
local previewer = builtin.buffer_or_file:extend()
function previewer:new(o, fzf_opts, fzf_win)
previewer.super.new(self, o, fzf_opts, fzf_win)
setmetatable(self, previewer)
return self
end
function previewer:parse_entry(entry_str)
local id = tonumber(entry_str:match("^(%d+)") or "0")
return entries[id] or {}
end
function previewer:preview_buf_post(entry, min_winopts)
builtin.buffer_or_file.preview_buf_post(self, entry, min_winopts)
Snacks.profiler.ui.highlight(
self.preview_bufnr,
vim.tbl_extend("force", {}, Snacks.profiler.config.pick.preview, { file = entry.path })
)
end
local contents = {} ---@type string[]
for e, entry in ipairs(entries) do
local display = { e .. " " } ---@type string[]
for _, text in ipairs(entry.text) do
display[#display + 1] = text[2] and fzf.utils.ansi_from_hl(text[2], text[1]) or text[1]
end
contents[#contents + 1] = table.concat(display)
end
require("fzf-lua").fzf_exec(contents, {
previewer = previewer,
-- multiline = true,
actions = {
-- Use fzf-lua builtin actions or your own handler
["default"] = function(selection, fzf_opts)
fzf.actions.file_edit(
vim.tbl_map(function(sel)
local id = tonumber(sel:match("^(%d+)") or "0")
return entries[id].path .. ":" .. entries[id].line
end, selection),
fzf_opts
)
end,
},
fzf_opts = {
["--no-multi"] = "",
["--with-nth"] = "2..",
["--no-sort"] = true,
},
})
end
return M

View file

@ -0,0 +1,228 @@
---@alias snacks.profiler.Trace.opts snacks.profiler.Trace|{id?:number, pid?:number, time?:number, depth?:number}
---@alias snacks.profiler.Event {id:number, pid:number, start:number, stop:number, measurements:number, ref?:snacks.profiler.Loc, idx:number, opts:snacks.profiler.Trace.opts}
---@alias snacks.profiler.Node {group:string, trace:snacks.profiler.Trace, children:table<string|number,snacks.profiler.Node>, order:(string|number)[]}
---@class snacks.profiler.tracer
local M = {}
M.root = {} ---@type snacks.profiler.Trace[]
function M.load()
M.root = {}
local traces = {} ---@type snacks.profiler.Trace[]
for _, event in ipairs(Snacks.profiler.core.events) do
local trace = setmetatable({}, { __index = event.opts })
trace.id = event.id
trace.pid = event.pid
trace.ref = event.ref
trace.depth = 0
if event.stop then
trace.time = event.stop - event.start
traces[event.id] = trace
if traces[event.pid] then
trace.depth = traces[event.pid].depth + 1
table.insert(traces[event.pid], trace)
elseif trace.time then
table.insert(M.root, trace)
end
end
end
end
---@param on_start? fun(entry:snacks.profiler.Trace):any?
---@param on_end? fun(entry:snacks.profiler.Trace, start?:any)
function M.walk(on_start, on_end)
---@param entry snacks.profiler.Trace
local function walk(entry)
local start = on_start and on_start(entry)
for _, child in ipairs(entry) do
walk(child)
end
if on_end then
on_end(entry, start)
end
end
for _, child in ipairs(M.root) do
walk(child)
end
end
---@param fn snacks.profiler.GroupFn
---@param opts? {structure?:boolean, sort?:"time"|"count"}
function M.group(fn, opts)
opts = opts or {}
---@type snacks.profiler.Node[]
local nodes = { { children = {}, order = {} } } -- root node
---@param entry snacks.profiler.Trace
M.walk(function(entry)
local group = fn(entry)
if group then
local key, parent, recursive = group.key, nodes[1], false
for n = 2, #nodes do
local node = nodes[n]
if node.group == key then
recursive = true
break
end
parent = opts.structure and node or parent
end
local node = parent.children[key]
if not node then
local trace = vim.tbl_extend("force", { time = 0, count = 0, name = key, depth = #nodes - 1 }, group)
node = { group = key, trace = trace, children = {}, order = {} } ---@type snacks.profiler.Node
---@diagnostic disable-next-line: no-unknown
parent.children[key] = node
table.insert(parent.order, key)
end
if not recursive then
table.insert(nodes, node)
node.trace.time = node.trace.time + entry.time
end
node.trace.count = node.trace.count + 1
table.insert(node.trace, entry)
return not recursive
end
end, function(_, start)
if start then
table.remove(nodes)
end
end)
assert(#nodes == 1, "node stack not empty")
return nodes[1]
end
---@param node snacks.profiler.Node
---@param opts? snacks.profiler.Find
function M.flatten(node, opts)
opts = opts or {}
local ret = {} ---@type snacks.profiler.Trace[]
---@param n snacks.profiler.Node
local function walk(n)
if n.trace and (n.trace.time / 1e6 >= (opts.min_time or 0)) then
table.insert(ret, n.trace)
end
if opts.sort then
local children = vim.tbl_values(n.children) ---@type snacks.profiler.Node[]
if opts.sort == "time" then
table.sort(children, function(a, b)
return a.trace.time > b.trace.time
end)
elseif opts.sort == "count" then
table.sort(children, function(a, b)
return a.trace.count > b.trace.count
end)
end
for _, child in ipairs(children) do
walk(child)
end
else
for _, key in ipairs(n.order) do
walk(n.children[key])
end
end
end
walk(node)
return ret
end
---@param opts snacks.profiler.Find
function M.find(opts)
opts = opts or {}
opts = vim.tbl_extend("force", {
group = "name",
structure = opts.group ~= false,
sort = (opts.group ~= false) and "time",
}, opts or {})
opts.group = opts.group == true and "name" or opts.group
opts.sort = opts.sort == true and "time" or opts.sort
---@cast opts snacks.profiler.Find
local key_parts = {} ---@type table<string, string[]>
local id = 0
---@param entry snacks.profiler.Trace
---@param key string|false
local function get(entry, key)
if key == false then
id = id + 1
return tostring(id), entry.name
end
local parts = key_parts[key]
if not parts then
parts = vim.split(key, "[_%.]")
if #parts == 1 and (parts[1] == "ref" or parts[1] == "def") then
parts[2] = "loc"
end
key_parts[key] = parts
end
local value = vim.tbl_get(entry, unpack(parts)) ---@type string?
if not value then
return
end
local name, loc = value, entry.def
if parts[1] == "ref" or parts[1] == "require" then
loc = entry.ref
elseif parts[1] == "name" and entry.require then
loc = entry.ref
end
if parts[2] == "def" or parts[1] == "name" then
name = entry.name
else
name = parts[#parts] .. ":" .. value
end
return value, name, loc
end
-- Build the filter
local filter = {} ---@type table<string, string|boolean>
local current ---@type snacks.profiler.Trace?
for k, v in pairs(opts.filter or {}) do
if v == true then
-- If the value is true, then we want the current location
if k:find("[rd]ef") then
if not current then
local loc = Snacks.profiler.loc.current()
---@diagnostic disable-next-line: missing-fields
current = { def = loc, ref = loc }
end
v = get(current, k) or false
else -- match all
v = "^.*$"
end
end
filter[k] = v
end
---@param entry snacks.profiler.Trace
local function match(entry)
for key, m in pairs(filter) do
local value = get(entry, key) or false
if type(m) == "string" and m:sub(1, 1) == "^" then
if not (value and value:find(m)) then
return false
end
elseif value ~= m then
return false
end
end
return true
end
---@type snacks.profiler.GroupFn
local group_fn = function(entry)
if opts.filter and not match(entry) then
return
end
local key, name, loc = get(entry, opts.group --[[@as string|false]])
if key then
loc = opts.loc and entry[opts.loc] or loc or entry.def or entry.ref
return { key = key, name = name, loc = loc, ref = entry.ref, def = entry.def }
end
end
local node = M.group(group_fn, opts)
return M.flatten(node, opts), node, opts
end
return M

262
lua/snacks/profiler/ui.lua Normal file
View file

@ -0,0 +1,262 @@
---@class snacks.profiler.ui
local M = {}
M.highlights = {} ---@type table<string, table<number, snacks.profiler.Trace>>
M.max_time = 0
M.ns = vim.api.nvim_create_namespace("snacks_profiler")
M.shades = 20
M.enabled = true
M.max_time = 0
---@type table<string, fun(entry:snacks.profiler.Trace):snacks.profiler.Badge>
M.badge_formats = {
time = function(entry)
local ms = entry.time / 1e6
return { icon = Snacks.profiler.config.icons.time, text = ("%.2f ms"):format(ms), level = M.get_level(ms, "time") }
end,
pct = function(entry)
local pct = entry.time / M.max_time * 100
return { icon = Snacks.profiler.config.icons.pct, text = ("%d%%"):format(pct), level = M.get_level(pct, "pct") }
end,
count = function(entry)
local count = entry.count or 1
return { icon = "", text = ("%d"):format(count), level = M.get_level(count, "count") }
end,
trace = function(entry)
local field, value = entry.name:match("^(%w+):(.*)$") ---@type string?, string?
value = field == "file" and vim.fn.fnamemodify(value, ":~:.") or value
value = field == "require" and ("require(%q)"):format(value) or value
value = field == "autocmd" and ("autocmd %s"):format(value) or value
value = Snacks.profiler.config.icons[field] and value or entry.name
return {
icon = Snacks.profiler.config.icons[field] or Snacks.profiler.config.icons.fn,
text = value,
padding = false,
level = "Trace",
}
end,
}
function M.toggle()
if M.enabled then
M.hide()
else
M.show()
end
end
function M.hide()
assert(M.enabled, "Highlights are not enabled")
M.enabled = false
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_is_loaded(buf) and vim.bo[buf].buftype == "" then
vim.api.nvim_buf_clear_namespace(buf, M.ns, 0, -1)
end
end
end
function M.show()
assert(not M.enabled, "Highlights are already enabled")
M.enabled = true
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_is_loaded(buf) and vim.bo[buf].buftype == "" then
M.highlight(buf, Snacks.profiler.config.highlights)
end
end
vim.api.nvim_create_autocmd("BufReadPost", {
group = vim.api.nvim_create_augroup("snacks_profiler_highlights", { clear = true }),
callback = function(ev)
if M.enabled then
M.highlight(ev.buf, Snacks.profiler.config.highlights)
end
end,
})
end
---@param trace snacks.profiler.Trace
function M.dump(trace)
local ret = {}
---@diagnostic disable-next-line: no-unknown
for k, v in pairs(trace) do
if type(k) == "string" then
---@diagnostic disable-next-line: no-unknown
ret[k] = v
end
end
return ret
end
function M.load()
M.highlights = {}
M.max_time = 10 * 1e6
M.colors()
local groups = {
defs = Snacks.profiler.tracer.find({ group = "def", structure = false, sort = false }),
refs = Snacks.profiler.tracer.find({ group = "ref", structure = false, sort = false }),
}
for group, entries in pairs(groups) do
for _, entry in pairs(entries) do
local loc = entry.loc
if loc then
---@diagnostic disable-next-line: inject-field
entry._group = group
M.max_time = math.max(M.max_time, entry.time)
M.highlights[loc.file] = M.highlights[loc.file] or {}
if Snacks.profiler.config.debug and M.highlights[loc.file][loc.line] then
local old = M.highlights[loc.file][loc.line]
Snacks.debug.inspect({ group = group, old = M.dump(old), new = M.dump(entry) })
end
M.highlights[loc.file][loc.line] = entry
end
end
end
end
function M.get_level(value, t)
return value > Snacks.profiler.config.thresholds[t][2] and "Error"
or value > Snacks.profiler.config.thresholds[t][1] and "Warn"
or "Info"
end
---@param entry snacks.profiler.Trace
---@param opts? { badges?: snacks.profiler.Badge.type[], indent?: boolean }
---@return snacks.profiler.Badge[]
function M.badges(entry, opts)
opts = opts or {}
opts.badges = opts.badges or { "time", "pct", "count", "name", "trace" }
local ret = {} ---@type snacks.profiler.Badge[]
local done = {} ---@type table<string, boolean>
local indented = false
for _, b in ipairs(opts.badges) do
if b == "trace" or b == "name" then
local entries = {} ---@type snacks.profiler.Trace[]
if b == "name" then
table.insert(entries, entry)
end
if b == "trace" then
vim.list_extend(entries, entry)
end
for _, e in ipairs(entries) do
if not done[e.name] then
done[e.name] = true
local badge = M.badge_formats.trace(e)
if opts.indent and not indented then
indented = true
badge.text = (" "):rep(e.depth) .. badge.text
end
table.insert(ret, badge)
end
end
else
table.insert(ret, M.badge_formats[b](entry))
end
end
return ret
end
---@param badges snacks.profiler.Badge[]
---@param opts? {widths?:number[]}
function M.format(badges, opts)
local text = {} ---@type string[][]
text[#text + 1] = { " ", "Normal" }
for b, badge in ipairs(badges) do
local level = badge.level or ""
local padding = badge.padding ~= false
and opts
and opts.widths
and (opts.widths[b] - vim.api.nvim_strwidth(badge.text))
or 0
text[#text + 1] = { badge.icon, "SnacksProfilerIcon" .. level }
text[#text + 1] = { " " .. (" "):rep(padding) .. badge.text .. " ", "SnacksProfilerBadge" .. level }
text[#text + 1] = { " ", "Normal" }
end
return text
end
---@param buf number
---@param opts? snacks.profiler.Highlights|{file?:string}
function M.highlight(buf, opts)
opts = opts or {}
vim.api.nvim_buf_clear_namespace(buf, M.ns, 0, -1)
local file = Snacks.profiler.loc.norm({ file = opts.file or vim.api.nvim_buf_get_name(buf), line = 0 }).file
local highlights = M.highlights[file]
if not highlights then
return
end
local keep = {} ---@type table<number, snacks.profiler.Trace>
for l, entry in pairs(highlights) do
if entry.time >= (opts.min_time or 0) then
keep[l] = entry
end
end
highlights = keep
local align = opts.align or 80
local buttons = {} ---@type table<number, snacks.profiler.Badge[]>
local widths = {} ---@type number[]
for line, entry in pairs(highlights) do
buttons[line] = M.badges(entry, opts --[[@as snacks.profiler.Highlights]])
for b, button in ipairs(buttons[line]) do
widths[b] = math.max(widths[b] or 0, vim.api.nvim_strwidth(button.text))
end
end
for line, entry in pairs(highlights) do
local text = M.format(buttons[line], { widths = widths })
if type(align) == "number" then
text[#text + 1] = { (" "):rep(vim.o.columns), "Normal" }
end
local mmax = math.min(M.max_time, 1e6 * Snacks.profiler.config.highlights.max_shade)
vim.api.nvim_buf_set_extmark(buf, M.ns, line - 1, 0, {
hl_mode = "combine",
virt_text = text,
virt_text_win_col = type(align) == "number" and align or nil,
virt_text_pos = align == "right" and "right_align" or align == "left" and "eol" or nil,
line_hl_group = ("SnacksProfilerHot%02d"):format(
math.max(math.min(math.floor(entry.time / mmax * M.shades), M.shades), 1)
),
})
end
end
---@param fg string foreground color
---@param bg string background color
---@param alpha number number between 0 and 1. 0 results in bg, 1 results in fg
function M.blend(fg, bg, alpha)
local bg_rgb = { tonumber(bg:sub(2, 3), 16), tonumber(bg:sub(4, 5), 16), tonumber(bg:sub(6, 7), 16) }
local fg_rgb = { tonumber(fg:sub(2, 3), 16), tonumber(fg:sub(4, 5), 16), tonumber(fg:sub(6, 7), 16) }
local blend = function(i)
local ret = (alpha * fg_rgb[i] + ((1 - alpha) * bg_rgb[i]))
return math.floor(math.min(math.max(0, ret), 255) + 0.5)
end
return string.format("#%02x%02x%02x", blend(1), blend(2), blend(3))
end
function M.colors()
---@type snacks.util.hl
local hl_groups = {
Icon = "SnacksProfilerIconInfo",
Badge = "SnacksProfilerBadgeInfo",
IconTrace = "SnacksProfilerIconInfo",
BadgeTrace = "SnacksProfilerBadgeInfo",
}
local fallbacks = { Info = "#0ea5e9", Warn = "#f59e0b", Error = "#dc2626" }
local bg = Snacks.util.color("Normal", "bg") or "#000000"
local red = Snacks.util.color("DiagnosticError") or fallbacks.Error
for _, s in ipairs({ "Info", "Warn", "Error" }) do
local color = Snacks.util.color("Diagnostic" .. s) or fallbacks[s]
hl_groups["Icon" .. s] = { fg = color, bg = M.blend(color, bg, 0.3) }
hl_groups["Badge" .. s] = { fg = color, bg = M.blend(color, bg, 0.1) }
end
for i = 1, M.shades do
hl_groups[("Hot%02d"):format(i)] = { bg = M.blend(red, bg, i / (M.shades + 1)) }
end
Snacks.util.set_hl(hl_groups, { prefix = "SnacksProfiler", managed = false })
vim.api.nvim_create_autocmd("ColorScheme", {
group = vim.api.nvim_create_augroup("snacks_profiler_colors", { clear = true }),
callback = M.colors,
})
end
return M

View file

@ -0,0 +1,78 @@
---@module 'trouble'
---@diagnostic disable: inject-field
local Item = require("trouble.item")
---@type trouble.Source
local M = {}
---@diagnostic disable-next-line: missing-fields
M.config = {
formatters = {
badges = function(ctx)
local trace = ctx.item.item ---@type snacks.profiler.Trace
local badges = Snacks.profiler.ui.badges(trace, { badges = { "time", "count" } })
local text = Snacks.profiler.ui.format(badges)
return vim.tbl_map(function(t)
return { text = t[1], hl = t[2] }
end, text)
end,
},
modes = {
profiler = {
events = { { event = "User", pattern = "SnacksProfilerLoaded" } },
source = "profiler",
groups = {
-- { "tag", format = "{todo_icon} {tag}" },
-- { "directory" },
{ "loc.plugin", format = "{file_icon} {loc.plugin} {count}" },
},
-- sort = { { buf = 0 }, "filename", "pos", "name" },
sort = { "-time" },
format = "{name} {badges} {pos}",
},
},
}
function M.preview(item, ctx)
Snacks.profiler.ui.highlight(ctx.buf, { file = item.item.loc.file })
end
function M.get(cb, ctx)
---@type snacks.profiler.Find
local opts = vim.tbl_deep_extend(
"force",
{ group = "name", structure = true },
type(ctx.opts.params) == "table" and ctx.opts.params or {}
)
local _, node = Snacks.profiler.find(opts)
local items = {} ---@type trouble.Item[]
local id = 0
---@param n snacks.profiler.Node
local function add(n)
if n.trace.def then
id = id + 1
local loc = n.trace.def
local item = Item.new({
id = id,
pos = { n.trace.def.line, 0 },
text = n.trace.name,
filename = loc and loc.file,
item = n.trace,
source = "profiler",
})
items[#items + 1] = item
for _, child in pairs(n.children) do
item:add_child(add(child))
end
return item
end
end
for _, child in pairs(node.children or {}) do
add(child)
end
cb(items)
end
return M