@ 261105.2
current
local helpers = require("fzf-lua.test.helpers")
local assert = helpers.assert
local eq = assert.are.same

local fzf = require("fzf-lua")
local utils = fzf.utils

describe("Testing utils module", function()
  it("separator", function()
    local is_windows = utils.__IS_WINDOWS
    utils.__IS_WINDOWS = false
    eq(utils._if_win_normalize_vars("--w=$COLUMNS"), "--w=$COLUMNS")

    utils.__IS_WINDOWS = true
    eq(utils._if_win_normalize_vars("--w=$COLUMNS", 1), "--w=%COLUMNS%")
    eq(utils._if_win_normalize_vars("--w=%COLUMNS%", 1), "--w=%COLUMNS%")
    eq(utils._if_win_normalize_vars("-w=$C -l=$L", 1), "-w=%C% -l=%L%")
    eq(utils._if_win_normalize_vars("--w=$COLUMNS", 2), "--w=!COLUMNS!")
    eq(utils._if_win_normalize_vars("--w=%COLUMNS%", 2), "--w=!COLUMNS!")
    eq(utils._if_win_normalize_vars("-w=$C -l=$L", 2), "-w=!C! -l=!L!")
    -- restore the module flag, other specs depend on it
    utils.__IS_WINDOWS = is_windows
  end)

  it("version formatter", function()
    eq(utils.ver2str(), nil)
    eq(utils.ver2str(""), nil)
    eq(utils.ver2str({}), nil)
    eq(utils.ver2str({ 0 }), "0.0.0")
    eq(utils.ver2str({ 1 }), "1.0.0")
    eq(utils.ver2str({ 1, 2 }), "1.2.0")
    eq(utils.ver2str({ 0, 1, 2 }), "0.1.2")
    eq(utils.ver2str({ 1, 2, 3 }), "1.2.3")
  end)

  it("version parser", function()
    eq(utils.parse_verstr(), nil)      -- Invalid
    eq(utils.parse_verstr(""), nil)    -- Invalid
    eq(utils.parse_verstr("0"), nil)   -- Invalid
    eq(utils.parse_verstr("all"), nil) -- Invalid
    eq(utils.parse_verstr("HEAD"), { 100, 0, 0 })
    eq(utils.parse_verstr("0.5"), { 0, 5, 0 })
    eq(utils.parse_verstr("0.5.0"), { 0, 5, 0 })
    eq(utils.parse_verstr("0.56"), { 0, 56, 0 })
    eq(utils.parse_verstr("0.56.3"), { 0, 56, 3 })
    eq(utils.parse_verstr("01.56.03"), { 1, 56, 3 })
    eq(utils.parse_verstr("10.56.30"), { 10, 56, 30 })
  end)

  it("has", function()
    eq(utils.has({}), false)
    eq(utils.has({}, "fzf"), false)
    eq(utils.has({ __SK_VERSION = {} }, "sk"), true)
    eq(utils.has({ __FZF_VERSION = {} }, "fzf"), true)
    eq(utils.has({ __FZF_VERSION = {} }, "sk"), false)
    eq(utils.has({ __SK_VERSION = {} }, "fzf"), false)
    eq(utils.has({ __SK_VERSION = { 0, 5, 0 } }, "sk", ""), false)
    eq(utils.has({ __SK_VERSION = { 0, 5, 0 } }, "sk", "0"), false)
    eq(utils.has({ __SK_VERSION = { 0, 5, 0 } }, "sk", "0.1"), true)
    eq(utils.has({ __SK_VERSION = { 0, 5, 0 } }, "sk", "0.4.9"), true)
    eq(utils.has({ __SK_VERSION = { 0, 15, 5 } }, "sk", "0.15"), true)
    eq(utils.has({ __SK_VERSION = { 0, 15, 5 } }, "sk", "0.15.0"), true)
    eq(utils.has({ __SK_VERSION = { 0, 15, 5 } }, "sk", "0.15.5"), true)
    eq(utils.has({ __SK_VERSION = { 0, 15, 5 } }, "sk", "0.15.6"), false)
    eq(utils.has({ __SK_VERSION = { 0, 5, 0 } }, "sk", "0.16"), false)
    eq(utils.has({ __SK_VERSION = { 0, 5, 0 } }, "sk", { 0, 5, 0 }), true)
    eq(utils.has({ __SK_VERSION = { 0, 5, 0 } }, "sk", { 0, 6, 0 }), false)
    eq(utils.has({ __SK_VERSION = { 0, 5, 0 } }, "sk", { 0, 5 }), true)
    eq(utils.has({ __SK_VERSION = { 0, 5, 0 } }, "sk", { 0, 6 }), false)
    eq(utils.has({ __SK_VERSION = { 0, 5, 0 } }, "sk", { 0 }), true)
    eq(utils.has({ __SK_VERSION = { 0, 5, 0 } }, "sk", { 0 }), true)
    eq(utils.has({ __SK_VERSION = { 0, 5, 0 } }, "sk", { 1 }), false)
    eq(utils.has({ __SK_VERSION = { 0, 5, 0 } }, "fzf", "0.5"), false)
    eq(utils.has({ __FZF_VERSION = { 1, 5, 0 } }, "fzf", "1.4"), true)
    eq(utils.has({ __FZF_VERSION = { 1, 5, 0 } }, "fzf", "1.5.0"), true)
    eq(utils.has({ __FZF_VERSION = { 0, 0, 5 } }, "fzf", "0.0.4"), true)
    eq(utils.has({ __FZF_VERSION = { 0, 0, 5 } }, "fzf", "0.0.5"), true)
    eq(utils.has({ __FZF_VERSION = { 0, 0, 5 } }, "fzf", "0.0.6"), false)
    eq(utils.has({ __FZF_VERSION = { 0, 5, 5 } }, "fzf", "0.5.4"), true)
    eq(utils.has({ __FZF_VERSION = { 0, 5, 5 } }, "fzf", "0.0.5"), true)
    eq(utils.has({ __FZF_VERSION = { 0, 5, 5 } }, "fzf", "0.5.6"), false)
    eq(utils.has({ __FZF_VERSION = { 2, 5, 5 } }, "fzf", "2.5.4"), true)
    eq(utils.has({ __FZF_VERSION = { 2, 5, 5 } }, "fzf", "2.5.5"), true)
    eq(utils.has({ __FZF_VERSION = { 2, 5, 5 } }, "fzf", "2.5.6"), false)
  end)

  it("fzf_tty_get_width probe order (ConPTY stdout before stdin)", function()
    helpers.SKIP_IF_NOT_WIN()
    local libuv = require("fzf-lua.libuv")
    local orig_console_width = libuv.win_console_width
    local orig = {}
    local sizes = {} ---@type table<integer, integer|false>
    ---@return function restore
    local function stub_new_tty()
      ---@diagnostic disable-next-line: deprecated
      local t = vim.uv or vim.loop
      if t and not orig[t] then
        orig[t] = t.new_tty
        t.new_tty = function(fd, _)
          if sizes[fd] == false then error("UV_ENOTTY", 0) end
          return {
            get_winsize = function() return sizes[fd] end,
            close = function() end,
          }
        end
      end
      return function()
        for tt, o in pairs(orig) do tt.new_tty = o end
      end
    end
    local restore = stub_new_tty()
    local ok, err = pcall(function()
      -- stdin tty reports no size under ConPTY (psmux): stdout must win
      sizes = { [1] = 226 }
      eq(utils.fzf_tty_get_width(), 226)
      -- stdout/stderr unusable, stdin is the console (previous behavior)
      sizes = { [0] = 120 }
      eq(utils.fzf_tty_get_width(), 120)
      -- all std handles redirected (pwsh PSReadLine key handlers):
      -- `uv.new_tty` fails on both pipes, the console device is queried
      sizes = { [1] = false, [2] = false, [0] = false }
      libuv.win_console_width = function() return 172 end
      eq(utils.fzf_tty_get_width(), 172)
      -- console unavailable: `$COLUMNS` is the last resort before o.columns
      libuv.win_console_width = function() return nil end
      local prev = vim.env.COLUMNS
      vim.env.COLUMNS = "222"
      eq(utils.fzf_tty_get_width(), 222)
      vim.env.COLUMNS = prev
    end)
    libuv.win_console_width = orig_console_width
    restore()
    if not ok then error(err) end
  end)

  it("fzf_tty_get_width ioctl request per platform", function()
    eq(utils._tty_ioctl_request("Linux"), 0x5413)
    eq(utils._tty_ioctl_request("OSX"), 0x40087468)
    eq(utils._tty_ioctl_request("BSD"), 0x40087468)
    eq(utils._tty_ioctl_request("Windows"), nil)
    eq(utils._tty_ioctl_request("POSIX"), nil)
  end)

  it("fzf_tty_get_width fallbacks", function()
    local libuv = require("fzf-lua.libuv")
    local orig_posix = utils._posix_tty_width
    local orig_console = libuv.win_console_width
    ---@diagnostic disable-next-line: deprecated
    local tty = vim.uv or vim.loop
    local orig_new_tty = tty.new_tty
    if helpers.IS_WIN() then
      -- force the std handle probes to fail so the stubs below are used
      tty.new_tty = function() return nil end
    end
    local prev_columns = vim.env.COLUMNS
    local ok, err = pcall(function()
      -- a successful probe wins
      utils._posix_tty_width = function() return 172 end
      libuv.win_console_width = function() return 172 end
      eq(utils.fzf_tty_get_width(), 172)
      -- failed probes fall through to `$COLUMNS`
      utils._posix_tty_width = function() return nil end
      libuv.win_console_width = function() return nil end
      vim.env.COLUMNS = "222"
      eq(utils.fzf_tty_get_width(), 222)
      -- then to `o.columns`
      vim.env.COLUMNS = nil
      eq(utils.fzf_tty_get_width(), vim.o.columns)
    end)
    utils._posix_tty_width = orig_posix
    libuv.win_console_width = orig_console
    tty.new_tty = orig_new_tty
    vim.env.COLUMNS = prev_columns
    if not ok then error(err) end
  end)

  it("fzf_tty_get_width without ffi (PUC Lua)", function()
    helpers.SKIP_IF_WIN()
    local saved_preload = package.preload.ffi
    local saved_loaded = package.loaded.ffi
    package.preload.ffi = nil
    package.loaded.ffi = nil
    local prev_columns = vim.env.COLUMNS
    local ok, err = pcall(function()
      vim.env.COLUMNS = "222"
      -- must not raise and must fall through to `$COLUMNS`
      eq(utils.fzf_tty_get_width(), 222)
    end)
    package.preload.ffi = saved_preload
    package.loaded.ffi = saved_loaded
    vim.env.COLUMNS = prev_columns
    if not ok then error(err) end
  end)

  it("setmetatable with gc", function()
    local gc_called = nil
    local _obj = utils.setmetatable({}, { __gc = function() gc_called = true end })
    eq(gc_called, nil)
    ---@diagnostic disable-next-line: assign-type-mismatch
    _obj = nil
    collectgarbage("collect")
    eq(gc_called, true)
  end)

  it("wo", function()
    -- store mini.test float so we can return to it when
    -- testing in main instance
    local win = vim.api.nvim_get_current_win()
    vim.cmd.new()
    utils.wo.nonexist = "this is nop"
    eq(nil, utils.wo.nonexist)

    vim.wo[0][0].nu = true -- setlocal
    vim.wo[0].rnu = true   -- setglobal
    eq(vim.wo[0][0].nu, vim.wo.nu)
    eq(vim.wo[0][0].rnu, vim.wo.rnu)
    vim.api.nvim_buf_delete(0, { force = true })
    vim.cmd.new()
    eq(vim.wo[0][0].nu, vim.wo.nu)
    eq(vim.wo[0][0].rnu, vim.wo.rnu)
    vim.api.nvim_buf_delete(0, { force = true })

    -- same behavior
    utils.wo[0][0].nu = true -- setlocal
    utils.wo[0].rnu = true   -- setglobal
    eq(utils.wo[0][0].nu, utils.wo.nu)
    eq(utils.wo[0][0].rnu, utils.wo.rnu)
    vim.cmd.new()
    eq(utils.wo[0][0].nu, utils.wo.nu)
    eq(utils.wo[0][0].rnu, utils.wo.rnu)
    vim.api.nvim_buf_delete(0, { force = true })
    vim.api.nvim_set_current_win(win)
  end)

  it("strsplit", function()
    eq(utils.strsplit("abc", "%s+"), { "abc" })
    eq(utils.strsplit("", "%s+"), { "" })
    eq(utils.strsplit("foo bar baz", "%s+"), { "foo", "bar", "baz" })
    eq(utils.strsplit("foo   bar    baz", "%s+"), { "foo", "bar", "baz" })
    eq(utils.strsplit("foo\tbar\nbaz", "%s+"), { "foo", "bar", "baz" })
    eq(utils.strsplit("  foo bar  ", "%s+"), { "", "foo", "bar", "" })
    eq(utils.strsplit("foo,,bar,,baz", ",+"), { "foo", "bar", "baz" })
    eq(utils.strsplit("a,b,c", ","), { "a", "b", "c" })
    eq(utils.strsplit(",,,", ",+"), { "", "" })
    eq(utils.strsplit(",foo,bar,", ","), { "", "foo", "bar", "" })
    eq(utils.strsplit("foobar", ","), { "foobar" })
  end)

  it("regex_strip_anchors", function()
    -- nil input
    eq({ utils.regex_strip_anchors(nil) }, { [2] = 0, [3] = 0 })
    -- empty string
    eq({ utils.regex_strip_anchors("") }, { "", 0, 0 })
    -- no anchors
    eq({ utils.regex_strip_anchors("foobar") }, { "foobar", 0, 0 })
    -- only start anchor
    eq({ utils.regex_strip_anchors("^foobar") }, { "foobar", 1, 0 })
    -- only end anchor
    eq({ utils.regex_strip_anchors("foobar$") }, { "foobar", 0, 1 })
    -- both anchors
    eq({ utils.regex_strip_anchors("^foobar$") }, { "foobar", 1, 1 })
    -- anchors in middle (should not be stripped)
    eq({ utils.regex_strip_anchors("foo^bar$baz") }, { "foo^bar$baz", 0, 0 })
    -- only anchors
    eq({ utils.regex_strip_anchors("^") }, { "", 1, 0 })
    eq({ utils.regex_strip_anchors("$") }, { "", 0, 1 })
    eq({ utils.regex_strip_anchors("^$") }, { "", 1, 1 })
    -- single character with anchors
    eq({ utils.regex_strip_anchors("^x$") }, { "x", 1, 1 })
    -- should not strip escaped $ (prepended by odd number of backslashes)
    eq({ utils.regex_strip_anchors([[x\$]]) }, { [[x\$]], 0, 0 })
    eq({ utils.regex_strip_anchors([[x\\$]]) }, { [[x\\]], 0, 1 })
    eq({ utils.regex_strip_anchors([[x\\\$]]) }, { [[x\\\$]], 0, 0 })
  end)

  it("ctag_match", function()
    -- no slashes
    eq(utils.ctag_match("foobar"), nil)
    -- single slash (needs at least 2 slashes)
    eq(utils.ctag_match("foo/bar"), nil)
    -- anchored slahes
    eq({ utils.ctag_match("/foo/") }, { 1, 5 })
    -- two slashes (finds the two rightmost unescaped slashes)
    eq({ utils.ctag_match("/foo/bar/baz") }, { 5, 9 })
    -- escaped slash only (should ignore escaped ones)
    eq(utils.ctag_match([[/foo\/bar]]), nil)
    eq(utils.ctag_match([[/foo\\\/bar]]), nil)
    -- escaped slash backslash before slash
    eq({ utils.ctag_match([[/foo\\/bar]]) }, { 1, 7 })
    eq({ utils.ctag_match([[/foo\\\\/bar]]) }, { 1, 9 })
    -- mixed escaped and unescaped
    eq({ utils.ctag_match([[foo/bar\/baz/qux]]) }, { 4, 13 })
    -- reverse search from end
    eq({ utils.ctag_match("/a/b/c/d") }, { 5, 7 })
  end)

  it("ctag_escape", function()
    -- empty string
    eq(utils.ctag_escape(""), "")
    -- no special characters
    eq(utils.ctag_escape("foobar"), "foobar")
    -- escaped backslash (\\ becomes \, then rg_escape doubles it back)
    eq(utils.ctag_escape("foo\\bar"), "foo\\\\bar")
    -- unescape escaped slash (\/ becomes /)
    eq(utils.ctag_escape("foo\\/bar"), "foo/bar")
    -- regex escape (rg_escape behavior)
    eq(utils.ctag_escape("foo.bar"), "foo\\.bar")
    eq(utils.ctag_escape("foo*bar"), "foo\\*bar")
    -- ^ at start gets unescaped (was escaped by rg_escape)
    eq(utils.ctag_escape("^foobar"), "^foobar")
    -- $ at end gets unescaped (was escaped by rg_escape)
    eq(utils.ctag_escape("foobar$"), "foobar$")
    -- already escaped ^ at start stays escaped (rg_escape adds another backslash)
    eq(utils.ctag_escape("\\^foobar"), "\\\\\\^foobar")
    -- already escaped $ at end stays escaped
    eq(utils.ctag_escape("foobar\\$"), "foobar\\\\$")
    -- ^ in middle stays escaped
    eq(utils.ctag_escape("foo^bar"), "foo\\^bar")
    -- $ in middle stays escaped
    eq(utils.ctag_escape("foo$bar"), "foo\\$bar")
    -- combination of patterns
    eq(utils.ctag_escape("^foo.bar$"), "^foo\\.bar$")
    -- escaped backslash before dot (\. becomes ., then rg_escape escapes both)
    eq(utils.ctag_escape("foo\\.bar"), "foo\\\\\\.bar")
  end)

  it("fs_stat_async", function()
    -- stat an existing path
    local dir = vim.fn.tempname()
    vim.fn.mkdir(dir, "p")
    local done, result = false, nil
    utils.fs_stat_async(dir, 1000, function(stat)
      done, result = true, stat
    end)
    eq(vim.wait(5000, function() return done end, 10), true)
    eq(result and result.type, "directory")
    vim.fn.delete(dir, "rf")

    -- stat a non-existent path
    local nonexistent = dir .. "-nonexistent"
    done, result = false, "unexpected"
    utils.fs_stat_async(nonexistent, 1000, function(stat)
      done, result = true, stat
    end)
    eq(vim.wait(5000, function() return done end, 10), true)
    eq(result, nil)
  end)

  it("file_is_readable_async", function()
    -- readable file
    local file = vim.fn.tempname()
    local fd = io.open(file, "w")
    eq(fd ~= nil, true)
    if fd then
      fd:write("test")
      fd:close()
    end
    local done, result = false, nil
    utils.file_is_readable_async(file, 1000, function(readable)
      done, result = true, readable
    end)
    eq(vim.wait(5000, function() return done end, 10), true)
    eq(result, true)
    os.remove(file)

    -- non-existent file
    local nonexistent = vim.fn.tempname() .. "-nonexistent"
    done, result = false, true
    utils.file_is_readable_async(nonexistent, 1000, function(readable)
      done, result = true, readable
    end)
    eq(vim.wait(5000, function() return done end, 10), true)
    eq(result, false)
  end)

  it("fs_request_timeout times out", function()
    helpers.SKIP_IF_WIN("requires mkfifo")
    -- `fs_open` on a fifo with no writer blocks indefinitely (#908),
    -- simulates a hung fs request on a stale mount (#2793)
    local fifo = vim.fn.tempname()
    vim.fn.system({ "mkfifo", fifo })
    eq(vim.v.shell_error, 0)
    local done, result = false, nil
    utils.file_is_readable_async(fifo, 50, function(readable)
      done, result = true, readable
    end)
    -- capture the results, unblock the stuck `fs_open` request (cancel
    -- fails with EBUSY when the request is already executing on the
    -- threadpool) and only then assert, so the cleanup always runs --
    -- a request stuck on the threadpool hangs the test process at exit
    local waited = vim.wait(5000, function() return done end, 10)
    -- open as read-write so we don't block waiting for a peer
    local f = io.open(fifo, "r+")
    -- give the stuck request a chance to complete
    vim.wait(200, function() return false end, 10)
    if f then f:close() end
    os.remove(fifo)
    eq(waited, true)
    eq(result, false)
  end)

  it("fs_stat_async times out", function()
    helpers.SKIP_IF_WIN("requires mkfifo")
    -- occupy the entire threadpool with blocking fifo opens so the
    -- stat request stays queued and times out (#2793)
    local fifo = vim.fn.tempname()
    vim.fn.system({ "mkfifo", fifo })
    eq(vim.v.shell_error, 0)
    local open_cbs = 0
    for _ = 1, 32 do
      vim.uv.fs_open(fifo, "r", 438, function(_, fd)
        open_cbs = open_cbs + 1
        if fd then vim.uv.fs_close(fd) end
      end)
    end
    local done = false
    ---@type table?
    local result
    utils.fs_stat_async(fifo, 50, function(stat)
      done, result = true, stat
    end)
    -- capture the results, unblock the stuck `fs_open` requests and only
    -- then assert, so the cleanup always runs -- a request stuck on the
    -- threadpool hangs the test process at exit
    local waited = vim.wait(5000, function() return done end, 10)
    -- keep the writer open until all requests complete or the queued
    -- opens will block again once the writer is gone
    local f = io.open(fifo, "r+")
    local drained = vim.wait(5000, function() return open_cbs == 32 end, 10)
    if f then f:close() end
    os.remove(fifo)
    eq(waited, true)
    eq(result, nil)
    eq(drained, true)
    eq(open_cbs, 32)
  end)
end)