From 935cd0dad0a4b038b4325d81615c1e99eed2367f Mon Sep 17 00:00:00 2001 From: Geoffrey Belcher Date: Wed, 17 Jun 2026 12:00:44 -0400 Subject: [PATCH] Properly handle `$` in pathnames with ClaudeCodeAdd --- lua/claudecode/init.lua | 6 ++- lua/claudecode/tools/open_file.lua | 5 ++- tests/unit/claudecode_add_command_spec.lua | 50 ++++++++++++++++------ tests/unit/tools/open_file_spec.lua | 33 ++++++++------ tests/unit/utils_spec.lua | 8 ++++ 5 files changed, 73 insertions(+), 29 deletions(-) diff --git a/lua/claudecode/init.lua b/lua/claudecode/init.lua index e9526b4d..a43f00c0 100644 --- a/lua/claudecode/init.lua +++ b/lua/claudecode/init.lua @@ -1030,7 +1030,11 @@ function M._create_commands() return end - file_path = vim.fn.expand(file_path) + -- Expand a leading `~` only. We intentionally avoid `vim.fn.expand`, which + -- treats `$name` as an environment variable and strips undefined ones -- + -- mangling literal `$` in paths (e.g. TanStack Router `$param` route files + -- like `src/routes/$post.tsx`) so the existence check below fails. + file_path = require("claudecode.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..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/tests/unit/claudecode_add_command_spec.lua b/tests/unit/claudecode_add_command_spec.lua index 5f98f652..acafaba5 100644 --- a/tests/unit/claudecode_add_command_spec.lua +++ b/tests/unit/claudecode_add_command_spec.lua @@ -62,7 +62,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 +124,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 +203,35 @@ 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) - assert.spy(mock_server.broadcast).was_called() + 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) end) @@ -412,7 +438,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 +445,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()