current commit
261020.20 · fix: address review findings (libuv/previewer/test)
---@diagnostic disable: inject-field
-- lcd so we can run current file even if cwd isn't fzf-lua
local _info = debug.getinfo(1, "S")
---@cast _info {source: string}
local __FILE__ = _info.source:gsub("^@", "")
vim.cmd.lcd(vim.fn.fnamemodify(__FILE__, ":p:h:h:h:h"))

local MiniTest = require("mini.test")
local screenshot = require("fzf-lua.test.screenshot")

---@class fzf-lua.test.helpers
---@field IS_WIN fun(): boolean
---@field IS_NOT_WIN fun(): boolean
---@field SKIP_IF_WIN fun(msg?: string): nil
---@field SKIP_IF_NOT_WIN fun(msg?: string): nil
---@field IS_LINUX fun(): boolean
---@field IS_NOT_LINUX fun(): boolean
---@field SKIP_IF_LINUX fun(msg?: string): nil
---@field SKIP_IF_NOT_LINUX fun(msg?: string): nil
---@field IS_MAC fun(): boolean
---@field IS_NOT_MAC fun(): boolean
---@field SKIP_IF_MAC fun(msg?: string): nil
---@field SKIP_IF_NOT_MAC fun(msg?: string): nil
---@field IS_STABLE fun(): boolean
---@field IS_NOT_STABLE fun(): boolean
---@field SKIP_IF_STABLE fun(msg?: string): nil
---@field SKIP_IF_NOT_STABLE fun(msg?: string): nil
---@field IS_NIGHTLY fun(): boolean
---@field IS_NOT_NIGHTLY fun(): boolean
---@field SKIP_IF_NIGHTLY fun(msg?: string): nil
---@field SKIP_IF_NOT_NIGHTLY fun(msg?: string): nil
local M = {}

-- Busted like expectations
M.assert = {
  is = {
    same = MiniTest.expect.equality,
    True = function(b) return MiniTest.expect.equality(b, true) end,
    False = function(b) return MiniTest.expect.equality(b, false) end,
  },
  are = {
    same = MiniTest.expect.equality,
    equal = MiniTest.expect.equality,
  },
}

M.NVIM_VERSION = function()
  if M._NVIM_VERSION == nil then
    local output = vim.api.nvim_exec2("version", { output = true }).output
    M._NVIM_VERSION = output:match("NVIM v(%d+%.%d+%.%d+)")
  end
  return M._NVIM_VERSION
end

local os_detect = {
  WIN = {
    name = "Windows",
    fn = function() return vim.fn.has("win32") == 1 or vim.fn.has("win64") == 1 end
  },
  MAC = { name = "MacOS", fn = function() return vim.fn.has("mac") == 1 end },
  LINUX = { name = "Linux", fn = function() return vim.fn.has("linux") == 1 end },
  STABLE = { name = "Neovim stable", fn = function() return M.NVIM_VERSION() == "0.12.4" end },
  NIGHTLY = { name = "Neovim nightly", fn = function() return vim.fn.has("nvim-0.13") == 1 end },
}

-- Creates M.IS_WIN(), M.IS_NOT_WIN(), M.SKIP_IF_WIN(), etc
for k, v in pairs(os_detect) do
  M["IS_" .. k] = function()
    local var = "_IS_" .. k
    if M[var] == nil then ---@diagnostic disable-next-line: assign-type-mismatch
      M[var] = v.fn()
    end
    ---@diagnostic disable-next-line: undefined-field
    return M[var]
  end
  M["IS_NOT_" .. k] = function()
    return not M["IS_" .. k]
  end
  M["SKIP_IF_" .. k] = function(msg)
    if M["IS_" .. k]() then
      MiniTest.skip(msg or string.format("Skip test on %s", v.name))
    end
  end
  M["SKIP_IF_NOT_" .. k] = function(msg)
    if not M["IS_" .. k]() then
      MiniTest.skip(msg or string.format("Skip test: not %s", v.name))
    end
  end
end

-- Add extra expectations
M.expect = vim.deepcopy(MiniTest.expect)

