current commit
261015.3 · refactor(core): emit raw fzf cli segments alongside escaped args
local Object = require "fzf-lua.class"
local path = require "fzf-lua.path"
local utils = require "fzf-lua.utils"
local libuv = require "fzf-lua.libuv"
local shell = require "fzf-lua.shell"

local M = {}

---@class fzf-lua.previewer.SwiperBase: fzf-lua.Object, {}
---@field opts table
---@field ctx_winid integer
---@field ctx_bufnr integer
---@field ctx_winopts table
---@field ns integer
---@field picker string
---@field win fzf-lua.Win
M.base = Object:extend()

---@param o fzf-lua.config.Previewer
---@param opts fzf-lua.config.Resolved
---@return fzf-lua.previewer.SwiperBase?
function M.base:new(o, opts)
  o = o or {}
  self.opts = opts;
  local ctx = opts.__CTX
  self.ctx_winid = ctx.winid
  self.ctx_bufnr = ctx.bufnr
  self.ctx_winopts = vim.deepcopy(ctx.winopts or {})
  assert(vim.api.nvim_buf_is_valid(self.ctx_bufnr), "origin buf is not valid")
  assert(vim.api.nvim_win_is_valid(self.ctx_winid), "origin win is not valid")
  assert(self.ctx_bufnr == vim.api.nvim_win_get_buf(self.ctx_winid), "win buf mismatch")
  self.ns = vim.api.nvim_create_namespace("fzf-lua.preview.swiper")
  self.picker = FzfLua.get_info().cmd
  if not utils.tbl_contains({
        "blines", "grep_curbuf", "lgrep_curbuf", "git_blame", "treesitter", "lsp_document_symbols"
      }, self.picker)
  then
    utils.warn("swiper previewer is not supported for '%s'", self.picker)
    return
  end
  return self
end

---@param opts table
---@return table
function M.base:setup_opts(opts)
  -- for `win:treesitter_attach.on_lines` max_col calc
  opts.winopts.preview.vertical = "up:0%"
  opts.winopts.preview.horizontal = "right:0%"
  opts.fzf_opts["--preview-window"] = "nohidden:right:0"
  opts.preview = self:preview_cmd()
  table.insert(opts._fzf_cli_args, libuv.cli_raw_arg("--bind=", "result:+" .. self:result_cmd()))
  -- NOTE: I don't feel we need the zero event, we just leave the cursor where it's at instead
  -- if utils.has(opts, "fzf", { 0, 40 }) then
  --   table.insert(opts._fzf_cli_args, "--bind=" .. libuv.shellescape("zero:+" .. self:zero_cmd()))
  -- end
  return opts
end

-- function M.base:zero_cmd(_)
--   return string.format("execute-silent:%s", shell.stringify_data(function(_, _, _)
--   end, self.opts))
-- end

