From f793344fccdca17fde61109d0d8a5185d9f9e2f8 Mon Sep 17 00:00:00 2001 From: "blink-so[bot]" <211532188+blink-so[bot]@users.noreply.github.com> Date: Wed, 11 Jun 2025 07:57:18 +0000 Subject: [PATCH 1/2] fix(range-selection): support :'<,'>ClaudeCodeSend and stabilize tests Adds range-selection handling via marks, fixes visual selection fallback, and guards terminal.setup call for tests. Resolves #25. Co-authored-by: Thomas Kosiewski --- lua/claudecode/init.lua | 30 ++- lua/claudecode/selection.lua | 86 +++++- lua/claudecode/visual_commands.lua | 20 +- tests/selection_test.lua | 174 +++++++++++++ tests/unit/claudecode_send_command_spec.lua | 273 ++++++++++++++++++++ 5 files changed, 560 insertions(+), 23 deletions(-) create mode 100644 tests/unit/claudecode_send_command_spec.lua diff --git a/lua/claudecode/init.lua b/lua/claudecode/init.lua index 7233391..d8f59ec 100644 --- a/lua/claudecode/init.lua +++ b/lua/claudecode/init.lua @@ -102,10 +102,11 @@ function M.setup(opts) -- even if terminal_opts (for split_side etc.) are not provided. local terminal_setup_ok, terminal_module = pcall(require, "claudecode.terminal") if terminal_setup_ok then - -- terminal_opts might be nil if user only configured top-level terminal_cmd - -- and not specific terminal appearance options. - -- The terminal.setup function handles nil for its first argument. - terminal_module.setup(terminal_opts, M.state.config.terminal_cmd) + -- Guard in case tests or user replace the module with a minimal stub without `setup`. + if type(terminal_module.setup) == "function" then + -- terminal_opts might be nil, which the setup function should handle gracefully. + terminal_module.setup(terminal_opts, M.state.config.terminal_cmd) + end else logger.error("init", "Failed to load claudecode.terminal module for setup.") end @@ -403,8 +404,8 @@ function M._create_commands() return end - local current_ft = vim.bo.filetype - local current_bufname = vim.api.nvim_buf_get_name(0) + local current_ft = (vim.bo and vim.bo.filetype) or "" + local current_bufname = (vim.api and vim.api.nvim_buf_get_name and vim.api.nvim_buf_get_name(0)) or "" local is_tree_buffer = current_ft == "NvimTree" or current_ft == "neo-tree" @@ -434,14 +435,23 @@ function M._create_commands() local selection_module_ok, selection_module = pcall(require, "claudecode.selection") if selection_module_ok then - local sent_successfully = selection_module.send_at_mention_for_visual_selection() + -- Pass range information if available (for :'<,'> commands) + local line1, line2 = nil, nil + if opts and opts.range and opts.range > 0 then + line1, line2 = opts.line1, opts.line2 + end + local sent_successfully = selection_module.send_at_mention_for_visual_selection(line1, line2) if sent_successfully then + -- Exit any potential visual mode (for consistency) and focus Claude terminal + pcall(function() + if vim.api and vim.api.nvim_feedkeys then + local esc = vim.api.nvim_replace_termcodes("", true, false, true) + vim.api.nvim_feedkeys(esc, "i", true) + end + end) local terminal_ok, terminal = pcall(require, "claudecode.terminal") if terminal_ok then terminal.open({}) - logger.debug("command", "ClaudeCodeSend: Focused Claude Code terminal after selection send.") - else - logger.warn("command", "ClaudeCodeSend: Failed to load terminal module for focusing.") end end else diff --git a/lua/claudecode/selection.lua b/lua/claudecode/selection.lua index a2ff7db..ec41134 100644 --- a/lua/claudecode/selection.lua +++ b/lua/claudecode/selection.lua @@ -573,25 +573,91 @@ function M.send_current_selection() vim.api.nvim_echo({ { "Selection sent to Claude", "Normal" } }, false, {}) end +--- Gets selection from range marks (e.g., when using :'<,'> commands) +-- @param line1 number The start line (1-indexed) +-- @param line2 number The end line (1-indexed) +-- @return table|nil A table containing selection text, file path, URL, and +-- start/end positions, or nil if invalid range +function M.get_range_selection(line1, line2) + if not line1 or not line2 or line1 < 1 or line2 < 1 or line1 > line2 then + return nil + end + + local current_buf = vim.api.nvim_get_current_buf() + local file_path = vim.api.nvim_buf_get_name(current_buf) + + -- Get the total number of lines in the buffer + local total_lines = vim.api.nvim_buf_line_count(current_buf) + + -- Ensure line2 doesn't exceed buffer bounds + if line2 > total_lines then + line2 = total_lines + end + + local lines_content = vim.api.nvim_buf_get_lines( + current_buf, + line1 - 1, -- Convert to 0-indexed + line2, -- nvim_buf_get_lines end is exclusive + false + ) + + if #lines_content == 0 then + return nil + end + + local final_text = table.concat(lines_content, "\n") + + -- For range selections, we treat them as linewise + local lsp_start_line = line1 - 1 -- Convert to 0-indexed + local lsp_end_line = line2 - 1 + local lsp_start_char = 0 + local lsp_end_char = #lines_content[#lines_content] + + return { + text = final_text or "", + filePath = file_path, + fileUrl = "file://" .. file_path, + selection = { + start = { line = lsp_start_line, character = lsp_start_char }, + ["end"] = { line = lsp_end_line, character = lsp_end_char }, + isEmpty = (not final_text or #final_text == 0), + }, + } +end + --- Sends an at_mentioned notification for the current visual selection. -function M.send_at_mention_for_visual_selection() +-- @param line1 number|nil Optional start line for range-based selection +-- @param line2 number|nil Optional end line for range-based selection +function M.send_at_mention_for_visual_selection(line1, line2) if not M.state.tracking_enabled or not M.server then logger.error("selection", "Claude Code is not running or server not available for send_at_mention.") return false end - local sel_to_send = M.state.latest_selection + local sel_to_send - if not sel_to_send or sel_to_send.selection.isEmpty then - -- Fallback: try to get current visual selection directly. - -- This helps if latest_selection was demoted or command was too fast. - local current_visual = M.get_visual_selection() - if current_visual and not current_visual.selection.isEmpty then - sel_to_send = current_visual - else - logger.warn("selection", "No visual selection to send as at-mention.") + -- If range parameters are provided, use them (for :'<,'> commands) + if line1 and line2 then + sel_to_send = M.get_range_selection(line1, line2) + if not sel_to_send or sel_to_send.selection.isEmpty then + logger.warn("selection", "Invalid range selection to send as at-mention.") return false end + else + -- Use existing logic for visual mode or tracked selection + sel_to_send = M.state.latest_selection + + if not sel_to_send or sel_to_send.selection.isEmpty then + -- Fallback: try to get current visual selection directly. + -- This helps if latest_selection was demoted or command was too fast. + local current_visual = M.get_visual_selection() + if current_visual and not current_visual.selection.isEmpty then + sel_to_send = current_visual + else + logger.warn("selection", "No visual selection to send as at-mention.") + return false + end + end end -- Sanity check: ensure the selection is for the current buffer diff --git a/lua/claudecode/visual_commands.lua b/lua/claudecode/visual_commands.lua index 4e76c41..202c023 100644 --- a/lua/claudecode/visual_commands.lua +++ b/lua/claudecode/visual_commands.lua @@ -44,7 +44,11 @@ function M.validate_visual_mode() -- Use pcall to handle test environments local mode_success = pcall(function() - current_mode = vim.api.nvim_get_mode().mode + if vim.api and vim.api.nvim_get_mode then + current_mode = vim.api.nvim_get_mode().mode + else + current_mode = vim.fn.mode(true) + end end) if not mode_success then @@ -78,7 +82,12 @@ function M.get_visual_range() -- Use pcall to handle test environments local range_success = pcall(function() -- Check if we're currently in visual mode - local current_mode = vim.api.nvim_get_mode().mode + local current_mode + if vim.api and vim.api.nvim_get_mode then + current_mode = vim.api.nvim_get_mode().mode + else + current_mode = vim.fn.mode(true) + end local is_visual = current_mode == "v" or current_mode == "V" or current_mode == "\022" if is_visual then @@ -177,7 +186,12 @@ end --- @return function The wrapped command function function M.create_visual_command_wrapper(normal_handler, visual_handler) return function(...) - local current_mode = vim.api.nvim_get_mode().mode + local current_mode + if vim.api and vim.api.nvim_get_mode then + current_mode = vim.api.nvim_get_mode().mode + else + current_mode = vim.fn.mode(true) + end if current_mode == "v" or current_mode == "V" or current_mode == "\022" then -- Use the neo-tree pattern: exit visual mode, then schedule execution diff --git a/tests/selection_test.lua b/tests/selection_test.lua index ef83483..6d7e14b 100644 --- a/tests/selection_test.lua +++ b/tests/selection_test.lua @@ -454,3 +454,177 @@ describe("Selection module", function() assert(selection.has_selection_changed(new_selection_diff_pos) == true) end) end) + +-- Tests for range selection functionality (fix for issue #25) +describe("Range Selection Tests", function() + local selection + + before_each(function() + -- Reset vim state + _G.vim._buffers = { + [1] = { + name = "/test/file.lua", + lines = { + "line 1", + "line 2", + "line 3", + "line 4", + "line 5", + "line 6", + "line 7", + "line 8", + "line 9", + "line 10", + }, + }, + } + _G.vim._windows = { + [1] = { + cursor = { 1, 0 }, + }, + } + _G.vim._current_mode = "n" + + -- Add nvim_buf_line_count function + _G.vim.api.nvim_buf_line_count = function(bufnr) + return _G.vim._buffers[bufnr] and #_G.vim._buffers[bufnr].lines or 0 + end + + -- Reload the selection module + package.loaded["claudecode.selection"] = nil + selection = require("claudecode.selection") + end) + + describe("get_range_selection", function() + it("should return valid selection for valid range", function() + local result = selection.get_range_selection(2, 4) + + assert(result ~= nil) + assert(result.text == "line 2\nline 3\nline 4") + assert(result.filePath == "/test/file.lua") + assert(result.fileUrl == "file:///test/file.lua") + assert(result.selection.start.line == 1) -- 0-indexed + assert(result.selection.start.character == 0) + assert(result.selection["end"].line == 3) -- 0-indexed + assert(result.selection["end"].character == 6) -- length of "line 4" + assert(result.selection.isEmpty == false) + end) + + it("should return valid selection for single line range", function() + local result = selection.get_range_selection(3, 3) + + assert(result ~= nil) + assert(result.text == "line 3") + assert(result.selection.start.line == 2) -- 0-indexed + assert(result.selection["end"].line == 2) -- 0-indexed + assert(result.selection.isEmpty == false) + end) + + it("should handle range that exceeds buffer bounds", function() + local result = selection.get_range_selection(8, 15) -- buffer only has 10 lines + + assert(result ~= nil) + assert(result.text == "line 8\nline 9\nline 10") + assert(result.selection.start.line == 7) -- 0-indexed + assert(result.selection["end"].line == 9) -- 0-indexed, clamped to buffer size + end) + + it("should return nil for invalid range (line1 > line2)", function() + local result = selection.get_range_selection(5, 3) + assert(result == nil) + end) + + it("should return nil for invalid range (line1 < 1)", function() + local result = selection.get_range_selection(0, 3) + assert(result == nil) + end) + + it("should return nil for invalid range (line2 < 1)", function() + local result = selection.get_range_selection(2, 0) + assert(result == nil) + end) + + it("should return nil for nil parameters", function() + local result1 = selection.get_range_selection(nil, 3) + local result2 = selection.get_range_selection(2, nil) + local result3 = selection.get_range_selection(nil, nil) + + assert(result1 == nil) + assert(result2 == nil) + assert(result3 == nil) + end) + + it("should handle empty buffer", function() + _G.vim._buffers[1].lines = {} + local result = selection.get_range_selection(1, 1) + assert(result == nil) + end) + end) + + describe("send_at_mention_for_visual_selection with range", function() + local mock_server + + before_each(function() + mock_server = { + broadcast = function(event, params) + mock_server.last_broadcast = { + event = event, + params = params, + } + return true + end, + } + + selection.state.tracking_enabled = true + selection.server = mock_server + end) + + it("should send range selection successfully", function() + local result = selection.send_at_mention_for_visual_selection(2, 4) + + assert(result == true) + assert(mock_server.last_broadcast ~= nil) + assert(mock_server.last_broadcast.event == "at_mentioned") + assert(mock_server.last_broadcast.params.filePath == "/test/file.lua") + assert(mock_server.last_broadcast.params.lineStart == 1) -- 0-indexed + assert(mock_server.last_broadcast.params.lineEnd == 3) -- 0-indexed + end) + + it("should fail for invalid range", function() + local result = selection.send_at_mention_for_visual_selection(5, 3) + assert(result == false) + end) + + it("should fall back to existing logic when no range provided", function() + -- Set up a tracked selection + selection.state.latest_selection = { + text = "tracked text", + filePath = "/test/file.lua", + fileUrl = "file:///test/file.lua", + selection = { + start = { line = 0, character = 0 }, + ["end"] = { line = 0, character = 12 }, + isEmpty = false, + }, + } + + local result = selection.send_at_mention_for_visual_selection() + + assert(result == true) + assert(mock_server.last_broadcast.params.lineStart == 0) + assert(mock_server.last_broadcast.params.lineEnd == 0) + end) + + it("should fail when server is not available", function() + selection.server = nil + local result = selection.send_at_mention_for_visual_selection(2, 4) + assert(result == false) + end) + + it("should fail when tracking is disabled", function() + selection.state.tracking_enabled = false + local result = selection.send_at_mention_for_visual_selection(2, 4) + assert(result == false) + end) + end) +end) diff --git a/tests/unit/claudecode_send_command_spec.lua b/tests/unit/claudecode_send_command_spec.lua new file mode 100644 index 0000000..49d7333 --- /dev/null +++ b/tests/unit/claudecode_send_command_spec.lua @@ -0,0 +1,273 @@ +require("tests.busted_setup") +require("tests.mocks.vim") + +describe("ClaudeCodeSend Command Range Functionality", function() + local claudecode + local mock_selection_module + local mock_server + local mock_terminal + local command_callback + + before_each(function() + -- Reset package cache + package.loaded["claudecode"] = nil + package.loaded["claudecode.selection"] = nil + package.loaded["claudecode.terminal"] = nil + package.loaded["claudecode.server.init"] = nil + package.loaded["claudecode.lockfile"] = nil + package.loaded["claudecode.config"] = nil + package.loaded["claudecode.logger"] = nil + package.loaded["claudecode.diff"] = nil + + -- Mock vim API + _G.vim = { + api = { + nvim_create_user_command = spy.new(function(name, callback, opts) + if name == "ClaudeCodeSend" then + command_callback = callback + end + end), + nvim_create_augroup = spy.new(function() + return "test_group" + end), + nvim_create_autocmd = spy.new(function() + return 1 + end), + nvim_feedkeys = spy.new(function() end), + nvim_replace_termcodes = spy.new(function(str) + return str + end), + }, + notify = spy.new(function() end), + log = { levels = { ERROR = 1, WARN = 2, INFO = 3 } }, + deepcopy = function(t) + return t + end, + tbl_deep_extend = function(behavior, ...) + local result = {} + for _, tbl in ipairs({ ... }) do + for k, v in pairs(tbl) do + result[k] = v + end + end + return result + end, + fn = { + mode = spy.new(function() + return "n" + end), + }, + } + + -- Mock selection module + mock_selection_module = { + send_at_mention_for_visual_selection = spy.new(function(line1, line2) + mock_selection_module.last_call = { line1 = line1, line2 = line2 } + return true + end), + } + + -- Mock terminal module + mock_terminal = { + open = spy.new(function() end), + } + + -- Mock server + mock_server = { + start = function() + return true, 12345 + end, + stop = function() + return true + end, + } + + -- Mock other modules + local mock_lockfile = { + create = function() + return true, "/mock/path" + end, + remove = function() + return true + end, + } + + local mock_config = { + apply = function(opts) + return { + auto_start = false, + track_selection = true, + visual_demotion_delay_ms = 200, + log_level = "info", + } + end, + } + + local mock_logger = { + setup = function() end, + debug = function() end, + error = function() end, + warn = function() end, + } + + local mock_diff = { + setup = function() end, + } + + -- Setup require mocks BEFORE requiring claudecode + local original_require = _G.require + _G.require = function(module_name) + if module_name == "claudecode.selection" then + return mock_selection_module + elseif module_name == "claudecode.terminal" then + return mock_terminal + elseif module_name == "claudecode.server.init" then + return mock_server + elseif module_name == "claudecode.lockfile" then + return mock_lockfile + elseif module_name == "claudecode.config" then + return mock_config + elseif module_name == "claudecode.logger" then + return mock_logger + elseif module_name == "claudecode.diff" then + return mock_diff + else + return original_require(module_name) + end + end + + -- Load and setup claudecode + claudecode = require("claudecode") + claudecode.setup({}) + + -- Manually set server state for testing + claudecode.state.server = mock_server + claudecode.state.port = 12345 + end) + + after_each(function() + -- Restore original require + _G.require = require + end) + + describe("ClaudeCodeSend command", function() + it("should be registered with range support", function() + assert.spy(_G.vim.api.nvim_create_user_command).was_called() + + -- Find the ClaudeCodeSend command call + local calls = _G.vim.api.nvim_create_user_command.calls + local claudecode_send_call = nil + for _, call in ipairs(calls) do + if call.vals[1] == "ClaudeCodeSend" then + claudecode_send_call = call + break + end + end + + assert(claudecode_send_call ~= nil, "ClaudeCodeSend command should be registered") + assert(claudecode_send_call.vals[3].range == true, "ClaudeCodeSend should support ranges") + end) + + it("should pass range information to selection module when range is provided", function() + assert(command_callback ~= nil, "Command callback should be set") + + -- Simulate command called with range + local opts = { + range = 2, + line1 = 5, + line2 = 8, + } + + command_callback(opts) + + assert.spy(mock_selection_module.send_at_mention_for_visual_selection).was_called() + assert(mock_selection_module.last_call.line1 == 5) + assert(mock_selection_module.last_call.line2 == 8) + end) + + it("should not pass range information when range is 0", function() + assert(command_callback ~= nil, "Command callback should be set") + + -- Simulate command called without range + local opts = { + range = 0, + line1 = 1, + line2 = 1, + } + + command_callback(opts) + + assert.spy(mock_selection_module.send_at_mention_for_visual_selection).was_called() + assert(mock_selection_module.last_call.line1 == nil) + assert(mock_selection_module.last_call.line2 == nil) + end) + + it("should not pass range information when range is nil", function() + assert(command_callback ~= nil, "Command callback should be set") + + -- Simulate command called without range + local opts = {} + + command_callback(opts) + + assert.spy(mock_selection_module.send_at_mention_for_visual_selection).was_called() + assert(mock_selection_module.last_call.line1 == nil) + assert(mock_selection_module.last_call.line2 == nil) + end) + + it("should exit visual mode and focus terminal on successful send", function() + assert(command_callback ~= nil, "Command callback should be set") + + local opts = { + range = 2, + line1 = 5, + line2 = 8, + } + + command_callback(opts) + + assert.spy(_G.vim.api.nvim_feedkeys).was_called() + assert.spy(mock_terminal.open).was_called() + end) + + it("should handle server not running", function() + assert(command_callback ~= nil, "Command callback should be set") + + -- Simulate server not running + claudecode.state.server = nil + + local opts = { + range = 2, + line1 = 5, + line2 = 8, + } + + command_callback(opts) + + assert.spy(_G.vim.notify).was_called() + assert.spy(mock_selection_module.send_at_mention_for_visual_selection).was_not_called() + end) + + it("should handle selection module failure", function() + assert(command_callback ~= nil, "Command callback should be set") + + -- Mock selection module to return false + mock_selection_module.send_at_mention_for_visual_selection = spy.new(function() + return false + end) + + local opts = { + range = 2, + line1 = 5, + line2 = 8, + } + + command_callback(opts) + + assert.spy(mock_selection_module.send_at_mention_for_visual_selection).was_called() + -- Should not exit visual mode or focus terminal on failure + assert.spy(_G.vim.api.nvim_feedkeys).was_not_called() + assert.spy(mock_terminal.open).was_not_called() + end) + end) +end) From 61e9f0625e1ef253ee37aea83f962f37d486fb5d Mon Sep 17 00:00:00 2001 From: "blink-so[bot]" <211532188+blink-so[bot]@users.noreply.github.com> Date: Wed, 11 Jun 2025 10:34:59 +0000 Subject: [PATCH 2/2] Address copilot feedback: fix require restoration and extract mode detection helper - Fix require restoration in test file to use original_require variable - Extract mode detection logic to shared helper function to reduce duplication - All quality gates pass: nix fmt, make check, make test Co-authored-by: ThomasK33 <2198487+ThomasK33@users.noreply.github.com> --- lua/claudecode/visual_commands.lua | 46 +++++++++------------ tests/unit/claudecode_send_command_spec.lua | 5 ++- 2 files changed, 23 insertions(+), 28 deletions(-) diff --git a/lua/claudecode/visual_commands.lua b/lua/claudecode/visual_commands.lua index 202c023..0aa7513 100644 --- a/lua/claudecode/visual_commands.lua +++ b/lua/claudecode/visual_commands.lua @@ -4,6 +4,23 @@ -- @module claudecode.visual_commands local M = {} +--- Get current vim mode with fallback for test environments +--- @param full_mode boolean|nil Whether to get full mode info (passed to vim.fn.mode) +--- @return string current_mode The current vim mode +local function get_current_mode(full_mode) + local current_mode = "n" -- Default fallback + + pcall(function() + if vim.api and vim.api.nvim_get_mode then + current_mode = vim.api.nvim_get_mode().mode + else + current_mode = vim.fn.mode(full_mode) + end + end) + + return current_mode +end + -- ESC key constant matching neo-tree's implementation local ESC_KEY local success = pcall(function() @@ -40,20 +57,7 @@ end --- @return boolean true if in visual mode, false otherwise --- @return string|nil error message if not in visual mode function M.validate_visual_mode() - local current_mode = "n" -- Default fallback - - -- Use pcall to handle test environments - local mode_success = pcall(function() - if vim.api and vim.api.nvim_get_mode then - current_mode = vim.api.nvim_get_mode().mode - else - current_mode = vim.fn.mode(true) - end - end) - - if not mode_success then - return false, "Cannot determine current mode (test environment)" - end + local current_mode = get_current_mode(true) local is_visual = current_mode == "v" or current_mode == "V" or current_mode == "\022" @@ -82,12 +86,7 @@ function M.get_visual_range() -- Use pcall to handle test environments local range_success = pcall(function() -- Check if we're currently in visual mode - local current_mode - if vim.api and vim.api.nvim_get_mode then - current_mode = vim.api.nvim_get_mode().mode - else - current_mode = vim.fn.mode(true) - end + local current_mode = get_current_mode(true) local is_visual = current_mode == "v" or current_mode == "V" or current_mode == "\022" if is_visual then @@ -186,12 +185,7 @@ end --- @return function The wrapped command function function M.create_visual_command_wrapper(normal_handler, visual_handler) return function(...) - local current_mode - if vim.api and vim.api.nvim_get_mode then - current_mode = vim.api.nvim_get_mode().mode - else - current_mode = vim.fn.mode(true) - end + local current_mode = get_current_mode(true) if current_mode == "v" or current_mode == "V" or current_mode == "\022" then -- Use the neo-tree pattern: exit visual mode, then schedule execution diff --git a/tests/unit/claudecode_send_command_spec.lua b/tests/unit/claudecode_send_command_spec.lua index 49d7333..93a6188 100644 --- a/tests/unit/claudecode_send_command_spec.lua +++ b/tests/unit/claudecode_send_command_spec.lua @@ -7,6 +7,7 @@ describe("ClaudeCodeSend Command Range Functionality", function() local mock_server local mock_terminal local command_callback + local original_require before_each(function() -- Reset package cache @@ -115,7 +116,7 @@ describe("ClaudeCodeSend Command Range Functionality", function() } -- Setup require mocks BEFORE requiring claudecode - local original_require = _G.require + original_require = _G.require _G.require = function(module_name) if module_name == "claudecode.selection" then return mock_selection_module @@ -147,7 +148,7 @@ describe("ClaudeCodeSend Command Range Functionality", function() after_each(function() -- Restore original require - _G.require = require + _G.require = original_require end) describe("ClaudeCodeSend command", function()