current commit
261105.2 · fix: vim.defer_fn to debounce "preview"
local path = require "fzf-lua.path"
local shell = require "fzf-lua.shell"
local utils = require "fzf-lua.utils"
local libuv = require "fzf-lua.libuv"
local Object = require "fzf-lua.class"
local TSContext = require "fzf-lua.previewer.tscontext"

---@diagnostic disable-next-line: deprecated
local uv = vim.uv or vim.loop
local api = vim.api
local fn = vim.fn

local Previewer = {}

---@class fzf-lua.previewer.Builtin : fzf-lua.Object,{}
---@field last_entry? string
---@field type "builtin"
---@field new function
---@field setup_opts function
---@field opts table
---@field win fzf-lua.Win
---@field delay integer
---@field title any
---@field title_pos "center"|"left"|"right"
---@field title_fnamemodify fun(title: string, width: integer?): string
---@field render_markdown table
---@field snacks_image table
---@field winopts fzf-lua.config.PreviewerWinopts
---@field syntax boolean
---@field syntax_delay integer
---@field syntax_limit_b integer
---@field syntax_limit_l integer
---@field limit_b integer
---@field _ts_limit_b_per_line integer
---@field treesitter table
---@field toggle_behavior "default"|"extend"
---@field winopts_orig table
---@field extensions { [string]: string|string[]? }
---@field ueberzug_scaler "crop"|"distort"|"contain"|"fit_contain"|"cover"|"forced_cover"
---@field bcache fzf-lua.Bcache
---@field clear_on_redraw boolean?
---@field timers? table<string, uv.uv_timer_t?>
---@field orig_pos? [integer, integer]
-- Methods are added to `Previewer.base`/`buffer_or_file` via `Object:extend()`'s
-- `__index` chain; emmylua 0.25.x can't see through this, so `self:method()` and
-- undeclared fields show as `never` / undefined. Real type errors are fixed inline.
---@diagnostic disable: call-non-callable, undefined-field
Previewer.base = Object:extend()

---@param o table
---@param opts table
---@return fzf-lua.previewer.Builtin
function Previewer.base:new(o, opts)
  local default = utils.nonnil
  o = o or {}
  self.type = "builtin"
  self.opts = opts
  self.title_fnamemodify = o.title_fnamemodify
  self.render_markdown = type(o.render_markdown) == "table" and o.render_markdown or {}
  self.render_markdown.filetypes =
      type(self.render_markdown.filetypes) == "table" and self.render_markdown.filetypes or {}
  self.snacks_image = type(o.snacks_image) == "table" and o.snacks_image or {}
  self.syntax = default(o.syntax, true)
  self.syntax_delay = tonumber(default(o.syntax_delay, 0))
  self.syntax_limit_b = tonumber(default(o.syntax_limit_b, 1024 * 1024))
  self.syntax_limit_l = tonumber(default(o.syntax_limit_l, 0))
  self.limit_b = tonumber(default(o.limit_b, 1024 * 1024 * 10))
  self._ts_limit_b_per_line = tonumber(default(o._ts_limit_b_per_line, 1000))
  self.treesitter = type(o.treesitter) == "table" and o.treesitter or {}
  self.toggle_behavior = o.toggle_behavior
  self.winopts_orig = {}
  self.extensions = {}
  for k, v in pairs(o.extensions or {}) do
    self.extensions[k:lower()] = v
  end
  -- validate the ueberzug image scaler
  local uz_scalers = {
    ["crop"]         = "crop",
    ["distort"]      = "distort",
    ["contain"]      = "contain",
    ["fit_contain"]  = "fit_contain",
    ["cover"]        = "cover",
    ["forced_cover"] = "forced_cover",
  }
  self.ueberzug_scaler = o.ueberzug_scaler and uz_scalers[o.ueberzug_scaler]
  if o.ueberzug_scaler and not self.ueberzug_scaler then
    utils.warn(("Invalid ueberzug image scaler '%s', option will be omitted.")
      :format(o.ueberzug_scaler))
  end
  self.bcache = require("fzf-lua.previewer.bcache").new(function(buf)
    self:safe_buf_delete(buf, true)
  end)
  return self
end

---@param opts table
---@return table
function Previewer.base:setup_opts(opts)
  -- Set the preview command line
  opts.preview = self:cmdline()
  opts.fzf_opts["--preview-window"] = self:preview_window()
  -- fzf 0.40 added 'zero' event for when there's no match
  -- clears the preview when there are no matching entries
  if utils.has(opts, "fzf", { 0, 40 }) then
    table.insert(opts._fzf_cli_args, libuv.cli_raw_arg("--bind=", "zero:+" .. self:zero()))
  end
  return opts
end

---@param do_not_clear_cache boolean?
function Previewer.base:close(do_not_clear_cache)
  TSContext.deregister()
  self:restore_winopts()
  self:clear_preview_buf()
  if not do_not_clear_cache and self.bcache then self.bcache:clear() end
  self.winopts_orig = {}
end

function Previewer.base:gen_winopts()
  local winopts = { wrap = self.win.preview_wrap }
  return vim.tbl_extend("keep", winopts, self.winopts)
end

function Previewer.base:backup_winopts()
  if rawequal(next(self.winopts_orig), nil) then
    self.winopts_orig = self.win:get_winopts(self.win.src_winid, self:gen_winopts())
  end
end

function Previewer.base:restore_winopts()
  self.win:set_winopts(self.win.preview_winid, self.winopts_orig)
end

function Previewer.base:set_style_winopts()
  -- NOTE: `true` to ignore events for initial TSContext.update
  self.win:set_winopts(self.win.preview_winid, self:gen_winopts(), true)
end

---@param win integer
function Previewer.base:reset_winhl(win)
  local hls = self.win.hls
  local winopts = self:gen_winopts()
  self.win:reset_winhl(win, winopts.winhl or winopts.winhighlight or {
    Normal = hls.preview_normal,
    NormalFloat = hls.preview_normal,
    FloatBorder = hls.preview_border,
    CursorLine = hls.cursorline,
    CursorLineNr = hls.cursorlinenr,
  })
end

---@diagnostic disable-next-line: unused
---@return integer
function Previewer.base:get_tmp_buffer()
  local tmp_buf = api.nvim_create_buf(false, true)
  vim.bo[tmp_buf].modeline = true
  vim.bo[tmp_buf].modifiable = true
  vim.bo[tmp_buf].bufhidden = "wipe"
  return tmp_buf
end