---@param line string
---@param is_entry boolean?
---@return integer? lnum
---@return integer? col
---@return integer? col_valid_idx
---@return integer? col_offset
function M.base:parse_lnum_col(line, is_entry)
  local prefix, lnum, col, off
  if self.picker == "git_blame" then
    prefix, lnum = line:match("^(.-)(%d+)%)")
    off = 1 + (lnum and #tostring(lnum) or 0)
  elseif is_entry then
    local parsed = path.entry_to_file(line, self.opts, self.opts._uri)
    lnum, col = parsed.line, parsed.col
  elseif self.picker == "blines" then
    prefix, lnum = line:match("^(.-)(%d+)")
    off = 1 + (lnum and #tostring(lnum) or 0)
  elseif self.picker:match("l?grep_curbuf") then
    prefix, lnum, col = line:match("^(.-)(%d+):(%d+):")
    if not lnum then
      -- col may be missing if search=""
      prefix, lnum = line:match("^(.-)(%d+):")
    end
    off = (lnum and #tostring(lnum) or 0) + (col and (#tostring(col) + 1) or 0)
  elseif self.picker == "treesitter" or self.picker == "lsp_document_symbols" then
    prefix = line:gsub("%s+$", ""):match("(.*%s+).+$")
    lnum, col = line:match("^.-(%d+):(%d+)%s")
    off = col and (1 - assert(utils.tointeger(col)) - 1) or nil
  end
  if lnum then
    -- use `vin.fn.strwidth` for proper adjustment of fzf's marker/pointer if unicode
    return utils.tointeger(lnum), utils.tointeger(col), prefix and vim.fn.strwidth(prefix) or 0,
        off or 0
  end
end

function M.base:preview_cmd()
  return shell.stringify_data(function(items, _, _)
    ---@type string?, string?, string?
    local entry, idx, _ = unpack(items, 1, 3)
    if not entry or not tonumber(idx) then return end
    local lnum, col = self:parse_lnum_col(entry, true)
    if not lnum or lnum < 1 then return end
    vim.api.nvim_win_set_cursor(self.ctx_winid, { lnum, col or 0 })
    vim.wo[self.ctx_winid].cursorline = true
    vim.wo[self.ctx_winid].winhl = "CursorLine:" .. self.opts.hls.cursorline
    vim.defer_fn(function() self:highlight_matches() end, 10)
    if col and col > 0 then
      vim.hl.range(self.ctx_bufnr, self.ns, self.opts.hls.cursor,
        { lnum - 1, col - 1 }, { lnum - 1, col }, {})
    end
  end, self.opts, "{} {n} {q}")
end

function M.base:result_cmd()
  return string.format("execute-silent:%s", shell.stringify_data(function(_, _, _)
    vim.defer_fn(function() self:highlight_matches() end, 10)
  end, self.opts, "{q}"))
end

function M.base:highlight_matches()
  -- Credit to phanen@GitHub:
  -- https://github.com/ibhagwan/fzf-lua/issues/1754#issuecomment-2944053022
  local hl = function(start_row, start_col, end_row, end_col)
    assert(start_col >= 0 and end_col >= 0, "start_col and end_col must be non-negative")
    vim.hl.range(self.ctx_bufnr, self.ns, "IncSearch",
      { start_row, start_col }, { end_row, end_col }, {})
  end
  vim.api.nvim_buf_clear_namespace(self.ctx_bufnr, self.ns, 0, -1)
  local hlgroup = self.opts.fzf_colors.hl and self.opts.fzf_colors.hl[2]
  if type(hlgroup) == "table" then
    hlgroup = hlgroup[1]
  end
  local fg = (function()
    local hldef = vim.api.nvim_get_hl(0, { link = false, name = hlgroup })
    return hldef and hldef.fg
  end)()
  local win = assert(utils.fzf_winobj())
  local fzf_win = win.fzf_winid
  local fzf_buf = win.fzf_bufnr
  if not vim.api.nvim_win_is_valid(fzf_win) or not vim.api.nvim_buf_is_valid(fzf_buf) then
    return
  end
  local top = vim.fn.line("w0", fzf_win)
  local bot = vim.fn.line("w$", fzf_win)
  local top_row = vim.fn.screenpos(fzf_win, top, 1).row
  local max_columns = vim.o.columns
  local buf_lines = vim.api.nvim_buf_get_lines(fzf_buf, 0, -1, false)
  for buf_lnum = top, bot do
    (function()
      local r = top_row + (buf_lnum - top)
      local lnum, _, col_valid_idx, col_off = self:parse_lnum_col(assert(buf_lines[buf_lnum]))
      if not lnum or lnum < 1 then return end
      local state = { bytelen = 0 }
      for c = 1, max_columns do
        local ok, ret = pcall(vim.api.nvim__inspect_cell, 1, r, c)
        if not ok or not ret[1] then break end
        (function()
          local in_match = ret[2] and (ret[2].reverse or ret[2].foreground == fg)
          if in_match and not state.matchlen then
            assert(col_valid_idx and col_off)
            if c < col_valid_idx then return end
            state.start_col = math.max(state.bytelen - col_valid_idx - col_off, 0)
            state.matchlen = ret[1]:len()
            return
          end
          if in_match then
            state.matchlen = state.matchlen + ret[1]:len()
            return
          end
          if state.matchlen then
            hl(lnum - 1, state.start_col, lnum - 1, state.start_col + state.matchlen)
            state.matchlen = nil
          end
        end)()
        state.bytelen = state.bytelen + vim.fn.strwidth(ret[1])
      end
    end)()
  end
end

function M.base:close()
  vim.api.nvim_buf_clear_namespace(self.ctx_bufnr, self.ns, 0, -1)
  -- on hide + change picker ctx will be nil
  local ctx = utils.__CTX()
  if not ctx then return end
  vim.wo[ctx.winid].winhl = ctx.winopts.winhl
  vim.wo[ctx.winid].cursorline = ctx.winopts.cursorline
  vim.api.nvim_win_set_cursor(ctx.winid, ctx.cursor)
  utils.zz()
end

---@class fzf-lua.previewer.Swiper : fzf-lua.previewer.SwiperBase,{}
---@field super fzf-lua.previewer.SwiperBase
M.default = M.base:extend()

function M.default:new(o, opts)
  M.default.super.new(self, o, opts)
  return self
end

return M