From 72b40e6df9f46a428721ae056b910bd8524cae19 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 22 Jun 2026 15:08:16 +0200 Subject: [PATCH 1/2] fix: preserve `$` in file paths for ClaudeCodeAdd and openFile vim.fn.expand() performs shell-style environment-variable substitution, so a real path segment like `$post` (e.g. TanStack Router's `src/routes/$post/`) was read as the undefined env var `post` and replaced with the empty string -- turning `src/routes/$post/index.tsx` into `src/routes//index.tsx`. The subsequent filereadable()/isdirectory() check then failed and the existing file was reported as missing. Replace vim.fn.expand() with claudecode.utils.expand_tilde() at both affected sites. The helper expands only a leading `~`/`~/` and leaves `$`, globs and everything else untouched: - lua/claudecode/init.lua (:ClaudeCodeAdd command) - lua/claudecode/tools/open_file.lua (openFile MCP tool, invoked by Claude) Add regression tests asserting a `$`-containing path survives the readability gate, plus a self-verifying headless repro (scripts/repro_issue_285.lua) and an interactive fixture (fixtures/issue-285/). Fixes #285 Change-Id: I88afde99ea40bae1e394ddd9070898839857db08 Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Thomas Kosiewski --- fixtures/issue-285/init.lua | 155 ++++++++++++ .../sample/src/routes/$post/index.tsx | 7 + lua/claudecode/init.lua | 6 +- lua/claudecode/tools/open_file.lua | 5 +- scripts/repro_issue_285.lua | 233 ++++++++++++++++++ tests/unit/claudecode_add_command_spec.lua | 60 +++-- tests/unit/tools/open_file_spec.lua | 49 +++- 7 files changed, 489 insertions(+), 26 deletions(-) create mode 100644 fixtures/issue-285/init.lua create mode 100644 fixtures/issue-285/sample/src/routes/$post/index.tsx create mode 100644 scripts/repro_issue_285.lua diff --git a/fixtures/issue-285/init.lua b/fixtures/issue-285/init.lua new file mode 100644 index 00000000..4b09fd22 --- /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:", + " :ClaudeCodeStart", + " :ClaudeCodeAdd " .. sample .. " <- FAILS (expand drops $post)", + " :ClaudeCodeAdd % <- works (% = literal buffer name)", + " :messages", + "", + "Expected on UNFIXED code:", + " [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..385ddcd1 100644 --- a/lua/claudecode/init.lua +++ b/lua/claudecode/init.lua @@ -8,6 +8,7 @@ local M = {} local logger = require("claudecode.logger") +local utils = require("claudecode.utils") --- Current plugin version ---@type ClaudeCodeVersion @@ -1030,7 +1031,10 @@ function M._create_commands() return end - file_path = vim.fn.expand(file_path) + -- Expand only a leading `~`; do NOT use vim.fn.expand(), which performs + -- environment-variable substitution and would mangle real paths containing + -- `$` (e.g. `src/routes/$post/index.tsx` -> `src/routes//index.tsx`). See #285. + file_path = utils.expand_tilde(file_path) 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..2c29f4e6 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 only a leading `~`; do NOT use vim.fn.expand(), which performs + -- environment-variable substitution and would mangle real paths containing + -- `$` (e.g. `src/routes/$post/index.tsx` -> `src/routes//index.tsx`). See #285. + 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..9c71a68e --- /dev/null +++ b/scripts/repro_issue_285.lua @@ -0,0 +1,233 @@ +-- 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 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 +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: replace vim.fn.expand() with claudecode.utils.expand_tilde() at both call sites.") +else + out("FIXED: the $-path file is accepted by both :ClaudeCodeAdd and openFile.") +end + +io.stdout:flush() +vim.cmd("cquit " .. (reproduced and 1 or 0)) diff --git a/tests/unit/claudecode_add_command_spec.lua b/tests/unit/claudecode_add_command_spec.lua index 5f98f652..db7bc7b7 100644 --- a/tests/unit/claudecode_add_command_spec.lua +++ b/tests/unit/claudecode_add_command_spec.lua @@ -5,6 +5,7 @@ describe("ClaudeCodeAdd command", function() local claudecode local mock_server local mock_logger + local mock_utils local saved_require = _G.require local function setup_mocks() @@ -21,18 +22,32 @@ describe("ClaudeCodeAdd command", function() warn = spy.new(function() end), } - -- Override vim.fn functions for our specific tests + -- The command expands only a leading `~` via claudecode.utils.expand_tilde + -- (NOT vim.fn.expand, which env-substitutes `$` segments -- see #285). This + -- mock mirrors the real helper: leading `~/` expands; `$` paths, relative + -- and absolute paths pass through unchanged. + mock_utils = { + expand_tilde = spy.new(function(path) + if type(path) == "string" and path:sub(1, 2) == "~/" then + return "/home/user" .. path:sub(2) + end + return path + end), + } + + -- Kept as a pass-through spy so a regression to vim.fn.expand() would still + -- be observable; the command must no longer route path inputs through it. vim.fn.expand = spy.new(function(path) - if path == "~/test.lua" then - return "/home/user/test.lua" - elseif path == "./relative.lua" then - return "/current/dir/relative.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 == "./relative.lua" + or path == "/repo/src/routes/$post/index.tsx" + then return 1 end return 0 @@ -97,6 +112,8 @@ describe("ClaudeCodeAdd command", function() return normal_handler end, } + elseif mod == "claudecode.utils" then + return mock_utils else return saved_require(mod) end @@ -191,14 +208,14 @@ 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_utils.expand_tilde).was_called_with("~/test.lua") assert.spy(mock_server.broadcast).was_called() end) - it("should expand relative paths", function() + it("should pass relative paths through unchanged", function() command_handler({ args = "./relative.lua" }) - assert.spy(vim.fn.expand).was_called_with("./relative.lua") + assert.spy(mock_utils.expand_tilde).was_called_with("./relative.lua") assert.spy(mock_server.broadcast).was_called() end) @@ -207,6 +224,21 @@ describe("ClaudeCodeAdd command", function() assert.spy(mock_server.broadcast).was_called() end) + + it("should not env-substitute `$` segments in paths (#285)", function() + -- A real file under a `$`-named directory (e.g. a TanStack Router + -- dynamic segment). vim.fn.expand() would turn `$post` into the empty + -- string and the readability check would wrongly fail. + command_handler({ args = "/repo/src/routes/$post/index.tsx" }) + + assert.spy(mock_utils.expand_tilde).was_called_with("/repo/src/routes/$post/index.tsx") + assert.spy(mock_server.broadcast).was_called_with("at_mentioned", { + filePath = "/repo/src/routes/$post/index.tsx", + lineStart = nil, + lineEnd = nil, + }) + assert.spy(mock_logger.error).was_not_called() + end) end) describe("broadcasting", function() @@ -412,7 +444,7 @@ 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_utils.expand_tilde).was_called_with("~/test.lua") assert.spy(mock_server.broadcast).was_called_with("at_mentioned", { filePath = "/home/user/test.lua", lineStart = 9, @@ -420,12 +452,12 @@ describe("ClaudeCodeAdd command", function() }) end) - it("should expand relative paths with line numbers", function() + it("should pass relative paths through unchanged with line numbers", function() command_handler({ args = "./relative.lua 5" }) - assert.spy(vim.fn.expand).was_called_with("./relative.lua") + assert.spy(mock_utils.expand_tilde).was_called_with("./relative.lua") assert.spy(mock_server.broadcast).was_called_with("at_mentioned", { - filePath = "relative.lua", + filePath = "./relative.lua", lineStart = 4, lineEnd = nil, }) diff --git a/tests/unit/tools/open_file_spec.lua b/tests/unit/tools/open_file_spec.lua index f8b5c680..24dec059 100644 --- a/tests/unit/tools/open_file_spec.lua +++ b/tests/unit/tools/open_file_spec.lua @@ -2,12 +2,29 @@ require("tests.busted_setup") -- Ensure test helpers are loaded describe("Tool: open_file", function() local open_file_handler + local mock_utils + local saved_utils before_each(function() -- Reset mocks and require the module under test package.loaded["claudecode.tools.open_file"] = nil open_file_handler = require("claudecode.tools.open_file").handler + -- The handler resolves paths via claudecode.utils.expand_tilde (NOT + -- vim.fn.expand, which env-substitutes `$` segments -- see #285). Mock it to + -- mirror the real helper: leading `~/` expands; everything else (including + -- `$` paths) passes through unchanged. + saved_utils = package.loaded["claudecode.utils"] + mock_utils = { + expand_tilde = spy.new(function(path) + if type(path) == "string" and path:sub(1, 2) == "~/" then + return "/Users/testuser" .. path:sub(2) + end + return path + end), + } + package.loaded["claudecode.utils"] = mock_utils + -- Mock Neovim functions used by the handler _G.vim = _G.vim or {} _G.vim.fn = _G.vim.fn or {} @@ -90,6 +107,7 @@ describe("Tool: open_file", function() _G.vim.fn.fnameescape = nil _G.vim.cmd = nil _G.vim.cmd_history = nil + package.loaded["claudecode.utils"] = saved_utils end) it("should error if filePath parameter is missing", function() @@ -109,7 +127,7 @@ 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(mock_utils.expand_tilde).was_called_with("non_readable_file.txt") assert.spy(_G.vim.fn.filereadable).was_called_with("non_readable_file.txt") end) @@ -124,7 +142,7 @@ 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(mock_utils.expand_tilde).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 +150,8 @@ 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 handle filePath needing tilde expansion", function() + -- mock_utils.expand_tilde (from before_each) expands a leading `~/`. local params = { filePath = "~/.config/nvim/init.lua" } local success, result = pcall(open_file_handler, params) @@ -147,12 +160,28 @@ describe("Tool: open_file", function() 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(mock_utils.expand_tilde).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") end) + it("should not env-substitute `$` segments in filePath (#285)", function() + -- A real file under a `$`-named directory (e.g. a TanStack Router dynamic + -- segment). vim.fn.expand() would drop `$post`, turning the path into + -- ".../routes//index.tsx" and making the readable file look missing. + local dollar_path = "/repo/src/routes/$post/index.tsx" + local params = { filePath = dollar_path } + local success, result = pcall(open_file_handler, params) + + expect(success).to_be_true() + expect(result.content[1].text).to_be("Opened file: " .. dollar_path) + assert.spy(mock_utils.expand_tilde).was_called_with(dollar_path) + assert.spy(_G.vim.fn.filereadable).was_called_with(dollar_path) + assert.spy(_G.vim.fn.fnameescape).was_called_with(dollar_path) + expect(_G.vim.cmd_history[1]).to_be("edit " .. dollar_path) + end) + it("should handle makeFrontmost=false to return detailed JSON", function() local params = { filePath = "test.txt", makeFrontmost = false } local success, result = pcall(open_file_handler, params) From 95323ace3121b2f11eff5658126caf01bd2d74b2 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 22 Jun 2026 16:05:51 +0200 Subject: [PATCH 2/2] fix: keep %/#/ token expansion in ClaudeCodeAdd The previous commit routed ALL :ClaudeCodeAdd path args through expand_tilde, which also dropped vim.fn.expand's current/alternate-file token expansion -- breaking the documented `:ClaudeCodeAdd %` "add current buffer" keymap (README). Expand Vim's %/#/<...> file tokens via vim.fn.expand and route only plain filesystem paths through expand_tilde, so both real `$` paths and the `%` workflow work. This is strictly better than the pre-PR behaviour: `$` is fixed while %/#/<...> behave exactly as before. Add a `%` regression test and a `%` scenario to the repro script. Addresses codex review feedback on #291. Change-Id: Idbdf3e1bfcd1eeb334acebd66314d386b5d914f0 Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Thomas Kosiewski --- fixtures/issue-285/init.lua | 8 ++--- lua/claudecode/init.lua | 17 +++++++--- scripts/repro_issue_285.lua | 36 +++++++++++++++++++--- tests/unit/claudecode_add_command_spec.lua | 22 +++++++++++-- 4 files changed, 68 insertions(+), 15 deletions(-) diff --git a/fixtures/issue-285/init.lua b/fixtures/issue-285/init.lua index 4b09fd22..1449b1ba 100644 --- a/fixtures/issue-285/init.lua +++ b/fixtures/issue-285/init.lua @@ -55,13 +55,13 @@ vim.api.nvim_buf_set_lines(banner_buf, 0, -1, false, { "", "Press x (or run :Repro285) for a one-line PASS/FAIL verdict.", "", - "Manual check:", + "Manual check (with the fix applied, both succeed):", " :ClaudeCodeStart", - " :ClaudeCodeAdd " .. sample .. " <- FAILS (expand drops $post)", - " :ClaudeCodeAdd % <- works (% = literal buffer name)", + " :ClaudeCodeAdd " .. sample, + " :ClaudeCodeAdd % (current buffer; % is still expanded, not taken literally)", " :messages", "", - "Expected on UNFIXED code:", + "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)", }) diff --git a/lua/claudecode/init.lua b/lua/claudecode/init.lua index 385ddcd1..9c65828a 100644 --- a/lua/claudecode/init.lua +++ b/lua/claudecode/init.lua @@ -1031,10 +1031,19 @@ function M._create_commands() return end - -- Expand only a leading `~`; do NOT use vim.fn.expand(), which performs - -- environment-variable substitution and would mangle real paths containing - -- `$` (e.g. `src/routes/$post/index.tsx` -> `src/routes//index.tsx`). See #285. - file_path = utils.expand_tilde(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 also performs + -- environment-variable substitution and mangles real paths containing `$` + -- (e.g. `src/routes/$post/index.tsx` -> `src/routes//index.tsx`, see #285). + -- So: expand() only the special-token forms; otherwise expand just a leading + -- `~` and leave `$`/globs untouched. + if file_path:match("^[%%#<]") then + file_path = vim.fn.expand(file_path) + else + file_path = 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/scripts/repro_issue_285.lua b/scripts/repro_issue_285.lua index 9c71a68e..334cbc08 100644 --- a/scripts/repro_issue_285.lua +++ b/scripts/repro_issue_285.lua @@ -192,6 +192,27 @@ 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") @@ -213,6 +234,7 @@ 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 @@ -224,10 +246,16 @@ if reproduced then if c_bug then out(" - openFile MCP tool reported an existing file as 'not found' for the same reason.") end - out(" Fix: replace vim.fn.expand() with claudecode.utils.expand_tilde() at both call sites.") -else - out("FIXED: the $-path file is accepted by both :ClaudeCodeAdd and openFile.") + 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 and 1 or 0)) +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 db7bc7b7..89fd747b 100644 --- a/tests/unit/claudecode_add_command_spec.lua +++ b/tests/unit/claudecode_add_command_spec.lua @@ -22,7 +22,7 @@ describe("ClaudeCodeAdd command", function() warn = spy.new(function() end), } - -- The command expands only a leading `~` via claudecode.utils.expand_tilde + -- Plain filesystem paths are resolved via claudecode.utils.expand_tilde -- (NOT vim.fn.expand, which env-substitutes `$` segments -- see #285). This -- mock mirrors the real helper: leading `~/` expands; `$` paths, relative -- and absolute paths pass through unchanged. @@ -35,9 +35,13 @@ describe("ClaudeCodeAdd command", function() end), } - -- Kept as a pass-through spy so a regression to vim.fn.expand() would still - -- be observable; the command must no longer route path inputs through it. + -- Vim's current/alternate-file tokens (`%`, `%:p`, `#`, ``, ...) are + -- still resolved via vim.fn.expand (the documented `:ClaudeCodeAdd %` + -- "add current buffer" keymap relies on this). Plain paths must NOT reach it. vim.fn.expand = spy.new(function(path) + if path == "%" or path == "%:p" then + return "/current/dir/buffer.lua" + end return path end) @@ -47,6 +51,7 @@ describe("ClaudeCodeAdd command", function() or path == "/home/user/test.lua" or path == "./relative.lua" or path == "/repo/src/routes/$post/index.tsx" + or path == "/current/dir/buffer.lua" then return 1 end @@ -239,6 +244,17 @@ describe("ClaudeCodeAdd command", function() }) 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 "%". + command_handler({ args = "%" }) + + assert.spy(vim.fn.expand).was_called_with("%") + assert.spy(mock_utils.expand_tilde).was_not_called() + assert.spy(mock_server.broadcast).was_called() + assert.spy(mock_logger.error).was_not_called() + end) end) describe("broadcasting", function()