261020.15 · ee1d77b5e7d5ac3b2661ffddb6243a7b477370fa
feat(win): spawn fzf via uv.spawn on a single route (CLI + TUI)
Windows fzf launches ran through `cmd.exe /c` with a single assembled command line, so shell escaping had to survive two layers (nvim -> cmd), user fragments needed caret escaping, and command lines over cmd.exe's 8191-char limit fell back to a PowerShell `ProcessStartInfo` script with its own quoting rules. The fallback also owned the producer pump and the result-file redirect, so quoting and lifecycle bugs only reproduced on one of the two paths.
Replace both with one managed route: `libuv.win_spawn` runs fzf with the raw argv (`_cli_segments`; no shell, no escaping, no command line limit), redirects stdout through the result file descriptor and pumps an optional producer (`cmd.exe /c <cmd>`) into fzf's stdin -- every chunk forwarded as-is, immediately, with backpressure (4MB/1MB watermarks). When fzf exits, the producer's process tree is reaped via `taskkill /PID /T /F`, which also covers producers blocked writing into a dead stdin.
TUI hosts (`utils.termopen`) need fzf to own the terminal buffer's pty: `win_spawn.lua`, a mediator nvim attached to that pty (`-u NONE -l`, like the existing `spawn.lua` producers), reads the serialized spec from a temp file (deleted right away), runs the same spawn route and exits with fzf's code. The CLI host (`scripts/cli.lua`) calls `libuv.win_spawn` in-process with a scheduled `on_exit`, keeping stdio like a bare `fzf`.
User fragments are cmd-ready strings (`shellescape` caret style), so `libuv.cmd_argv` reproduces what `cmd.exe /c` plus the Win32 argv parser would deliver (caret consumption, then quoting rules) and replaces `shellparse` on Windows -- existing configs keep working, including `--bind=load:...` payloads built by `stringify_data` (regression tests).
Removed: the PowerShell fallback and its BOM strip, `libuv.ps_escape`, the `escape_cli` "powershell" variant and the fzf_spec tests for the fallback. Note: fragments are now data, exactly like on posix -- `cmd` operators in user options are no longer interpreted by an outer shell (caret-escaped fragments are unaffected; values are still expanded by any shell fzf itself invokes).
Tests: `win_spawn_spec` (byte-exact pump/argv quoting, exit codes, 4.8MB backpressure, mediator round-trip, producer tree-kill on early exit), `libuv_spec` (`cmd_argv` round-trips, callback payload), `fzf_spec` (route wiring for both hosts), `api_spec` repro case.
reading declarations
@@ -778,7 +778,17 @@ M.build_fzf_cli = function(opts) table.insert(cli_args, arg) -- Split raw user argument strings (e.g. fzf_args entries with spaces) -- into separate argument elements for native quoting.- local tokens = shellparse(tostring(arg))+ -- On Windows these strings are `cmd.exe` command line fragments+ -- (typically pre-escaped with `libuv.shellescape`): recover the raw+ -- argv values `cmd.exe` + the Win32 argv parser would deliver so+ -- the shell-less spawn route (`libuv.win_spawn`) gets identical+ -- values (`libuv.cmd_argv`); posix uses the shellwords parser.+ local tokens+ if utils.__IS_WINDOWS then+ tokens = libuv.cmd_argv(tostring(arg))+ else+ tokens = shellparse(tostring(arg))+ end if type(tokens) ~= "table" then tokens = { tostring(arg) } end ---@cast tokens string[] for _, tok in ipairs(tokens) do
@@ -1,5 +1,7 @@ -- slimmed down version of nvim-fzf's 'raw_fzf', changes include:--- DOES NOT SUPPORT WINDOWS+-- 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@@ -9,6 +11,11 @@ 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.@@ -58,8 +65,35 @@ function M.raw_fzf(contents, fzf_cli_args, opts) -- local readEOL = get_EOL("read0") local printEOL = get_EOL("print0") - table.insert(cmd, ">")- table.insert(cmd, libuv.shellescape(outputtmpname))+ -- 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)@@ -97,87 +131,14 @@ function M.raw_fzf(contents, fzf_cli_args, opts) -- }) end - -- Windows PowerShell fallback when the assembled fzf launcher command line- -- exceeds cmd.exe's 8191-char limit. Rebuild from raw segments so values are- -- PowerShell-escaped; nested `nvim -l` action payloads stay on fast cmd.exe- -- via SHELL=cmd.exe.- local use_powershell = false- local ps_command ---@type string?- local cmd_len = #table.concat(cmd, " ")- if opts.pipe_cmd and FZF_DEFAULT_COMMAND then- -- the non-powershell branch below prepends the item command as- -- `(FZF_DEFAULT_COMMAND) | ` to the same cmd.exe command line- cmd_len = cmd_len + #FZF_DEFAULT_COMMAND + 8- end- if utils.__IS_WINDOWS and not opts.is_fzf_tmux- and opts._cli_segments and #opts._cli_segments > 0- and cmd_len > 8000 then- use_powershell = true- -- Reconstruct the raw argv strings from the segments, encode each one with- -- Windows CommandLineToArgvW quoting rules, and pass the joined command- -- line as a single string to Start-Process. This keeps empty arguments as- -- "" and correctly escapes embedded quotes/backslashes while avoiding both- -- cmd.exe's 8191-char limit and Start-Process array-literal edge cases.- 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 ps_fzf_bin = libuv.ps_escape(cmd[1])- local ps_outfile = libuv.ps_escape(outputtmpname)- local argline = table.concat(vim.tbl_map(function(a)- return libuv.shellescape(a, 1)- end, raw_args), " ")- local ps_parts = {}- if opts.pipe_cmd and FZF_DEFAULT_COMMAND then- -- `(item) | fzf` can't be used in pipe mode: the item command would be- -- prepended to the same command line, which cmd.exe rejects past 8191- -- chars. Start-Process can't chain two processes either, so launch both- -- through ProcessStartInfo (its `Arguments` string is passed verbatim,- -- no CommandLineToArgvW re-quoting) and pump the item command's stdout- -- into fzf's stdin from this script. fzf reads stdin in the background,- -- so the picker starts immediately while items stream in, matching the- -- concurrency of the `(item) | fzf` cmd.exe branch.- table.insert(ps_parts, string.format(- "$ipsi = New-Object System.Diagnostics.ProcessStartInfo; $ipsi.FileName = 'cmd.exe'; $ipsi.Arguments = %s; $ipsi.UseShellExecute = $false; $ipsi.RedirectStandardOutput = $true; $iproc = [System.Diagnostics.Process]::Start($ipsi);",- libuv.ps_escape("/d /e:off /f:off /v:off /c (" .. FZF_DEFAULT_COMMAND .. ")")))- table.insert(ps_parts, string.format(- "$fpsi = New-Object System.Diagnostics.ProcessStartInfo; $fpsi.FileName = %s; $fpsi.Arguments = %s; $fpsi.UseShellExecute = $false; $fpsi.RedirectStandardInput = $true; $fpsi.RedirectStandardOutput = $true; $fproc = [System.Diagnostics.Process]::Start($fpsi);",- ps_fzf_bin, libuv.ps_escape(argline)))- -- drain fzf's stdout into the result file asynchronously while the pump- -- runs: the selection is written at exit and can exceed the pipe buffer,- -- and fzf stops reading stdin by then, so draining only after the pump- -- would block fzf on stdout and the pump on fzf's stdin at the same time- table.insert(ps_parts, string.format(- "$ofs = [System.IO.File]::Create(%s); $drain = $fproc.StandardOutput.BaseStream.CopyToAsync($ofs);",- ps_outfile))- -- the pump runs async so fzf's exit is noticed while it still streams;- -- the producer is killed as soon as fzf is gone, even when it is- -- blocked without producing output (a sync pump would sit in Read- -- until the producer yields); the kill must take the whole tree:- -- `$iproc` is only the wrapper `cmd.exe`, a plain `Kill()` orphans- -- the actual producer, whose stdout handle keeps the pump reading- -- until it exits on its own; stdin is closed once the pump finishes- -- (producer EOF), since fzf may need that EOF to finish (--select-1)- table.insert(ps_parts,- "$pump = $iproc.StandardOutput.BaseStream.CopyToAsync($fproc.StandardInput.BaseStream); $pumpDone = $false; while (-not $fproc.WaitForExit(200)) { if (-not $pumpDone -and $pump.IsCompleted) { $pumpDone = $true; try { $fproc.StandardInput.Close() } catch {} } }; $code = $fproc.ExitCode; if (-not $iproc.HasExited) { & taskkill.exe /PID $iproc.Id /T /F 2>$null | Out-Null; $iproc.WaitForExit() }; try { $null = $pump.Wait(2000) } catch {}; if (-not $pumpDone) { try { $fproc.StandardInput.Close() } catch {} }; $drain.Wait(); $ofs.Close(); exit $code")- FZF_DEFAULT_COMMAND = nil- else- table.insert(ps_parts, string.format(- "$proc = Start-Process -FilePath %s -ArgumentList %s -RedirectStandardOutput %s -Wait -NoNewWindow -PassThru -ErrorAction Stop;",- ps_fzf_bin, libuv.ps_escape(argline), ps_outfile))- table.insert(ps_parts, "if ($null -eq $proc) { exit 1 }; exit $proc.ExitCode")- end- ps_command = table.concat(ps_parts, " ")- end- if opts.debug and type(opts.debug) ~= "number" then utils.info("FZF_DEFAULT_COMMAND: %s", tostring(FZF_DEFAULT_COMMAND))- if use_powershell then- utils.info("fzf cmd [powershell]: %s", ps_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, " "))@@ -186,11 +147,31 @@ function M.raw_fzf(contents, fzf_cli_args, opts) local co = coroutine.running() ---@cast co thread- local jobstart = _G.fzf_jobstart or opts.is_fzf_tmux and vim.fn.jobstart or utils.termopen- local shell_cmd- if use_powershell then- shell_cmd = { "powershell.exe", "-NoProfile", "-NonInteractive", "-Command", ps_command }+ 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" }@@ -209,57 +190,64 @@ function M.raw_fzf(contents, fzf_cli_args, opts) 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)- local nvim_opt_shellslash = utils.__WIN_HAS_SHELLSLASH and vim.o.shellslash+ -- (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 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 = {- ["SHELL"] = use_powershell 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"] = "",- },+ 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()- -- Redirected stdout from the PowerShell fallback may begin with an- -- optional leading UTF-8 BOM in some PowerShell versions; strip it- -- before splitting so selected lines stay clean.- if use_powershell then- text = text:gsub("^\239\187\191", "")- end output = vim.split(text, printEOL) -- `file:read("*a")` appends an empty string on EOL output[#output] = nil
@@ -205,6 +205,181 @@ M.win_stdio_no_inherit = function(stdio) end end +---@class fzf-lua.WinSpawnSpec+---@field bin string fzf executable (`fzf`/`sk` or full path)+---@field args string[] raw fzf argv (`uv.spawn` prepends `bin` as argv[0])+---@field item? string producer command (cmd syntax), piped into fzf stdin+---@field outfile string fzf stdout is redirected to this file+---@field env? table<string,any>+---@field cwd? string+---@field stdin? integer fzf stdin fd when there is no `item` (default 0)+---@field stderr? integer fzf stderr fd (default `win_stderr_fd()`)++-- Windows ("one route") fzf spawn for `fzf.raw_fzf`: no shell, no escaping.+-- `uv.spawn` runs fzf with the raw argv (`_cli_segments`), stderr stays on+-- the console/pty (fzf's light renderer requires a console, see+-- `IsLightRendererSupported` in fzf's `src/tui/light_windows.go`), stdout is+-- redirected to `spec.outfile` (replaces the `> outfile` shell redirect) and+-- the optional `spec.item` producer is spawned via `cmd.exe /c` with its+-- stdout pumped into fzf's stdin, every chunk forwarded as-is, immediately.+-- This replaces the caret-escaped cmd.exe launcher, the `(item) | fzf` cmd+-- pipeline and the >8191-char PowerShell fallback. Callers: `scripts/cli.lua`+-- (CLI nvim) and `win_spawn.lua` (mediator for TUI terminal buffers).+---@param spec fzf-lua.WinSpawnSpec+---@param opts { on_exit: fun(rc: integer) } called once fzf and the producer+--- tree exited (the `outfile` is flushed before that)+---@return boolean ok false when fzf failed to spawn (on_exit(1) still called)+M.win_spawn = function(spec, opts)+ assert(_is_win, "win_spawn is Windows only")+ -- pause the producer read when this many bytes are queued for fzf+ local HIGH, LOW = 4 * 1024 * 1024, 1024 * 1024+ local item = spec.item and #spec.item > 0 and spec.item or nil+ local stderr_fd = spec.stderr or M.win_stderr_fd()+ ---@type integer?+ local out_fd = assert(uv.fs_open(spec.outfile, "w", 420))+ local prod_out = item and assert(uv.new_pipe(false)) or nil+ local fzf_in = item and assert(uv.new_pipe(false)) or nil+ local fzf_handle, prod_handle, kill_handle, kill_null+ local prod_pid+ local fzf_done, prod_done, kill_done = false, item == nil, true+ local fzf_rc, finished+ local writes, pending, paused, eof = 0, 0, false, false++ ---@param h uv.uv_pipe_t|uv.uv_process_t|nil+ local function close_handle(h)+ if h and not h:is_closing() then h:close() end+ end++ local function finish(rc)+ if finished then return end+ finished = true+ close_handle(fzf_in)+ close_handle(prod_out)+ close_handle(kill_handle)+ close_handle(kill_null)+ close_handle(fzf_handle)+ close_handle(prod_handle)+ if out_fd then+ uv.fs_close(out_fd)+ out_fd = nil+ end+ opts.on_exit(rc)+ end++ local function maybe_finish()+ if fzf_done and prod_done and kill_done then+ finish(fzf_rc == nil and 1 or fzf_rc)+ end+ end++ -- producer EOF: flush the pending writes, then close our end of fzf's+ -- stdin so fzf sees EOF (`--select-1` and `reload` actions rely on it)+ local function close_write_end()+ if eof and writes == 0 and fzf_in and not fzf_in:is_closing() then+ fzf_in:close()+ end+ end++ -- byte pump: forward each chunk as-is, immediately, no processing+ local function pump(err, data)+ if err or data == nil then+ eof = true+ close_handle(prod_out)+ close_write_end()+ return+ end+ if not fzf_in or fzf_in:is_closing() then return end+ local len = #data+ writes, pending = writes + 1, pending + len+ fzf_in:write(data, function(werr)+ writes, pending = writes - 1, pending - len+ -- after fzf exits the queued writes error (EPIPE/ECANCELED)+ if not werr and fzf_in and not fzf_in:is_closing() then+ if paused and pending < LOW and prod_out and not prod_out:is_closing() then+ paused = false+ prod_out:read_start(pump)+ end+ close_write_end()+ end+ end)+ if not paused and pending > HIGH and prod_out and not prod_out:is_closing() then+ paused = true+ prod_out:read_stop()+ end+ end++ -- fzf first: a spawn failure aborts before the producer is started+ local ok, spawn_err = pcall(function()+ fzf_handle = M.uv_spawn(spec.bin, {+ cwd = spec.cwd,+ args = spec.args,+ stdio = { fzf_in or spec.stdin or 0, out_fd, stderr_fd },+ env = spec.env,+ }, function(rc, _)+ fzf_done, fzf_rc = true, rc+ close_handle(fzf_handle)+ if prod_out and not prod_out:is_closing() then+ prod_out:read_stop()+ end+ -- fzf exited while the producer may still be running (e.g. the user+ -- aborted a `ping`-like producer): luv's `kill` is not a tree kill+ -- on Windows, `taskkill /T /F` reaps the launcher and its children+ if not prod_done and prod_pid then+ kill_done = false+ local kok = pcall(function()+ if not kill_null then kill_null = assert(uv.new_pipe(false)) end+ kill_handle = M.uv_spawn("taskkill.exe", {+ args = { "/PID", tostring(prod_pid), "/T", "/F" },+ stdio = { nil, kill_null, kill_null },+ }, function()+ kill_done = true+ close_handle(kill_handle)+ maybe_finish()+ end)+ close_handle(kill_null)+ kill_null = nil+ end)+ if not kok then kill_done = true end+ end+ maybe_finish()+ end)+ end)+ if not ok or not fzf_handle then+ io.stderr:write(+ ("[fzf-lua] failed to spawn '%s': %s\n"):format(spec.bin, tostring(spawn_err)))+ finish(1)+ return false+ end++ if item then+ local pok = pcall(function()+ prod_handle, prod_pid = M.uv_spawn("cmd.exe", {+ -- pass the producer command verbatim: it is a raw `cmd /c` line,+ -- libuv's quoting would corrupt it (same as the old launcher)+ args = { "/d", "/e:off", "/f:off", "/v:off", "/c", item },+ verbatim = true,+ cwd = spec.cwd,+ env = spec.env,+ stdio = { nil, prod_out, stderr_fd },+ }, function(_, _)+ prod_done = true+ close_handle(prod_handle)+ maybe_finish()+ end)+ end)+ if pok and prod_handle and prod_out then+ prod_out:read_start(pump)+ else+ -- producer failed to spawn (e.g. malformed command): close the write+ -- end so fzf still terminates (empty list, `shell_nop` semantics)+ prod_done, eof = true, true+ close_write_end()+ maybe_finish()+ end+ end+ return true+end+ ---@class fzf-lua.SpawnOpts ---@field cwd? string@@ -821,7 +996,9 @@ end -- this function is a better fit for utils but we're -- trying to avoid having any 'require' in this file ---@param s string----@param win_style integer|string? 1=classic, 2=caret+---@param win_style integer|string|boolean? force Windows escaping on posix:+--- 1 = classic CommandLineToArgvW backslash escape, 2 = caret escape with+--- delayed expansion double-escape, `true`/nil (on Windows) = caret escape ---@return string M.shellescape = function(s, win_style) if _is_win or win_style then@@ -947,14 +1124,80 @@ M.shellescape = function(s, win_style) end end --- PowerShell escaping for the fzf launcher on Windows when the command line--- exceeds cmd.exe's 8191-char limit (see `fzf.raw_fzf`). A single-quoted--- PowerShell string treats every character literally except a single quote,--- which is escaped by doubling it (''), no caret/backslash/metachar handling--- needed (our internal `nvim -l` payloads are already base64-encoded).----@param s string----@return string-M.ps_escape = function(s) return "'" .. s:gsub("'", "''") .. "'" end+-- Parse a `cmd.exe` command line fragment into argv values, exactly as+-- `cmd.exe` + the Win32 argv parser (Go runtime; fzf is a Go binary) would+-- deliver them. User provided argument strings (`fzf_args`, `fzf_cli_args`,+-- etc.) are written for the platform shell: on Windows they are cmd.exe+-- fragments, typically pre-escaped with `shellescape` (caret style). The+-- Windows direct spawn route (`libuv.win_spawn`) passes the raw argv to+-- `uv.spawn` with no shell, so this reverses the two parsing passes:+-- 1. cmd.exe caret consumption: `^X` -> `X` (and thus `^^` -> `^`)+-- 2. Win32 argv parsing: quotes toggle and are dropped, backslash runs+-- before quotes collapse per CommandLineToArgvW rules (`2n` backslashes+-- followed by a quote -> `n` backslashes + toggle quote mode, `2n+1` ->+-- `n` backslashes + literal quote), unquoted whitespace splits args+---@param s string cmd.exe command line fragment+---@return string[] argv+M.cmd_argv = function(s)+ if not s or s == "" then return {} end+ -- (1) consume cmd.exe caret escapes+ local parts = {}+ local i, len = 1, #s+ while i <= len do+ local c = s:sub(i, i)+ if c == "^" then+ if i == len then break end -- dangling caret is dropped by cmd.exe+ parts[#parts + 1] = s:sub(i + 1, i + 1)+ i = i + 2+ else+ parts[#parts + 1] = c+ i = i + 1+ end+ end+ s = table.concat(parts)+ -- (2) parse the unescaped command line into argv values+ local argv = {}+ local j = 1+ len = #s+ while j <= len do+ local sp = s:sub(j, j)+ if sp == " " or sp == "\t" then+ j = j + 1+ else+ local buf, in_quotes = {}, false+ while j <= len do+ local c = s:sub(j, j)+ if c == "\\" then+ local k = j+ while k <= len and s:sub(k, k) == "\\" do k = k + 1 end+ local nback = k - j+ if k <= len and s:sub(k, k) == '"' then+ buf[#buf + 1] = string.rep("\\", math.floor(nback / 2))+ if nback % 2 == 1 then+ buf[#buf + 1] = '"'+ else+ in_quotes = not in_quotes+ end+ j = k + 1+ else+ buf[#buf + 1] = string.rep("\\", nback)+ j = k+ end+ elseif c == '"' then+ in_quotes = not in_quotes+ j = j + 1+ elseif not in_quotes and (c == " " or c == "\t") then+ break+ else+ buf[#buf + 1] = c+ j = j + 1+ end+ end+ argv[#argv + 1] = table.concat(buf)+ end+ end+ return argv+end ---@class fzf-lua.CliSegment ---@field text string -- raw (unescaped) text@@ -967,16 +1210,17 @@ M.ps_escape = function(s) return "'" .. s:gsub("'", "''") .. "'" end ---@alias fzf-lua.CliElement fzf-lua.CliSegment[] -- Escape a list of CLI elements for the given shell, re-fusing each element's--- segments into a single string. "posix"/"cmd" reuse `shellescape` (cmd uses--- the caret/win_style variant), "powershell" uses `ps_escape`. Literal segments--- (`escape=false`) are emitted verbatim in all shells.+-- segments into a single string. Both shells reuse `shellescape` (`cmd` uses+-- the caret/win_style variant). Literal segments (`escape=false`) are emitted+-- verbatim. ---@param elements fzf-lua.CliElement[]----@param shell "posix"|"cmd"|"powershell"----@param win_style integer|string? forwarded to shellescape for "cmd"+---@param shell "posix"|"cmd"+---@param win_style integer|string|boolean? forwarded to shellescape for "cmd" ---@return string[] M.escape_cli = function(elements, shell, win_style)- local escapefn = shell == "powershell" and M.ps_escape- or function(s) return M.shellescape(s, shell == "cmd" and win_style or nil) end+ local escapefn = function(s)+ return M.shellescape(s, shell == "cmd" and win_style or nil)+ end local argv = {} for _, elem in ipairs(elements) do local buf = {}@@ -991,9 +1235,10 @@ end -- Marker for `_fzf_cli_args` entries that carry a raw (unescaped) value -- alongside the escaped string form. `build_fzf_cli` consumes these to emit -- BOTH the legacy escaped string (for cmd/posix, byte-identical) and a raw--- CliElement segment list (used to rebuild the command for the PowerShell--- fallback when the cmd line exceeds 8191 chars). `prefix` is the literal,--- never-escaped flag text (e.g. "--bind=" or "--interactive --cmd ").+-- CliElement segment list -- `fzf.raw_fzf` rebuilds the raw argv from the+-- segments for the Windows direct spawn (`libuv.win_spawn`, no shell and no+-- escaping; shares `fzf-tmux` and the CLI launcher equally). `prefix` is the+-- literal, never-escaped flag text (e.g. "--bind=" or "--interactive --cmd "). ---@param prefix string literal flag prefix (e.g. "--bind=") ---@param value string raw (unescaped) value ---@return table marker
@@ -187,7 +187,8 @@ M.new_child_neovim = function() -- 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 (better exit kill + powershell fallback).+ -- 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", ..., {
@@ -0,0 +1,40 @@+---@diagnostic disable-next-line: deprecated+-- This file should only be loaded from the mediator headless instance,+-- spawned by `fzf.raw_fzf` via `termopen` inside a terminal buffer (TUI).+-- It executes a serialized `fzf-lua.WinSpawnSpec`: `uv.spawn` runs fzf with+-- raw argv (no shell, no escaping), stderr inherits this pty (fzf's light+-- renderer needs a console), stdout is redirected to the result file and the+-- optional item producer is pumped into fzf's stdin. The exit code becomes+-- the terminal job's exit code (see `fzf.raw_fzf`).+assert(#vim.api.nvim_list_uis() == 0)++-- path to this file+local _info = debug.getinfo(1, "S")+---@cast _info {source: string}+local __FILE__ = _info.source:gsub("^@", "")++-- add the current folder to package.path so we can 'require'+-- prepend this folder first, so our modules always get first+-- priority over some unknown random module with the same name+package.path = ("%s/?.lua;"):format(vim.fn.fnamemodify(__FILE__, ":h:h")) .. package.path++local libuv = require("fzf-lua.libuv")++local spec_path = assert(_G.arg[1], "win_spawn.lua: missing spec path")+local f = assert(io.open(spec_path, "r"), "win_spawn.lua: cannot open " .. spec_path)+local spec_str = f:read("*a") --[[@as string]]+f:close()+vim.fn.delete(spec_path)++local rc ---@type integer?+local spec = libuv.deserialize(spec_str)+---@cast spec fzf-lua.WinSpawnSpec+libuv.win_spawn(spec, {+ on_exit = function(code) rc = code end,+})++-- keep the event loop running until fzf and the producer tree exited,+-- then exit with fzf's code (`cquit` keeps the temp dir cleanup intact,+-- unlike `os.exit`)+vim.wait(2147483647, function() return rc ~= nil end)+vim.cmd.cquit({ count = rc })
@@ -54,12 +54,26 @@ end _G.fzf_jobstart = function(cmd, opts) if fn.has("win32") == 1 or fn.has("win64") == 1 then- -- Inherit stdio like bare `fzf` (works on any terminal: conhost,- -- Windows Terminal, tmux pane). Join argv verbatim for the `cmd.exe`- -- launcher: the caret-escaped args from `fzf.raw_fzf` are built for- -- raw `cmd /c` parsing and libuv quoting corrupts them ("...syntax- -- is incorrect"). The PowerShell fallback (>8000 chars) keeps- -- default quoting.+ -- One managed Windows route: `fzf.raw_fzf` hands us a `win_spawn` spec+ -- (raw argv, result file, optional item command); `libuv.win_spawn`+ -- runs fzf directly via `uv.spawn` -- no shell, no escaping, no+ -- PowerShell fallback, results via file descriptor and the item+ -- command piped into fzf's stdin (see `libuv.win_spawn`).+ if cmd.__win_spawn then+ FzfLua.libuv.win_spawn(cmd.__win_spawn, {+ on_exit = vim.schedule_wrap(function(rc)+ opts.on_exit(nil, rc, nil)+ if not opts.no_quit and #api.nvim_get_proc_children(fn.getpid()) == 0 then+ vim.cmd.cquit { count = rc, bang = true }+ end+ end),+ })+ return+ end+ -- Legacy `cmd.exe` launcher for callers without raw segments (direct+ -- `raw_fzf` invocations). Join argv verbatim for `cmd.exe`: the+ -- caret-escaped args from `fzf.raw_fzf` are built for raw `cmd /c`+ -- parsing and libuv quoting corrupts them ("...syntax is incorrect"). -- stderr: fzf's light renderer (`--height`) requires a console on -- stderr (it draws the UI there and falls back to fullscreen without -- one, see `IsLightRendererSupported` in src/tui/light_windows.go).
@@ -7,38 +7,27 @@ local eq = assert.are.equal local same = assert.are.same local _orig_is_windows = utils.__IS_WINDOWS+local _orig_termopen = utils.termopen describe("Testing fzf launcher", function() after_each(function() utils.__IS_WINDOWS = _orig_is_windows+ utils.termopen = _orig_termopen _G.fzf_jobstart = nil end) ---@param fzf_cli_args string[] ---@param opts table- ---@param bom_content string? ---@return table captured ---@return table? output ---@return integer? rc- local function mock_run(fzf_cli_args, opts, bom_content)+ local function mock_run(fzf_cli_args, opts) local captured = {} local output, rc local old_jobstart = _G.fzf_jobstart _G.fzf_jobstart = function(shell_cmd, job_opts) captured.shell_cmd = shell_cmd captured.env = job_opts.env- if bom_content and shell_cmd[1] == "powershell.exe" then- -- outputtmpname is ps_escaped inside the single fused -Command string- local qpath = shell_cmd[5]:match("%-RedirectStandardOutput%s+('[^']+')")- if qpath then- local path = qpath:sub(2, -2):gsub("''", "'")- local f = io.open(path, "w")- if f then- f:write(bom_content)- f:close()- end- end- end -- Real jobstart calls on_exit asynchronously; mimic that so the -- coroutine is suspended before it is resumed. vim.schedule(function() job_opts.on_exit({}, 1, {}) end)@@ -57,160 +46,165 @@ describe("Testing fzf launcher", function() return captured, output, rc end - it("uses cmd.exe below threshold on Windows", function()+ --- Same as `mock_run` but for the TUI route (no `_G.fzf_jobstart`, the+ --- `termopen` mediator is intercepted instead of being executed)+ ---@return table captured+ ---@return table? output+ ---@return integer? rc+ local function mock_run_tui(fzf_cli_args, opts)+ local captured = {}+ local output, rc+ local ok, err = pcall(function()+ utils.termopen = function(shell_cmd, job_opts)+ captured.shell_cmd = shell_cmd+ captured.env = job_opts.env+ vim.schedule(function() job_opts.on_exit({}, 1, {}) end)+ return 1+ end+ coroutine.wrap(function()+ local fzf = require("fzf-lua.fzf")+ output, rc = fzf.raw_fzf("echo foo", fzf_cli_args, opts)+ end)()+ vim.wait(2000, function() return rc ~= nil end)+ end)+ if not ok then error(err) end+ return captured, output, rc+ end++ it("uses the legacy cmd.exe launcher without raw segments", function() utils.__IS_WINDOWS = true local fzf_cli_args = { "--bind=" .. libuv.shellescape("ctrl-t:execute(echo hi)") } local captured = mock_run(fzf_cli_args, {}) eq(captured.shell_cmd[1], "cmd.exe") eq(captured.env.SHELL, "cmd.exe")+ eq(captured.env.FZF_DEFAULT_COMMAND, "echo foo || break") end) - it("uses powershell.exe above 8000 chars on Windows", function()+ it("builds a win_spawn spec with raw argv (no shell escaping)", function() utils.__IS_WINDOWS = true- local val = string.rep("a", 8100)- local raw_bind = "ctrl-t:" .. val+ local raw_bind = [[ctrl-t:execute(echo "hi" && echo bye)]] local fzf_cli_args = { "--bind=" .. libuv.shellescape(raw_bind) } local segments = { { { text = "--bind=", escape = false }, { text = raw_bind, escape = true } }, }- local captured = mock_run(fzf_cli_args, { _cli_segments = segments })- eq(captured.shell_cmd[1], "powershell.exe")- eq(captured.shell_cmd[2], "-NoProfile")- eq(captured.shell_cmd[3], "-NonInteractive")- eq(captured.shell_cmd[4], "-Command")+ local captured = mock_run(fzf_cli_args,+ { _cli_segments = segments, cwd = "C:/some/dir" })+ -- no shell command line, just the direct spawn spec+ eq(captured.shell_cmd[1], nil)+ local spec = captured.shell_cmd.__win_spawn+ eq(spec.bin, "fzf")+ -- raw unescaped argument, uv quoting handles the rest+ eq(spec.args[1], "--bind=" .. raw_bind)+ eq(spec.cwd, "C:/some/dir")+ eq(spec.item, nil)+ eq(type(spec.outfile), "string")+ assert.is.True(#spec.outfile > 0) eq(captured.env.SHELL, "cmd.exe")- local fused = captured.shell_cmd[5]- assert.is.True(fused:find("Start%-Process") ~= nil)- assert.is.True(fused:find("%-FilePath%s+'fzf'") ~= nil)- assert.is.True(fused:find("%-ArgumentList%s+'.-'") ~= nil)- assert.is.True(fused:find("%-RedirectStandardOutput%s+'.-'") ~= nil)- assert.is.True(fused:find("exit $proc%.ExitCode$") ~= nil)- -- The argument line uses CommandLineToArgvW-style double-quote quoting.- local expected = libuv.shellescape("--bind=" .. raw_bind, 1)- assert.is.True(fused:find(expected, 1, true) ~= nil)+ eq(captured.env.FZF_DEFAULT_COMMAND, "echo foo || break") end) - it("empty --query remains a distinct argument for PowerShell", function()+ it("has no cmd.exe/PowerShell length limit for long arguments", function() utils.__IS_WINDOWS = true- local val = string.rep("a", 8100)- local raw_bind = "ctrl-t:" .. val- local fzf_cli_args = {- "--query", libuv.shellescape(""),- "--bind=" .. libuv.shellescape(raw_bind),+ local raw_bind = "ctrl-t:" .. string.rep("a", 8100)+ local fzf_cli_args = { "--bind=" .. libuv.shellescape(raw_bind) }+ local segments = {+ { { text = "--bind=", escape = false }, { text = raw_bind, escape = true } }, }+ local captured = mock_run(fzf_cli_args, { _cli_segments = segments })+ local spec = captured.shell_cmd.__win_spawn+ same(spec.args, { "--bind=" .. raw_bind })+ end)++ it("keeps empty arguments as distinct raw argv entries", function()+ utils.__IS_WINDOWS = true local segments = { { { text = "--query", escape = false } }, { { text = "", escape = true } },- { { text = "--bind=", escape = false }, { text = raw_bind, escape = true } }, }- local captured = mock_run(fzf_cli_args, { _cli_segments = segments })- eq(captured.shell_cmd[1], "powershell.exe")- local fused = captured.shell_cmd[5]- local expected = libuv.shellescape("--query", 1) .. " " .. libuv.shellescape("", 1)- assert.is.True(fused:find("-ArgumentList '" .. expected, 1, true) ~= nil)+ local captured = mock_run({ "--query", libuv.shellescape("") },+ { _cli_segments = segments })+ local spec = captured.shell_cmd.__win_spawn+ same(spec.args, { "--query", "" }) end) - it("Windows-quotes arguments with embedded quotes and backslashes", function()+ it("keeps embedded quotes and backslashes raw in the spec argv", function() utils.__IS_WINDOWS = true- local val = string.rep("a", 8100)- local raw_bind = "ctrl-t:" .. val local tricky = [[foo"bar\]]- local fzf_cli_args = {- "--query", libuv.shellescape(tricky),- "--bind=" .. libuv.shellescape(raw_bind),- } local segments = { { { text = "--query", escape = false } }, { { text = tricky, escape = true } },- { { text = "--bind=", escape = false }, { text = raw_bind, escape = true } }, }- local captured = mock_run(fzf_cli_args, { _cli_segments = segments })- eq(captured.shell_cmd[1], "powershell.exe")- local fused = captured.shell_cmd[5]- local expected = libuv.shellescape("--query", 1) .. " " .. libuv.shellescape(tricky, 1)- assert.is.True(fused:find(expected, 1, true) ~= nil)+ local captured = mock_run({ "--query", libuv.shellescape(tricky) },+ { _cli_segments = segments })+ local spec = captured.shell_cmd.__win_spawn+ same(spec.args, { "--query", tricky }) end) - it("does not wrap fzf-tmux in powershell even when over threshold", function()+ it("streams pipe_cmd items through the spec item command", function() utils.__IS_WINDOWS = true- local val = string.rep("a", 8100)- local raw_bind = "ctrl-t:" .. val- local fzf_cli_args = { "--bind=" .. libuv.shellescape(raw_bind) } local segments = {- { { text = "--bind=", escape = false }, { text = raw_bind, escape = true } },+ { { text = "--bind=", escape = false }, { text = "ctrl-t:execute(echo hi)", escape = true } }, }- local captured = mock_run(fzf_cli_args, { is_fzf_tmux = true, _cli_segments = segments })- eq(captured.shell_cmd[1], "cmd.exe")+ local captured = mock_run({ "--bind=" .. libuv.shellescape("ctrl-t:execute(echo hi)") },+ { pipe_cmd = true, _cli_segments = segments })+ local spec = captured.shell_cmd.__win_spawn+ eq(spec.item, "echo foo || break")+ eq(spec.args[1], "--bind=ctrl-t:execute(echo hi)")+ -- the item command is no longer exported via the environment+ eq(captured.env.FZF_DEFAULT_COMMAND, nil) eq(captured.env.SHELL, "cmd.exe") end) - it("streams pipe_cmd items into fzf above threshold (win)", function()+ it("wires the TUI route through the win_spawn.lua mediator", function() utils.__IS_WINDOWS = true- local val = string.rep("a", 8100)- local raw_bind = "ctrl-t:" .. val- local fzf_cli_args = { "--bind=" .. libuv.shellescape(raw_bind) }+ local raw_bind = "ctrl-t:execute(echo hi)" local segments = { { { text = "--bind=", escape = false }, { text = raw_bind, escape = true } }, }- local captured = mock_run(fzf_cli_args, { pipe_cmd = true, _cli_segments = segments })- eq(captured.shell_cmd[1], "powershell.exe")- eq(captured.env.SHELL, "cmd.exe")- eq(captured.env.FZF_DEFAULT_COMMAND, nil)- local fused = captured.shell_cmd[5]- -- both processes start through ProcessStartInfo (verbatim `Arguments`),- -- no Start-Process and no blocking wait on the item producer- assert.is.True(fused:find("New%-Object System%.Diagnostics%.ProcessStartInfo") ~= nil)- assert.is.False(fused:find("Start%-Process") ~= nil)- -- item command runs through cmd.exe without a redirect; its stdout is- -- pumped into fzf's stdin asynchronously so items stream in while fzf- -- is already up and fzf's exit is observed while the pump still runs- assert.is.True(fused:find(- "$ipsi.Arguments = '/d /e:off /f:off /v:off /c (echo foo || break)'", 1, true) ~= nil)- assert.is.True(fused:find(- "$pump = $iproc.StandardOutput.BaseStream.CopyToAsync($fproc.StandardInput.BaseStream)", 1, true) ~= nil)- assert.is.True(fused:find("$fproc.WaitForExit(200)", 1, true) ~= nil)- -- the producer is killed as a whole process tree: `$iproc` is only the- -- wrapper cmd.exe, a plain `Process.Kill()` would orphan the actual- -- producer still holding the stdout pipe (the pump would stay blocked- -- until its natural exit); `$iproc` is then reaped before the pump is- -- awaited and its exit code is propagated- assert.is.True(fused:find(- "& taskkill.exe /PID $iproc.Id /T /F 2>$null | Out-Null; $iproc.WaitForExit()", 1, true) ~= nil)- assert.is.False(fused:find("$iproc.Kill()", 1, true) ~= nil)- -- fzf receives the long argument line through the verbatim Arguments string- assert.is.True(fused:find("$fpsi.Arguments = ", 1, true) ~= nil)- local expected = libuv.shellescape("--bind=" .. raw_bind, 1)- assert.is.True(fused:find(expected, 1, true) ~= nil)- -- fzf's stdout is drained into the output temp file for on_exit- -- asynchronously (before the pump, so a large selection can't dead-lock- -- against the still-running item producer) and the fzf exit code is- -- propagated after both finish- assert.is.True(fused:find("$drain = $fproc.StandardOutput.BaseStream.CopyToAsync($ofs)", 1, true) ~= nil)- assert.is.True(fused:find("$drain.Wait(); $ofs.Close()", 1, true) ~= nil)- assert.is.True(fused:find("exit $code", 1, true) ~= nil)+ local captured = mock_run_tui({ "--bind=" .. libuv.shellescape(raw_bind) },+ { _cli_segments = segments })+ local shell_cmd = captured.shell_cmd+ ---@cast shell_cmd string[]+ eq(shell_cmd[1], os.getenv("FZF_LUA_NVIM_BIN") or vim.v.progpath)+ eq(shell_cmd[2], "-u")+ eq(shell_cmd[3], "NONE")+ eq(shell_cmd[4], "-l")+ local prog = shell_cmd[5] or error("missing win_spawn.lua argument")+ assert.is.True(prog:find("win_spawn%.lua") ~= nil)+ -- the spec file exists and round-trips the spawn spec+ local spec_path = shell_cmd[6] or error("missing spec path argument")+ eq(vim.fn.filereadable(spec_path), 1)+ local f = io.open(spec_path, "r")+ if not f then error("cannot open " .. spec_path) end+ local payload = f:read("*a")+ f:close()+ local spec = libuv.deserialize(payload)+ eq(spec.bin, "fzf")+ same(spec.args, { "--bind=" .. raw_bind })+ eq(spec.env.SHELL, "cmd.exe")+ vim.fn.delete(spec_path) end) - it("uses cmd.exe for pipe_cmd below threshold", function()+ it("keeps fzf-tmux on the shell route", function() utils.__IS_WINDOWS = true local fzf_cli_args = { "--bind=" .. libuv.shellescape("ctrl-t:execute(echo hi)") }- local captured = mock_run(fzf_cli_args, { pipe_cmd = true })+ local segments = {+ { { text = "--bind=", escape = false }, { text = "ctrl-t:execute(echo hi)", escape = true } },+ }+ local captured = mock_run(fzf_cli_args, { is_fzf_tmux = true, _cli_segments = segments }) eq(captured.shell_cmd[1], "cmd.exe")- eq(captured.env.FZF_DEFAULT_COMMAND, nil)- assert.is.True(captured.shell_cmd[7]:find("echo foo", 1, true) ~= nil)+ eq(captured.env.SHELL, "cmd.exe")+ -- result file redirect via the legacy shell route+ assert.is.True(table.concat(captured.shell_cmd, " "):find(">", 1, true) ~= nil) end) - it("strips UTF-8 BOM from powershell output", function()+ it("uses the legacy cmd.exe pipe for pipe_cmd without raw segments", function() utils.__IS_WINDOWS = true- local val = string.rep("a", 8100)- local raw_bind = "ctrl-t:" .. val- local fzf_cli_args = { "--bind=" .. libuv.shellescape(raw_bind) }- local segments = {- { { text = "--bind=", escape = false }, { text = raw_bind, escape = true } },- }- local captured, output = mock_run(fzf_cli_args, { _cli_segments = segments },- "\239\187\191line1\nline2\n")- eq(captured.shell_cmd[1], "powershell.exe")- same(output, { "line1", "line2" })+ local captured = mock_run({}, { pipe_cmd = true })+ eq(captured.shell_cmd[1], "cmd.exe")+ eq(captured.env.FZF_DEFAULT_COMMAND, nil)+ assert.is.True(captured.shell_cmd[7]+ :find("(echo foo || break) | ", 1, true) ~= nil) end) it("does not emit --with-shell for skim", function()
@@ -95,15 +95,43 @@ describe("Testing libuv module", function() eq(libuv.shellescape([[foo\\\^^^"]], 2), [[^"foo\\\^^^^^^\^"^"]]) end) - it("ps_escape", function()- -- PowerShell single-quoted strings: everything literal except '' (escaped ')- eq(libuv.ps_escape([[plain]]), [['plain']])- eq(libuv.ps_escape([[it's]]), [['it''s']])- eq(libuv.ps_escape([['quoted']]), [['''quoted''']])- -- metacharacters ($, backtick, ", (), &, |) stay literal inside single quotes- eq(libuv.ps_escape([[$(x) `" & | ; ( )]]), [['$(x) `" & | ; ( )']])- eq(libuv.ps_escape([[]]), [['']])- eq(libuv.ps_escape([[a b]]), [['a b']])+ it("cmd_argv round-trips shellescape (cmd style)", function()+ -- `win_style = true` forces the `cmd.exe` caret escaping (the default+ -- on Windows), also runs on posix CI+ local values = {+ "foo", "a b", "x^y", "a^^b", "p%q", "a&b", "it's", [["q"]], [[E\f]],+ "汉 é", "bang!x", "eq=a", "semi;x", "lt<x", "pipe|x", "paren(x)",+ "amp&pipe|semi;", "trailback\\", " caret^ leading", "^caret",+ "trailcaret^", [[a"b]], [[a\"b]], "two spaces", "10%%", "a!b",+ "%TEMP%", "a(b)c", "cur|pipe", "d>e", "a<b",+ "load:+execute-silent:echo hi",+ }+ for _, v in ipairs(values) do+ same(libuv.cmd_argv(libuv.shellescape(v, true)), { v })+ end+ -- the pre-escaped user fragment pattern, e.g. test harness+ -- `"--bind=" .. shellescape("load:+execute-silent:...")`+ same(libuv.cmd_argv("--bind=" .. libuv.shellescape("load:+execute-silent:echo hi", true)),+ { "--bind=load:+execute-silent:echo hi" })+ end)++ it("cmd_argv parses cmd fragments", function()+ same(libuv.cmd_argv(""), {})+ same(libuv.cmd_argv(" "), {})+ same(libuv.cmd_argv("--no-multi --layout=reverse"), { "--no-multi", "--layout=reverse" })+ same(libuv.cmd_argv([[--header="a b" --no-multi]]), { "--header=a b", "--no-multi" })+ same(libuv.cmd_argv("--bind=ctrl-a:first --expect=ctrl-e"),+ { "--bind=ctrl-a:first", "--expect=ctrl-e" })+ -- caret escaping quotes/spaces (user args are cmd fragments)+ same(libuv.cmd_argv([[--header=^"a b^"]]), { "--header=a b" })+ same(libuv.cmd_argv([[--bind=^"load:+execute-silent:echo^ hi^"]]),+ { "--bind=load:+execute-silent:echo hi" })+ same(libuv.cmd_argv("^^"), { "^" })+ -- backslashes: literal unless collapsing before a quote (2n/2n+1 rules)+ same(libuv.cmd_argv([[C:\dir\sub]]), { [[C:\dir\sub]] })+ same(libuv.cmd_argv([[a\"b]]), { [[a"b]] })+ same(libuv.cmd_argv([[trailback\\]]), { [[trailback\\]] })+ same(libuv.cmd_argv([[\^^]]), { [[\^]] }) end) it("escape_cli escapes value segments per shell, keeps literal segments", function()@@ -117,11 +145,6 @@ describe("Testing libuv module", function() eq(cmd[1], "--bind=" .. libuv.shellescape("ctrl-r:reload(a b)")) eq(cmd[2], "--query") eq(cmd[3], libuv.shellescape("it's"))- -- powershell: value segments via ps_escape- local ps = libuv.escape_cli(elems, "powershell")- eq(ps[1], "--bind='ctrl-r:reload(a b)'")- eq(ps[2], "--query")- eq(ps[3], "'it''s'") -- posix: value segments via posix shellescape local posix = libuv.escape_cli(elems, "posix") eq(posix[1], "--bind=" .. libuv.shellescape("ctrl-r:reload(a b)"))
@@ -0,0 +1,194 @@+local helpers = require("fzf-lua.test.helpers")+local assert = helpers.assert++---@diagnostic disable-next-line: deprecated+local uv = vim.uv or vim.loop+local libuv = require("fzf-lua.libuv")+local utils = require("fzf-lua.utils")+local eq = assert.are.equal++local fzf_bin = vim.fn.exepath("fzf")++---@param path string+---@param content string+local function write_file(path, content)+ local f = io.open(path, "w")+ if not f then error("cannot open " .. path .. " for writing") end+ f:write(content)+ f:close()+end++---@param path string+---@return string+local function read_file(path)+ local f = io.open(path, "r")+ if not f then return "" end+ local content = f:read("*a") or ""+ f:close()+ return content+end++describe("Testing win_spawn (Windows one route)", function()+ --- Run one full `libuv.win_spawn` cycle (item producer -> pump -> result+ --- file) and return fzf's exit code and the result file contents+ ---@param item string producer command (cmd syntax)+ ---@param args string[] raw fzf argv (without the executable)+ ---@return integer? rc+ ---@return string out+ local function win_run(item, args)+ local outfile = utils.tempname()+ local rc ---@type integer?+ local ok, err = pcall(libuv.win_spawn, {+ bin = fzf_bin,+ args = args,+ item = item,+ outfile = outfile,+ env = { SHELL = "cmd.exe" },+ }, { on_exit = function(code) rc = code end })+ if not ok then error(err) end+ vim.wait(30000, function() return rc ~= nil end)+ local out = read_file(outfile)+ vim.fn.delete(outfile)+ return rc, out+ end++ it("pump preserves bytes: argv quoting, streaming and result file", function()+ helpers.SKIP_IF_NOT_WIN()+ local infile = utils.tempname()+ local lines = { "ab c", [[E"f G]], [[H\I]], "汉 é" }+ write_file(infile, table.concat(lines, "\n") .. "\n")+ local item = string.format('type "%s"', infile)+ -- filter arguments with a space (single argv token), quotes, a trailing+ -- backslash and multi-byte characters survive the uv quoting + pump+ for _, case in ipairs({+ { filter = "ab c", match = "ab c" },+ { filter = [[E"f G]], match = [[E"f G]] },+ { filter = [[H\I]], match = [[H\I]] },+ { filter = "汉 é", match = "汉 é" },+ }) do+ local rc, out = win_run(item, { "--filter=" .. case.filter })+ eq(rc, 0)+ eq(out, case.match .. "\n")+ end+ -- empty filter: match everything, the full stream is byte-identical+ local rc, out = win_run(item, { "--filter=", "--no-sort" })+ eq(rc, 0)+ eq(out, table.concat(lines, "\n") .. "\n")+ vim.fn.delete(infile)+ end)++ it("propagates fzf's exit code when nothing matches", function()+ helpers.SKIP_IF_NOT_WIN()+ local rc, out = win_run("echo abc", { "--filter=zzz" })+ eq(rc, 1)+ eq(out, "")+ end)++ it("streams a large input (backpressure) and matches the last line", function()+ helpers.SKIP_IF_NOT_WIN()+ local infile = utils.tempname()+ local f = io.open(infile, "w")+ if not f then error("cannot open " .. infile .. " for writing") end+ local buf = {}+ for i = 0, 399999 do+ buf[#buf + 1] = ("line %06d\n"):format(i)+ if #buf >= 4096 then+ f:write(table.concat(buf))+ buf = {}+ end+ end+ f:write(table.concat(buf))+ f:close()+ -- ~4.8MB (> the 4MB high watermark): exercises the pump across many+ -- chunks with the producer paused by the write queue backpressure+ local rc, out = win_run(string.format('type "%s"', infile),+ { "--filter=line 399999" })+ eq(rc, 0)+ eq(out, "line 399999\n")+ vim.fn.delete(infile)+ end)++ it("kills the producer's process tree when fzf exits early", function()+ helpers.SKIP_IF_NOT_WIN()+ -- use a uniquely named copy of PING.EXE so the tasklist check below cannot+ -- match an unrelated `ping` running on the machine+ local exe_name = "fzflua_spawn_ping.exe"+ local tmpdir = utils.tempname() .. "_treekill"+ vim.fn.mkdir(tmpdir, "p")+ assert.is.True(vim.fn.isdirectory(tmpdir) == 1)+ local exe = tmpdir .. "/" .. exe_name+ uv.fs_copyfile(vim.fn.exepath("ping"), exe)+ assert.is.True(vim.fn.filereadable(exe) == 1)+ ---@return boolean+ local function ping_running()+ local out = vim.fn.system({ "tasklist", "/FI", "IMAGENAME eq " .. exe_name, "/NH" })+ return out:lower():find(exe_name) ~= nil+ end+ -- control: the unique binary is detected by `ping_running` while running+ local job = vim.fn.jobstart({ exe, "-n", "60", "127.0.0.1" })+ assert.is.True(job > 0)+ vim.wait(5000, ping_running)+ assert.is.True(ping_running())+ vim.fn.jobstop(job)+ vim.wait(5000, function() return not ping_running() end)+ assert.is.False(ping_running())+ -- fzf exits immediately with an invalid option while the producer is still+ -- alive (output to NUL so only the tree-kill can stop it)+ local rc = win_run(string.format('"%s" -n 60 127.0.0.1 >NUL', exe),+ { "--fzflua-bad-option" })+ eq(rc, 2)+ assert.is.False(ping_running())+ vim.fn.delete(tmpdir, "rf")+ end)++ it("runs the spec through the win_spawn.lua mediator script", function()+ helpers.SKIP_IF_NOT_WIN()+ ---@diagnostic disable-next-line: deprecated+ local _info = debug.getinfo(1, "S")+ ---@cast _info {source: string}+ local root = vim.fn.fnamemodify(_info.source:gsub("^@", ""), ":h:h")+ local script = (root .. "/lua/fzf-lua/win_spawn.lua"):gsub("\\", "/")++ ---@param item string+ ---@param filter string+ ---@return integer? rc+ ---@return string out+ local function run_mediator(item, filter)+ local outfile = utils.tempname()+ local spec_path = utils.tempname()+ write_file(spec_path, libuv.serialize({+ bin = fzf_bin,+ args = { "--filter=" .. filter },+ item = item,+ outfile = outfile,+ env = { SHELL = "cmd.exe" },+ }, true))+ local rc ---@type integer?+ local handle = uv.spawn((vim.v.progpath):gsub("\\", "/"), {+ args = { "-u", "NONE", "-l", script, (spec_path:gsub("\\", "/")) },+ stdio = { nil, nil, nil },+ }, function(code, _)+ rc = code+ end)+ assert.is.True(handle ~= nil)+ vim.wait(30000, function() return rc ~= nil end)+ local out = read_file(outfile)+ vim.fn.delete(outfile)+ -- the mediator removes the spec file after reading it+ eq(vim.fn.filereadable(spec_path), 0)+ return rc, out+ end++ local infile = utils.tempname()+ write_file(infile, "abc\n")+ local item = string.format('type "%s"', infile)+ -- fzf's exit code becomes the mediator script's exit code+ local rc, out = run_mediator(item, "abc")+ eq(rc, 0)+ eq(out, "abc\n")+ rc, out = run_mediator(item, "zzz")+ eq(rc, 1)+ eq(out, "")+ vim.fn.delete(infile)+ end)+end)