M.expect.match = MiniTest.new_expectation(
  "string matching",
  function(str, pattern) return str:find(pattern) ~= nil end,
  function(str, pattern)
    return string.format("Pattern: %s\nObserved string: %s", vim.inspect(pattern), str)
  end
)

M.expect.no_match = MiniTest.new_expectation(
  "no string matching",
  function(str, pattern) return str:find(pattern) == nil end,
  function(str, pattern)
    return string.format("Pattern: %s\nObserved string: %s", vim.inspect(pattern), str)
  end
)

M.make_partial_tbl = function(tbl, ref)
  local res = {}
  for k, v in pairs(ref) do
    res[k] = (type(tbl[k]) == "table" and type(v) == "table") and M.make_partial_tbl(tbl[k], v) or
        tbl[k]
  end
  for i = 1, #tbl do
    if ref[i] == nil then res[i] = tbl[i] end
  end
  return res
end

M.expect.equality_partial_tbl = MiniTest.new_expectation(
  "equality of tables only in reference fields",
  function(x, y)
    if type(x) == "table" and type(y) == "table" then x = M.make_partial_tbl(x, y) end
    return vim.deep_equal(x, y)
  end,
  function(x, y)
    return string.format("Left: %s\nRight: %s", vim.inspect(M.make_partial_tbl(x, y)),
      vim.inspect(y))
  end
)

