current commit
261015.3 · refactor(core): emit raw fzf cli segments alongside escaped args
---@diagnostic disable-next-line: deprecated
local uv = vim.uv or vim.loop
local core = require "fzf-lua.core"
local path = require "fzf-lua.path"
local utils = require "fzf-lua.utils"
local libuv = require "fzf-lua.libuv"
local config = require "fzf-lua.config"

local M = {}

---@param opts fzf-lua.config.Builtin|{}
---@return thread?, string?, table?
M.metatable = function(opts)
  if not opts then return end

  ---@diagnostic disable-next-line: inject-field
  if not opts.metatable then opts.metatable = getmetatable("").__index end

  local methods = {}
  for k, _ in pairs(opts.metatable) do
    if not opts.metatable_exclude or opts.metatable_exclude[k] == nil then
      table.insert(methods, k)
    end
  end

  table.sort(methods, function(a, b) return a < b end)

  return core.fzf_exec(methods, opts)
end

---@param dir string
---@param fn fun(fname: string, name: string, type: string)
local function ls(dir, fn)
  local handle = uv.fs_scandir(dir)
  while handle do
    local name, t = uv.fs_scandir_next(handle)
    if not name then
      break
    end

    local fname = path.join({ dir, name })

    -- HACK: type is not always returned due to a bug in luv,
    -- so fecth it with fs_stat instead when needed.
    -- see https://github.com/folke/lazy.nvim/issues/306
    fn(fname, name, t or (uv.fs_stat(fname) or {}).type)
  end
end

