diff --git a/lua/snacks/explorer/git.lua b/lua/snacks/explorer/git.lua index dee9611d6..25338da18 100644 --- a/lua/snacks/explorer/git.lua +++ b/lua/snacks/explorer/git.lua @@ -1,4 +1,6 @@ ---@diagnostic disable: missing-fields +local Spawn = require("snacks.util.spawn") + local M = {} ---@class snacks.explorer.git.Status @@ -55,7 +57,7 @@ function M.update(cwd, opts) local output = "" local stdout = assert(uv.new_pipe()) local handle ---@type uv.uv_process_t - handle = uv.spawn("git", { + handle = uv.spawn(Spawn.resolve_cmd("git"), { stdio = { nil, stdout, nil }, cwd = root, hide = true, diff --git a/lua/snacks/picker/source/proc.lua b/lua/snacks/picker/source/proc.lua index 93d23505c..d77248f7d 100644 --- a/lua/snacks/picker/source/proc.lua +++ b/lua/snacks/picker/source/proc.lua @@ -1,5 +1,6 @@ ---@diagnostic disable: await-in-sync local Async = require("snacks.picker.util.async") +local Spawn = require("snacks.util.spawn") local M = {} @@ -58,7 +59,7 @@ function M.proc(opts, ctx) local handle ---@type uv.uv_process_t ---@diagnostic disable-next-line: missing-fields - handle = uv.spawn(opts.cmd, spawn_opts, function(code, _signal) + handle = uv.spawn(Spawn.resolve_cmd(opts.cmd), spawn_opts, function(code, _signal) if not aborted and code ~= 0 and opts.notify ~= false then local full = { opts.cmd or "" } vim.list_extend(full, opts.args or {}) diff --git a/lua/snacks/util/spawn.lua b/lua/snacks/util/spawn.lua index d7aadc01d..049207f5e 100644 --- a/lua/snacks/util/spawn.lua +++ b/lua/snacks/util/spawn.lua @@ -5,6 +5,34 @@ local M = {} local uv = vim.uv or vim.loop +local is_windows = (uv.os_uname().sysname or ""):lower():find("windows") ~= nil + +--- Resolve a bare executable name to an absolute path on Windows. +--- libuv's `spawn` does not search `PATH` for bare names on Windows the way +--- it does on POSIX, so a command like `rg` may return ENOENT even when +--- `vim.fn.exepath("rg")` resolves it (e.g. through a scoop shim). +--- Returns the input unchanged on non-Windows systems, when given a path +--- (anything containing `/` or `\`), or when resolution fails. +---@param cmd string +---@return string +function M.resolve_cmd(cmd) + if not is_windows or type(cmd) ~= "string" or cmd == "" then + return cmd + end + if cmd:find("[/\\]") then + return cmd + end + local resolved = vim.fn.exepath(cmd .. ".exe") + if resolved ~= "" then + return resolved + end + resolved = vim.fn.exepath(cmd) + if resolved ~= "" then + return resolved + end + return cmd +end + ---@class snacks.spawn.Config: uv.spawn.options,{} ---@field cmd string ---@field args? (string|number)[] @@ -149,7 +177,7 @@ function Proc:run() hide = true, args = vim.tbl_map(tostring, self.opts.args or {}), }) - self.handle = uv.spawn(self.opts.cmd, opts, function(code, signal) + self.handle = uv.spawn(M.resolve_cmd(self.opts.cmd), opts, function(code, signal) self.code = code self.signal = signal self:on_exit() diff --git a/tests/spawn_spec.lua b/tests/spawn_spec.lua new file mode 100644 index 000000000..153a843d5 --- /dev/null +++ b/tests/spawn_spec.lua @@ -0,0 +1,32 @@ +---@module 'luassert' + +describe("snacks.spawn.resolve_cmd", function() + local Spawn = require("snacks.util.spawn") + + local is_windows = (vim.uv or vim.loop).os_uname().sysname:lower():find("windows") ~= nil + + it("returns non-string input unchanged", function() + assert.are.same(nil, Spawn.resolve_cmd(nil)) + end) + + it("returns empty string unchanged", function() + assert.are.same("", Spawn.resolve_cmd("")) + end) + + it("returns an absolute POSIX path unchanged", function() + assert.are.same("/usr/bin/rg", Spawn.resolve_cmd("/usr/bin/rg")) + end) + + it("returns a Windows-style backslash path unchanged", function() + assert.are.same([[C:\Tools\rg.exe]], Spawn.resolve_cmd([[C:\Tools\rg.exe]])) + end) + + if not is_windows then + it("returns a bare command unchanged on non-Windows", function() + -- On POSIX, libuv's spawn searches PATH for bare names, so resolve_cmd + -- is a no-op for bare commands like "rg" or "git". + assert.are.same("rg", Spawn.resolve_cmd("rg")) + assert.are.same("git", Spawn.resolve_cmd("git")) + end) + end +end)