-- Monkey-patch `MiniTest.new_child_neovim` with helpful wrappers
M.new_child_neovim = function()
  ---@class fzf-lua.test.chlid: MiniTest.child
  local child = MiniTest.new_child_neovim()

  local prevent_hanging = function(method)
    if not child.is_blocked() then return end

    local msg = string.format("Can not use `child.%s` because child process is blocked.", method)
    error(msg)
  end

  child.init = function()
    -- `child.stop` raises when the old job survives `jobstop`; that must
    -- abort `restart` before mini.test's `start`, which no-ops while a job
    -- is running and would silently run this case against the stale child
    child.restart({ "-u", "scripts/minimal_init.lua" })
    child.cmd("cd deps/fzf-lua")

    -- Change initial buffer to be readonly. This not only increases execution
    -- speed, but more closely resembles manually opened Neovim.
    child.bo.readonly = false
  end

  local child_lua = child.lua
  child.lua = function(code, arg)
    if type(code) == "string" then return child_lua(code, arg) end
    return require("fzf-lua.test.exec_lua").run(child_lua, 2, code, arg)
  end

  -- TODO: support "function" upvalue
  child.FzfLua = setmetatable({}, {
    __index = function(_, k)
      return function(...)
        child.lua(function(s)
          local args = require("fzf-lua.test.exec_lua").deserialize(s) or {}
          FzfLua[k](unpack(args))
        end, { require("fzf-lua.test.exec_lua").serialize(...) })
      end
    end
  })

  --- Setup fzf-lua
  ---@param config? table, config table
  child.setup = function(config)
    -- using "FZF_DEFAULT_OPTS" hangs the command on the
    -- child process and the loading indicator never stops
    local defaults = { fzf_opts = { ["--gutter"] = " " } }
    -- Windows: $FZF_DEFAULT_COMMAND is only honored when fzf's stdin is a tty
    -- (`FZF_DEFAULT_COMMAND - Default command to use when input is tty`, see
    -- src/util/util_windows.go Executor with `/s/c`). Inside mini.test child
    -- processes (headless nvim started via jobstart without pty, nested
    -- termopen pty on Windows) fzf's IsTty() is false, so it ignores the env
    -- and blocks waiting for stdin EOF that never arrives: the headless
    -- `nvim -l spawn.lua` never even starts (no spawn log/marker), fzf shows
    -- `0/0` with loading spinner forever, `load` never fires and `wait_until`
    -- timeout+retries (5x on Windows) make the suite appear hung. Piping via
    -- `cmd.exe /c "(cmd) | fzf"` uses a stdin pipe (which closes) and works
    -- regardless of tty detection, so default to pipe for headless (tests, cli)
    -- on Windows. GUI sessions keep env (fzf kills its own default command on
    -- exit; `pipe_cmd` routes the command through the `win_spawn` item pump).
    if M.IS_WIN() then defaults.pipe_cmd = true end
    child.lua(function(...)
      require("fzf-lua").setup(vim.tbl_deep_extend("keep", ..., {
        { "default-title" },
        defaults = defaults,
        winopts = {
          on_create = function(e)
            _G._fzf_lua_on_create = true
            if vim.api.nvim_win_get_config(e.winid).relative == "" then
              vim.wo[e.winid].statusline = "fzf://"
            end
          end,
          on_close = function()
            _G._fzf_lua_on_create = nil
            _G._fzf_postprocess_called = nil
            _G._fzf_load_called = nil
          end,
        },
        -- Using _fzf_cli_args so we can test keymap.fzf.load actions.load (api.events)
        -- keymap = { fzf = { true, load = function() _G._fzf_load_called = true end } }
        fzf_cli_args = "--bind=" .. FzfLua.libuv.shellescape("load:+execute-silent:"
          .. FzfLua.shell.stringify_data(function(_, _, _)
            _G._fzf_load_called = true
          end, {}))
      }))
    end, { config or {} })
  end

  --- Override `child.stop` (mini.test): avoid `rpcrequest`-ing `0cquit` to a
  --- process that exits mid-request. On Windows this can hang indefinitely
  --- (peer exits before the response, named pipe close not always detected).
  --- Use `rpcnotify` (fire-and-forget) and terminate if it doesn't exit.
  child.stop = function()
    local job = child.job ---@type table|nil
    if not job then return end
    local job_id = job.id ---@type integer
    local job_channel = job.channel ---@type integer
    local job_address = job.address ---@type string

    pcall(child.api_notify.nvim_command, "silent! 0cquit")
    if vim.fn.jobwait({ job_id }, 1000)[1] == -1 then
      pcall(vim.fn.jobstop, job_id)
      if vim.fn.jobwait({ job_id }, 1000)[1] == -1 then
        -- still running after jobstop: keep `child.job` so a later `stop` can
        -- retry instead of deleting the address of a live process, and raise
        -- so `restart`/`init` abort instead of `start` silently no-oping
        -- (`is_running()`) and running the next case against the stale child.
        -- `vim.notify` keeps it visible to callers that `pcall(child.stop)`
        -- (post_once)
        local msg = string.format(
          "child.stop: job %d still alive after `jobstop`, kept for a retry",
          job_id)
        vim.notify(msg, vim.log.levels.WARN)
        error(msg, 0)
      end
    end

    -- Close all used channels. Prevents `too many open files` type of errors.
    pcall(vim.fn.chanclose, job_channel)
    pcall(vim.fn.chanclose, job_id)

    -- Remove file for address to reduce chance of "can't open file" errors, as
    -- address uses temporary unique files
    pcall(vim.fn.delete, job_address)

    child.job = nil
  end

  --- Unload fzf-lua and side effects
  child.unload = function()
    child.o.tabline = ""
    -- Unload Lua module
    child.lua([[_G.FzfLua = nil]])
    child.lua(function()
      for k, _ in pairs(package.loaded) do
        if k:match("^fzf%-lua") then
          package.loaded[k] = nil
        end
      end
    end)

    -- Remove global vars
    for _, var in ipairs({ "server", "directory", "root" }) do
      child.g["fzf_lua_" .. var] = nil
    end

    -- Remove autocmd groups
    for _, group in ipairs({ "VimResized", "WinClosed" }) do
      if child.fn.exists("#FzfLua" .. group) == 1 then
        child.api.nvim_del_augroup_by_name("FzfLua" .. group)
      end
    end
  end

  child.set_lines = function(arr, start, finish)
    prevent_hanging("set_lines")

    if type(arr) == "string" then arr = vim.split(arr, "\n") end

    child.api.nvim_buf_set_lines(0, start or 0, finish or -1, false, arr)
  end

  child.get_lines = function(start, finish)
    prevent_hanging("get_lines")

    return child.api.nvim_buf_get_lines(0, start or 0, finish or -1, false)
  end

  child.set_cursor = function(line, column, win_id)
    prevent_hanging("set_cursor")

    child.api.nvim_win_set_cursor(win_id or 0, { line, column })
  end

  child.get_cursor = function(win_id)
    prevent_hanging("get_cursor")

    return child.api.nvim_win_get_cursor(win_id or 0)
  end

  child.set_size = function(lines, columns)
    prevent_hanging("set_size")

    if type(lines) == "number" then child.o.lines = lines end

    if type(columns) == "number" then child.o.columns = columns end
  end

  child.get_size = function()
    prevent_hanging("get_size")

    return { child.o.lines, child.o.columns }
  end

  --- Assert visual marks
  ---
  --- Useful to validate visual selection
  ---
  ---@param first number|table Table with start position or number to check linewise.
  ---@param last number|table Table with finish position or number to check linewise.
  ---@private
  child.expect_visual_marks = function(first, last)
    child.ensure_normal_mode()

    first = type(first) == "number" and { first, 0 } or first
    last = type(last) == "number" and { last, 2147483647 } or last

    MiniTest.expect.equality(child.api.nvim_buf_get_mark(0, "<"), first)
    MiniTest.expect.equality(child.api.nvim_buf_get_mark(0, ">"), last)
  end

  child.expect_screenshot = function(opts, path)
    opts = opts or {}
    local screenshot_opts = { redraw = opts.redraw }
    opts.redraw = nil
    opts.force = not not vim.env["update_screenshots"]
    child.wait_screen_stable()
    child.screen_invalidate()
    screenshot.with_retry(MiniTest.expect.reference_screenshot,
      function() return child.get_screenshot(screenshot_opts) end, path, opts)
  end

  ---@alias test.ScreenOpts { start_line: integer?, end_line: integer?, no_ruler: boolean?,
  ---normalize_paths: boolean?, redraw: boolean? }
  ---@param opts test.ScreenOpts
  ---@return MiniTestScreenshot
  child.get_screen_lines = function(opts)
    return screenshot.fromChildScreen(child, opts)
  end

  -- Expect screenshot without the "attrs" (highlights)
  child.expect_screen_lines = function(opts, path)
    opts = opts or {}
    ---@type test.ScreenOpts
    local screenshot_opts = {
      redraw = opts.redraw,
      normalize_paths = opts.normalize_paths,
      start_line = opts.start_line,
      end_line = opts.end_line,
      no_ruler = opts.no_ruler,
    }
    opts.redraw = nil
    opts.force = not not vim.env["update_screenshots"]
    child.wait_screen_stable()
    child.screen_invalidate()
    screenshot.with_retry(screenshot.reference_screenshot,
      function() return child.get_screen_lines(screenshot_opts) end, path, opts)
  end

  ---@param opts test.ScreenOpts
  ---@param buf integer
  ---@return MiniTestScreenshot
  child.get_buf_lines = function(buf, opts)
    return screenshot.fromChildBufLines(child, buf, opts)
  end

  child.expect_buflines = function(buf, opts, path)
    opts = opts or {}
    ---@type test.ScreenOpts
    local screenshot_opts = {
      redraw = opts.redraw,
      normalize_paths = opts.normalize_paths,
      start_line = opts.start_line,
      end_line = opts.end_line,
      no_ruler = opts.no_ruler,
    }
    opts.redraw = nil
    opts.force = not not vim.env["update_screenshots"]
    screenshot.with_retry(screenshot.reference_screenshot,
      function() return child.get_buf_lines(buf, screenshot_opts) end, path, opts)
  end

  ---@param str string
  ---@param opts test.ScreenOpts
  child.assert_screen_lines = function(str, opts)
    opts = opts or {}
    -- we don't need this if we compare inline
    opts.no_ruler = true
    local lines = str and vim.split(str, "\n") or { "" }
    if #lines > 1 then lines[#lines] = nil end
    local screen_ref = screenshot.from_lines(lines, opts)
    local screen_obs = child.get_screen_lines(opts)
    screenshot.compare(screen_ref, screen_obs, opts)
  end

  local wait_timeout = (M.IS_LINUX() and 2000 or 5000)
  wait_timeout = os.getenv("CI") and wait_timeout * 3 or wait_timeout
  --- waits until condition fn evals to true, checking every interval ms
  --- times out at timeout ms
  ---@param condition fun(): boolean
  ---@param timeout? integer, defaults to 2000
  ---@param interval? integer, defaults to 100
  child.wait_until = function(condition, timeout, interval)
    local max = timeout or wait_timeout
    local inc = interval or 100
    for _ = 0, max, inc do
      if condition() then
        return
      else
        vim.uv.sleep(inc)
      end
    end

    error(string.format("Timed out waiting for condition after %d ms\n\n%s\n\n%s", max,
      tostring(child.cmd_capture("messages")),
      tostring(child.get_screenshot())
    ))
  end

  --- Wait until consecutive child screenshots are identical, e.g. the UI
  --- finished rendering (fzf previews spawn nested processes and resize
  --- windows asynchronously, fixed sleeps are not enough on slow machines).
  --- No-op on Linux where screenshots are not flakey and this only adds time.
  ---@param opts? { timeout?: integer, interval?: integer }
  child.wait_screen_stable = function(opts)
    if M.IS_LINUX() then return end
    opts = opts or {}
    local timeout, interval = opts.timeout or 2500, opts.interval or 200
    local prev = tostring(child.get_screen_lines({}))
    for _ = interval, timeout, interval do
      vim.uv.sleep(interval)
      local cur = tostring(child.get_screen_lines({}))
      if cur == prev then return end
      prev = cur
    end
  end

  --- On Windows, moving/closing floating windows can leave previously
  --- composited cells behind (the vacated region is not always redrawn),
  --- e.g. the fzf term float moving from its initial fullscreen position
  --- to the picker's final layout, or the preview scrollbar thumb moving
  --- over the preview border. Plain `:redraw` skips cells it deems valid,
  --- so invalidate the whole screen to refetch every cell from the current
  --- window layout (no-op on Linux where this is not needed).
  child.screen_invalidate = function()
    if M.IS_LINUX() then return end
    child.api.nvim__redraw({ valid = false, flush = true })
  end

  --- waits until child screenshot contains text
  ---@param text string
  child.wait_until_screenshot_text_match = function(text)
    child.wait_until(function()
      local screenshotText = tostring(child.get_screenshot())
      return string.find(screenshotText, text, 1, true) ~= nil
    end)
  end

  -- Poke child's event loop to make it up to date
  child.poke_eventloop = function() child.api.nvim_eval("1") end

  child.sleep = function(ms)
    vim.uv.sleep(math.max(ms, 1))
    child.poke_eventloop()
  end

  return child