---@param opts fzf-lua.config.Profiles|{}?
---@return thread?, string?, table?
M.profiles = function(opts)
  ---@type fzf-lua.config.Profiles
  opts = config.normalize_opts(opts, "profiles")
  if not opts then return end

  if opts.load then
    -- silent = [2]
    require("fzf-lua").setup({ opts.load, false })
    return
  end

  local dirs = {
    path.join({ vim.g.fzf_lua_directory, "profiles" })
  }

  local contents = function(cb)
    coroutine.wrap(function()
      local co = coroutine.running()
      ---@cast co thread

      for _, d in ipairs(dirs) do
        ls(d, function(fname, name, type)
          local ext = path.extension(fname)
          if type == "file" and ext == "lua" then
            local profile = name:sub(1, #name - 4)
            local res = utils.load_profile_fname(fname, profile, true)
            if res then
              local entry = string.format("%s:%-30s%s", fname,
                utils.ansi_codes.yellow(profile), res.desc or "")
              cb(entry, function(err)
                coroutine.resume(co)
                if err then cb(nil) end
              end)
              coroutine.yield()
            end
          end
        end)
      end
      -- done
      cb(nil)
    end)()
  end

  return core.fzf_exec(contents, opts)
end

local function gen_def(n, o)
  local _, contents, opts = FzfLua[n](o)
  return {
    name = n,
    opts = opts,
    contents = contents,
  }
end

M.combine = function(t)
  t = t or {}

  local pickers = (type(t.pickers) == "table" and t.pickers
    or type(t.pickers) == "string" and utils.strsplit(t.pickers, "[,;]"))

  assert(pickers)
  local opts = t
  t.pickers = nil

  -- Tells fzf_wrap to not start the fzf process
  opts._start = false

  local cmds = {}
  local opts_copy = vim.deepcopy(opts)
  for i, name in ipairs(pickers) do
    if FzfLua[name] then
      -- Default picker opts set the tone for this picker options
      -- this way convert reload / exec_silent actions will use a consistent
      -- opts ref in the callbacks so we can later modify internal values
      local def = gen_def(name, i == 1 and opts or opts_copy)
      if i == 1 then
        -- Override opts with the modified return opts and remove start suppression
        opts = def.opts
        opts._start = nil
      end
      -- Instantiate the previewer, nil check as opts isn't guaranteed if the
      -- picker isn't avilable, e.g. `tags` when no tags file exists
      if def.opts and def.opts.previewer then
        def.previewer = require("fzf-lua.previewer").new(def.opts.previewer, def.opts)
      end
      -- Add content (shell command) to cmd array
      table.insert(cmds, def.contents)
    else
      utils.warn("invalid picker '%s', ignoring.", name)
    end
  end

  -- _G.dump(cmds)
  local contents = table.concat(cmds, utils.__IS_WINDOWS and "&" or ";")

  return core.fzf_wrap(contents, opts)
end

---@param opts fzf-lua.config.Global|{}?
---@return thread?, string?, table?
M.global = function(opts)
  -- -@type fzf-lua.config.GlobalStrict
  ---@type fzf-lua.config.Global
  opts = config.normalize_opts(opts, "global")
  if not opts then return end

  if opts.line_query and not utils.has(opts, "fzf", { 0, 59 }) then
    utils.warn("'global' requires fzf >= 0.59, reverting to files.")
    return FzfLua.files(opts)
  end

  if type(opts.pickers) == "function" then
    opts.pickers = opts.pickers()
  end

  opts._start = false    -- Tells fzf_wrap to not start the fzf process
  opts._normalized = nil -- We need to "normalize" again with the picker opts
  local pickers = {}
  local opts_copy = vim.deepcopy(opts)
  for _, t in ipairs(opts.pickers) do
    local name = t[1]
    if FzfLua[name] then
      local def
      if not t.prefix then
        -- Default picker opts set the tone for this picker options
        -- this way convert reload / exec_silent actions will use a consistent
        -- opts ref in the callbacks so we can later modify internal values
        def = gen_def(name, opts)
        pickers[name] = def
        -- Override opts with the return opts and store a copy of `pickers[]`
        -- as we patch the opts when switching a picker in the change event
        opts = def.opts
        opts._start = nil -- remove the start suppression
        def.opts = vim.deepcopy(opts)
      else
        -- Each subsequent picker gets a fresh copy of the original opts
        -- (unmodified by the default picker)
        def = gen_def(name, t.opts
          and vim.tbl_deep_extend("force", {}, opts_copy, t.opts)
          or opts_copy)
        pickers[name] = def
      end
      -- HACK: inherit fzf_opts from the normalized picker opts so we have
      -- with-nth for the transform (e.g. for blines)
      -- TODO: we can probably inerit all opts but I'm not sure what would be
      -- the effects of that, this entire picker needs refactoring
      local picker_opts = config.normalize_opts(t.opts,
        name:gsub("^lsp_", "lsp."):gsub("^git_", "git."):gsub("^dap_", "dap."))
      if not utils.map_get(def, "opts.fzf_opts") then utils.map_set(def, "opts.fzf_opts", {}) end
      def.opts.fzf_opts = vim.tbl_deep_extend("force", def.opts.fzf_opts, picker_opts.fzf_opts)
      -- Instantiate the previewer, opts isn't guaranteed if the picker
      -- isn't avilable, e.g. `tags` when not tags file exists
      if def.opts and def.opts.previewer then
        ---@diagnostic disable-next-line: inject-field
        def.previewer = require("fzf-lua.previewer").new(def.opts.previewer, def.opts)
      end
    else
      utils.warn("invalid picker '%s', ignoring.", name)
    end
  end

  -- Test for default/starting picker
  local default_picker = opts.pickers[1] and pickers[opts.pickers[1][1]]
  if not default_picker or default_picker.prefix then
    utils.error("default picker not defined or has a prefix, aborting.")
    return
  end

  ---@param q string?
  ---@return table?, integer?
  local get_picker = function(q)
    if type(q) == "string" and #q > 0 then
      for _, t in ipairs(opts.pickers) do
        local name = t[1]
        if t.prefix and #t.prefix > 0 and q:match("^" .. utils.lua_regex_escape(t.prefix)) then
          return pickers[name], #t.prefix + 1
        end
      end
    end
    return default_picker, 1
  end


  local cur_picker, cur_sub

  local transform_picker = function(start)
    return FzfLua.shell.stringify_data(function(args, _, _)
      local q = args[1]
      local new_picker, new_sub = get_picker(q)
      assert(new_picker)
      local reload = ""
      if start or new_picker and new_picker ~= cur_picker then
        -- New picker requested, reload the contents and transform
        -- the search string to exclude the picker prefix
        -- Patch the opts refs with important values for path parsing
        -- e.g. formatter, path_shorten, etc
        cur_picker = cur_picker or new_picker
        -- TODO: is there a better way to override the callback opts ref?
        opts.__alt_opts = new_picker.opts
        -- Attach a new previewer if exists
        local win = FzfLua.utils.fzf_winobj()
        if win
            and new_picker.previewer
            and new_picker.previewer ~= cur_picker.previewer
        then
          win:close_preview()
          win:attach_previewer(new_picker.previewer)
          win:redraw_preview()
        end
        reload = string.format("reload(%s)+", new_picker.contents)
        -- fzf >= 0.69 supports `change-with-nth` to dynamically change `--with-nth`
        -- dynamically update `--with-nth` per-picker (fzf >= 0.69)
        -- e.g. blines uses `--with-nth=4..` to hide buffer numbers
        if utils.has(opts, "fzf", { 0, 69 }) then
          -- `false|nil` defaults to all fields (..)
          local with_nth = utils.map_get(new_picker, "opts.fzf_opts.--with-nth")
          reload = reload .. string.format("change-with-nth(%s)+", with_nth or "..")
        end
        cur_sub = new_sub
        cur_picker = new_picker
      end
      if not q or not cur_sub then return end
      return reload .. string.format("search(%s)", q:sub(cur_sub))
    end, opts, "{q}")
  end

  opts._fzf_cli_args = opts._fzf_cli_args or {}
  -- Insert at the start of the args table so `line_query` callback is first
  table.insert(opts._fzf_cli_args, 1,
    libuv.cli_raw_arg("--bind=", "start:+transform:" .. transform_picker(true)))

  table.insert(opts._fzf_cli_args, 2,
    libuv.cli_raw_arg("--bind=", "change:+transform:" .. transform_picker(false)))

  if opts.header ~= false then
    local header = {}
    for _, t in pairs(opts.pickers) do
      table.insert(header, string.format("<%s> %s",
        utils.ansi_from_hl(opts.hls.header_bind, t.prefix or "default"),
        utils.ansi_from_hl(opts.hls.header_text, t.desc or t[1])))
    end

    opts.header = table.concat(header, "|")
  end

  return core.fzf_wrap(utils.shell_nop(), opts)
end

return M