current commit
261020.19 · fix(win): pin parent NVIM in spawn env, always quit CLI on attach
-- slimmed down version of nvim-fzf's 'raw_fzf', changes include:
-- Windows runs a single managed route: `uv.spawn` with raw argv and the
-- result file as stdio, mediated by `libuv.win_spawn` (CLI) or the
-- `win_spawn.lua` script nvim attached to the terminal buffer (TUI).
-- does not close the pipe before all writes are complete
-- option to not add '\n' on content function callbacks
-- https://github.com/vijaymarupudi/nvim-fzf/blob/master/lua/fzf.lua

local utils = require "fzf-lua.utils"
local libuv = require "fzf-lua.libuv"

local M = {}

-- used to locate `win_spawn.lua` (the TUI mediator) next to this file
local _info = debug.getinfo(1, "S")
---@cast _info {source: string}
local __FILE__ = _info.source:gsub("^@", "")

-- contents can be either a table with tostring()able items, or a function that
-- can be called repeatedly for values. The latter can use coroutines for async
-- behavior.
---@param contents string?
---@param fzf_cli_args string[]
---@param opts table
---@return table selected
---@return integer exit_code
function M.raw_fzf(contents, fzf_cli_args, opts)
  assert(not contents or type(contents) == "string",
    "contents must be of type string: " .. tostring(contents))
  if not coroutine.running() then
    error("[Fzf-lua] function must be called inside a coroutine.")
  end

  if not opts then opts = {} end
  local cmd = { opts.fzf_bin or "fzf" }
  local outputtmpname = utils.tempname()

  -- we use a temporary env $FZF_DEFAULT_COMMAND instead of piping
  -- the command to fzf, this way fzf kills the command when it exits.
  -- This is especially important with our shell helper as io.write fails
  -- to select when the pipe is broken (EPIPE) so the neovim headless
  -- instance never terminates which hangs fzf on exit
  local FZF_DEFAULT_COMMAND = (function()
    if not contents then return nil end
    if #contents == 0 then
      contents = utils.shell_nop()
    end
    if opts.silent_fail ~= false then
      contents = contents .. " || " .. utils.shell_nop()
    end
    return contents
  end)()

  utils.tbl_join(cmd, fzf_cli_args or {})

  local function get_EOL(flag)
    for _, f in ipairs(cmd) do
      if f:match("%-%-" .. flag) then
        return "\0"
      end
    end
    return "\n"
  end

  -- local readEOL = get_EOL("read0")
  local printEOL = get_EOL("print0")

  -- Windows direct spawn (CLI + TUI mediator), see `libuv.win_spawn`:
  -- raw argv rebuilt from the CLI segments, fzf's stdout is redirected via
  -- the result file descriptor (no `>`/PowerShell), no shell escaping and
  -- no cmd.exe 8191-char command line limit
  local win_spec = nil ---@type fzf-lua.WinSpawnSpec?
  if utils.__IS_WINDOWS and not opts.is_fzf_tmux
      and opts._cli_segments and #opts._cli_segments > 0 then
    local raw_args = {}
    for _, elem in ipairs(opts._cli_segments) do
      local arg = {}
      for _, seg in ipairs(elem) do
        table.insert(arg, seg.text)
      end
      table.insert(raw_args, table.concat(arg))
    end
    local item = opts.pipe_cmd and FZF_DEFAULT_COMMAND or nil
    if item then FZF_DEFAULT_COMMAND = nil end
    win_spec = {
      bin = cmd[1],
      args = raw_args,
      item = item,
      outfile = outputtmpname,
      cwd = opts.cwd,
    }
  else
    -- legacy shell route: redirect fzf's stdout to the result file
    table.insert(cmd, ">")
    table.insert(cmd, libuv.shellescape(outputtmpname))
  end

  if not opts.is_fzf_tmux then
    utils.eventignore(function() vim.bo.filetype = "fzf" end)

    -- A pesky bug I fixed upstream and was merged in 0.11/0.10.2:
    -- <C-c> in term buffers was making neovim freeze, as a workaround in older
    -- versions (not perfect could still hang) we map <C-c> to <Esc> locally
    -- https://github.com/neovim/neovim/issues/20726
    -- https://github.com/neovim/neovim/pull/30056
    if not utils.__HAS_NVIM_0102 then
      vim.keymap.set("t", "<C-c>", "<Esc>", { buffer = 0 })
    end

    -- A more robust way of entering TERMINAL mode "t". We had quite a few issues
    -- sending `feedkeys|startinsert` after the term job is started, this approach
    -- seems more consistent as it triggers when entering terminal normal mode "nt"
    -- NOTE: **DO NOT USE** seems to cause valrious issues see #1672
    -- vim.api.nvim_create_autocmd("ModeChanged", {
    --   once = true,
    --   buffer = 0,
    --   callback = function(e)
    --     if e.match:match(":nt") then
    --       vim.defer_fn(function()
    --         -- Prevents inserting "i" when spamming `ctrl-g` in `grep_lgrep`
    --         -- Also verify we're not already in TERMINAL mode, could happen
    --         -- if the user has an autocmd for TermOpen with `startinsert`
    --         if vim.api.nvim_buf_is_valid(e.buf)
    --             and vim.api.nvim_get_mode().mode ~= "t"
    --         then
    --           vim.cmd("startinsert")
    --         end
    --       end, 0)
    --     end
    --   end
    -- })
  end

  if opts.debug and type(opts.debug) ~= "number" then
    utils.info("FZF_DEFAULT_COMMAND: %s", tostring(FZF_DEFAULT_COMMAND))
    if win_spec then
      utils.info("fzf cmd [nvim/uv]: %s %s",
        win_spec.bin, table.concat(win_spec.args, " "))
      if win_spec.item then
        utils.info("fzf item [cmd.exe]: %s", win_spec.item)
      end
    else
      utils.info("fzf cmd [%s]: %s",
        utils.__IS_WINDOWS and "cmd.exe" or "sh", table.concat(cmd, " "))
    end
  end

  local co = coroutine.running()
  ---@cast co thread
  local jobstart ---@type fun(cmd: any, opts: table): any
  local shell_cmd ---@type table
  -- TUI mediator spec file path (written below, once the environment is
  -- assembled, and deleted by `win_spawn.lua` right after reading)
  local win_spec_path ---@type string?
  if win_spec then
    if _G.fzf_jobstart then
      -- CLI: the current process hosts the spawn (`libuv.win_spawn`)
      shell_cmd = { __win_spawn = win_spec }
      jobstart = _G.fzf_jobstart
    else
      -- TUI: fzf must own this terminal buffer's pty, run the same spawn
      -- route from a mediator script nvim attached to the pty (`-u NONE -l`,
      -- like `spawn.lua` producers); the spec travels via a temp file
      local nvim_bin = os.getenv("FZF_LUA_NVIM_BIN") or vim.v.progpath
      win_spec_path = utils.tempname()
      shell_cmd = {
        nvim_bin, "-u", "NONE", "-l",
        vim.fn.fnamemodify(__FILE__, ":h") .. "/win_spawn.lua",
        win_spec_path,
      }
      jobstart = utils.termopen
    end
  else
    jobstart = _G.fzf_jobstart or opts.is_fzf_tmux and vim.fn.jobstart or utils.termopen
    shell_cmd = utils.__IS_WINDOWS
        -- MSYS2 comes with "/usr/bin/cmd" that precedes "cmd.exe" (#1396)
        and { "cmd.exe", "/d", "/e:off", "/f:off", "/v:off", "/c" }
        or { "sh", "-c" }
    if opts.pipe_cmd then
      if FZF_DEFAULT_COMMAND then
        table.insert(cmd, 1, string.format("(%s) | ", FZF_DEFAULT_COMMAND))
        FZF_DEFAULT_COMMAND = nil
      end
      table.insert(shell_cmd, table.concat(cmd, " "))
    elseif utils.__IS_WINDOWS then
      utils.tbl_join(shell_cmd, cmd)
    else
      table.insert(shell_cmd, table.concat(cmd, " "))
    end
  end
  -- This obscure option makes jobstart fail with: "The syntax of the command is incorrect"
  -- temporarily set to `false`, for more info see `:help shellslash` (#1055)
  -- (only affects the legacy cmd.exe launcher, not the direct route)
  local nvim_opt_shellslash = not win_spec
      and utils.__WIN_HAS_SHELLSLASH and vim.o.shellslash
  if nvim_opt_shellslash then vim.o.shellslash = false end
  local job_env = {
    ["SHELL"] = win_spec and "cmd.exe" or shell_cmd[1],
    -- Nullify "NVIM_LISTEN_ADDRESS", will cause issues if already in use (#2253)
    ["NVIM_LISTEN_ADDRESS"] = "",
    ["FZF_DEFAULT_COMMAND"] = FZF_DEFAULT_COMMAND,
    ["SKIM_DEFAULT_COMMAND"] = FZF_DEFAULT_COMMAND,
    ["FZF_LUA_SERVER"] = vim.g.fzf_lua_server,
    -- sk --tmux didn't pass all environemnt variable (https://github.com/skim-rs/skim/issues/732)
    ["SKIM_FZF_LUA_SERVER"] = vim.g.fzf_lua_server,
    ["VIMRUNTIME"] = vim.env.VIMRUNTIME,
    ["FZF_DEFAULT_OPTS"] = (function()
      -- Newer style `--preview-window` options in FZF_DEFAULT_OPTS such as:
      --   --preview-window "right,50%,hidden,<60(up,70%,hidden)"
      -- prevents our previewer from working properly, since there is never
      -- a reason to inherit `preview-window` options it can be safely stripped
      -- from FZF_DEFAULT_OPTS (#1107)
      local default_opts = os.getenv("FZF_DEFAULT_OPTS")
      if not default_opts then return end
      local patterns = { "--preview-window" }
      for _, p in ipairs(patterns) do
        -- remove flag end of string
        default_opts = default_opts:gsub(utils.lua_regex_escape(p) .. "[=%s]+[^%-]+%s-$", "")
        -- remove flag start/mid of string
        default_opts = default_opts:gsub(utils.lua_regex_escape(p) .. "[=%s]+.-%s+%-%-", " --")
      end
      return default_opts
    end)(),
    -- Nullify user's RG config as this can cause conflicts
    -- with fzf-lua's rg opts (#1266)
    ["RIPGREP_CONFIG_PATH"] = type(opts.RIPGREP_CONFIG_PATH) == "string"
        and libuv.expand(opts.RIPGREP_CONFIG_PATH) or "",
    -- Prevents spamming rust logs with skim (#1959)
    ["RUST_LOG"] = "",
  }
  -- the direct route (`win_spawn`) receives the same environment in its spec
  -- (used in-process by the CLI host and serialized for the TUI mediator)
  if win_spec then
    -- pin `NVIM` to this instance: `libuv.uv_spawn` fills missing variables
    -- from the process that spawns fzf, so without this the TUI mediator's
    -- spawns would expose the mediator's own server address to fzf's children
    -- (`nvim --server $NVIM`, nvim_server previewer, item commands) instead
    -- of the editor that owns the picker
    job_env["NVIM"] = vim.v.servername
    win_spec.env = job_env
  end
  -- write the mediator spec file now that it carries the full environment
  if win_spec and win_spec_path then
    local payload = libuv.serialize(win_spec, true)
    local f = assert(io.open(win_spec_path, "w"))
    f:write(payload)
    f:close()
  end
  jobstart(shell_cmd, {
    cwd = opts.cwd,
    pty = true,
    env = job_env,
    on_exit = function(_, rc, _)
      local output = nil ---@type string[]?
      local f = io.open(outputtmpname)
      if f then
        local text = f:read("*a")
        f:close()
        output = vim.split(text, printEOL)
        -- `file:read("*a")` appends an empty string on EOL
        output[#output] = nil
      end
      -- Windows only, restore `shellslash` if was true before `jobstart`
      if nvim_opt_shellslash then vim.o.shellslash = nvim_opt_shellslash end
      vim.fn.delete(outputtmpname)
      if output and #output == 0 then output = nil end
      coroutine.resume(co, output, rc)
    end
  })

  -- fzf-tmux spawns outside neovim, don't set filetype/insert mode
  if not opts.is_fzf_tmux then
    vim.bo.filetype = "fzf"

    local fzfwin = utils.fzf_winobj()
    if fzfwin then fzfwin:update_statusline() end

    -- See note in "ModeChanged" above
    -- NOTE: feedkeys hack not required since
    -- https://github.com/neovim/neovim/commit/3621af9b970c80d2a6ff36569d7495391599c334
    if not (utils.__HAS_NVIM_012 or utils.__HAS_NVIM_0116)
        and vim.api.nvim_get_mode().mode == "t"
    then
      -- Called from another fzf-win most likely
      utils.feed_keys_termcodes("i")
    else
      -- TODO(#2683):
      -- utils.feed_keys_termcodes("i")
      vim.cmd [[startinsert]]
    end
  end

  return coroutine.yield()
end

return M