end

---@param child fzf-lua.test.chlid
---@param opts? table
---@param setup_opts? table
---@return table
M.new_set_with_child = function(child, opts, setup_opts)
  opts = opts or {}
  opts.hooks = opts.hooks or {}
  return MiniTest.new_set({
    hooks = {
      pre_once = function()
        if opts.hooks.pre_once then
          opts.hooks.pre_once()
        end
      end,
      pre_case = function()
        child.init()
        child.setup(setup_opts)

        -- Reasonable screen size
        child.set_size(28, 64)

        -- So we can read `:messages`
        child.o.cmdheight = 4

        -- Caller's opts
        if opts.hooks.pre_case then
          opts.hooks.pre_case()
        end
      end,
      post_case = function()
        if opts.hooks.post_case then
          opts.hooks.post_case()
        end
        -- job may already die
        if pcall(child.unload) then
          local errmsg = child.v.errmsg
          assert(errmsg == "", errmsg)
        end
      end,
      post_once = function()
        if opts.hooks.post_once then
          opts.hooks.post_once()
        end
        pcall(child.stop) -- job may already die
      end,
    },
    -- n_retry = helpers.get_n_retry(2),
  })
end

M.sleep = function(ms, child)
  vim.uv.sleep(math.max(ms, 1))
  if child ~= nil then child.poke_eventloop() end
