current commit
260708.2 · chore(types): emmylua_check lint warnings
---@diagnostic disable-next-line: deprecated
local uv = vim.uv or vim.loop
local path = require "fzf-lua.path"
local utils = require "fzf-lua.utils"
local Object = require "fzf-lua.class"

-- Our "copy" of the devicons library functions so we can load the library
-- from the headless instance and better support edge cases like multi-part
-- extension names (#1053)

local DevIconsBase = Object:extend()

function DevIconsBase:loaded()
  return self._package_loaded
end

function DevIconsBase:path()
  return self._package_path
end

function DevIconsBase:name()
  return self._name
end

function DevIconsBase:state()
  return self._state
end

function DevIconsBase:set_state(state)
  self._state = state
end

function DevIconsBase:unload()
  self._state = nil
  self._package = nil
  self._package_path = nil
  self._package_loaded = nil
end

local NvimWebDevicons = DevIconsBase:extend()

function NvimWebDevicons:new()
  self._name = "devicons"
  self._package_name = "nvim-web-devicons"
  return self
end

function NvimWebDevicons:load(do_not_lazy_load)
  if not self._package_loaded
      -- do not trigger lazy loading
      and (not do_not_lazy_load or package.loaded[self._package_name])
  then
    self._package_loaded, self._package = pcall(require, self._package_name)
    if self._package_loaded then
      local _info = debug.getinfo(self._package.setup, "S")
      ---@cast _info {source: string}
      ---@diagnostic disable-next-line: param-type-mismatch
      self._package_path = path.parent(path.parent(path.normalize(
        _info.source:gsub("^@", ""))))
    end
  end
  return self._package_loaded
end

function NvimWebDevicons:is_mock()
  return type(self._package_path) == "string"
      and self._package_path:match("mini") ~= nil
end

---@param opts table
---@return boolean|nil success
function NvimWebDevicons:load_icons(opts)
  if not self:loaded() then return end

  self._state = vim.tbl_deep_extend("force", self._state or {}, {
    icon_padding = type(opts.icon_padding) == "string" and opts.icon_padding or nil,
    dir_icon = vim.tbl_extend("force", { icon = "", color = nil }, opts.dir_icon or {}),
    default_icon =
        vim.tbl_extend("force", { icon = "", color = "#6d8086" }, opts.default_icon or {}),
  })

  -- test if we have the correct icon set for the current background
  -- if the background changed from light<->dark, refresh the icons (#855)
  if self._state and self._state.icons
      and self._state.bg == vim.o.bg
      and self._state.termguicolors == vim.o.termguicolors
  then
    return true
  end
  assert(self._state)

  -- save the current background & termguicolors
  self._state.bg = vim.o.bg
  self._state.termguicolors = vim.o.termguicolors

  -- The state object needs to be RPC request compatible
  -- rpc request cannot return a table that has mixed elements
  -- of both indexed items and key value, it will fail with
  -- "Cannot convert given lua table", we therefore build our
  -- state object as a key/value map

  -- NOTES:
  -- (1) devicons.get_icons() returns the default icon in [1]
  -- (2) we cannot rely on having either .name or .color (#817)
  local ok, all_devicons = pcall(function()
    if self._state and self._state.icons then
      self._package.refresh() -- reloads light|dark theme
    end
    return self._package.get_icons()
  end)
  if not ok or not all_devicons or utils.tbl_isempty(all_devicons) then
    -- something is wrong with devicons
    utils.error("devicons.get_icons() is nil or empty!")
    return
  end ---@cast all_devicons table
  local icons = {
    by_filename = self._package.get_icons_by_filename(),
    by_extension = self._package.get_icons_by_extension(),
  }
  if type(all_devicons[1]) == "table" then
    self._state.default_icon.icon = all_devicons[1].icon or self._state.default_icon.icon
    self._state.default_icon.color =
        (self._state.termguicolors and all_devicons[1].color or all_devicons[1].cterm_color) or
        self._state.default_icon.color
  end
  self._state.icons = {
    by_filename = {},  -- full filename (path.tail) lookup
    by_ext = {},       -- simple extension lookup
    by_ext_2part = {}, -- 2-part extensions, e.g. "foo.test.js"
    -- lookup table to indicate extension has potentially has better match
    -- in the 2part for example, ".js" will send us looking for "test.js"
    ext_has_2part = {},

  }
  for k, v in pairs(all_devicons) do
    -- skip all indexed (numeric) entries
    if type(k) == "string" then
      local info = {
        -- NOTE: we no longer need name since we use the RGB color directly
        -- name = v.name or k,
        icon = v.icon or "",
        color = (self._state.termguicolors and v.color or v.cterm_color)
            or (function()
              -- some devicons customizations remove `info.color`
              -- retrieve the color from the highlight group (#801)
              local hlgroup = "DevIcon" .. (v.name or k)
              local hexcol = utils.hexcol_from_hl(hlgroup, "fg", opts.mode)
              if hexcol and #hexcol > 0 then
                return hexcol
              end
            end)(),
      }
      -- NOTE: entries like "R" can appear in both icons by filename/extension
      if icons.by_filename[k] then
        self._state.icons.by_filename[k] = info
      end
      if icons.by_extension[k] then
        if k:match(".+%.") then
          self._state.icons.by_ext_2part[k] = info
          self._state.icons.ext_has_2part[path.extension(k)] = true
        else
          self._state.icons.by_ext[k] = info
        end
      end
      -- if not icons_by_extension[k] and not icons_by_filename[k] then
      --   print("icons_by_operating_system", k)
      -- end
    end
  end
  return true
end

function NvimWebDevicons:icon_by_ft(ft)
  if not self:loaded() then return end
  return self._package.get_icon_by_filetype(ft)
end

local MiniIcons = DevIconsBase:extend()

function MiniIcons:new()
  self._name = "mini"
  self._package_name = "mini.icons"
  return self
end

function MiniIcons:load(do_not_lazy_load)
  if not self._package_loaded
      -- do not trigger lazy loading
      and (not do_not_lazy_load or package.loaded[self._package_name])
  then
    self._package_loaded, self._package = pcall(require, self._package_name)
    if self._package_loaded then
      local _info = debug.getinfo(self._package.setup, "S")
      ---@cast _info {source: string}
      ---@diagnostic disable-next-line: param-type-mismatch
      self._package_path = path.parent(path.parent(path.parent(path.normalize(
        _info.source:gsub("^@", "")))))
    end
  end
  return self._package_loaded
end

function MiniIcons:refresh_hlgroups(mode)
  if not self._state or not self._hlgroups then return end
  self._state.hl2hex = {}
  for hl, _ in pairs(self._hlgroups) do
    self._state.hl2hex["_" .. hl] = utils.hexcol_from_hl(hl, "fg", mode)
  end
end

function MiniIcons:load_icons(opts)
  if not self:loaded() then return end

  -- Icon set already loaded, refresh hlgroups and return
  if self._state and self._state.icons then
    self:refresh_hlgroups(opts.mode)
    return true
  end

  -- Mini.icons requires calling `setup()`
  ---@diagnostic disable-next-line: undefined-field
  if not _G.MiniIcons then
    require(self._package_name).setup()
  end

  -- Something isn't right
  if not _G.MiniIcons then return end

  -- Automatically discover highlight groups used by mini
  self._hlgroups = {}

  local function mini_get(category, name)
    local icon, hl = _G.MiniIcons.get(category, name)
    -- Store for `:refresh_hlgroups()`
    self._hlgroups[hl] = true
    -- Adding _underscore tells `get_devicon` to resolve using `_state.hl2hex`
    return { icon = icon, color = "_" .. hl }
  end

  self._state = vim.tbl_deep_extend("force", self._state or {}, {
    icon_padding = type(opts.icon_padding) == "string" and opts.icon_padding or nil,
    dir_icon = mini_get("default", "directory"),
    default_icon = mini_get("default", "file"),
    icons = {
      by_filename_case_sensitive = true,
      by_filename = {},  -- full filename (path.tail) lookup
      by_filetype = {},  -- filetype lookup (vim.filetype.match)
      by_ext = {},       -- simple extension lookup
      by_ext_2part = {}, -- 2-part extensions, e.g. "foo.test.js"
      -- lookup table to indicate extension has potentially has better match
      -- in the 2part for example, ".js" will send us looking for "test.js"
      ext_has_2part = {},
    }
  })

  for _, file in ipairs(_G.MiniIcons.list("file")) do
    self._state.icons.by_filename[file] = mini_get("file", file)
  end

  for _, ext in ipairs(_G.MiniIcons.list("extension")) do
    local info = mini_get("extension", ext)
    if ext:match(".+%.") then
      self._state.icons.by_ext_2part[ext] = info
      self._state.icons.ext_has_2part[path.extension(ext)] = true
    else
      self._state.icons.by_ext[ext] = info
    end
  end

  for _, ft in ipairs(_G.MiniIcons.list("filetype")) do
    self._state.icons.by_filetype[ft] = mini_get("filetype", ft)
  end

  -- Extensions that have weird behaviors within `vim.filetype.match`
  -- https://github.com/ibhagwan/fzf-lua/issues/1358#issuecomment-2254215160
  for k, v in pairs({
    sh   = "sh",
    bash = "sh",
    ksh  = "sh",
    tcsh = "sh",
  }) do
    self._state.icons.by_ext[k] = self._state.icons.by_filetype[v]
  end

  -- Resolve discovered hlgroups to colors
  self:refresh_hlgroups(opts.mode)

  return true
end

function MiniIcons:icon_by_ft(ft)
  if not self:loaded() then return end
  return self._package.get("filetype", ft)
end

local FzfLuaServer = DevIconsBase:extend()

function FzfLuaServer:new()
  self._name = "srv"
  return self
end

---@diagnostic disable-next-line: unused
function FzfLuaServer:path()
  ---@diagnostic disable-next-line: undefined-field
  return _G._fzf_lua_server or vim.g.fzf_lua_server
end

function FzfLuaServer:load(_)
  return type(self:path()) == "string"
end

function FzfLuaServer:load_icons(opts)
  if type(self._state) == "table" then
    return self._state
  end
  local ok, errmsg = pcall(function()
    local chan_id = vim.fn.sockconnect("pipe", self:path(), { rpc = true })
    self._state = vim.rpcrequest(
      chan_id,
      "nvim_exec_lua",
      "return require'fzf-lua.devicons'.state(...)",
      { opts and opts.srv_plugin or nil })
    vim.fn.chanclose(chan_id)
  end)
  if not ok then
    io.stdout:write(string.format(
      "RPC error getting fzf_lua:devicons:STATE (%s): %s\n", self:path(), errmsg))
  end
  return self._state == "table"
end

-- When using mini from the external process we store the new icon cache on process exit
function FzfLuaServer:update_state_mini()
  -- Abort when `self._state` is `nil`, can happen with live_grep
  -- `exec_empty_query=false` (default) as icons aren't loaded (#1391)
  if not self:path() or type(self._state) ~= "table" then return end
  local ok, errmsg = pcall(function()
    local chan_id = vim.fn.sockconnect("pipe", self:path(), { rpc = true })
    self._state = vim.rpcrequest(chan_id, "nvim_exec_lua", [[
      require"fzf-lua.devicons".set_state(...)
      ]], { "mini", self._state })
    vim.fn.chanclose(chan_id)
  end)
  if not ok then
    io.stdout:write(string.format(
      "RPC error setting fzf_lua:devicons:STATE (%s): %s\n", self:path(), errmsg))
  end
end

local M = {}

M.__SRV = FzfLuaServer:new()
M.__MINI = MiniIcons:new()
M.__DEVICONS = NvimWebDevicons:new()

-- Load an icons provider and sets the module local var `M.PLUGIN`
-- "auto" prefers nvim-web-devicons, "srv" RPC-queries main instance
---@param provider nil|boolean|string|"auto"|"devicons"|"mini"|"srv"
---@param do_not_lazy_load boolean?
---@return boolean success
M.plugin_load = function(provider, do_not_lazy_load)
  -- Called from "make_entry.lua" without params (already loaded)
  if provider == nil and M.PLUGIN and M.PLUGIN:loaded() then
    return true
  end
  M.PLUGIN = provider == "srv" and M.__SRV
      or provider == "mini" and M.__MINI
      or provider == "devicons" and M.__DEVICONS
      or (function()
        if _G._fzf_lua_is_headless then
          -- headless instance, fzf-lua server exists, attempt
          -- to load icons from main neovim instance
          ---@diagnostic disable-next-line: undefined-field
          if type(_G._fzf_lua_server) == "string" then
            return M.__SRV
          end
          ---@diagnostic disable-next-line: undefined-field
          if _G._devicons_path then
            -- headless instance, no fzf-lua server was specified
            -- but we got devicon's lib path, add to runtime path
            -- so `load()` can find the library
            ---@diagnostic disable-next-line: undefined-field
            vim.opt.runtimepath:append(_G._devicons_path)
          else
            -- FATAL: headless but no global vars are defined
            local errmsg = "fzf-lua fatal: '_G._fzf_lua_server', '_G._devicons_path' both nil\n"
            io.stderr:write(errmsg)
            print(errmsg)
          end
        end
        -- Prioritize nvim-web-devicons
        local ret = M.__DEVICONS
        -- Load mini only if `_G.MiniIcons` is present or if using `mock_nvim_web_devicons()`
        -- at which point we would like to replace the mock with first-class MiniIcons (#1358)
        ---@diagnostic disable-next-line: undefined-field
        if not M.__DEVICONS:load(do_not_lazy_load) and _G.MiniIcons
            or M.__DEVICONS:is_mock() and M.__MINI:load(do_not_lazy_load)
        then
          ret = M.__MINI
        end
        -- Load custom setup file
        if _G._fzf_lua_is_headless
            ---@diagnostic disable-next-line: undefined-field
            and _G._devicons_setup and uv.fs_stat(_G._devicons_setup) then
          ---@diagnostic disable-next-line: undefined-field
          local file = loadfile(_G._devicons_setup)
          if file then pcall(file) end
        end
        return ret
      end)()
  return M.PLUGIN:load(do_not_lazy_load)
end

-- Attempt to load icons plugin at least once on init but
-- do not use `require` so we do not lazy load the plugin
M.plugin_load(nil, true)

M.plugin_loaded = function()
  return M.PLUGIN:loaded()
end

M.plugin_path = function()
  return M.PLUGIN:path()
end

M.plugin_name = function()
  return M.PLUGIN:name()
end

M.icon_by_ft = function(ft)
  return M.PLUGIN:icon_by_ft(ft)
end

-- NOTE: plugin_name is only sent when called from `FzfLuaServer:load_icons`
-- it is used when testing from "devicons_spec.lua" as calling `M.load()`
-- changes the ref in `M.PLUGIN` and will then return a nil `:state()`
---@param plugin_name string?
---@return table STATE
M.state = function(plugin_name)
  if plugin_name == "mini" then
    return M.__MINI:state()
  elseif plugin_name == "devicons" then
    return M.__DEVICONS:state()
  else
    return M.PLUGIN:state()
  end
end

-- NOTE: this gets called when on "make_entry.postprocess" when `file_icons="mini"`
M.set_state = function(plugin_name, state)
  if plugin_name == "mini" then
    return M.__MINI:set_state(state)
  elseif plugin_name == "devicons" then
    return M.__DEVICONS:set_state(state)
  else
    return M.PLUGIN:set_state(state)
  end
end

-- For testing
M.unload = function()
  M.PLUGIN:unload()
end


---@param filepath string
---@param extensionOverride string?
---@return string, string?
M.get_devicon = function(filepath, extensionOverride)
  local STATE = M.state()
  if not STATE or not STATE.icons then
    return "", nil
  end

  local function validate_hl(col)
    if col and col:match("^_") then
      return STATE.hl2hex[col]
    else
      return col
    end
  end

  if path.ends_with_separator(filepath) then
    -- path is directory
    return
        STATE.dir_icon.icon .. (type(STATE.icon_padding) == "string" and STATE.icon_padding or ""),
        validate_hl(STATE.dir_icon.color)
  end

  local icon, color
  local filename = path.tail(filepath)
  local ext = extensionOverride or path.extension(filename, true)

  -- lookup directly by filename
  local by_filename = STATE.icons.by_filename
      [STATE.icons.by_filename_case_sensitive and filename or filename:lower()]
  if by_filename then
    icon, color = by_filename.icon, by_filename.color
  end

  if ext then ext = ext:lower() end

  -- check for `ext` as extension can be nil, e.g. "dockerfile"
  -- lookup by 2 part extensions, e.g. "foo.test.tsx"
  if ext and not icon and STATE.icons.ext_has_2part[ext] then
    local ext2 = path.extension(filename:sub(1, #filename - #ext - 1))
    if ext2 then
      local by_ext_2part = STATE.icons.by_ext_2part[ext2:lower() .. "." .. ext]
      if by_ext_2part then
        icon, color = by_ext_2part.icon, by_ext_2part.color
      end
    end
  end

  -- finally lookup by "one-part" extension (i.e. no dots in ext)
  if ext and not icon then
    local by_ext = STATE.icons.by_ext[ext]
    if by_ext then
      icon, color = by_ext.icon, by_ext.color
    end
  end

  -- mini.icons supports lookup by filetype
  if not icon and STATE.icons.by_filetype then
    -- NOTE: due to our mocks we should never fail on fast events but plugins such as
    -- "snacks/bigfile" uses `vim.filetype.add` to a pattern function callback that makes
    -- `vim.{api|fn}` calls so we still have to make sure we schedule the call on the
    -- main thread but not on headless as it will fail due to uv callbacks (#1831/#1841)
    local ft_match = _G._fzf_lua_is_headless and path.ft_match or path.ft_match_fast_event
    local ft = ft_match({ filename = filename })

    ---@type  { icon: string?, color: string? }?
    local by_ft = ft and #ft > 0 and STATE.icons.by_filetype[ft]

    if not by_ft then
      -- store default icon in cache to avoid lookup by ft a second time
      ---@diagnostic disable-next-line: assign-type-mismatch
      by_ft = { icon = STATE.default_icon.icon, color = STATE.default_icon.color }
    end

    icon, color = by_ft.icon, by_ft.color

    -- Store in the corresponding lookup table to prevent another `vim.filetype.match`
    -- NOTE: this logic has a flaw by design where certain filenames/extensions will
    -- differ from mini.icons as mini performs fullpath lookup and caching which results
    -- in a very large cache which is better avoided in fzf-lua for performance reasons
    if ext then
      STATE.icons.by_ext[ext] = by_ft
    else
      STATE.icons.by_filename[filename] = by_ft
    end
  end

  -- Default icon/color, we never return nil
  icon = icon or STATE.default_icon.icon
  color = color or STATE.default_icon.color

  if STATE.icon_padding then
    icon = icon .. STATE.icon_padding
  end

  return icon, validate_hl(color)
end

---@param opts table?
---@return boolean|nil success
M.load = function(opts)
  opts = opts or {}

  -- If unable to load mini/devicons, abort
  if not M.plugin_load(opts.plugin) then return end

  -- Load/refresh the icon set, does nothing unless unloaded or bg changed
  return M.PLUGIN:load_icons(opts)
end

return M