---@param bufnr? integer
---@param del_cached? boolean
function Previewer.base:safe_buf_delete(bufnr, del_cached)
  -- can be nil after closing preview with <F4>
  if not bufnr then return end
  assert(type(bufnr) == "number" and bufnr > 0)
  -- store currently listed buffers, this helps us determine which buffers
  -- navigated with 'vim.lsp.util.show_document' we can safely unload
  -- since show_document reuses buffers and I couldn't find a better way
  -- to determine if the destination buffer was listed prior to the jump
  local buflist = (utils.__CTX() or {}).buflist or utils.CTX({ includeBuflist = true }).buflist or {}
  if vim.tbl_contains(buflist, bufnr) and fn.buflisted(bufnr) == 1 then
    -- print("safe_buf_delete LISTED", bufnr)
    return
  elseif not api.nvim_buf_is_valid(bufnr) then
    -- print("safe_buf_delete INVALID", bufnr)
    return
  elseif not del_cached and self.bcache:get_pos(bufnr) then
    -- print("safe_buf_delete CACHED", bufnr)
    return
  end
  -- print("safe_buf_delete DELETE", bufnr)
  -- delete buffer marks
  api.nvim_buf_call(bufnr, function()
    vim.cmd([[delm \"]])
  end)
  -- delete the buffer
  api.nvim_buf_delete(bufnr, { force = true })
end

---@param newbuf integer
---@param min_winopts boolean?
---@param no_wipe boolean?
function Previewer.base:set_preview_buf(newbuf, min_winopts, no_wipe)
  ---@cast self fzf-lua.previewer.Builtin
  if not self.win or not self.win:validate_preview() then return end
  -- Set the preview window to the new buffer
  local curbuf = api.nvim_win_get_buf(self.win.preview_winid)
  if curbuf == newbuf then return end
  -- Something went terribly wrong
  assert(curbuf ~= newbuf)
  utils.win_set_buf_noautocmd(self.win.preview_winid, newbuf)
  -- to make gc work, don't reference `win._previewer` in a callback
  local winid = self.win.fzf_winid
  vim.keymap.set("n", "i", function()
    api.nvim_set_current_win(winid)
    vim.cmd("startinsert")
  end, { buffer = newbuf })
  self.preview_bufnr = newbuf
  -- set preview window options
  -- sets the style defined by `winopts.preview.winopts`
  self:set_style_winopts()
  if min_winopts then
    -- removes 'number', 'signcolumn', 'cursorline', etc
    self.win:set_style_minimal(self.win.preview_winid, true)
  end
  if not no_wipe then
    -- although the buffer has 'bufhidden:wipe' it sometimes doesn't
    -- get wiped when pressing `ctrl-g` too quickly
    self:safe_buf_delete(curbuf)
  end
end

---@param newbuf boolean?
---@return integer?
function Previewer.base:clear_preview_buf(newbuf)
  local retbuf = nil
  if ((self.win and self.win._reuse) or newbuf)
      -- Attach a temp buffer to the window when reusing (`ctrl-g`)
      -- so we don't invalidate the window when deting the buffer
      -- in `safe_buf_delete` call below
      -- We don't use 'self.win:validate_preview()' because we want
      -- to detach the buffer even when 'self.win.closing = true'
      and self.win and self.win.preview_winid
      and tonumber(self.win.preview_winid) > 0
      and api.nvim_win_is_valid(self.win.preview_winid) then
    -- attach a temp buffer to the window
    -- so we can safely delete the buffer
    -- ('nvim_buf_delete' removes the attached win)
    retbuf = self:get_tmp_buffer()
    utils.win_set_buf_noautocmd(self.win.preview_winid, retbuf)
    -- redraw the title line and clear the scrollbar
    self.win:close_preview_scrollbar()
  end
  -- since our temp buffers have 'bufhidden=wipe' the tmp
  -- buffer will be automatically wiped and 'nvim_buf_is_valid'
  -- will return false
  -- one case where the buffer may remain valid after detaching
  -- from the preview window is with URI type entries after calling
  -- 'vim.lsp.util.show_document' which can reuse existing buffers,
  -- so technically this should never be executed unless the
  -- user wrote an fzf-lua extension and set the preview buffer to
  -- a random buffer without the 'bufhidden' property
  self:safe_buf_delete(self.preview_bufnr)
  self.preview_bufnr = nil
  self.loaded_entry = nil
  return retbuf
end

function Previewer.base:display_last_entry()
  self:display_entry(self.last_entry)
end

---@diagnostic disable-next-line: unused
function Previewer.base:should_clear_preview(_) end -- for lint

---@diagnostic disable-next-line: unused
function Previewer.base:populate_preview_buf(_) end -- for lint

---@param name string
---@param delay integer
---@param func function
function Previewer.base:debounce(name, delay, func)
  self.timers = self.timers or {} ---@type table<string, uv.uv_timer_t?>
  local timer = self.timers[name]
  if tonumber(delay) > 0 then
    if not timer or timer:is_closing() then
      timer = assert(uv.new_timer())
      self.timers[name] = timer
    end
    timer:start(delay, 0, function()
      timer:close()
      self.timers[name] = nil
      vim.schedule(func)
    end)
  else
    func()
  end
end

---@param entry_str string?
function Previewer.base:display_entry(entry_str)
  if not entry_str then return end
  if not self.win or not self.win:validate_preview() then return end

  -- verify backup the current window options
  -- will store only of `winopts_orig` is nil
  self:backup_winopts()

  -- clears the current preview buffer and set to a new temp buffer
  -- recommended to return false from 'should_clear_preview' and use
  -- 'self:set_preview_buf()' instead for flicker-free experience
  if self:should_clear_preview(entry_str) then
    self.preview_bufnr = self:clear_preview_buf(true)
  end

  ---@async
  local populate_preview_buf = function(entry_str_)
    if not self.win or not self.win:validate_preview() then return end

    -- specialized previewer populate function
    ---@cast self fzf-lua.previewer.BufferOrFile
    if self:populate_preview_buf(entry_str_) == false then return end

    -- reset the preview window highlights
    self:reset_winhl(self.win.preview_winid)
  end

  -- debounce preview entries
  self:debounce("preview", self.delay, coroutine.wrap(function()
    populate_preview_buf(entry_str)
  end))
end

function Previewer.base:cmdline(_)
  local act = shell.stringify_data(function(items, _, _)
    ---@type string?, string?, string?
    local entry, idx, query = unpack(items, 1, 3)
    -- NOTE: see comment regarding {n} in `core.convert_exec_silent_actions`
    -- convert empty string to nil
    if not tonumber(idx) then entry = nil end
    -- upvalue incase previewer was detached/re-attached (global picker)
    self = self.win._previewer or self ---@type fzf-lua.previewer.Builtin
    -- on windows, query may not be expanded to a string: #1887
    FzfLua.get_info().query = query or ""
    self:display_entry(entry)
    -- save last entry even if we don't display
    self.last_entry = entry
    return ""
  end, self.opts, "{} {n} {q}")
  return act
end

function Previewer.base:zero(_)
  --
  -- debounce the zero event call to prevent reentry which may
  -- cause a hang of fzf  or the nvim RPC server (#909)
  -- mkdir is an atomic operation and will fail if the directory
  -- already exists effectively creating a singleton shell command
  --
  -- currently awaiting an upstream fix:
  -- https://github.com/junegunn/fzf/issues/3516
  --
  self._zero_lock = self._zero_lock or path.normalize(fn.tempname())
  local act = string.format("execute-silent(mkdir %s && %s)",
    libuv.shellescape(self._zero_lock),
    shell.stringify_data(function(_, _, _)
      vim.defer_fn(function()
        fn.delete(self._zero_lock, "d")
      end, self.delay)
      -- upvalue incase previewer was detached/re-attached (global picker)
      self = self.win._previewer or self ---@type fzf-lua.previewer.Builtin
      -- capture the entry displayed at zero-event time; if a different entry
      -- is displayed before the deferred callback runs (e.g. the list was
      -- still loading on Windows) the preview belongs to that newer entry
      local entry_at_zero = self.last_entry
      self:debounce("preview", self.delay, function()
        -- a new entry may have been displayed after the zero event, don't
        -- clear its preview (only clear when the entry is unchanged)
        local cur_entry = self.last_entry
        if (not cur_entry or cur_entry == entry_at_zero) and self.win:validate_preview() then
          self:clear_preview_buf(true)
          TSContext.close(self.win.preview_winid)
          self.win:update_preview_title("")
        end
        -- only reset when `last_entry` still refers to the zero-event entry:
        -- a newer entry set by `display_entry` (whose preview was kept above)
        -- stays so `display_last_entry` can restore it, e.g. when the
        -- preview window is toggled back on
        if self.loaded_entry and cur_entry == entry_at_zero then
          self.last_entry = nil
        end
      end)
    end, self.opts, ""))
  return act
end

function Previewer.base:preview_window(_)
  if self.win and not self.win.winopts.split then
    return "nohidden:right:0"
  else
    return nil
  end
end

---@param direction "top"|"bottom"|"half-page-up"|"half-page-down"|"page-up"|"page-down"|"line-up"|"line-down"|"reset"
function Previewer.base:scroll(direction)
  local preview_winid = self.win.preview_winid
  if not self.preview_bufnr or preview_winid < 0 or not direction then return end
  if not api.nvim_win_is_valid(preview_winid) then return end

  -- map direction to scroll commands ('g8' on char to display)
  local input = ({
    ["top"]            = "gg",
    ["bottom"]         = "G",
    ["half-page-up"]   = ("%c"):format(0x15), -- [[]]
    ["half-page-down"] = ("%c"):format(0x04), -- [[]]
    ["page-up"]        = ("%c"):format(0x02), -- [[]]
    ["page-down"]      = ("%c"):format(0x06), -- [[]]
    -- ^Y/^E doesn't seem to work
    ["line-up"]        = "Hgk",
    ["line-down"]      = "Lgj",
    ["reset"]          = true, -- dummy for exit condition
  })[direction]

  if not input then return end

  local scroll = function()
    pcall(api.nvim_win_call, preview_winid, function()
      vim.cmd([[norm! ]] .. input)
      -- `zb` at bottom?
      local height = api.nvim_win_get_height(preview_winid)
      local topline = fn.line("w0")
      local botline = fn.line("w$")
      if height > (botline - topline) then
        api.nvim_win_set_cursor(0, { botline, 1 })
        vim.cmd("norm! zvzb")
      end
    end)
  end

  if direction == "reset" then
    pcall(api.nvim_win_call, preview_winid, function()
      -- for some reason 'nvim_win_set_cursor'
      -- only moves forward, so set to (1,0) first
      api.nvim_win_set_cursor(0, { 1, 0 })
      if self.orig_pos then
        api.nvim_win_set_cursor(0, self.orig_pos)
      end
      utils.zz()
    end)
  else
    if utils.is_term_buffer(self.preview_bufnr) and api.nvim_get_mode().mode == "t" then
      vim.cmd.stopinsert()
      vim.schedule(function()
        scroll()
        vim.cmd.startinsert()
      end)
    else
      scroll()
    end
  end

  -- 'cursorline' is effectively our match highlight. Once the
  -- user scrolls, the highlight is no longer relevant (#462).
  -- Conditionally toggle 'cursorline' based on cursor position
  self:maybe_set_cursorline(preview_winid, self.orig_pos)
  -- HACK: Hijack cached bufnr value as last scroll position
  self.bcache:update_pos(self.preview_bufnr,
    direction == "reset" or api.nvim_win_get_cursor(preview_winid))
  self.win:update_preview_scrollbar()
  self:update_render_markdown()
  self:update_ts_context()
end

-- https://github.com/kevinhwang91/nvim-bqf/blob/b51a37fcd808edafd52511458467c8c9a701ea8d/lua/bqf/preview/extmark.lua#L20
-- copy extmarks from src_buf to buf
---@param src_buf integer
---@param buf integer
---@param win integer
---@param topline integer 0-based
---@param botline integer 0-based
---@param ns integer
local copy_extmarks = function(src_buf, buf, win, topline, botline, ns)
  local src_win = fn.win_findbuf(src_buf)[1]
  local src_leftcol = src_win and api.nvim_win_call(src_win, fn.winsaveview).leftcol or nil
  -- virt_text is related to screen https://github.com/neovim/neovim/issues/14050
  local no_virt_text = api.nvim_win_call(win, fn.winsaveview).leftcol ~= src_leftcol
  api.nvim_buf_clear_namespace(buf, ns, topline, botline)
  local extmarks = api.nvim_buf_get_extmarks(src_buf, -1, { topline, 0 }, { botline, -1 },
    { details = true })
  local namespaces = api.nvim_get_namespaces()
  local ignore = {}
  if namespaces["snacks.image"] then ignore[namespaces["snacks.image"]] = true end
  for _, m in ipairs(extmarks) do
    local _, row, col, details = unpack(m) ---@cast details -?
    if not ignore[details.ns_id] then
      details.ns_id = nil
      if no_virt_text and details.virt_text then details.virt_text = nil end
      ---@diagnostic disable-next-line: param-type-mismatch
      pcall(api.nvim_buf_set_extmark, buf, ns, row, col, details)
    end
  end
end

---@return fun()
function Previewer.base:copy_extmarks()
  self.ns_extmarks = self.ns_extmarks or api.nvim_create_namespace("fzf-lua.preview.extmarks")
  api.nvim_set_decoration_provider(self.ns_extmarks, {
    on_win = function(_, win, buf, topline, botline)
      if win ~= self.win.preview_winid then return end
      local src_buf = self.loaded_entry and self.loaded_entry.bufnr
      if not src_buf or not api.nvim_buf_is_valid(src_buf) then return end
      copy_extmarks(src_buf, buf, win, topline, botline, self.ns_extmarks)
      return false
    end,
  })
  return function()
    api.nvim_set_decoration_provider(self.ns_extmarks, {})
  end
end

function Previewer.base:ts_ctx_toggle()
  local bufnr, winid = self.preview_bufnr, self.win.preview_winid
  if not bufnr or winid < 0 or not api.nvim_win_is_valid(winid) then return end
  if self.treesitter.context then
    self.treesitter._context = self.treesitter.context
    self.treesitter.context = nil
  else
    self.treesitter.context = self.treesitter._context or true
    self.treesitter._context = nil
  end
  TSContext.toggle(winid, bufnr)
end

function Previewer.base:ts_ctx_inc_dec_maxlines(num)
  local bufnr, winid = self.preview_bufnr, self.win.preview_winid
  if winid < 0 or not api.nvim_win_is_valid(winid) then return end
  TSContext.inc_dec_maxlines(num, winid, bufnr)
end

---@class fzf-lua.previewer.BufferOrFile : fzf-lua.previewer.Builtin,{}
---@field match_id? integer
---@field loaded_entry? fzf-lua.buffer_or_file.Entry
---@field super fzf-lua.previewer.Builtin
Previewer.buffer_or_file = Previewer.base:extend()

function Previewer.buffer_or_file:new(o, opts)
  Previewer.buffer_or_file.super.new(self, o, opts)
  return self
end

function Previewer.buffer_or_file:close(do_not_clear_cache)
  Previewer.base.close(self, do_not_clear_cache)
  self:stop_ueberzug()
end

---@param entry_str string
---@return fzf-lua.path.Entry
function Previewer.buffer_or_file:entry_to_file(entry_str)
  return path.entry_to_file(entry_str, self.opts)
end

-- replace `{file}` placeholder with the filename
---@param cmd string[]
---@param filepath string
---@param placeholder string
---@return string[]
local function replace_placeholder(cmd, placeholder, filepath)
  local append = true
  for i, arg in ipairs(cmd) do
    if type(arg) == "string" and arg:match(placeholder) then
      cmd[i] = arg:gsub(placeholder, filepath)
      append = false
    end
  end
  -- or add filename as last parameter
  if append then cmd[#cmd + 1] = filepath end
  return cmd
end

---async result can be return by "_cb(res)"
---@param entry_str string
---@param _cb? function
---@return fzf-lua.buffer_or_file.Entry
function Previewer.buffer_or_file:parse_entry(entry_str, _cb)
  ---@type fzf-lua.buffer_or_file.Entry
  local entry = self:entry_to_file(entry_str)
  local buf = entry.bufnr
  if buf and api.nvim_buf_is_loaded(buf) then
    entry.tick = vim.b[buf].changedtick
  elseif buf and api.nvim_buf_is_valid(buf) then
    -- buffer is not loaded, can happen when calling "lines" with `set nohidden`
    -- or when starting nvim with an arglist, fix entry.path since it contains
    -- filename only
    entry.path = path.relative_to(api.nvim_buf_get_name(buf), utils.cwd())
    if entry.path:find("^fugitive://") then
      api.nvim_buf_call(buf, function()
        api.nvim_exec_autocmds("BufReadCmd", {
          group = fn.exists("#fugitive#BufReadCmd") == 1 and "fugitive" or nil,
          pattern = entry.path
        })
      end)
    end
  end
  if entry.path and next(self.extensions) then
    local ext = path.extension(entry.path)
    local cmd = ext and self.extensions[ext:lower()] or nil
    cmd = type(cmd) == "string" and { cmd } or utils.deepcopy(cmd) or {}
    ---@cast cmd string[]
    if cmd[1] then
      -- special case: we build ueberzug cmd separately
      entry._is_ueberzug = (cmd[1] --[[@as string]]):match("ueberzug") and true or false
      if not entry._is_ueberzug and fn.executable(cmd[1] --[[@as string]]) == 1 then
        entry.cmd = replace_placeholder(cmd, "[<{]file[}>]", entry.path)
        entry.pty = true
      end
    end
  end
  local stat = entry.path and not entry.uri and uv.fs_stat(entry.path) or nil
  entry.tick = entry.tick or
      (stat and vim.inspect(stat.mtime, { newline = "", indent = "" }) or nil)
  if entry.debug then
    entry.content = { { { entry.debug, "Error" } } }
  elseif not stat then
    entry.content = { { { "Unable to stat file: ", "Error" }, { entry.path or "nil", "DiagnosticInfo" } } }
    -- TODO: https://github.com/EmmyLuaLs/emmylua-analyzer-rust/issues/1026
  elseif stat and stat.type == "directory" then
    entry.cmd = utils._if_win({ "cmd.exe", "/c", "dir", entry.path },
      { "ls", "-la", entry.path })
  elseif stat and stat.size > 0 and utils.perl_file_is_binary(entry.path) then
    entry.content = { { "Preview is not supported for binary files.", "Error" } }
  elseif stat and stat.size > self.limit_b then
    entry.content = {
      { { ("Preview file size limit (>%dMB) reached, file size %dMB.")
          :format(self.limit_b / (1024 * 1024), stat.size / (1024 * 1024)), "ERROR" } }
    }
  end
  return entry
end

---@diagnostic disable-next-line: unused
---@return boolean
function Previewer.buffer_or_file:should_clear_preview(_)
  return false
end

function Previewer.buffer_or_file:start_ueberzug()
  if self._ueberzug_fifo then return self._ueberzug_fifo end
  self._ueberzug_fifo = path.join({
    fn.fnamemodify(fn.tempname(), ":h"),
    string.format("fzf-lua-%d-ueberzug", fn.getpid())
  })
  utils.io_system({ "mkfifo", self._ueberzug_fifo })
  self._ueberzug_job = fn.jobstart({ "sh", "-c",
    string.format(
      "tail -f %s | ueberzug layer --parser json",
      libuv.shellescape(self._ueberzug_fifo))
  }, {
    on_exit = function(_, rc, _)
      if rc ~= 0 and rc ~= 143 then
        utils.warn(("ueberzug exited with error %d"):format(rc) ..
          ", run ':messages' to see the detailed error.")
      end
    end,
    on_stderr = function(_, data, _)
      for _, l in ipairs(data or {}) do
        if #l > 0 then
          utils.info("ueberzug: " .. l)
        end
      end
      -- populate the preview buffer with the error message
      if self.preview_bufnr and self.preview_bufnr > 0 and
          api.nvim_buf_is_valid(self.preview_bufnr) then
        local lines = api.nvim_buf_get_lines(self.preview_bufnr, 0, -1, false)
        for _, l in ipairs(data or {}) do
          table.insert(lines, l)
        end
        api.nvim_buf_set_lines(self.preview_bufnr, 0, -1, false, lines)
      end
    end
  }
  )
  self._ueberzug_pid = fn.jobpid(self._ueberzug_job)
  return self._ueberzug_fifo
end

function Previewer.buffer_or_file:stop_ueberzug()
  if self._ueberzug_job then
    fn.jobstop(self._ueberzug_job)
    if type(uv.os_getpriority(self._ueberzug_pid)) == "number" then
      uv.kill(self._ueberzug_pid, 9)
    end
    self._ueberzug_job = nil
    self._ueberzug_pid = nil
  end
  if self._ueberzug_fifo and uv.fs_stat(self._ueberzug_fifo) then
    fn.delete(self._ueberzug_fifo)
    self._ueberzug_fifo = nil
  end
end

---TODO: now this only handle ueberzug image previews
---@param tmpbuf integer
---@param entry fzf-lua.buffer_or_file.Entry
---@return boolean?
function Previewer.buffer_or_file:populate_terminal_cmd(tmpbuf, entry)
  if not entry._is_ueberzug then return end
  -- no caching: preview buf must be reattached for
  -- terminal image previews to have the correct size
  entry.do_not_cache = true
  -- when terminal execution ends last line in the buffer
  -- will display "[Process exited 0]", this will enable
  -- the scrollbar which we wish to hide
  entry.no_scrollbar = true
  -- both ueberzug and terminal cmds need a clear
  -- on redraw to fit the new window dimensions
  self.clear_on_redraw = true
  -- 2nd arg `true`: minimal style window
  self:set_preview_buf(tmpbuf, true)
  local fifo = self:start_ueberzug()
  if not fifo then return end
  local wincfg = api.nvim_win_get_config(self.win.preview_winid)
  local winpos = api.nvim_win_get_position(self.win.preview_winid)
  local params = {
    action     = "add",
    identifier = "preview",
    x          = winpos[2] + 1,
    y          = winpos[1] + 2,
    width      = wincfg.width - 2,
    height     = wincfg.height - 2,
    scaler     = self.ueberzug_scaler,
    path       = entry.path and path.is_absolute(entry.path) and entry.path or
        path.join({ self.opts.cwd or utils.cwd(), entry.path }),
  }
  local json = vim.json.encode(params)
  -- both 'fs_open|write|close' and 'fn.system' work.
  -- We prefer the libuv method as it doesn't rely on the shell
  -- cmd = { "sh", "-c", ("echo '%s' > %s"):format(json, self._ueberzug_fifo) }
  -- fn.system(cmd)
  local fd = uv.fs_open(self._ueberzug_fifo, "a", -1)
  if fd then
    uv.fs_write(fd, json .. "\n", nil, function(_)
      uv.fs_close(fd)
    end)
  end
  -- run here so title gets updated
  -- even if the image is still loading
  self:preview_buf_post(entry, true)
  return true
end

---@alias fzf-lua.line (string|[string,string])[]
---@param content (string|fzf-lua.line)[]
---@return string[], table
local parse_rich = function(content)
  local lines, extmarks = {}, {}
  for i, line in ipairs(content) do
    -- If the line is a string, just add it directly.
    -- Otherwise, it's a table of parts (rich text line)
    if type(line) == "string" then
      lines[#lines + 1] = line
    else
      local parts = {}
      local col = 0
      for _, part in ipairs(line) do
        local norm_part = type(part) == "string" and { part } or part
        local text, hl = norm_part[1] or "", norm_part[2]
        parts[#parts + 1] = text
        local end_col = col + #text
        if hl then
          extmarks[#extmarks + 1] = { row = i - 1, col = col, end_col = end_col, hl_group = hl }
        end
        col = end_col
      end
      lines[#lines + 1] = table.concat(parts, "")
    end
  end
  return lines, extmarks
end

---@param buf integer
---@param entry fzf-lua.cmd.Entry
---@param opts table
---@return vim.SystemObj
local start_cmd = function(buf, entry, opts)
  local chan ---@type integer?
  local obj ---@type vim.SystemObj?
  local close = function(err)
    if chan then
      pcall(fn.chanclose, chan)
      chan = nil
    end
    if obj and not obj:is_closing() then
      obj:kill(uv.constants.SIGTERM)
      obj = nil
    end
    if err then error(err) end
  end
  local try_send = function(data)
    if not opts.cancel() and chan and pcall(api.nvim_chan_send, chan, data) then
      return opts.on_send(data)
    end
  end
  local stdout, on_exit
  if entry.cmd_stream ~= false then
    chan = api.nvim_open_term(buf, {})
    stdout = vim.schedule_wrap(function(err, data)
      if err then close(err) end
      if data then try_send(data) end
    end)
  end
  on_exit = vim.schedule_wrap(function(obj0)
    if not api.nvim_buf_is_valid(buf) then return end
    if entry.cmd_stream ~= false then
      opts.on_exit()
    else
      chan = api.nvim_open_term(buf, {})
      try_send(vim.trim(obj0.stderr or "") ~= "" and obj0.stderr or obj0.stdout or "")
    end
  end)
  local sopts = entry.cmd_opts or {}
  sopts = vim.tbl_deep_extend("force", sopts, { stdout = stdout, stderr = stdout })
  ---@diagnostic disable-next-line: param-type-mismatch
  ---@diagnostic disable-next-line: redundant-parameter
  return vim.system(entry.cmd, sopts, on_exit)
end

---@param entry fzf-lua.buffer_or_file.Entry
---@return [integer?, integer?]
local entry_pos = function(entry)
  local line = entry.line and entry.line > 0 and entry.line or nil
  local col = entry.col and entry.col > 0 and entry.col - 1 or nil
  return { line, col }
end

---@param data string
---@return string[]
local split_file_data = function(data)
  local lines = vim.split(data, "[\r]?\n")
  if data:sub(#data, #data) == "\n" or data:sub(#data - 1, #data) == "\r\n" then
    table.remove(lines)
  end
  return lines
end

---@param entry fzf-lua.buffer_or_file.Entry
---@param cached fzf-lua.BcacheEntry
function Previewer.buffer_or_file:_apply_cached_preview(entry, cached)
  -- same file/buffer as previous entry no need to change preview buf
  -- (e.g. only lnum changed, or unhide/resume)
  if cached.bufnr == self.preview_bufnr then
    if not vim.deep_equal(entry_pos(entry), self.orig_pos) then
      self.bcache:reset_pos(self.preview_bufnr)
    end
  else
    self:set_preview_buf(cached.bufnr, cached.min_winopts)
  end
  self:preview_buf_post(entry, cached.min_winopts)
end

---@return nil
function Previewer.buffer_or_file:_stop_preview_job()
  self:stop_ueberzug()
  if self.stop_job then
    self.stop_job()
    self.stop_job = nil
  end
end

---@param tmpbuf integer
---@param entry fzf-lua.buffer_or_file.Entry
---@return nil
function Previewer.buffer_or_file:_populate_loaded_buffer_preview(tmpbuf, entry)
  -- WE NO LONGER REUSE THE CURRENT BUFFER
  -- this changes the buffer's 'getbufinfo[1].lastused'
  -- which messes up our `buffers()` sort
  entry.filetype = vim.bo[entry.bufnr].filetype
  ---@diagnostic disable-next-line: param-type-mismatch
  local lines = api.nvim_buf_get_lines(entry.bufnr, 0, -1, false)
  vim.bo[tmpbuf].expandtab = vim.bo[entry.bufnr].expandtab
  vim.bo[tmpbuf].shiftwidth = vim.bo[entry.bufnr].shiftwidth
  vim.bo[tmpbuf].tabstop = vim.bo[entry.bufnr].tabstop
  api.nvim_buf_set_lines(tmpbuf, 0, -1, false, lines)
  -- terminal buffers use minimal window style (2nd arg)
  self:set_preview_buf(tmpbuf, entry.terminal)
  self:preview_buf_post(entry, entry.terminal)
end

---@param tmpbuf integer
---@param entry fzf-lua.buffer_or_file.Entry
function Previewer.buffer_or_file:_populate_location_preview(tmpbuf, entry)
  -- LSP 'jdt://' entries, see issue #195
  -- https://github.com/ibhagwan/fzf-lua/issues/195
  local loc = { uri = entry.uri, range = entry.range }
  local win = self.win.preview_winid
  local ok, res = api.nvim_win_call(win, function()
    ---@diagnostic disable-next-line: param-type-mismatch
    return pcall(utils.jump_to_location, loc, "utf-16", false)
  end)
  if ok then
    entry.bufnr = api.nvim_win_get_buf(win)
    self:_populate_loaded_buffer_preview(tmpbuf, entry)
    return
  end
  -- in case of an error display the stacktrace in the preview buffer
  local name = utils.__HAS_NVIM_011 and "show_document" or "jump_to_location"
  local lines = { ("lsp.util.%s failed for '%s':"):format(name, entry.uri), "" }
  vim.list_extend(lines, type(res) == "string" and utils.strsplit(res, "\n") or { "null" })
  tmpbuf = tmpbuf or self:get_tmp_buffer()
  api.nvim_buf_set_lines(tmpbuf, 0, -1, false, lines)
  self:set_preview_buf(tmpbuf)
  self:preview_buf_post(entry)
end

---@param tmpbuf integer
---@param entry fzf-lua.buffer_or_file.Entry
---@return nil
function Previewer.buffer_or_file:_set_preview_lines(tmpbuf, entry)
  local textlines, extmarks = parse_rich(entry.content or {})
  extmarks = entry.extmarks or extmarks
  pcall(api.nvim_buf_set_lines, tmpbuf, 0, -1, false, textlines)
  if extmarks and #extmarks > 0 then
    local setmark = utils.nil_wrap(api.nvim_buf_set_extmark)
    local ns = api.nvim_create_namespace("fzf-lua.preview.hl")
    for _, extmark in ipairs(extmarks) do
      setmark(tmpbuf, ns, extmark.row, extmark.col,
        { end_col = extmark.end_col, hl_group = extmark.hl_group })
    end
  end
  if entry.open_term then api.nvim_open_term(tmpbuf, {}) end
  self:set_preview_buf(tmpbuf)
  self:preview_buf_post(entry)
end

---@param tmpbuf integer
---@param entry fzf-lua.buffer_or_file.Entry
---@param entry_str string
---@return nil
function Previewer.buffer_or_file:_populate_cmd_preview(tmpbuf, entry, entry_str)
  local opened, job = false, nil
  if entry.pty then
    self.clear_on_redraw = true -- cache it, but clear on resize
    self:set_preview_buf(tmpbuf, true)
    vim.bo[tmpbuf].modifiable = true
    job = api.nvim_buf_call(tmpbuf, function()
      return utils.termopen(entry.cmd, {
        cwd = self.opts.cwd,
        on_exit = function() -- run post only after terminal job finished
          if not job then return end
          self:preview_buf_post(entry, true)
          self._job_id = nil
        end
      })
    end)
    self.stop_job = function()
      self.clear_on_redraw = false
      fn.jobstop(job)
      job = nil
    end
    return
  end
  job = start_cmd(tmpbuf, entry, {
    cancel = function() return entry_str ~= self._last_entry end,
    on_send = function()
      if not job or opened then return end
      opened = true
      self:set_preview_buf(tmpbuf, true)
    end,
    on_exit = function()
      if not job then return end
      self:preview_buf_post(entry)
    end,
  })
  self.stop_job = function()
    job:kill(uv.constants.SIGTERM) ---@diagnostic disable-next-line: assign-type-mismatch
    job = nil
  end
end

---@async
---@param entry_str string
---@return false? no preview
function Previewer.buffer_or_file:populate_preview_buf(entry_str)
  ---@cast self fzf-lua.previewer.BufferOrFile
  if not self.win or not self.win:validate_preview() then return end
  local co = assert(coroutine.running())
  -- schedule can avoid "cannot resume running coroutine"
  local entry = self:parse_entry(entry_str,
    vim.schedule_wrap(function(res) assert(coroutine.resume(co, res)) end))
  self._last_entry = entry_str
  entry = entry or coroutine.yield() ---@cast entry fzf-lua.buffer_or_file.Entry
  if entry_str ~= self._last_entry or not self.win:validate_preview() then return false end
  if utils.tbl_isempty(entry) then return end

  -- kill previously running jobs
  self:_stop_preview_job()

  if entry._scratch_buf then
    self:set_preview_buf(entry._scratch_buf)
    self:preview_buf_post(entry)
    return
  end
  -- check if cached is update-to-date to be reuse
  local cached, stale = self.bcache:check(entry) ---@cast cached fzf-lua.BcacheEntry?
  entry.cached = cached

  if cached and not stale then
    self:_apply_cached_preview(entry, cached)
    return
  end

  -- if cached exists, must be stale, otherwise alloc a new buffer
  local buf ---@type integer
  if cached then buf = cached.bufnr else buf = self:get_tmp_buffer() end

  if entry.bufnr and api.nvim_buf_is_loaded(entry.bufnr) and vim.bo[entry.bufnr].filetype ~= "image" then
    self:_populate_loaded_buffer_preview(buf, entry)
    return
  elseif entry.uri and entry.range then
    self:_populate_location_preview(buf, entry)
    return
  end
  if self:populate_terminal_cmd(buf, entry) then
    return
  end
  if entry.cmd then
    return self:_populate_cmd_preview(buf, entry, entry_str)
  end
  if self:attach_snacks_image_buf(buf, entry) then
    return self:preview_buf_post(entry)
  end
  if entry.content then
    return self:_set_preview_lines(buf, entry)
  end
  utils.read_file_async(entry.path, vim.schedule_wrap(function(data)
    if entry_str ~= self._last_entry then return end
    -- if file ends in new line, don't write an empty string as the last line.
    api.nvim_buf_set_lines(buf, 0, -1, false, split_file_data(data))
    -- swap preview buffer with new one
    self:set_preview_buf(buf)
    self:preview_buf_post(entry)
  end))
end

-- Attach ts highlighter, neovim >= v0.9
---@param bufnr integer
---@param ft string
---@return boolean?
local ts_attach = function(bufnr, ft)
  -- ts is already attach, see $VIMRUNTIME/lua/vim/treesitter/highlighter.lua
  if vim.b[bufnr].ts_highlight then return true end
  local lang = vim.treesitter.language.get_lang(ft)
  local loaded = lang and utils.has_ts_parser(lang, "highlights")
  if lang and loaded then
    local ok, err = pcall(vim.treesitter.start, bufnr, lang)
    if not ok then
      utils.warn("unable to attach treesitter highlighter for filetype '%s': %s", ft, err)
    end
    return ok
  end
end

function Previewer.base:update_ts_context()
  local bufnr = self.preview_bufnr
  if not bufnr
      or not self.win
      or not self.win:validate_preview()
      or not self.treesitter.enabled
      or not self.treesitter.context
      or not package.loaded["treesitter-context"]
  then
    return
  end
  local cancel = function()
    local new_bufnr = self.preview_bufnr
    return bufnr ~= new_bufnr or not api.nvim_buf_is_valid(bufnr)
  end
  TSContext.schedule_update(self.win.preview_winid, bufnr, self.win.winopts.zindex + 20, cancel,
    self.treesitter.context)
end

function Previewer.base:update_render_markdown()
  local bufnr = self.preview_bufnr
  local ft = bufnr and vim.b[bufnr] and vim.b[bufnr]._ft or nil
  if not ft
      or not self.render_markdown.enabled
      or not self.render_markdown.filetypes[ft]
  then
    return
  end
  if package.loaded["render-markdown"] then
    ---@diagnostic disable-next-line: unresolved-require
    require("render-markdown").render({ buf = bufnr, event = "FzfLua" })
  elseif package.loaded["markview"] then
    --- Render strictly to save performance.
    ---
    --- Use `strict:render(bufnr, 1000)` to stop rendering if
    --- line count >= 1000.
    local strict = package.loaded["markview"].strict_render;
    ---@diagnostic disable-next-line: undefined-field
    if strict then strict:render(bufnr); end
  end
end

---@param buf integer
---@param entry fzf-lua.buffer_or_file.Entry
---@return boolean success
function Previewer.base:attach_snacks_image_buf(buf, entry)
  ---@diagnostic disable-next-line: undefined-field
  local simg = self.snacks_image.enabled and (_G.Snacks or {}).image
  if not simg or not simg.config.enabled or not simg.supports(entry.path) then
    return false
  end
  simg.buf.attach(buf, { src = entry.path })
  vim.b[buf].snacks_image_attached = true
  -- Similar settings as `populate_terminal_cmd`
  entry.do_not_cache = not utils.map_get(simg.terminal, "_env.placeholders")
  entry.no_scrollbar = true
  self.clear_on_redraw = true
  -- 2nd arg `true`: minimal style window
  self:set_preview_buf(buf, true)
  return true
end

function Previewer.base:attach_snacks_image_inline()
  ---@diagnostic disable-next-line: undefined-field
  local simg = (_G.Snacks or {}).image
  local bufnr = self.preview_bufnr
  if not simg
      or not bufnr
      or not simg.config.enabled
      or not self.snacks_image.enabled
      or not self.snacks_image.render_inline
      or vim.b[bufnr].snacks_image_attached then
    return
  end
  if not simg.terminal._terminal and simg.terminal.detect then -- detection must be done before terminal.env()
    simg.terminal.detect(function() end)
    return
  end
  if not simg.supports_terminal() or not simg.terminal.env().placeholders then
    return
  end

  local ft = vim.b[bufnr]._ft
  if not ft then return end
  local lang = vim.treesitter.language.get_lang(ft)
  if not lang or not utils.has_ts_parser(lang, "images") then
    return
  end
  vim.b[bufnr].snacks_image_attached = simg.inline.new(bufnr)
  vim.defer_fn(function()
    self.win:update_preview_scrollbar()
  end, 500)
end

--- `:h filetype-detect`
---@param bufnr integer
---@param filepath? string
---@return string?
local filetype_detect = function(bufnr, filepath)
  -- prepend the buffer number to the path and
  -- set as buffer name, this makes sure 'filetype detect'
  -- gets the right filetype which enables the syntax
  if filepath then
    local tempname = path.join({ tostring(bufnr), filepath })
    pcall(api.nvim_buf_set_name, bufnr, tempname)
  end
  -- nvim_buf_call has less side-effects than window switch
  -- doautocmd filetypedetect BufRead (vim.filetype.match + ftdetect) + do_modeline
  local ok, _ = pcall(api.nvim_buf_call, bufnr, function()
    utils.eventignore(function() vim.cmd("filetype detect") end, "FileType")
  end)
  if not ok then
    utils.warn(("':filetype detect' failed for '%s'"):format(tostring(filepath)))
  end
  local ft = vim.bo[bufnr].filetype
  return ft ~= "" and ft or nil
end

---@param entry fzf-lua.buffer_or_file.Entry
function Previewer.buffer_or_file:do_syntax(entry)
  if not self.preview_bufnr or entry.cached then return end -- vim.bo[buf]._ft
  local bufnr = self.preview_bufnr
  local preview_winid = self.win.preview_winid
  local filepath = entry.path ---@type string?
  if not api.nvim_buf_is_valid(bufnr)
      or vim.bo[bufnr].filetype ~= ""
      or fn.bufwinid(bufnr) ~= preview_winid
  then
    return
  end

  -- do not enable for large files, treesitter still has perf issues:
  -- https://github.com/nvim-treesitter/nvim-treesitter/issues/556
  -- https://github.com/nvim-treesitter/nvim-treesitter/issues/898
  local lcount = api.nvim_buf_line_count(bufnr)
  local bytes = api.nvim_buf_get_offset(bufnr, lcount)
  local syntax_limit_reached = 0
  if self.syntax_limit_l > 0 and lcount > self.syntax_limit_l then
    syntax_limit_reached = 1
  end
  if self.syntax_limit_b > 0 and bytes > self.syntax_limit_b then
    syntax_limit_reached = 2
  end
  if syntax_limit_reached > 0 and self.opts.silent == false then
    utils.info(
      "syntax disabled for '%s' (%s), consider increasing '%s(%d)'", filepath,
      syntax_limit_reached == 1 and ("%d lines"):format(lcount) or ("%db"):format(bytes),
      syntax_limit_reached == 1 and "syntax_limit_l" or "syntax_limit_b",
      syntax_limit_reached == 1 and self.syntax_limit_l or self.syntax_limit_b
    )
  end

  if syntax_limit_reached ~= 0 then
    return
  end

  -- filetype detect
  local did_filetype_detect = entry.filetype and true or false
  local ft = entry.filetype or vim.filetype.match({
    buf = bufnr,
    filename = filepath,
    contents = entry.content and parse_rich(entry.content) or nil
  })
  if not ft then
    ft = filetype_detect(bufnr, filepath)
    if not ft then return end
    did_filetype_detect = true
  end
  if ft == "diff" or ft == "git" then
    api.nvim_buf_call(bufnr, function()
      api.nvim_exec_autocmds("FileType", { pattern = ft, modeline = false })
    end)
  end

  -- Use buf local var as setting ft might have unintended consequences
  -- used in `update_render_markdown`, `attach_snacks_image`
  vim.b[bufnr]._ft = ft

  local ts_enabled = (function()
    -- disable treesitter on minified (long line) file
    if (bytes / lcount) > self._ts_limit_b_per_line then return false end
    if not self.treesitter or
        self.treesitter.enabled == false or
        self.treesitter.disabled == true or
        (type(self.treesitter.enabled) == "table" and
          not utils.tbl_contains(self.treesitter.enabled, ft)) or
        (type(self.treesitter.disabled) == "table" and
          utils.tbl_contains(self.treesitter.disabled, ft)) then
      return false
    end
    return true
  end)()

  while true do
    if ts_enabled and ts_attach(bufnr, ft) then return end
    if did_filetype_detect then break end
    -- sometimes vim.filetype.match get a poor filetype (e.g. ft=text)
    -- this should be a fallback we should detect it again from ftdetect/modeline
    ft = filetype_detect(bufnr, filepath) or ft
    did_filetype_detect = true
  end
  vim.bo[bufnr].syntax = ft
end

function Previewer.base:maybe_set_cursorline(win, pos)
  if not pos then return end
  local cursorline = false
  if pos[1] >= fn.line("w0", win) and pos[1] <= fn.line("w$", win) then
    -- reset cursor pos even when it's already there, no bigggie
    -- local curpos = api.nvim_win_get_cursor(win)
    api.nvim_win_set_cursor(win, pos)
    cursorline = self.winopts.cursorline or false
  end
  utils.wo[win].cursorline = cursorline and true or false
end

---@param entry fzf-lua.buffer_or_file.Entry
function Previewer.buffer_or_file:set_cursor_hl(entry)
  local mgrep, glob_args = require("fzf-lua.providers.grep"), nil
  local regex = self.opts.__ACT_TO == mgrep.grep and FzfLua.get_info().query
      or self.opts.__ACT_TO == mgrep.live_grep and self.opts.search or nil
  local opts = self.opts.__ACT_TO == mgrep.live_grep and self.opts.__call_opts or self.opts
  if regex and self.opts.fn_transform_cmd then
    local _, query = self.opts.fn_transform_cmd(regex, self.opts.cmd, self.opts)
    regex = query or regex
  elseif regex and (opts.rg_glob or self.opts.rg_glob) and self.opts.glob_separator then
    regex, glob_args = require("fzf-lua.make_entry").glob_parse(regex, self.opts)
  end
  if regex then
    (function()
      -- Check both the cmd and glob_args in case the user has a custom
      -- `rg_glob_fn` which uses raw args (as the wiki example)
      for _, s in ipairs({ self.opts.cmd or self.opts._cmd, glob_args }) do
        if s and (s:match("%-%-fixed%-strings") or s:match("%-F")) then
          regex = utils.rg_escape(regex)
          return
        end
      end
    end)()
  end

  local case_sensitive = regex and regex:lower() ~= regex
  -- If called from tags previewer, can happen when using ctags cmd
  -- "ctags -R --c++-kinds=+p --fields=+iaS --extras=+q --excmd=combine"
  -- vim.regex is always magic, see `:help vim.regex`
  regex = regex and #regex > 0 and utils.regex_to_magic(regex)
      or entry.ctag and utils.ctag_to_magic(entry.ctag)

  ---@diagnostic disable-next-line: unnecessary-assert
  local buf, win, hls = assert(self.preview_bufnr, self.win.preview_winid, self.win.hls)

  local cached_pos = self.bcache:get_pos(buf)
  if type(cached_pos) ~= "table" then cached_pos = nil end
  local lnum, col = entry.line, math.max(1, entry.col or 1)

  if (not lnum or lnum == 0) and entry.ctag and regex then
    -- pcall(fn.clearmatches, self.win.preview_winid)
    pcall(api.nvim_win_call, win, function()
      -- start searching at line 1 in case we
      -- didn't reload the buffer (same file)
      api.nvim_win_set_cursor(0, { 1, 0 })
      fn.clearmatches()
      -- test the regex so we can alert the user of the search fail
      if not utils.vim_regex(regex, { silent = true }) then return end
      fn.search(regex, "W")
      if hls.search then fn.matchadd(hls.search, regex) end
      self.orig_pos = api.nvim_win_get_cursor(0)
      utils.zz()
    end)
    return
  end

  if not lnum or lnum < 1 then
    -- set win option is slow with bigfile
    utils.wo.cursorline = false
    self.orig_pos = { 1, 0 }
    api.nvim_win_set_cursor(win, cached_pos or self.orig_pos)
    return
  end

  self.orig_pos = { lnum, col - 1 }
  ---@diagnostic disable-next-line: param-type-mismatch
  if not pcall(api.nvim_win_set_cursor, win, cached_pos or self.orig_pos) then
    return
  end
  self:maybe_set_cursorline(win, self.orig_pos)

  self.ns_previewer = self.ns_previewer or api.nvim_create_namespace("fzf-lua.preview.hl")
  api.nvim_buf_clear_namespace(buf, self.ns_previewer, 0, -1)
  local extmark

  -- If regex is available (grep/lgrep), match on current line
  if regex and hls.search then
    local regex_start, regex_end
    -- vim.regex is always magic, see `:help vim.regex`
    ---@diagnostic disable-next-line: param-type-mismatch
    local reg = utils.vim_regex(regex, { silent = true })
    if reg then
      regex_start, regex_end = utils.npcall(function()
        if case_sensitive then
          ---@diagnostic disable-next-line: redundant-return-value
          return reg:match_line(buf, lnum - 1, col - 1)
        else
          local line = api.nvim_buf_get_lines(buf, lnum - 1, lnum, false)[1] or ""
          ---@diagnostic disable-next-line: redundant-return-value
          return reg:match_str(line:sub(col):lower())
        end
      end)
    end
    if regex_start and regex_end then
      extmark = api.nvim_buf_set_extmark(buf, self.ns_previewer, lnum - 1, regex_start + col - 1, {
        end_line = lnum - 1,
        end_col = regex_end + col - 1,
        hl_group = entry.hlgroup or hls.search
      })
    end
  end

  -- Fallback to cursor hl, only if column exists
  if not extmark and hls.cursor and entry.col and entry.col > 0 then
    local end_lnum, end_col = entry.end_line or lnum, entry.end_col or col + 1
    -- stale line/col can cause out-of-range, e.g. marks
    utils.nil_wrap(api.nvim_buf_set_extmark)(buf, self.ns_previewer, lnum - 1, col - 1, {
      end_line = end_lnum - 1,
      end_col = math.max(1, end_col) - 1,
      hl_group = entry.hlgroup or hls.cursor,
    })
  end

  api.nvim_win_call(win, utils.zz)
end

---@param entry fzf-lua.buffer_or_file.Entry
---@return nil
function Previewer.buffer_or_file:update_title(entry)
  if not self.title then return end
  if entry.title then return self.win:update_preview_title(entry.title) end
  local filepath, cwd = entry.path, self.opts.cwd
  local title = entry.debug and "[DEBUG]"
      or filepath and (cwd and path.relative_to(filepath, cwd) or filepath)
      or entry.uri or entry.bufname or ""
  -- was transform function defined?
  if self.title_fnamemodify then
    local wincfg = api.nvim_win_get_config(self.win.preview_winid)
    title = self.title_fnamemodify(title, wincfg and wincfg.width)
  end
  if entry.bufnr then
    title = string.format("buf %d: %s", entry.bufnr, title)
  end
  self.win:update_preview_title(" " .. title .. " ")
end

---@param entry fzf-lua.buffer_or_file.Entry
---@param min_winopts boolean?
function Previewer.buffer_or_file:preview_buf_post(entry, min_winopts)
  ---@cast self fzf-lua.previewer.BufferOrFile
  if not self.win or not self.win:validate_preview() then return end

  -- set cursor highlights for line|col or tag
  self:set_cursor_hl(entry)

  local syntax = function()
    if self.syntax then
      self:do_syntax(entry)
      self:update_render_markdown()
      self:update_ts_context()
      self:attach_snacks_image_inline()
    end
    -- for attach_snacks_image_{inline,buf}
    -- https://github.com/folke/snacks.nvim/pull/1615
    if self.preview_bufnr and vim.b[self.preview_bufnr].snacks_image_attached then
      utils.wo[self.win.preview_winid][0].winblend = 0
    end
  end

  -- syntax highlighting
  self:debounce("syntax", self.syntax_delay, syntax)

  self:update_title(entry)

  if entry.no_scrollbar then
    self.win:close_preview_scrollbar()
  else
    self.win:update_preview_scrollbar()
  end

  -- save the loaded entry so we can compare
  -- bufnr|path with the next entry. If equal
  -- we can skip loading the buffer again
  self.loaded_entry = entry --[[@as fzf-lua.buffer_or_file.Entry]]

  -- Should we cache the current preview buffer?
  -- we cache only named buffers with valid path/uri
  self.bcache:set(entry, self.preview_bufnr, min_winopts)
end

---@class fzf-lua.previewer.Tags : fzf-lua.previewer.BufferOrFile,{}
Previewer.tags = Previewer.buffer_or_file:extend()

---@class fzf-lua.previewer.HelpTags : fzf-lua.previewer.BufferOrFile,{}
Previewer.help_tags = Previewer.buffer_or_file:extend()

---@diagnostic disable-next-line: unused
function Previewer.help_tags:parse_entry(entry_str)
  local tag = entry_str:match("^[^%s]+")
  if not tag then
    return { stripped = entry_str }
  end
  local vimdoc = entry_str:match(string.format("[^%s]+$", utils.nbsp))
  assert(vimdoc and #vimdoc > 0, "help_tags entry missing vimdoc path")
  local ctag = (function()
    -- lazy.nvim can generate helptags from markdown
    -- https://github.com/folke/lazy.nvim/blob/56a34a825b55e0e30cd9df0e055e428a13afd4aa/lua/lazy/help.lua#L35
    if package.loaded.lazy and vimdoc:match("%.md$") then
      local tagfile = path.join({ path.parent(vimdoc), "tags" })
      if uv.fs_stat(tagfile) then
        for line in io.lines(tagfile) do
          local res = line:match("^" .. vim.pesc(tag) .. "\t[^\t]+\t/(.*)")
          if res then return res end
        end
      end
    end
    return ([[*%s*]]):format(tag)
  end)()
  return {
    ctag = ctag,
    path = vimdoc,
  }
end

function Previewer.help_tags:gen_winopts()
  local winopts = {
    wrap   = self.win.preview_wrap,
    number = false
  }
  return vim.tbl_extend("keep", winopts, self.winopts)
end

---@class fzf-lua.previewer.ManPages : fzf-lua.previewer.BufferOrFile,{}
---@field super fzf-lua.previewer.BufferOrFile,{}
Previewer.man_pages = Previewer.buffer_or_file:extend()

---@diagnostic disable-next-line: unused
---@return boolean
function Previewer.man_pages:should_clear_preview(_)
  return false
end

function Previewer.man_pages:gen_winopts()
  local winopts = {
    wrap       = self.win.preview_wrap,
    cursorline = false,
    number     = false,
    list       = false,
  }
  return vim.tbl_extend("keep", winopts, self.winopts)
end

function Previewer.man_pages:new(o, opts)
  Previewer.man_pages.super.new(self, o, opts)
  self.cmd = o.cmd or "man -c %s | col -bx"
  self.cmd = type(self.cmd) == "function" and self.cmd() or self.cmd
  return self
end

---@diagnostic disable-next-line: unused
function Previewer.man_pages:parse_entry(entry_str)
  local entry = require("fzf-lua.providers.manpages").manpage_sh_arg(entry_str)
  local cmd = self.cmd:format(entry) ---@type string|string[]
  if type(cmd) == "string" then cmd = { "sh", "-c", cmd } end
  ---@cast cmd string[]
  local output, _ = utils.io_systemlist(cmd)
  return { filetype = "man", content = output }
end

---@class fzf-lua.previewer.Marks : fzf-lua.previewer.BufferOrFile,{}
---@field super fzf-lua.previewer.BufferOrFile,{}
Previewer.marks = Previewer.buffer_or_file:extend()

function Previewer.marks:parse_entry(entry_str)
  local bufnr = nil
  local mark, lnum, col, filepath = entry_str:match("([^ ])%s+(%d+)%s+(%d+)%s+(.*)")
  if not mark then return {} end
  -- try to acquire position from sending buffer
  -- if this succeeds (line>0) the mark is inside

  -- nvim_buf_get_mark cannot get `'` mark correctly without curwin
  -- https://github.com/neovim/neovim/issues/29807
  local win = api.nvim_win_is_valid(self.win.src_winid) and self.win.src_winid or 0
  local buf = api.nvim_buf_is_valid(self.win.src_bufnr) and self.win.src_bufnr or 0
  local pos = api.nvim_win_call(win, function()
    return api.nvim_buf_get_mark(buf, mark)
  end)
  if pos and pos[1] > 0 then
    bufnr = self.win.src_bufnr
    filepath = api.nvim_buf_get_name(bufnr)
  end
  if #filepath > 0 then
    filepath = path.entry_to_file(filepath --[[@as string]], self.opts).path
  end
  return {
    bufnr = bufnr,
    path  = filepath,
    line  = utils.tointeger(lnum) or 1,
    col   = utils.tointeger(col) or 1,
  }
end

---@class fzf-lua.previewer.Jumps : fzf-lua.previewer.BufferOrFile,{}
---@field super fzf-lua.previewer.BufferOrFile,{}
Previewer.jumps = Previewer.buffer_or_file:extend()

function Previewer.jumps:parse_entry(entry_str)
  local bufnr = nil
  local _, lnum, col0, filepath = entry_str:match("(%d+)%s+(%d+)%s+(%d+)%s+(.*)")
  if filepath then
    local ok, res = pcall(libuv.expand, filepath)
    if ok then
      filepath = path.relative_to(res --[[@as string]], utils.cwd())
    end
    if not uv.fs_stat(filepath) then
      -- file is not accessible,
      -- text is a string from current buffer
      bufnr = self.win.src_bufnr
      filepath = api.nvim_buf_get_name(self.win.src_bufnr)
    end
  end
  local col = utils.tointeger(col0)
  return {
    bufnr = bufnr,
    path  = filepath,
    line  = utils.tointeger(lnum) or 1,
    col   = col and col + 1 or 1,
  }
end

---@class fzf-lua.previewer.Hightlights : fzf-lua.previewer.BufferOrFile,{}
---@field super fzf-lua.previewer.Builtin,{}
Previewer.highlights = Previewer.buffer_or_file:extend()

---@diagnostic disable-next-line: unused
---@return boolean
function Previewer.highlights:should_clear_preview(_)
  return false
end

function Previewer.highlights:gen_winopts()
  local winopts = {
    wrap   = self.win.preview_wrap,
    number = false
  }
  return vim.tbl_extend("keep", winopts, self.winopts)
end

---@diagnostic disable-next-line: unused
function Previewer.highlights:parse_entry(entry_str)
  local hl = entry_str:match("^[^%s]+")
  local hlgroup = hl
  local lines = {}
  repeat
    local hl_def = api.nvim_get_hl(0, { name = hl, link = true })
    local block = utils.strsplit(vim.inspect(hl_def, { indent = (" "):rep(fn.shiftwidth()) }), "\n")
    block[1] = string.format("%s = %s", hl, block[1])
    vim.tbl_map(function(l) table.insert(lines, l) end, block)
    hl = hl_def.link
  until not hl
  table.insert(lines, [[]])
  table.insert(lines, { { [["THE QUICK BROWN FOX JUMPS OVER THE LAZY DOG"]], hlgroup } })
  return {
    filetype = "lua",
    title = hl,
    content = lines,
  }
end

---@class fzf-lua.previewer.Quickfix : fzf-lua.previewer.BufferOrFile,{}
---@field super fzf-lua.previewer.Builtin,{}
Previewer.quickfix = Previewer.buffer_or_file:extend()

---@diagnostic disable-next-line: unused
---@return boolean
function Previewer.quickfix:should_clear_preview(_)
  return true
end

function Previewer.quickfix:gen_winopts()
  local winopts = {
    wrap       = self.win.preview_wrap,
    cursorline = false,
    number     = false
  }
  return vim.tbl_extend("keep", winopts, self.winopts)
end

function Previewer.quickfix:parse_entry(entry_str)
  local nr = entry_str:match("[(%d+)]")
  if not nr or tonumber(nr) <= 0 then return {} end
  local qf_list = self.opts.is_loclist and
      ---@diagnostic disable-next-line: assign-type-mismatch
      fn.getloclist(self.win.src_winid, { all = "", nr = tonumber(nr) })
      ---@diagnostic disable-next-line: assign-type-mismatch
      or fn.getqflist({ all = "", nr = tonumber(nr) })
  if utils.tbl_isempty(qf_list) or utils.tbl_isempty(qf_list.items) then return {} end
  local lines = {}
      ---@diagnostic disable-next-line: param-type-mismatch
  for _, e in ipairs(qf_list.items) do
    table.insert(lines, string.format("%s|%d col %d|%s",
      path.HOME_to_tilde(path.relative_to(
        api.nvim_buf_get_name(e.bufnr), utils.cwd())),
      e.lnum, e.col, e.text))
  end
  return {
    title = string.format(" %s: %s ", nr, qf_list.title),
    filetype = "qf",
    content = lines,
  }
end

---@class fzf-lua.previewer.Autocmds : fzf-lua.previewer.BufferOrFile,{}
---@field super fzf-lua.previewer.BufferOrFile,{}
Previewer.autocmds = Previewer.buffer_or_file:extend()

function Previewer.autocmds:gen_winopts()
  if not self._is_vimL_command then
    return self.winopts
  end
  -- set wrap and no cursorline/numbers for vimL commands
  local winopts = {
    wrap       = true,
    cursorline = false,
    number     = false,
  }
  return vim.tbl_extend("keep", winopts, self.winopts)
end

function Previewer.autocmds:parse_entry(entry_str)
  local entry = self.super.parse_entry(self, entry_str)
  self._is_vimL_command = entry.path and entry.path:sub(-6) == "<none>"
  if not self._is_vimL_command then return entry end
  local content = {}
  local parts = vim.tbl_map(vim.trim, vim.split(entry_str, "│"))
  local event, pattern, group, code = unpack(parts, 2)
  content[#content + 1] = "autocmd " .. event .. (pattern ~= "" and (" " .. pattern) or "")
  content[#content + 1] = "\\ " .. code
  local cmd = ("verb au %s %s %s"):format(group, event, pattern)
  local output = vim.split(api.nvim_exec2(cmd, { output = true }).output, "\n")
  local file, lnum = (output[#output] or ""):match("Last set from (.-) line (%d+)")
  if file and lnum then
    self._is_vimL_command = false
    return { path = vim.fs.normalize(file), line = utils.tointeger(lnum) or 1 }
  end
  if group ~= "" then
    content = vim.tbl_map(function(s) return (" "):rep(fn.shiftwidth()) .. s end, content)
    table.insert(content, 1, "augroup " .. group)
    content[#content + 1] = "augroup END"
  end
  return { cache_key = false, filetype = "vim", content = content }
end

---@class fzf-lua.previewer.Keymaps : fzf-lua.previewer.BufferOrFile,{}
---@field super fzf-lua.previewer.BufferOrFile,{}
Previewer.keymaps = Previewer.buffer_or_file:extend()

---@diagnostic disable-next-line: unused
---@param entry_str string
---@return fzf-lua.keymap.Entry
function Previewer.keymaps:parse_entry(entry_str)
  local entry = path.keymap_to_entry(entry_str)
  if not entry.vmap then return entry end
  return {
    filetype = "lua",
    title = string.format("%s:%s", entry.mode, entry.key),
    content = utils.strsplit(entry.vmap, "\n"),
  }
end

---@class fzf-lua.previewer.NvimOptions : fzf-lua.previewer.BufferOrFile,{}
---@field super fzf-lua.previewer.BufferOrFile,{}
Previewer.nvim_options = Previewer.buffer_or_file:extend()

function Previewer.nvim_options:new(o, opts)
  Previewer.nvim_options.super.new(self, o, opts)
  local paths = fn.globpath(vim.o.rtp, "doc/options.txt", false, true)
  self.lines = fn.readfile(paths[1])
  return self
end

function Previewer.nvim_options:gen_winopts()
  local winopts = {
    wrap = true,
    number = false,
    relativenumber = false,
    cursorline = false,
    conceallevel = 3,
  }
  return vim.tbl_extend("keep", winopts, self.winopts)
end

function Previewer.nvim_options:get_help_text(tag)
  local tag_pattern = "%*'" .. tag .. "'%*"

  local start_index
  for i, line in ipairs(self.lines) do
    if line:match(tag_pattern) then
      start_index = i
      break
    end
  end
  if not start_index then
    return nil, nil
  end

  local heading_pattern = "%*'[^']*'%*"
  local end_index = #self.lines
  for j = start_index + 1, #self.lines do
    if self.lines[j]:match(heading_pattern) then
      end_index = j - 1
      break
    end
  end

  -- get lines between start and end
  local lines = {}
  for i = start_index, end_index do
    lines[#lines + 1] = self.lines[i]
  end

  return lines
end

function Previewer.nvim_options:parse_entry(entry_str)
  local parts = vim.split(entry_str, self.opts.separator) ---@cast parts [string, string]
  local option = vim.trim(parts[1])
  local value = vim.trim(parts[2])
  local content = { "Value: " .. value, "", "" }
  vim.list_extend(content, self:get_help_text(option) or {})
  local info = vim.inspect(api.nvim_get_option_info2(option, {}))
  vim.list_extend(content, { "*info* >lua" })
  vim.list_extend(content, vim.tbl_map(function(s) return "  " .. s end, vim.split(info, "\n")))
  return { title = string.format(" %s ", option), filetype = "help", content = content }
end

---@class fzf-lua.previewer.VTerm : fzf-lua.previewer.BufferOrFile,{}
---@field super fzf-lua.previewer.BufferOrFile,{}
Previewer.vterm = Previewer.buffer_or_file:extend()

---@param o table
---@param opts table
---@param previewer fzf-lua.previewer.Fzf
---@return fzf-lua.previewer.VTerm
function Previewer.vterm:new(o, opts, previewer)
  Previewer.vterm.super.new(self, o, opts)
  self.previewer = previewer
  return self
end

function Previewer.vterm:parse_entry(entry_str)
  local entry = Previewer.vterm.super.parse_entry(self, entry_str)
  local spec = self.previewer:cmdline() ---@type fzf-lua.preview.spec
  assert(type(spec) == "table" and spec.fn, "invalid vterm previewer spec")
  ---@diagnostic disable-next-line: need-check-nil
  local preview = assert(self.win.layout.preview)
  -- usually `{} {q}` is used as most common field index
  ---@diagnostic disable-next-line: param-type-mismatch
  local res = spec.fn({ entry_str, FzfLua.get_info().query }, preview.height, preview.width)
  if spec.type == "cmd" then
    local cmdspec = type(res) == "table" and res or { cmd = { "sh", "-c", res }, env = nil }
    local cmd = type(cmdspec.cmd) == "table" and cmdspec.cmd or { "sh", "-c", cmdspec.cmd }
    return { cmd = cmd, cmd_opts = { env = cmdspec.env }, line = entry.line, col = entry.col }
  else
    assert(type(res) == "table", res)
    ---@diagnostic disable-next-line: assign-type-mismatch
    return { content = res, open_term = true, line = entry.line, col = entry.col }
  end
end

return Previewer