end

--- Helper object to run fzf-lua API's via a child process and expect a screenshot
--- e.g. helpers.FzfLua.files({...}) or helpers.FzfLua.fzf_exec(contents, {...})
M.FzfLua = setmetatable({}, {
  __index = function(_, api)
    return function(child, ...)
      local eq = M.expect.equality
      -- When using `fzf_exec` opts are at index[2], or otherwise at index[1]
      -- remove index[1] into "contents" so index[1] is always our "opts"
      local opts, ci_opts, contents
      contents = (api == "fzf_exec" or api == "fzf_live") and (select(1, ...)) or nil
      opts = (api == "fzf_exec" or api == "fzf_live") and (select(2, ...)) or (select(1, ...)) or {}

      -- Split "opts" and "ci_opts" which are double underscore prefixed
      opts, ci_opts = (function()
        local o, ci_o = {}, {}
        for k, v in pairs(opts) do
          if k:match("^__") then
            ci_o[k] = v
          else
            o[k] = v
          end
        end
        return o, ci_o
      end)()

      -- Added addittional wait for `fn_postprocess` due to #1914, proved to be flakey
      -- on MacOs but I actually like it that we're testing `fn_postprocess` as well as
      -- RPC connection from child to main instance using with `_G._fzf_lua_server`
      if ci_opts.__postprocess_wait then
        ---@diagnostic disable-next-line: undefined-field
        opts.fn_postprocess = opts.multiprocess
            and [[return function(opts)
              local chan_id = vim.fn.sockconnect("pipe", _G._fzf_lua_server, { rpc = true })
              vim.rpcrequest(chan_id, "nvim_exec_lua", "_G._fzf_postprocess_called=true", {})
              vim.fn.chanclose(chan_id)
            end]]
            or [[return function(_) _G._fzf_postprocess_called = true end]]
      end

      -- Verify postprocess var is nil before opening fzf-lua
      eq(child.lua_get([[_G._fzf_postprocess_called]]), vim.NIL)

      if contents then
        child.FzfLua[api](contents, opts)
      else
        child.FzfLua[api](opts)
      end

      -- Verify `winopts.on_create` was called
      eq(child.lua_get([[_G._fzf_lua_on_create]]), true)

      -- Wait for fzf's "load" event
      child.wait_until(function()
        return child.lua_get([[_G._fzf_load_called]]) == true
      end)

      -- If requested, wait for `fn_postprocess` to be calleed
      if ci_opts.__postprocess_wait then
        if M.IS_MAC() then
          vim.uv.sleep(200)
        else
          child.wait_until(function()
            return child.lua_get([[_G._fzf_postprocess_called]]) == true
          end)
        end
      end

      -- Call the "after open" callback (set by some tests)
      if type(ci_opts.__after_open) == "function" then
        ci_opts.__after_open()
      end

      -- Ignore last "-- TERMINAL --" line and paths on Windows (separator is "\")
      local screen_opts = ci_opts.__screen_opts
          or { ignore_text = { 28 }, normalize_paths = M.IS_WIN() }

      child.api.nvim__redraw({ valid = true, flush = true })
      child.cmd("redraw")
      if ci_opts.__expect_lines then
        -- Compare screen lines without "attrs" so we can test on
        -- stable, nightly and windows
        child.expect_screen_lines(screen_opts)
      elseif ci_opts.__expect_lines == nil then
        -- Compare screenshots including attrs (highlights, etc)
        child.expect_screenshot(screen_opts)
      end

      if ci_opts.__no_abort then return end

      -- Feed the abort key and exit fzf
      child.type_keys(ci_opts.__abort_key or "<c-c>")

      -- Wait for `winopts.on_close` to be called
      child.wait_until(function()
        return child.lua_get([[_G._fzf_lua_on_create]]) == vim.NIL
      end)
    end
  end
})

return M