diff --git a/nvim-core/src/lib.rs b/nvim-core/src/lib.rs index 5ae5078..52af157 100644 --- a/nvim-core/src/lib.rs +++ b/nvim-core/src/lib.rs @@ -1091,16 +1091,30 @@ pub async fn run_mcp_loop(app_name: &str, app_version: &str) { "enum": ["get_active", "read", "open", "create_scratch", "save", "reload", "close", "list", "search", "edit"], "description": "Action to perform on the buffer" }, - "file": { "type": "string", "description": "Target file path (for open, read, search, edit)" }, + "file": { "type": "string", "description": "Target file path (for open, read, search)" }, "buf_id": { "type": "integer", "description": "Buffer ID (for close, reload, or split)" }, "content": { "type": "string", "description": "Initial text content (for create_scratch)" }, "name": { "type": "string", "description": "Buffer display name (for create_scratch)" }, "filetype": { "type": "string", "description": "Filetype syntax (for open, create_scratch)" }, - "start_line": { "type": "integer", "description": "1-indexed start line (for read, edit)" }, - "end_line": { "type": "integer", "description": "1-indexed end line (for read, edit)" }, - "replacement_content": { "type": "string", "description": "New content for the specified line range (for edit)" }, + "start_line": { "type": "integer", "description": "1-indexed start line (for read)" }, + "end_line": { "type": "integer", "description": "1-indexed end line (for read)" }, "pattern": { "type": "string", "description": "Regex or substring pattern to search for (for search)" }, - "force": { "type": "boolean", "description": "Force reload or close (for reload, close)" } + "force": { "type": "boolean", "description": "Force reload or close (for reload, close)" }, + "edits": { + "type": "array", + "description": "Array of edits to apply sequentially (for edit). Grouped by file, applied in descending order.", + "items": { + "type": "object", + "properties": { + "file": { "type": "string", "description": "Target file path" }, + "start_line": { "type": "integer", "description": "1-indexed start line" }, + "end_line": { "type": "integer", "description": "1-indexed end line" }, + "replacement_content": { "type": "string", "description": "New content" }, + "expected_content": { "type": "string", "description": "Optional: exact content expected in the replacement range to prevent line drift corruption" } + }, + "required": ["file", "start_line", "end_line", "replacement_content"] + } + } }, "required": ["action"] } @@ -1327,7 +1341,16 @@ pub async fn run_mcp_loop(app_name: &str, app_version: &str) { } "edit" => { let mut modified_args = args.clone(); - if let Some(content) = modified_args.get_mut("replacement_content") { + if let Some(edits) = modified_args.get_mut("edits").and_then(|v| v.as_array_mut()) { + for edit in edits.iter_mut() { + if let Some(content) = edit.get_mut("replacement_content") { + if let Some(s) = content.as_str() { + let lines: Vec = s.split('\n').map(|l| l.trim_end_matches('\r').to_string()).collect(); + *content = serde_json::json!(lines); + } + } + } + } else if let Some(content) = modified_args.get_mut("replacement_content") { if let Some(s) = content.as_str() { let lines: Vec = s.split('\n').map(|l| l.trim_end_matches('\r').to_string()).collect(); *content = serde_json::json!(lines); @@ -1336,36 +1359,83 @@ pub async fn run_mcp_loop(app_name: &str, app_version: &str) { let args_rmp = json_to_rmpv(&modified_args); let code = " local args = ... - local file = args.file - if not file or file == '' or file == vim.NIL then - error(\"Missing 'file'\") - end - local start_line = tonumber(args.start_line) - if not start_line then - error(\"Missing 'start_line'\") - end - local end_line = tonumber(args.end_line) - if not end_line then - error(\"Missing 'end_line'\") - end - local content_lines = args.replacement_content - if not content_lines or content_lines == vim.NIL then - error(\"Missing 'replacement_content'\") + local edits = args.edits + if not edits or type(edits) ~= 'table' then + if args.file and args.start_line and args.end_line and args.replacement_content then + edits = { + { + file = args.file, + start_line = tonumber(args.start_line), + end_line = tonumber(args.end_line), + replacement_content = args.replacement_content, + expected_content = args.expected_content + } + } + else + error(\"Missing 'edits' array or valid single edit parameters\") + end end - local bufnr = vim.fn.bufnr(file, true) - if vim.fn.bufloaded(bufnr) == 0 then - vim.fn.bufload(bufnr) + local grouped_edits = {} + for i, edit in ipairs(edits) do + local file = edit.file + if not file or file == '' or file == vim.NIL then + error(\"Edit missing 'file'\") + end + if not grouped_edits[file] then + grouped_edits[file] = {} + end + table.insert(grouped_edits[file], { + start_line = tonumber(edit.start_line), + end_line = tonumber(edit.end_line), + replacement_content = edit.replacement_content, + expected_content = edit.expected_content, + original_index = i + }) end - vim.api.nvim_buf_set_lines(bufnr, start_line - 1, end_line, false, content_lines) - - local win = vim.fn.bufwinid(bufnr) - if win ~= -1 then - pcall(vim.api.nvim_win_set_cursor, win, {start_line, 0}) + local results = {} + for file, file_edits in pairs(grouped_edits) do + local bufnr = vim.fn.bufnr(file, true) + if vim.fn.bufloaded(bufnr) == 0 then + vim.fn.bufload(bufnr) + end + + table.sort(file_edits, function(a, b) + return a.start_line > b.start_line + end) + + for _, edit in ipairs(file_edits) do + local sl = edit.start_line + local el = edit.end_line + local rc = edit.replacement_content + local ec = edit.expected_content + + if not sl then error(\"Edit missing 'start_line'\") end + if not el then error(\"Edit missing 'end_line'\") end + if not rc or rc == vim.NIL then error(\"Edit missing 'replacement_content'\") end + + if ec and type(ec) == 'string' and ec ~= vim.NIL and ec ~= '' then + local current_lines = vim.api.nvim_buf_get_lines(bufnr, sl - 1, el, false) + local current_text = table.concat(current_lines, '\\n') + local ec_clean = string.gsub(ec, '\\r', '') + if current_text ~= ec_clean then + error(\"Safety check failed in \" .. file .. \": Expected content at lines \" .. tostring(sl) .. \"-\" .. tostring(el) .. \" does not match actual content.\\nExpected:\\n\" .. ec_clean .. \"\\nActual:\\n\" .. current_text) + end + end + + vim.api.nvim_buf_set_lines(bufnr, sl - 1, el, false, rc) + end + + local win = vim.fn.bufwinid(bufnr) + if win ~= -1 and #file_edits > 0 then + pcall(vim.api.nvim_win_set_cursor, win, {file_edits[#file_edits].start_line, 0}) + end + + table.insert(results, \"Edited \" .. file .. \" (\" .. tostring(#file_edits) .. \" blocks)\") end - return 'Successfully edited lines ' .. tostring(start_line) .. ' to ' .. tostring(end_line) .. ' in ' .. file + return table.concat(results, ', ') "; handle_lua_result!( id,