From dcb82fa975f7517ad54058bc7468850d64cf029c Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Tue, 2 Sep 2025 03:08:51 +0200 Subject: [PATCH] feat(diff): add experimental unified diff support, refactor handling - Introduce experimental unified diff parsing and application utilities - Refactor mappings to support both block and unified diff formats - Add configuration option to select diff format - Update prompts and instructions for diff formats and tool usage - Improve chat UI parsing for diff blocks - Add tests for diff utilities and edge cases Signed-off-by: Tomas Slusny --- lua/CopilotChat/config.lua | 2 + lua/CopilotChat/config/mappings.lua | 340 ++++++------------ lua/CopilotChat/config/prompts.lua | 59 +-- lua/CopilotChat/init.lua | 16 +- .../instructions/edit_file_block.lua | 41 +++ .../instructions/edit_file_unified.lua | 68 ++++ lua/CopilotChat/instructions/tool_use.lua | 12 + lua/CopilotChat/ui/chat.lua | 42 ++- lua/CopilotChat/utils/diff.lua | 229 ++++++++++++ tests/diff_spec.lua | 111 ++++++ 10 files changed, 620 insertions(+), 300 deletions(-) create mode 100644 lua/CopilotChat/instructions/edit_file_block.lua create mode 100644 lua/CopilotChat/instructions/edit_file_unified.lua create mode 100644 lua/CopilotChat/instructions/tool_use.lua create mode 100644 lua/CopilotChat/utils/diff.lua create mode 100644 tests/diff_spec.lua diff --git a/lua/CopilotChat/config.lua b/lua/CopilotChat/config.lua index b525e995..5261c70e 100644 --- a/lua/CopilotChat/config.lua +++ b/lua/CopilotChat/config.lua @@ -19,6 +19,7 @@ ---@field tools string|table|nil ---@field resources string|table|nil ---@field sticky string|table|nil +---@field diff 'block'|'unified'? ---@field language string? ---@field temperature number? ---@field headless boolean? @@ -62,6 +63,7 @@ return { tools = nil, -- Default tool or array of tools (or groups) to share with LLM (can be specified manually in prompt via @). resources = 'selection', -- Default resources to share with LLM (can be specified manually in prompt via #). sticky = nil, -- Default sticky prompt or array of sticky prompts to use at start of every new chat (can be specified manually in prompt via >). + diff = 'block', -- Default diff format to use, 'block' or 'unified'. language = 'English', -- Default language to use for answers temperature = 0.1, -- Result temperature diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index 23867f61..97a384f8 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -4,103 +4,25 @@ local client = require('CopilotChat.client') local constants = require('CopilotChat.constants') local select = require('CopilotChat.select') local utils = require('CopilotChat.utils') +local diff = require('CopilotChat.utils.diff') local files = require('CopilotChat.utils.files') ----@class CopilotChat.config.mappings.Diff ----@field change string ----@field reference string ----@field filename string ----@field filetype string ----@field start_line number ----@field end_line number ----@field bufnr number? - ---- Get diff data from a block ----@param bufnr number ----@param block CopilotChat.ui.chat.Block? ----@return CopilotChat.config.mappings.Diff? -local function get_diff(bufnr, block) - -- If no block found, return nil - if not block then - return nil - end - - local header = block.header - local selection = select.get(bufnr) - local filename = nil - local filetype = nil - local start_line = nil - local end_line = nil - local reference = nil - local bufnr = nil - - if selection then - -- If we have a selection, use it as default source of truth - filename = selection.filename - filetype = selection.filetype - start_line = selection.start_line - end_line = selection.end_line - reference = selection.content - bufnr = selection.bufnr - end - - -- If we have header info, use it as source of truth - if header.start_line and header.end_line then - filename = files.uri_to_filename(header.filename) - filetype = header.filetype or files.filetype(filename) - start_line = header.start_line - end_line = header.end_line - - -- Try to find matching buffer and window - bufnr = nil - for _, win in ipairs(vim.api.nvim_list_wins()) do - local win_buf = vim.api.nvim_win_get_buf(win) - if files.filename_same(vim.api.nvim_buf_get_name(win_buf), header.filename) then - bufnr = win_buf - break - end - end - - -- If we found a valid buffer, get the reference content - if bufnr and utils.buf_valid(bufnr) then - local lines = vim.api.nvim_buf_get_lines(bufnr, start_line - 1, end_line, false) - reference = table.concat(lines, '\n') - filetype = vim.bo[bufnr].filetype - end - end - - -- If we are missing info, there is no diff to be made - if not start_line or not end_line or not filename then - return nil - end - - return { - change = block.content, - reference = reference or '', - filetype = filetype or '', - filename = filename, - start_line = start_line, - end_line = end_line, - bufnr = bufnr, - } -end - --- Prepare a buffer for applying a diff ----@param diff CopilotChat.config.mappings.Diff? ----@param source CopilotChat.source? ----@return CopilotChat.config.mappings.Diff? -local function prepare_diff_buffer(diff, source) - if not diff then - return diff +---@param filename string? +---@param source CopilotChat.source +---@return integer +local function prepare_diff_buffer(filename, source) + if not filename then + filename = vim.api.nvim_buf_get_name(source.bufnr) end - local diff_bufnr = diff.bufnr + local diff_bufnr = nil -- If buffer is not found, try to load it if not diff_bufnr then -- Try to find matching buffer first for _, buf in ipairs(vim.api.nvim_list_bufs()) do - if files.filename_same(vim.api.nvim_buf_get_name(buf), diff.filename) then + if files.filename_same(vim.api.nvim_buf_get_name(buf), filename) then diff_bufnr = buf break end @@ -108,11 +30,9 @@ local function prepare_diff_buffer(diff, source) -- If still not found, create a new buffer if not diff_bufnr then - diff_bufnr = vim.fn.bufadd(diff.filename) + diff_bufnr = vim.fn.bufadd(filename) vim.fn.bufload(diff_bufnr) end - - diff.bufnr = diff_bufnr end -- If source exists, update it to point to the diff buffer @@ -121,7 +41,7 @@ local function prepare_diff_buffer(diff, source) vim.api.nvim_win_set_buf(source.winnr, diff_bufnr) end - return diff + return diff_bufnr end ---@class CopilotChat.config.mapping @@ -132,9 +52,6 @@ end ---@class CopilotChat.config.mapping.yank_diff : CopilotChat.config.mapping ---@field register string? ----@class CopilotChat.config.mapping.show_diff : CopilotChat.config.mapping ----@field full_diff boolean? - ---@class CopilotChat.config.mappings ---@field complete CopilotChat.config.mapping|false|nil ---@field close CopilotChat.config.mapping|false|nil @@ -145,7 +62,7 @@ end ---@field jump_to_diff CopilotChat.config.mapping|false|nil ---@field quickfix_diffs CopilotChat.config.mapping|false|nil ---@field yank_diff CopilotChat.config.mapping.yank_diff|false|nil ----@field show_diff CopilotChat.config.mapping.show_diff|false|nil +---@field show_diff CopilotChat.config.mapping|false|nil ---@field show_info CopilotChat.config.mapping|false|nil ---@field show_help CopilotChat.config.mapping|false|nil return { @@ -248,87 +165,41 @@ return { normal = '', insert = '', callback = function(source) - local diff = get_diff(source.bufnr, copilot.chat:get_block(constants.ROLE.ASSISTANT, true)) - diff = prepare_diff_buffer(diff, source) - if not diff then + local block = copilot.chat:get_block(constants.ROLE.ASSISTANT, true) + if not block then return end - local lines = utils.split_lines(diff.change) - vim.api.nvim_buf_set_lines(diff.bufnr, diff.start_line - 1, diff.end_line, false, lines) - select.set(source.bufnr, source.winnr, diff.start_line, diff.start_line + #lines - 1) - select.highlight(source.bufnr) + local path = block.header.filename + local bufnr = prepare_diff_buffer(path, source) + local new_lines, applied = diff.apply_diff(block, bufnr) + if not applied then + new_lines = utils.split_lines(block.content) + end + + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, new_lines) + local first, last = diff.get_diff_region(block, bufnr) + if first and last then + select.set(bufnr, source.winnr, first, last) + select.highlight(bufnr) + end end, }, jump_to_diff = { normal = 'gj', callback = function(source) - local diff = get_diff(source.bufnr, copilot.chat:get_block(constants.ROLE.ASSISTANT, true)) - diff = prepare_diff_buffer(diff, source) - if not diff then + local block = copilot.chat:get_block(constants.ROLE.ASSISTANT, true) + if not block then return end - select.set(source.bufnr, source.winnr, diff.start_line, diff.end_line) - select.highlight(source.bufnr) - end, - }, - - quickfix_answers = { - normal = 'gqa', - callback = function() - local items = {} - local messages = copilot.chat:get_messages() - for i, message in ipairs(messages) do - if message.section and message.role == constants.ROLE.ASSISTANT then - local prev_message = messages[i - 1] - local text = '' - if prev_message then - text = prev_message.content - end - - table.insert(items, { - bufnr = copilot.chat.bufnr, - lnum = message.section.start_line, - end_lnum = message.section.end_line, - text = text, - }) - end - end - - vim.fn.setqflist(items) - vim.cmd('copen') - end, - }, - - quickfix_diffs = { - normal = 'gqd', - callback = function(source) - local items = {} - local messages = copilot.chat:get_messages() - for _, message in ipairs(messages) do - if message.section then - for _, block in ipairs(message.section.blocks) do - local diff = get_diff(source.bufnr, block) - if diff then - local text = string.format('%s (%s)', diff.filename, diff.filetype) - if diff.start_line and diff.end_line then - text = text .. string.format(' [lines %d-%d]', diff.start_line, diff.end_line) - end - - table.insert(items, { - bufnr = copilot.chat.bufnr, - lnum = block.start_line, - end_lnum = block.end_line, - text = text, - }) - end - end - end - - vim.fn.setqflist(items) - vim.cmd('copen') + local path = block.header.filename + local bufnr = prepare_diff_buffer(path, source) + local first, last = diff.get_diff_region(block, bufnr) + if first and last and bufnr then + select.set(bufnr, source.winnr, first, last) + select.highlight(bufnr) end end, }, @@ -348,99 +219,96 @@ return { show_diff = { normal = 'gd', - full_diff = false, -- Show full diff instead of unified diff when showing diff window callback = function(source) - local diff = get_diff(source.bufnr, copilot.chat:get_block(constants.ROLE.ASSISTANT, true)) - diff = prepare_diff_buffer(diff, source) - if not diff then + local block = copilot.chat:get_block(constants.ROLE.ASSISTANT, true) + if not block then return end + local path = block.header.filename + local bufnr = prepare_diff_buffer(path, source) + local new_lines, applied = diff.apply_diff(block, bufnr) + if not applied then + new_lines = utils.split_lines(block.content) + end + local original_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local opts = { - filetype = diff.filetype, - syntax = 'diff', + filetype = vim.bo[bufnr].filetype, + text = applied and table.concat(new_lines, '\n') or table.concat(original_lines, '\n'), } - if copilot.config.mappings.show_diff.full_diff then - local original = utils.buf_valid(diff.bufnr) and vim.api.nvim_buf_get_lines(diff.bufnr, 0, -1, false) or {} - - if #original > 0 then - -- Find all diffs from the same file in this section - local message = copilot.chat:get_message(constants.ROLE.ASSISTANT, true) - local section = message and message.section - local same_file_diffs = {} - if section then - for _, block in ipairs(section.blocks) do - local block_diff = get_diff(source.bufnr, block) - if block_diff and block_diff.bufnr == diff.bufnr then - table.insert(same_file_diffs, block_diff) - end - end - end + opts.on_show = function() + vim.api.nvim_win_call(source.winnr, function() + vim.cmd('diffthis') + end) - -- Ensure we at least apply the current diff - if #same_file_diffs == 0 then - table.insert(same_file_diffs, diff) - end - - -- Sort diffs by start_line in descending order (apply from bottom to top) - table.sort(same_file_diffs, function(a, b) - return a.start_line > b.start_line - end) + vim.api.nvim_win_call(copilot.chat.winnr, function() + vim.cmd('diffthis') + end) + end - local result = vim.deepcopy(original) + opts.on_hide = function() + vim.api.nvim_win_call(copilot.chat.winnr, function() + vim.cmd('diffoff') + end) + end - -- Apply diffs from bottom to top so line numbers remain valid - for _, d in ipairs(same_file_diffs) do - local change_lines = utils.split_lines(d.change) + copilot.chat:overlay(opts) + end, + }, - -- Remove original lines (from end to start to avoid index shifting) - for i = d.end_line, d.start_line, -1 do - if result[i] then - table.remove(result, i) - end + quickfix_diffs = { + normal = 'gqd', + callback = function() + local items = {} + local messages = copilot.chat:get_messages() + for _, message in ipairs(messages) do + if message.section then + for _, block in ipairs(message.section.blocks) do + local text = string.format('%s (%s)', block.header.filename, block.header.filetype) + if block.header.start_line and block.header.end_line then + text = text .. string.format(' [lines %d-%d]', block.header.start_line, block.header.end_line) end - -- Insert replacement lines at start_line - for i = #change_lines, 1, -1 do - table.insert(result, d.start_line, change_lines[i]) - end + table.insert(items, { + bufnr = copilot.chat.bufnr, + lnum = block.start_line, + end_lnum = block.end_line, + text = text, + }) end - - opts.text = table.concat(result, '\n') - else - opts.text = diff.change end - opts.on_show = function() - vim.api.nvim_win_call(vim.fn.bufwinid(diff.bufnr), function() - vim.cmd('diffthis') - end) + vim.fn.setqflist(items) + vim.cmd('copen') + end + end, + }, - vim.api.nvim_win_call(copilot.chat.winnr, function() - vim.cmd('diffthis') - end) - end + quickfix_answers = { + normal = 'gqa', + callback = function() + local items = {} + for i, message in ipairs(copilot.chat.messages) do + if message.section and message.role == constants.ROLE.ASSISTANT then + local prev_message = copilot.chat.messages[i - 1] + local text = '' + if prev_message then + text = prev_message.content + end - opts.on_hide = function() - vim.api.nvim_win_call(copilot.chat.winnr, function() - vim.cmd('diffoff') - end) + table.insert(items, { + bufnr = copilot.chat.bufnr, + lnum = message.section.start_line, + end_lnum = message.section.end_line, + text = text, + }) end - else - opts.text = tostring(vim.diff(diff.reference, diff.change, { - result_type = 'unified', - ignore_blank_lines = true, - ignore_whitespace = true, - ignore_whitespace_change = true, - ignore_whitespace_change_at_eol = true, - ignore_cr_at_eol = true, - algorithm = 'myers', - ctxlen = #diff.reference, - })) end - copilot.chat:overlay(opts) + vim.fn.setqflist(items) + vim.cmd('copen') end, }, diff --git a/lua/CopilotChat/config/prompts.lua b/lua/CopilotChat/config/prompts.lua index 6b8562d2..40d9be1e 100644 --- a/lua/CopilotChat/config/prompts.lua +++ b/lua/CopilotChat/config/prompts.lua @@ -23,14 +23,15 @@ The user works in editor called Neovim which has these core concepts: - Treesitter: Provides syntax highlighting, code folding, and structural text editing based on syntax tree parsing - Visual selection: Text selected in visual mode that can be shared as context The user is working on a {OS_NAME} machine. Please respond with system specific commands if applicable. -The user is currently in workspace directory {DIR} (typically the project root). Current file paths will be relative to this directory. +The user is currently in workspace directory {DIR} (project root). File paths are relative to this directory. Context is provided to you in several ways: - Resources: Contextual data shared via "# " headers and referenced via "##" links - Code blocks with file path labels and line numbers (e.g., ```lua path=/file.lua start_line=1 end_line=10```) + Note: Line numbers prefixed to each line are for reference only and should never be included when outputting code - Visual selections: Text selected in visual mode that can be shared as context -- Diffs: Changes shown in unified diff format with line prefixes (+, -, etc.) +- Diffs: Changes shown in unified diff format (+, -, etc.) - Conversation history When resources (like buffers, files, or diffs) change, their content in the chat history is replaced with the latest version rather than appended as new data. @@ -40,57 +41,8 @@ If you can infer the project type (languages, frameworks, libraries) from contex For implementing features, break down the request into concepts and provide a clear solution. Think creatively to provide complete solutions based on the information available. Never fabricate or hallucinate file contents you haven't actually seen in the provided context. +When outputting code, never include line number prefixes - they are only for reference when analyzing the provided context. - -If tools are available for a requested action (such as file edit, read, search, diagnostics, etc.), you MUST use the tool to perform the action. Only provide manual code or instructions if no tool exists for that purpose. -- Always prefer tool usage over manual edits or suggestions. -- Follow JSON schema precisely when using tools, including all required properties and outputting valid JSON. -- Use appropriate tools for tasks rather than asking for manual actions or generating code for actions you can perform directly. -- Execute actions directly when you indicate you'll do so, without asking for permission. -- Only use tools that exist and use proper invocation procedures - no multi_tool_use.parallel unless specified. -- Before using tools to retrieve information, check if context is already available as described in the context instructions above. -- If you don't have explicit tool definitions in your system prompt, clearly state this limitation when asked. NEVER pretend to have tool capabilities you don't possess. - - -Use these instructions when editing files via code blocks. Your goal is to produce clear, minimal, and precise file edits. - -Steps for presenting code changes: -1. For each change, use the following markdown code block format with triple backticks: - ``` path= start_line= end_line= - - ``` - -2. Examples: - ```lua path={DIR}/lua/CopilotChat/init.lua start_line=40 end_line=50 - local function example() - print("This is an example function.") - end - ``` - - ```python path={DIR}/scripts/example.py start_line=10 end_line=15 - def example_function(): - print("This is an example function.") - ``` - - ```json path={DIR}/config/settings.json start_line=5 end_line=8 - { - "setting": "value", - "enabled": true - } - ``` - -3. Requirements for code content: - - Always use the absolute file path in the code block header. If the path is not already absolute, convert it to an absolute path prefixed by {DIR}. - - Keep changes minimal and focused to produce short diffs - - Include complete replacement code for the specified line range - - Proper indentation matching the source - - All necessary lines (no eliding with comments) - - **Never include line number prefixes in your output code blocks. Only output valid code, exactly as it should appear in the file. Line numbers are only allowed in the code block header.** - - Address any diagnostics issues when fixing code - -4. If multiple changes are needed, present them as separate code blocks. - - ]], }, @@ -205,10 +157,9 @@ If no issues found, confirm the code is well-written and explain why. }, Commit = { - prompt = 'Write commit message for the change with commitizen convention. Keep the title under 50 characters and wrap message at 72 characters. Format as a gitcommit code block. If user has COMMIT_EDITMSG opened, generate replacement block for whole buffer.', + prompt = 'Write commit message for the change with commitizen convention. Keep the title under 50 characters and wrap message at 72 characters. Format as a gitcommit code block.', resources = { 'gitdiff:staged', - 'buffer', }, }, } diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 69d6ac77..5dc129af 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -563,7 +563,21 @@ function M.resolve_prompt(prompt, config) config.system_prompt = M.config.prompts[config.system_prompt].system_prompt end - config.system_prompt = config.system_prompt .. '\n' .. M.config.prompts.COPILOT_BASE.system_prompt + config.system_prompt = vim.trim(config.system_prompt) .. '\n' .. M.config.prompts.COPILOT_BASE.system_prompt + config.system_prompt = vim.trim(config.system_prompt) + .. '\n' + .. vim.trim(require('CopilotChat.instructions.tool_use')) + + if config.diff == 'unified' then + config.system_prompt = vim.trim(config.system_prompt) + .. '\n' + .. vim.trim(require('CopilotChat.instructions.edit_file_unified')) + else + config.system_prompt = vim.trim(config.system_prompt) + .. '\n' + .. vim.trim(require('CopilotChat.instructions.edit_file_block')) + end + config.system_prompt = config.system_prompt:gsub('{OS_NAME}', jit.os) config.system_prompt = config.system_prompt:gsub('{LANGUAGE}', config.language) config.system_prompt = config.system_prompt:gsub('{DIR}', state.source.cwd()) diff --git a/lua/CopilotChat/instructions/edit_file_block.lua b/lua/CopilotChat/instructions/edit_file_block.lua new file mode 100644 index 00000000..8abc8719 --- /dev/null +++ b/lua/CopilotChat/instructions/edit_file_block.lua @@ -0,0 +1,41 @@ +return [[ + +Use these instructions when editing files via code blocks. Your goal is to produce clear, minimal, and precise file edits. + +Steps for presenting code changes: +1. For each change, use the following markdown code block format with triple backticks: + ``` path= start_line= end_line= + + ``` + +2. Examples: + ```lua path={DIR}/lua/CopilotChat/init.lua start_line=40 end_line=50 + local function example() + print("This is an example function.") + end + ``` + + ```python path={DIR}/scripts/example.py start_line=10 end_line=15 + def example_function(): + print("This is an example function.") + ``` + + ```json path={DIR}/config/settings.json start_line=5 end_line=8 + { + "setting": "value", + "enabled": true + } + ``` + +3. Requirements for code content: + - Always use the absolute file path in the code block header. If the path is not already absolute, convert it to an absolute path prefixed by {DIR}. + - Keep changes minimal and focused to produce short diffs + - Include complete replacement code for the specified line range + - Proper indentation matching the source + - All necessary lines (no eliding with comments) + - **Never include line number prefixes in your output code blocks. Only output valid code, exactly as it should appear in the file. Line numbers are only allowed in the code block header.** + - Address any diagnostics issues when fixing code + +4. If multiple changes are needed, present them as separate code blocks. + +]] diff --git a/lua/CopilotChat/instructions/edit_file_unified.lua b/lua/CopilotChat/instructions/edit_file_unified.lua new file mode 100644 index 00000000..b5d20861 --- /dev/null +++ b/lua/CopilotChat/instructions/edit_file_unified.lua @@ -0,0 +1,68 @@ +return [[ + +Return edits similar to unified diffs that `diff -U0` would produce. + +- Always include the first 2 lines with the file paths (no timestamps). +- Start each hunk of changes with a `@@ ... @@` line. +- Do not include line numbers in the hunk header. +- The user's patch tool needs CORRECT patches that apply cleanly against the current contents of the file. +- Indentation matters in the diffs! + +Context lines: +- For each hunk that contains changes, you MUST always include 2-3 context lines before the change. +- ALWAYS prefix every context line with a single space character. +- Context lines MUST ONLY appear BEFORE changes, NEVER after changes. +- MISSING CONTEXT LINES WILL CAUSE PATCH FAILURES - they are mandatory, not optional. +- MISSING SPACE PREFIXES WILL CAUSE PATCH FAILURES - they are mandatory, not optional. + +Change lines: +- Mark all lines to be removed or changed with `-`. +- Mark all new or modified lines with `+`. +- Only output hunks that specify changes with `+` or `-` lines. + +Other instructions: +- Start a new hunk for each section of the file that needs changes. +- When editing a function, method, loop, etc., replace the entire code block: delete the entire existing version with `-` lines, then add the new, updated version with `+` lines. +- To move code within a file, use 2 hunks: one to delete it from its current location, one to insert it in the new location. +- To make a new file, show a diff from `--- /dev/null` to `+++ path/to/new/file.ext`. + +Example: + +```diff +--- mathweb/flask/app.py ++++ mathweb/flask/app.py +@@ ... @@ +-class MathWeb: ++import sympy ++ ++class MathWeb: +@@ ... @@ +-def is_prime(x): +- if x < 2: +- return False +- for i in range(2, int(math.sqrt(x)) + 1): +- if x % i == 0: +- return False +- return True +@@ ... @@ +-@app.route('/prime/') +-def nth_prime(n): +- count = 0 +- num = 1 +- while count < n: +- num += 1 +- if is_prime(num): +- count += 1 +- return str(num) ++@app.route('/prime/') ++def nth_prime(n): ++ count = 0 ++ num = 1 ++ while count < n: ++ num += 1 ++ if sympy.isprime(num): ++ count += 1 ++ return str(num) +``` + +]] diff --git a/lua/CopilotChat/instructions/tool_use.lua b/lua/CopilotChat/instructions/tool_use.lua new file mode 100644 index 00000000..989bf209 --- /dev/null +++ b/lua/CopilotChat/instructions/tool_use.lua @@ -0,0 +1,12 @@ +return [[ + +If tools are available for a requested action (such as file edit, read, search, diagnostics, etc.), you MUST use the tool to perform the action. Only provide manual code or instructions if no tool exists for that purpose. +- Always prefer tool usage over manual edits or suggestions. +- Follow JSON schema precisely when using tools, including all required properties and outputting valid JSON. +- Use appropriate tools for tasks rather than asking for manual actions or generating code for actions you can perform directly. +- Execute actions directly when you indicate you'll do so, without asking for permission. +- Only use tools that exist and use proper invocation procedures - no multi_tool_use.parallel unless specified. +- Before using tools to retrieve information, check if context is already available as described in the context instructions above. +- If you don't have explicit tool definitions in your system prompt, clearly state this limitation when asked. NEVER pretend to have tool capabilities you don't possess. + +]] diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index f018d6b7..51604d6a 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -16,11 +16,6 @@ function CopilotChatFoldExpr(lnum, separator) return '=' end -local HEADER_PATTERNS = { - '^(%w+)%s+path=(%S+)%s+start_line=(%d+)%s+end_line=(%d+)$', - '^(%w+)$', -} - ---@param headers table? ---@return string?, string? local function match_section_header(headers, separator, line) @@ -43,7 +38,12 @@ local function match_block_header(header) return end - for _, pattern in ipairs(HEADER_PATTERNS) do + local patterns = { + '^(%w+)%s+path=(%S+)%s+start_line=(%d+)%s+end_line=(%d+)$', + '^(%w+)$', + } + + for _, pattern in ipairs(patterns) do local type, path, start_line, end_line = header:match(pattern) if path then return type, path, tonumber(start_line) or 1, tonumber(end_line) or tonumber(start_line) or 1 @@ -53,6 +53,23 @@ local function match_block_header(header) end end +---@param header? CopilotChat.ui.chat.Header +---@param content? string +---@return string? +local function match_block_content(header, content) + if not header or header.filetype ~= 'diff' or not content then + return + end + + local lines = vim.split(content, '\n') + for _, line in ipairs(lines) do + local diff_filename = line:match('^%+%+%+%s+(.*)') + if diff_filename then + return vim.trim(diff_filename) + end + end +end + --- Get the last line and column of the chat window. ---@param bufnr number ---@return number, number @@ -69,10 +86,10 @@ local function last(bufnr) end ---@class CopilotChat.ui.chat.Header ----@field filename string ----@field start_line number ----@field end_line number ---@field filetype string +---@field filename string +---@field start_line number? +---@field end_line number? ---@class CopilotChat.ui.chat.Block ---@field header CopilotChat.ui.chat.Header @@ -694,6 +711,7 @@ function Chat:parse() if name == 'block_header' then local header_text = vim.treesitter.get_node_text(node, self.bufnr) local filetype, filename, start_line, end_line = match_block_header(header_text) + if filetype then current_block = { header = { @@ -710,6 +728,12 @@ function Chat:parse() elseif name == 'block_content' then local content = vim.treesitter.get_node_text(node, self.bufnr) current_block.end_line = end_row + + local filename = match_block_content(current_block.header, content) + if filename then + current_block.header.filename = filename + end + table.insert(current_block.content, content) end end diff --git a/lua/CopilotChat/utils/diff.lua b/lua/CopilotChat/utils/diff.lua new file mode 100644 index 00000000..8ca1a58e --- /dev/null +++ b/lua/CopilotChat/utils/diff.lua @@ -0,0 +1,229 @@ +local M = {} + +--- Parse unified diff, return file_path and hunks +---@param diff_text string The unified diff text +---@return string?, table[] +function M.parse_unified_diff(diff_text) + local hunks = {} + local current_hunk = nil + local file_path = nil + + for _, line in ipairs(vim.split(diff_text, '\n')) do + local diff_filename = line:match('^%+%+%+%s+(.*)') + if diff_filename then + file_path = diff_filename + elseif line:match('^@@') then + if current_hunk then + table.insert(hunks, current_hunk) + end + current_hunk = { minus = {}, plus = {}, context = {} } + elseif current_hunk then + local prefix = line:sub(1, 1) + local rest = line:sub(2) + if prefix == '-' then + table.insert(current_hunk.minus, rest) + elseif prefix == '+' then + table.insert(current_hunk.plus, rest) + elseif #current_hunk.plus == 0 and #current_hunk.minus == 0 then + if prefix == ' ' then + table.insert(current_hunk.context, rest) + elseif line ~= '' then + table.insert(current_hunk.context, line) + end + end + end + end + if current_hunk then + table.insert(hunks, current_hunk) + end + return file_path, hunks +end + +--- Apply unified diff to a table of lines and return new lines +---@param diff_text string +---@param original_lines table +---@return table, boolean +function M.apply_unified_diff(diff_text, original_lines) + local _, hunks = M.parse_unified_diff(diff_text) + local lines = vim.deepcopy(original_lines) + local applied_any = false + + for _, hunk in ipairs(hunks) do + -- Build the full hunk pattern: context + minus lines + local hunk_pattern = {} + for _, ctx in ipairs(hunk.context) do + table.insert(hunk_pattern, ctx) + end + for _, minus in ipairs(hunk.minus) do + table.insert(hunk_pattern, minus) + end + + -- Find all possible matches for the hunk pattern + local match_indices = {} + for i = 1, #lines - #hunk_pattern + 1 do + local match = true + for j = 1, #hunk_pattern do + if vim.trim(lines[i + j - 1]) ~= vim.trim(hunk_pattern[j]) then + match = false + break + end + end + if match then + table.insert(match_indices, i) + end + end + + if #match_indices == 1 then + local idx = match_indices[1] + -- Replace the matched region with context + plus lines + local new_region = {} + for _, ctx in ipairs(hunk.context) do + table.insert(new_region, ctx) + end + for _, plus in ipairs(hunk.plus) do + table.insert(new_region, plus) + end + + for j = 1, #hunk_pattern do + table.remove(lines, idx) + end + for j = #new_region, 1, -1 do + table.insert(lines, idx, new_region[j]) + end + applied_any = true + end + + -- If no match or multiple matches, just skip to next hunk + end + + return lines, applied_any +end + +--- Apply diff indices from vim.diff to original and new lines +---@param hunks table Indices from vim.diff (result_type = 'indices') +---@param original_lines table Lines before patch +---@param new_lines table Lines after patch +---@return table Patched lines +function M.apply_diff_indices(hunks, original_lines, new_lines) + local result = {} + local orig_idx = 1 + + for _, hunk in ipairs(hunks) do + local start_a, count_a, start_b, count_b = unpack(hunk) + -- Add unchanged lines before hunk + for i = orig_idx, start_a - 1 do + table.insert(result, original_lines[i]) + end + -- Add changed lines from new_lines + for i = start_b, start_b + count_b - 1 do + table.insert(result, new_lines[i]) + end + orig_idx = start_a + count_a + end + -- Add remaining lines + for i = orig_idx, #original_lines do + table.insert(result, original_lines[i]) + end + return result +end + +--- Get changed regions for jump/highlight +---@param diff_text string The unified diff text +---@return number?, number? +function M.get_unified_diff_region(diff_text, original_lines) + local _, hunks = M.parse_unified_diff(diff_text) + local first, last + + for _, hunk in ipairs(hunks) do + for i = 1, #original_lines - #hunk.minus + 1 do + local match = true + for j = 1, #hunk.minus do + if vim.trim(original_lines[i + j - 1]) ~= vim.trim(hunk.minus[j]) then + match = false + break + end + end + if match then + local region_start = i + local region_end = i + #hunk.plus - 1 + if not first or region_start < first then + first = region_start + end + if not last or region_end > last then + last = region_end + end + break + end + end + end + + if first and last then + return first, last + end + + return nil, nil +end + +--- Apply a diff (unified or indices) to buffer lines +---@param block CopilotChat.ui.chat.Block Block containing diff info +---@param bufnr integer Buffer number +---@return table new_lines, boolean applied +function M.apply_diff(block, bufnr) + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + if block.header.filetype == 'diff' then + return M.apply_unified_diff(block.content, lines) + elseif block.header.start_line and block.header.end_line then + local start_idx = block.header.start_line + local end_idx = block.header.end_line + local original_lines = vim.list_slice(lines, start_idx, end_idx) + local patched_lines = vim.split(block.content, '\n') + local hunks = vim.diff( + table.concat(original_lines, '\n'), + table.concat(patched_lines, '\n'), + { result_type = 'indices', algorithm = 'myers', ctxlen = 3 } + ) + local region_new_lines = M.apply_diff_indices(hunks, original_lines, patched_lines) + local new_lines = {} + -- Add lines before region + for i = 1, start_idx - 1 do + table.insert(new_lines, lines[i]) + end + -- Add patched region + for _, line in ipairs(region_new_lines) do + table.insert(new_lines, line) + end + -- Add lines after region + for i = end_idx + 1, #lines do + table.insert(new_lines, lines[i]) + end + return new_lines, true + end + return lines, false +end + +--- Get changed region for diff (unified or indices) +---@param block CopilotChat.ui.chat.Block Block containing diff info +---@param bufnr integer Buffer number +---@return number? first, number? last +function M.get_diff_region(block, bufnr) + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + if block.header.filetype == 'diff' then + return M.get_unified_diff_region(block.content, lines) + elseif block.header.start_line and block.header.end_line then + local original_lines = vim.api.nvim_buf_get_lines(bufnr, block.header.start_line - 1, block.header.end_line, false) + local patched_lines = vim.split(block.content, '\n') + local hunks = vim.diff( + table.concat(original_lines, '\n'), + table.concat(patched_lines, '\n'), + { result_type = 'indices', algorithm = 'myers', ctxlen = 3 } + ) + if hunks and #hunks > 0 then + local first = hunks[1][1] + local last = hunks[#hunks][1] + hunks[#hunks][2] - 1 + return first, last + end + end + return nil, nil +end + +return M diff --git a/tests/diff_spec.lua b/tests/diff_spec.lua new file mode 100644 index 00000000..62866c40 --- /dev/null +++ b/tests/diff_spec.lua @@ -0,0 +1,111 @@ +local diff = require('CopilotChat.utils.diff') + +describe('CopilotChat.utils.diff', function() + it('parses unified diff', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ ... @@ + context line +-old line ++new line +]] + local file_path, hunks = diff.parse_unified_diff(diff_text) + assert.equals('b/foo.txt', file_path) + assert.equals('context line', hunks[1].context[1]) + assert.equals('old line', hunks[1].minus[1]) + assert.equals('new line', hunks[1].plus[1]) + end) + + it('applies unified diff', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ ... @@ + context +-old ++new +]] + local original = { 'context', 'old', 'other' } + local result, applied = diff.apply_unified_diff(diff_text, original) + assert.is_true(applied) + assert.are.same({ 'context', 'new', 'other' }, result) + end) + + it('gets unified diff region', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ ... @@ + context +-old ++new +]] + local original = { 'context', 'old', 'other' } + local first, last = diff.get_unified_diff_region(diff_text, original) + assert.equals(2, first) + assert.equals(2, last) + end) + + it('applies unified diff with no context', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ ... @@ +-old ++new +]] + local original = { 'old', 'other' } + local result, applied = diff.apply_unified_diff(diff_text, original) + assert.is_true(applied) + assert.are.same({ 'new', 'other' }, result) + end) + + it('applies unified diff with multiline edits', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ ... @@ + context1 + context2 +-old1 +-old2 ++new1 ++new2 +]] + local original = { + 'context1', + 'context2', + 'old1', + 'old2', + 'context3', + 'other', + } + local result, applied = diff.apply_unified_diff(diff_text, original) + assert.is_true(applied) + assert.are.same({ + 'context1', + 'context2', + 'new1', + 'new2', + 'context3', + 'other', + }, result) + end) + + it('does not apply ambiguous edit', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ ... @@ + context +-old ++new +]] + local original = { 'context', 'old', 'context', 'old' } + local result, applied = diff.apply_unified_diff(diff_text, original) + -- Should not apply because there are two possible matches + assert.is_false(applied) + assert.are.same({ 'context', 'old', 'context', 'old' }, result) + end) +end)