Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion lua/claudecode/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion lua/claudecode/tools/open_file.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 37 additions & 13 deletions tests/unit/claudecode_add_command_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -412,18 +438,16 @@ 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,
lineEnd = 19,
})
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,
Expand Down
33 changes: 19 additions & 14 deletions tests/unit/tools/open_file_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -124,33 +123,39 @@ 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")

expect(#_G.vim.cmd_history).to_be(1)
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)

expect(success).to_be_true()
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()
Expand Down
8 changes: 8 additions & 0 deletions tests/unit/utils_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down