diff --git a/fixtures/issue-285/init.lua b/fixtures/issue-285/init.lua new file mode 100644 index 00000000..1449b1ba --- /dev/null +++ b/fixtures/issue-285/init.lua @@ -0,0 +1,155 @@ +-- Fixture for issue #285: +-- "[BUG] ClaudeCodeAdd fails when adding a file in a directory with a `$`" +-- https://github.com/coder/claudecode.nvim/issues/285 +-- +-- The sample tree under fixtures/issue-285/sample/ contains a REAL file whose +-- parent directory is literally named "$post": +-- fixtures/issue-285/sample/src/routes/$post/index.tsx +-- +-- :ClaudeCodeAdd and the openFile MCP tool both pass the path through +-- vim.fn.expand(), which substitutes "$post" with the (undefined) env var -> +-- the path becomes ".../src/routes//index.tsx", which does not exist, so the +-- command reports "File or directory does not exist". +-- +-- Usage (from repo root): +-- source fixtures/nvim-aliases.sh && vv issue-285 +-- The $-path file opens automatically. Then either: +-- * press x -> runs :Repro285 (self-contained verdict), or +-- * run :Repro285 -> drives the REAL :ClaudeCodeAdd + openFile on the +-- $-path file and echoes a one-line PASS/FAIL verdict. +-- For a fully hand-driven check: :ClaudeCodeStart, then +-- :ClaudeCodeAdd (FAILS), vs +-- :ClaudeCodeAdd % (works), +-- then read :messages. + +local config_dir = vim.fn.stdpath("config") +local repo_root = vim.fn.fnamemodify(config_dir, ":h:h") +vim.opt.rtp:prepend(repo_root) + +vim.g.mapleader = " " +vim.g.maplocalleader = "\\" +vim.o.laststatus = 2 + +local ok, claudecode = pcall(require, "claudecode") +assert(ok, "Failed to load claudecode.nvim from repo root: " .. tostring(claudecode)) + +claudecode.setup({ + auto_start = false, + log_level = "info", + terminal = { provider = "native", auto_close = false }, +}) + +-- Resolve the real $-path sample file shipped with this fixture. +local sample = repo_root .. "/fixtures/issue-285/sample/src/routes/$post/index.tsx" + +-- Open it so the buffer name itself carries the `$` (this is the file a user +-- would be "adding to the buffer"). fnameescape keeps `$` literal for :edit. +vim.cmd("edit " .. vim.fn.fnameescape(sample)) + +local banner_buf = vim.api.nvim_create_buf(false, true) +vim.api.nvim_buf_set_lines(banner_buf, 0, -1, false, { + "claudecode.nvim -- issue #285 reproduction fixture", + "", + "Sample file (exists on disk, parent dir is literally '$post'):", + " " .. sample, + "", + "Press x (or run :Repro285) for a one-line PASS/FAIL verdict.", + "", + "Manual check (with the fix applied, both succeed):", + " :ClaudeCodeStart", + " :ClaudeCodeAdd " .. sample, + " :ClaudeCodeAdd % (current buffer; % is still expanded, not taken literally)", + " :messages", + "", + "Before this fix, the first form errored with:", + " [ClaudeCode] [command] [ERROR] ClaudeCodeAdd: File or directory does", + " not exist: .../src/routes//index.tsx (note the '//' -- $post vanished)", +}) +vim.bo[banner_buf].modifiable = false +vim.bo[banner_buf].buftype = "nofile" + +-- Capture the plugin logger so the verdict can show the exact error it logs. +local logger = require("claudecode.logger") +local function with_captured_logger(fn) + local saved = { error = logger.error, debug = logger.debug, warn = logger.warn, info = logger.info } + local lines = {} + local function cap(level) + return function(component, ...) + local parts = {} + for _, v in ipairs({ ... }) do + parts[#parts + 1] = tostring(v) + end + lines[#lines + 1] = { level = level, msg = table.concat(parts, " ") } + end + end + logger.error, logger.debug, logger.warn, logger.info = cap("error"), cap("debug"), cap("warn"), cap("info") + local ok_run, err = pcall(fn) + logger.error, logger.debug, logger.warn, logger.info = saved.error, saved.debug, saved.warn, saved.info + return lines, ok_run, err +end + +local function last_error(lines) + for i = #lines, 1, -1 do + if lines[i].level == "error" then + return lines[i].msg + end + end + return nil +end + +---Drive the REAL :ClaudeCodeAdd and openFile on the $-path sample, then echo a +---one-line verdict. Self-contained: if the integration isn't started, the +---run-state guard is stubbed so the expand()/filereadable() gate is still +---reached (that gate is where #285 lives -- no Claude connection is involved). +local function repro_285() + local cc = require("claudecode") + cc.state = cc.state or {} + local restore_server = false + if not cc.state.server then + cc.state.server = { _stub = true } + restore_server = true + end + local saved_send = cc.send_at_mention + local reached_send = false + cc.send_at_mention = function() + reached_send = true + return true, nil + end + + local lines = with_captured_logger(function() + vim.cmd({ cmd = "ClaudeCodeAdd", args = { sample } }) + end) + local add_err = last_error(lines) + + local open_ok, open_err = pcall(require("claudecode.tools.open_file").handler, { + filePath = sample, + makeFrontmost = false, + }) + local open_msg = type(open_err) == "table" and tostring(open_err.data or open_err.message) or tostring(open_err) + + cc.send_at_mention = saved_send + if restore_server then + cc.state.server = nil + end + + local add_bug = (add_err ~= nil) and not reached_send + local open_bug = (not open_ok) and open_msg:find("not found", 1, true) ~= nil + local reproduced = add_bug or open_bug + + local report = {} + report[#report + 1] = { + reproduced and "issue #285 REPRODUCED" or "issue #285 FIXED", + reproduced and "ErrorMsg" or "MoreMsg", + } + vim.api.nvim_echo(report, true, {}) + -- Detail lines land in :messages. + vim.api.nvim_echo({ + { (" ClaudeCodeAdd : %s"):format(add_err or (reached_send and "accepted (reached send)" or "no error")) }, + }, true, {}) + vim.api.nvim_echo({ + { (" openFile tool : %s"):format(open_ok and "opened" or open_msg) }, + }, true, {}) +end + +vim.api.nvim_create_user_command("Repro285", repro_285, { desc = "Repro #285 ($ in path)" }) +vim.keymap.set("n", "x", repro_285, { desc = "Repro #285 ($ in path)" }) diff --git a/fixtures/issue-285/sample/src/routes/$post/index.tsx b/fixtures/issue-285/sample/src/routes/$post/index.tsx new file mode 100644 index 00000000..8f951045 --- /dev/null +++ b/fixtures/issue-285/sample/src/routes/$post/index.tsx @@ -0,0 +1,7 @@ +// Sample file for issue #285. The PARENT directory is literally named "$post" +// (a TanStack Router / file-based-routing dynamic segment). The `$` is what +// trips vim.fn.expand(): it reads `$post` as the (undefined) env var `post` and +// substitutes the empty string, so the path the plugin checks no longer exists. +export default function Post() { + return null; +} diff --git a/lua/claudecode/init.lua b/lua/claudecode/init.lua index e9526b4d..d5cdbedf 100644 --- a/lua/claudecode/init.lua +++ b/lua/claudecode/init.lua @@ -1030,7 +1030,19 @@ function M._create_commands() return end - file_path = vim.fn.expand(file_path) + -- Resolve the path argument. Vim's current/alternate-file tokens (`%`, + -- `%:p`, `#`, ``, ...) must still be expanded -- `:ClaudeCodeAdd %` + -- is the documented "add current buffer" keymap (README). But a plain + -- filesystem path must NOT go through `vim.fn.expand`, which treats `$name` + -- as an environment variable and strips undefined ones -- mangling literal + -- `$` in paths (e.g. TanStack Router `$param` files like `src/routes/$post.tsx`) + -- so the existence check below would fail. So: expand() only the special + -- token forms; otherwise expand just a leading `~` and leave `$` intact. + if file_path:match("^[%%#<]") then + file_path = vim.fn.expand(file_path) + else + file_path = require("claudecode.utils").expand_tilde(file_path) + end if vim.fn.filereadable(file_path) == 0 and vim.fn.isdirectory(file_path) == 0 then logger.error("command", "ClaudeCodeAdd: File or directory does not exist: " .. file_path) return diff --git a/lua/claudecode/tools/open_file.lua b/lua/claudecode/tools/open_file.lua index 53c274a4..fb00624b 100644 --- a/lua/claudecode/tools/open_file.lua +++ b/lua/claudecode/tools/open_file.lua @@ -107,7 +107,10 @@ local function handler(params) error({ code = -32602, message = "Invalid params", data = "Missing filePath parameter" }) end - local file_path = vim.fn.expand(params.filePath) + -- Expand a leading `~` only. `vim.fn.expand` would treat `$name` as an + -- environment variable and strip undefined ones, breaking literal `$` paths + -- (e.g. TanStack Router `$param` files like `src/routes/$post.tsx`). + local file_path = require("claudecode.utils").expand_tilde(params.filePath) if vim.fn.filereadable(file_path) == 0 then -- Using a generic error code for tool-specific operational errors diff --git a/scripts/repro_issue_285.lua b/scripts/repro_issue_285.lua new file mode 100644 index 00000000..334cbc08 --- /dev/null +++ b/scripts/repro_issue_285.lua @@ -0,0 +1,261 @@ +-- Reproduction / verification for issue #285: +-- "[BUG] ClaudeCodeAdd fails when adding a file in a directory with a `$`" +-- https://github.com/coder/claudecode.nvim/issues/285 +-- +-- Root cause (one defect, two call sites): user-supplied file paths are passed +-- through vim.fn.expand(), which performs SHELL-STYLE EXPANSION -- including +-- environment-variable substitution. A real path segment like `$post` (common in +-- TanStack Router / file-based routing, e.g. `src/routes/$post/index.tsx`) is +-- read by expand() as the env var `$post`; since it is undefined, expand() +-- replaces it with the empty string. `src/routes/$post/index.tsx` therefore +-- becomes `src/routes//index.tsx`, which does not exist, so the subsequent +-- filereadable()/isdirectory() check fails: +-- +-- * lua/claudecode/init.lua:1033 (the :ClaudeCodeAdd command) +-- file_path = vim.fn.expand(file_path) +-- if filereadable(file_path)==0 and isdirectory(file_path)==0 -> ERROR +-- * lua/claudecode/tools/open_file.lua:110 (the openFile MCP tool) +-- local file_path = vim.fn.expand(params.filePath) +-- if filereadable(file_path)==0 -> ERROR "File not found" +-- +-- The command-line layer is NOT to blame: a user-command arg keeps its literal +-- `$` (verified separately); only expand() mangles it. +-- +-- The proposed fix (from the reporter) is to use the existing +-- require("claudecode.utils").expand_tilde() helper, which expands a leading +-- `~`/`~/` but leaves `$`, globs, and every other character untouched. Scenario D +-- demonstrates that this helper preserves the `$` path while still expanding `~`. +-- +-- This script drives the REAL :ClaudeCodeAdd command (registered via +-- M._create_commands()) and the REAL open_file.handler against ACTUAL files on +-- disk. No WebSocket server or Claude CLI is needed: the bug fires at the +-- expand()+filereadable() gate, before any send/broadcast. M.state.server is +-- stubbed truthy only to pass the "integration not running" guard, and +-- M.send_at_mention is stubbed so the control case can confirm it reached the +-- send path (i.e. passed the gate). +-- +-- Run from the repo root: +-- nvim --headless -u NONE -l scripts/repro_issue_285.lua +-- +-- Exit code: 1 if the bug is present (a file that EXISTS is rejected), 0 if fixed. +-- The detailed verdict is printed to stdout either way. + +local script_path = debug.getinfo(1, "S").source:sub(2) +local repo_root = vim.fn.fnamemodify(script_path, ":h:h") +vim.opt.rtp:prepend(repo_root) + +local function out(msg) + io.stdout:write(msg .. "\n") +end + +local uv = vim.loop + +-- ---------------------------------------------------------------------------- +-- Arrange: real files on disk, one with a `$` segment, one without (control). +-- ---------------------------------------------------------------------------- +local work = vim.fn.tempname() .. "_issue285" +local dollar_dir = work .. "/src/routes/$post" +local plain_dir = work .. "/src/routes/post" +local file_dollar = dollar_dir .. "/index.tsx" +local file_plain = plain_dir .. "/index.tsx" + +vim.fn.mkdir(dollar_dir, "p") +vim.fn.mkdir(plain_dir, "p") +for _, f in ipairs({ file_dollar, file_plain }) do + local fh = assert(io.open(f, "w")) + fh:write("export default function Page() { return null }\n") + fh:close() +end + +-- Ground truth via libuv (raw syscall, performs NO `$`/`~` expansion): both +-- files genuinely exist on disk at their literal paths. +local function exists_on_disk(p) + return uv.fs_stat(p) ~= nil +end +assert(exists_on_disk(file_dollar), "setup: $-path file was not created on disk") +assert(exists_on_disk(file_plain), "setup: control file was not created on disk") + +-- ---------------------------------------------------------------------------- +-- Capture logger output. `logger` is a module-level singleton table required by +-- init.lua, so replacing its functions here is seen by the command's closure. +-- ---------------------------------------------------------------------------- +local logger = require("claudecode.logger") +local log = {} -- { {level=, component=, msg=}, ... } +local function capture(level) + return function(component, ...) + local parts = {} + for _, v in ipairs({ ... }) do + parts[#parts + 1] = tostring(v) + end + log[#log + 1] = { level = level, component = component, msg = table.concat(parts, " ") } + end +end +logger.error = capture("error") +logger.warn = capture("warn") +logger.debug = capture("debug") +logger.info = capture("info") + +local function last_error() + for i = #log, 1, -1 do + if log[i].level == "error" then + return log[i].msg + end + end + return nil +end +local function clear_log() + log = {} +end + +-- ---------------------------------------------------------------------------- +-- Register the REAL commands and stub just enough to pass the run-state guard +-- and observe whether the send path was reached. +-- ---------------------------------------------------------------------------- +local cc = require("claudecode") +cc.state = cc.state or {} +cc.state.server = { _stub = true } -- truthy: pass the "integration is not running" guard +cc.state.config = cc.state.config or {} + +local sent = {} +cc.send_at_mention = function(file_path, start_line, end_line, context) + sent[#sent + 1] = { file_path = file_path, start_line = start_line, end_line = end_line, context = context } + return true, nil +end + +cc._create_commands() + +-- ---------------------------------------------------------------------------- +-- Helpers to run the real command / real tool handler. +-- ---------------------------------------------------------------------------- +local function run_add(path) + clear_log() + -- Invoke exactly as a mapping / file-explorer integration would: literal arg. + vim.cmd({ cmd = "ClaudeCodeAdd", args = { path } }) +end + +local open_file = require("claudecode.tools.open_file") +local function run_open(path) + local ok, err = pcall(open_file.handler, { filePath = path, makeFrontmost = false }) + return ok, err +end + +-- ---------------------------------------------------------------------------- +out("== issue #285 reproduction ($ in path mangled by vim.fn.expand) ==") +out(("Neovim: %s"):format(tostring(vim.version()))) +out(("work dir: %s"):format(work)) +out("") + +-- Diagnostic: show exactly how expand() mangles the path vs the literal truth. +local expanded = vim.fn.expand(file_dollar) +out("-- mechanism --") +out((" literal path : %s"):format(file_dollar)) +out((" exists on disk (uv) : %s"):format(tostring(exists_on_disk(file_dollar)))) +out((" filereadable(literal): %d (file functions do NOT expand $)"):format(vim.fn.filereadable(file_dollar))) +out((" vim.fn.expand(...) : %s"):format(expanded)) +out((" filereadable(expand) : %d (<- expand() dropped the $post segment)"):format(vim.fn.filereadable(expanded))) +out("") + +-- Scenario A: the issue. :ClaudeCodeAdd on a file that EXISTS but has a `$`. +run_add(file_dollar) +local a_err = last_error() +local a_reached_send = #sent > 0 +out("[A] :ClaudeCodeAdd <$-path file that exists>") +out((" error logged : %s"):format(a_err or "(none)")) +out((" reached send : %s"):format(tostring(a_reached_send))) +local a_bug = (a_err ~= nil) and not a_reached_send +out((" => %s"):format(a_bug and "BUG: existing file rejected" or "ok: file accepted")) +out("") + +-- Scenario B: control. Same command, sibling file WITHOUT `$`. Must always pass +-- the gate and reach the send path -- proves the harness is sound and the only +-- variable is the `$`. +sent = {} +run_add(file_plain) +local b_err = last_error() +local b_reached_send = #sent > 0 +out("[B] :ClaudeCodeAdd ") +out((" error logged : %s"):format(b_err or "(none)")) +out((" reached send : %s"):format(tostring(b_reached_send))) +out((" => %s"):format(b_reached_send and "ok: file accepted (harness sound)" or "UNEXPECTED: control failed")) +out("") + +-- Scenario C: the second call site -- the openFile MCP tool handler. +local c_ok, c_err = run_open(file_dollar) +local c_msg = type(c_err) == "table" and tostring(c_err.data or c_err.message) or tostring(c_err) +out("[C] openFile MCP tool handler <$-path file that exists>") +if c_ok then + out(" result : opened (no error)") +else + out((" error thrown : %s"):format(c_msg)) +end +local c_bug = (not c_ok) and c_msg:find("not found", 1, true) ~= nil +out((" => %s"):format(c_bug and "BUG: existing file reported not found" or "ok: file accepted")) +out("") + +-- Scenario E: regression guard. The documented `:ClaudeCodeAdd %` "add current +-- buffer" keymap (README) must keep working: expand_tilde alone would leave `%` +-- literal and the readability check would reject it. The fix still expands Vim's +-- %/#/<...> tokens via vim.fn.expand while keeping `$` paths intact. +sent = {} +vim.cmd("edit " .. vim.fn.fnameescape(file_plain)) +run_add("%") +local e_err = last_error() +local e_reached_send = #sent > 0 +local e_sent_path = e_reached_send and sent[#sent].file_path or nil +out("[E] :ClaudeCodeAdd % ") +out((" error logged : %s"):format(e_err or "(none)")) +out((" reached send : %s (resolved: %s)"):format(tostring(e_reached_send), tostring(e_sent_path))) +local e_bug = not e_reached_send +out( + (" => %s"):format( + e_bug and "BUG: % current-buffer token rejected (regression)" or "ok: % expanded to current buffer" + ) +) +out("") + +-- Scenario D: the proposed fix. expand_tilde() preserves `$` (and globs) while +-- still expanding a leading `~`. +local utils = require("claudecode.utils") +local et_dollar = utils.expand_tilde(file_dollar) +local home = os.getenv("HOME") or "" +local et_tilde = utils.expand_tilde("~/some/$dir/file.tsx") +out("-- proposed fix: require('claudecode.utils').expand_tilde --") +out((" expand_tilde($-path) : %s"):format(et_dollar)) +out((" filereadable(expand_tilde) : %d (<- $ preserved, file found)"):format(vim.fn.filereadable(et_dollar))) +out((" expand_tilde('~/x/$dir/..') : %s"):format(et_tilde)) +local tilde_ok = home ~= "" and et_tilde == (home .. "/some/$dir/file.tsx") +out((" tilde still expands : %s"):format(tilde_ok and "yes" or "(HOME unset; skipped)")) +out("") + +-- ---------------------------------------------------------------------------- +-- Verdict +-- ---------------------------------------------------------------------------- +pcall(vim.fn.delete, work, "rf") + +out("== verdict ==") +local reproduced = a_bug or c_bug +local regressed = e_bug +if not b_reached_send then + out("WARNING: control scenario B failed; harness may be unsound -- treat results with care.") +end +if reproduced then + out("BUG #285 REPRODUCED:") + if a_bug then + out(" - :ClaudeCodeAdd rejected an existing file because expand() dropped the $ segment.") + end + if c_bug then + out(" - openFile MCP tool reported an existing file as 'not found' for the same reason.") + end + out(" Fix: route plain paths through claudecode.utils.expand_tilde() at both call sites.") +end +if regressed then + out("REGRESSION: :ClaudeCodeAdd % no longer resolves the current buffer (the fix must") + out(" still expand Vim's %/#/<...> tokens, only $-substitution should be dropped).") +end +if not reproduced and not regressed then + out("FIXED: the $-path file is accepted by both :ClaudeCodeAdd and openFile,") + out(" and the `%` current-buffer token still expands.") +end + +io.stdout:flush() +vim.cmd("cquit " .. ((reproduced or regressed) and 1 or 0)) diff --git a/tests/unit/claudecode_add_command_spec.lua b/tests/unit/claudecode_add_command_spec.lua index 5f98f652..ef77838e 100644 --- a/tests/unit/claudecode_add_command_spec.lua +++ b/tests/unit/claudecode_add_command_spec.lua @@ -27,12 +27,19 @@ describe("ClaudeCodeAdd command", function() return "/home/user/test.lua" elseif path == "./relative.lua" then return "/current/dir/relative.lua" + elseif path == "%" or path == "%:p" then + return "/current/dir/buffer.lua" end return path end) vim.fn.filereadable = spy.new(function(path) - if path == "/existing/file.lua" or path == "/home/user/test.lua" or path == "/current/dir/relative.lua" then + if + path == "/existing/file.lua" + or path == "/home/user/test.lua" + or path == "/current/dir/relative.lua" + or path == "/current/dir/buffer.lua" + then return 1 end return 0 @@ -62,7 +69,18 @@ describe("ClaudeCodeAdd command", function() vim.notify = spy.new(function() end) _G.require = function(mod) - if mod == "claudecode.logger" then + if mod == "claudecode.utils" then + -- Tilde-only expansion mock: expand a leading `~`, otherwise return the + -- path unchanged. Crucially preserves literal `$` (TanStack `$param` files). + return { + expand_tilde = function(path) + if path == "~/test.lua" then + return "/home/user/test.lua" + end + return path + end, + } + elseif mod == "claudecode.logger" then return mock_logger elseif mod == "claudecode.config" then return { @@ -113,6 +131,7 @@ describe("ClaudeCodeAdd command", function() package.loaded["claudecode.diff"] = nil package.loaded["claudecode.visual_commands"] = nil package.loaded["claudecode.terminal"] = nil + package.loaded["claudecode.utils"] = nil claudecode = require("claudecode") @@ -191,21 +210,46 @@ describe("ClaudeCodeAdd command", function() it("should expand tilde paths", function() command_handler({ args = "~/test.lua" }) - assert.spy(vim.fn.expand).was_called_with("~/test.lua") - assert.spy(mock_server.broadcast).was_called() + assert.spy(mock_server.broadcast).was_called_with("at_mentioned", { + filePath = "/home/user/test.lua", + lineStart = nil, + lineEnd = nil, + }) end) - it("should expand relative paths", function() - command_handler({ args = "./relative.lua" }) + it("should handle absolute paths", function() + command_handler({ args = "/existing/file.lua" }) - assert.spy(vim.fn.expand).was_called_with("./relative.lua") assert.spy(mock_server.broadcast).was_called() end) - it("should handle absolute paths", function() - command_handler({ args = "/existing/file.lua" }) + it("should preserve a literal $ in the path (e.g. TanStack $param files)", function() + -- Regression for the bug where vim.fn.expand treated `$post` as an + -- undefined env var and stripped it, breaking ClaudeCodeAdd on paths + -- like src/routes/$post.tsx. + vim.fn.filereadable = spy.new(function(path) + return path == "/current/dir/src/routes/$post.tsx" and 1 or 0 + end) + + command_handler({ args = "/current/dir/src/routes/$post.tsx" }) + + assert.spy(mock_server.broadcast).was_called_with("at_mentioned", { + filePath = "src/routes/$post.tsx", + lineStart = nil, + lineEnd = nil, + }) + assert.spy(mock_logger.error).was_not_called() + end) + + it("should expand the current-buffer token `%` (the documented 'add current buffer' keymap)", function() + -- `:ClaudeCodeAdd %` (README keymap) must resolve `%` to the current + -- buffer path via vim.fn.expand, NOT be left as the literal "%" -- which + -- a tilde-only expansion would do, breaking the workflow. + command_handler({ args = "%" }) + assert.spy(vim.fn.expand).was_called_with("%") assert.spy(mock_server.broadcast).was_called() + assert.spy(mock_logger.error).was_not_called() end) end) @@ -412,7 +456,6 @@ describe("ClaudeCodeAdd command", function() it("should expand tilde paths with line numbers", function() command_handler({ args = "~/test.lua 10 20" }) - assert.spy(vim.fn.expand).was_called_with("~/test.lua") assert.spy(mock_server.broadcast).was_called_with("at_mentioned", { filePath = "/home/user/test.lua", lineStart = 9, @@ -420,10 +463,9 @@ describe("ClaudeCodeAdd command", function() }) end) - it("should expand relative paths with line numbers", function() - command_handler({ args = "./relative.lua 5" }) + it("should format cwd-relative paths with line numbers", function() + command_handler({ args = "/current/dir/relative.lua 5" }) - assert.spy(vim.fn.expand).was_called_with("./relative.lua") assert.spy(mock_server.broadcast).was_called_with("at_mentioned", { filePath = "relative.lua", lineStart = 4, diff --git a/tests/unit/tools/open_file_spec.lua b/tests/unit/tools/open_file_spec.lua index f8b5c680..5b207b71 100644 --- a/tests/unit/tools/open_file_spec.lua +++ b/tests/unit/tools/open_file_spec.lua @@ -109,7 +109,6 @@ describe("Tool: open_file", function() expect(err.code).to_be(-32000) -- File operation error assert_contains(err.message, "File operation error") assert_contains(err.data, "File not found: non_readable_file.txt") - assert.spy(_G.vim.fn.expand).was_called_with("non_readable_file.txt") assert.spy(_G.vim.fn.filereadable).was_called_with("non_readable_file.txt") end) @@ -124,7 +123,6 @@ describe("Tool: open_file", function() expect(result.content[1].type).to_be("text") expect(result.content[1].text).to_be("Opened file: readable_file.txt") - assert.spy(_G.vim.fn.expand).was_called_with("readable_file.txt") assert.spy(_G.vim.fn.filereadable).was_called_with("readable_file.txt") assert.spy(_G.vim.fn.fnameescape).was_called_with("readable_file.txt") @@ -132,13 +130,9 @@ describe("Tool: open_file", function() expect(_G.vim.cmd_history[1]).to_be("edit readable_file.txt") end) - it("should handle filePath needing expansion", function() - _G.vim.fn.expand = spy.new(function(path) - if path == "~/.config/nvim/init.lua" then - return "/Users/testuser/.config/nvim/init.lua" - end - return path - end) + it("should expand a leading tilde to $HOME", function() + local home = os.getenv("HOME") + local expanded = home .. "/.config/nvim/init.lua" local params = { filePath = "~/.config/nvim/init.lua" } local success, result = pcall(open_file_handler, params) @@ -146,11 +140,22 @@ describe("Tool: open_file", function() expect(result.content).to_be_table() expect(result.content[1]).to_be_table() expect(result.content[1].type).to_be("text") - expect(result.content[1].text).to_be("Opened file: /Users/testuser/.config/nvim/init.lua") - assert.spy(_G.vim.fn.expand).was_called_with("~/.config/nvim/init.lua") - assert.spy(_G.vim.fn.filereadable).was_called_with("/Users/testuser/.config/nvim/init.lua") - assert.spy(_G.vim.fn.fnameescape).was_called_with("/Users/testuser/.config/nvim/init.lua") - expect(_G.vim.cmd_history[1]).to_be("edit /Users/testuser/.config/nvim/init.lua") + expect(result.content[1].text).to_be("Opened file: " .. expanded) + assert.spy(_G.vim.fn.filereadable).was_called_with(expanded) + assert.spy(_G.vim.fn.fnameescape).was_called_with(expanded) + expect(_G.vim.cmd_history[1]).to_be("edit " .. expanded) + end) + + it("should preserve a literal $ in the path (e.g. TanStack $param files)", function() + -- Regression: vim.fn.expand treated `$post` as an undefined env var and + -- stripped it; expand_tilde leaves it intact so the file is found. + local params = { filePath = "src/routes/$post.tsx" } + local success, result = pcall(open_file_handler, params) + + expect(success).to_be_true() + expect(result.content[1].text).to_be("Opened file: src/routes/$post.tsx") + assert.spy(_G.vim.fn.filereadable).was_called_with("src/routes/$post.tsx") + expect(_G.vim.cmd_history[1]).to_be("edit src/routes/$post.tsx") end) it("should handle makeFrontmost=false to return detailed JSON", function() diff --git a/tests/unit/utils_spec.lua b/tests/unit/utils_spec.lua index 60c3758f..73db9b75 100644 --- a/tests/unit/utils_spec.lua +++ b/tests/unit/utils_spec.lua @@ -70,6 +70,14 @@ describe("claudecode.utils.expand_tilde", function() it("returns non-tilde arguments unchanged", function() assert.are.equal("--model", utils.expand_tilde("--model")) end) + + it("preserves a literal $ in the path (does not expand env vars)", function() + -- TanStack Router `$param` route files would otherwise be mangled by + -- vim.fn.expand treating `$post` as an undefined environment variable. + assert.are.equal("src/routes/$post.tsx", utils.expand_tilde("src/routes/$post.tsx")) + assert.are.equal("src/routes/$post/index.tsx", utils.expand_tilde("src/routes/$post/index.tsx")) + assert.are.equal(home .. "/routes/$post.tsx", utils.expand_tilde("~/routes/$post.tsx")) + end) end) describe("claudecode.utils.parse_command", function()