diff --git a/.all-contributorsrc b/.all-contributorsrc index f658f7f4..04915e9e 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -466,6 +466,76 @@ "avatar_url": "https://avatars.githubusercontent.com/u/49014608?v=4", "profile": "https://github.com/ctchen222", "contributions": ["code"] + }, + { + "login": "towoe", + "name": "Tobias Wölfel", + "avatar_url": "https://avatars.githubusercontent.com/u/8666134?v=4", + "profile": "https://github.com/towoe", + "contributions": ["code"] + }, + { + "login": "garcia5", + "name": "Alexander Garcia", + "avatar_url": "https://avatars.githubusercontent.com/u/21695295?v=4", + "profile": "https://github.com/garcia5", + "contributions": ["code"] + }, + { + "login": "kharandziuk", + "name": "Max Kharandziuk", + "avatar_url": "https://avatars.githubusercontent.com/u/3404755?v=4", + "profile": "https://github.com/kharandziuk", + "contributions": ["code"] + }, + { + "login": "pxwg", + "name": "Xinyu Xiang", + "avatar_url": "https://avatars.githubusercontent.com/u/149765160?v=4", + "profile": "https://github.com/pxwg", + "contributions": ["code"] + }, + { + "login": "junqizhang", + "name": "junqizhang", + "avatar_url": "https://avatars.githubusercontent.com/u/22600124?v=4", + "profile": "https://github.com/junqizhang", + "contributions": ["code"] + }, + { + "login": "Tlunch", + "name": "Calum Lynch", + "avatar_url": "https://avatars.githubusercontent.com/u/89159592?v=4", + "profile": "http://card.calumhub.xyz", + "contributions": ["code"] + }, + { + "login": "sirjls", + "name": "sirjls", + "avatar_url": "https://avatars.githubusercontent.com/u/270346599?v=4", + "profile": "https://github.com/sirjls", + "contributions": ["code"] + }, + { + "login": "kolchurinvv", + "name": "Vladimir Kolchurin", + "avatar_url": "https://avatars.githubusercontent.com/u/18503099?v=4", + "profile": "https://github.com/kolchurinvv", + "contributions": ["code"] + }, + { + "login": "RoseSecurity", + "name": "RoseSecurity", + "avatar_url": "https://avatars.githubusercontent.com/u/72598486?v=4", + "profile": "https://rosesecurity.dev", + "contributions": ["doc", "code"] + }, + { + "login": "abhr-0", + "name": "Abhraneel Mukherjee", + "avatar_url": "https://avatars.githubusercontent.com/u/121384410?v=4", + "profile": "https://github.com/abhr-0", + "contributions": ["code"] } ], "contributorsPerLine": 7, diff --git a/.emmyrc.json b/.emmyrc.json new file mode 100644 index 00000000..5f9691d9 --- /dev/null +++ b/.emmyrc.json @@ -0,0 +1,9 @@ +{ + "runtime": { + "version": "LuaJIT", + "requirePattern": ["lua/?.lua", "lua/?/init.lua"] + }, + "workspace": { + "library": ["$VIMRUNTIME"] + } +} diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index ce9dcccd..4b3b3fbc 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,3 +1,3 @@ # These are supported funding model platforms -github: [acheong08, jellydn] +github: [deathbeam, jellydn] diff --git a/.github/workflows/pullfrog.yml b/.github/workflows/pullfrog.yml new file mode 100644 index 00000000..6516ae2e --- /dev/null +++ b/.github/workflows/pullfrog.yml @@ -0,0 +1,57 @@ +# PULLFROG ACTION — DO NOT EDIT EXCEPT WHERE INDICATED +name: Pullfrog +run-name: ${{ inputs.name || github.workflow }} +on: + workflow_dispatch: + inputs: + prompt: + type: string + description: Agent prompt + name: + type: string + description: Run name + +permissions: + contents: read + +jobs: + pullfrog: + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 1 + - name: Run agent + uses: pullfrog/pullfrog@v0 + with: + prompt: ${{ inputs.prompt }} + env: + # add at least one provider API key + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + GOOGLE_GENERATIVE_AI_API_KEY: + ${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }} + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + XAI_API_KEY: ${{ secrets.XAI_API_KEY }} + DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} + MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }} + OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} + + # for Amazon Bedrock (https://docs.pullfrog.com/bedrock) + # AWS_BEARER_TOKEN_BEDROCK: ${{ secrets.AWS_BEARER_TOKEN_BEDROCK }} + # AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + # AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + # AWS_REGION: us-east-1 + # BEDROCK_MODEL_ID: + + # for Google Vertex AI (https://docs.pullfrog.com/vertex) + # VERTEX_SERVICE_ACCOUNT_JSON: ${{ secrets.VERTEX_SERVICE_ACCOUNT_JSON }} + # GOOGLE_CLOUD_PROJECT: my-project + # VERTEX_LOCATION: global + # VERTEX_MODEL_ID: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7602f97a..283f1965 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,6 +4,6 @@ repos: hooks: - id: prettier - repo: https://github.com/JohnnyMorganz/StyLua - rev: v2.3.0 + rev: v2.5.2 hooks: - id: stylua-github diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..560393bb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,75 @@ +# AGENTS.md + +## Overview + +Neovim plugin (pure Lua) providing GitHub Copilot Chat integration. Requires Neovim 0.10.0+, curl 8.0.0+, plenary.nvim. + +## Commands + +```bash +# Run tests (headless Neovim + plenary test harness) +make test + +# Format check (what CI runs) +stylua --check . +``` + +`make test` runs `nvim --headless --clean -u ./scripts/test.lua`, which clones plenary.nvim into `.dependencies/` on first run, then executes all `tests/*_spec.lua` files via plenary's busted-style harness. + +## Project layout + +``` +plugin/CopilotChat.lua — Neovim plugin entry: commands, highlights, autocmds +lua/CopilotChat/ + init.lua — Main module: setup(), ask(), open/close/toggle, save/load + client.lua — Copilot API client (auth, streaming, tool calls) + config.lua — Default configuration schema + config/ — Sub-configs: functions, mappings, prompts, providers + constants.lua — Shared constants (roles, etc.) + completion.lua — Completion source + functions.lua — Built-in functions/tools exposed to the LLM + prompts.lua — Built-in prompt definitions + resources.lua — Resource handling + select.lua — Selection strategies (visual, buffer, diagnostics, git diff) + tiktoken.lua — Token counting via native tiktoken lib + health.lua — :checkhealth integration + notify.lua — Notification utilities + instructions/ — System prompt templates injected into LLM conversations (not agent guidance) + ui/ — Chat window, overlay, spinner + utils.lua — General utilities + utils/ — Utility modules: class, curl, diff, files, orderedmap, stringbuffer +queries/ — Treesitter queries for copilot-chat filetype +tests/ — Plenary busted-style specs (*_spec.lua) +scripts/ + test.lua — Test runner bootstrap (sets up plenary) + minimal.lua — Minimal reproduction config +doc/CopilotChat.txt — Auto-generated vimdoc (do NOT edit; generated from README by panvimdoc in CI) +``` + +## Style and formatting + +- **Lua formatter:** StyLua — 2-space indent, 120 column width, single quotes preferred, Unix line endings. Config in `.stylua.toml`. +- **Pre-commit hooks:** Prettier (markdown/json/yaml) + StyLua (Lua). CI will fail if StyLua check fails. +- **No linter** (no luacheck/selene configured). +- Type annotations use EmmyLua/LuaCATS `---@class`, `---@param`, `---@return` style. + +## Testing + +- Framework: plenary.nvim busted-style (`describe`, `it`, `before_each`, `after_each`, `assert`). +- Test files live in `tests/` and must be named `*_spec.lua`. +- CI runs tests against Neovim nightly with LuaJIT 2.1 and LuaRocks 3.12.2. +- Tests are unit-level (class, diff, utils, orderedmap, stringbuffer, functions, init). No integration tests requiring Copilot auth. + +## CI and releases + +- CI (`ci.yml`): lint (StyLua) + test (plenary) on all PRs; vimdoc generation on main only. +- Releases via release-please (`simple` type). Version tracked in `version.txt`. +- `doc/CopilotChat.txt` is auto-committed by CI — do not edit manually. +- `CHANGELOG.md` is managed by release-please — do not edit manually. + +## Key gotchas + +- The module is loaded as `require('CopilotChat')` (capital C's) — this matches the `lua/CopilotChat/` directory name. Case matters. +- `init.lua` uses lazy self-initialization via `__index` metamethod — accessing any field triggers `setup()` if not already called. +- `.dependencies/` is gitignored and auto-populated by the test runner (plenary clone). +- `build/` is gitignored and holds downloaded tiktoken native libraries. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3be55bac..393f93c9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,67 +50,77 @@ Go to the CopilotChat.nvim in your GitHub account, select your branch, and click ![structure.drawio](https://github.com/CopilotC-Nvim/CopilotChat.nvim/assets/5115805/e7517736-0152-47a3-8cb9-36a5dffcb6cc) -### Main components +### Core -- [init.lua](/lua/CopilotChat/init.lua): This file initializes Copilot Chat - plugin. It includes functions for appending to the chat window, showing help, - completing, getting selection, opening and closing the chat window, asking - questions to the Copilot model, resetting the chat window, enabling/disabling - debug, and setting up the plugin. +- [init.lua](/lua/CopilotChat/init.lua): Main module. Plugin initialization + (`setup()`), chat lifecycle (`ask()`, `open()`, `close()`, `toggle()`, + `reset()`), save/load, and sticky prompt processing. -- [config.lua](/lua/CopilotChat/config.lua): This file contains default - configuration for Copilot Chat plugin. +- [client.lua](/lua/CopilotChat/client.lua): Copilot API client. Handles + authentication, model listing, streaming requests, and tool call execution. -- [copilot.lua](/lua/CopilotChat/copilot.lua): This file contains the core - functionality of the Copilot. It includes functions for generating unique IDs, - finding configuration paths, authenticating, asking questions to the Copilot, - generating embeddings, and managing the running job. +- [config.lua](/lua/CopilotChat/config.lua): Default configuration schema. -- [chat.lua](/lua/CopilotChat/chat.lua): This file manages the chat window. It - includes functions for creating, validating, appending to, clearing, opening, - closing, and focusing on the chat window. +- [config/](/lua/CopilotChat/config/): Sub-configs for + [functions](/lua/CopilotChat/config/functions.lua), + [mappings](/lua/CopilotChat/config/mappings.lua), + [prompts](/lua/CopilotChat/config/prompts.lua), and + [providers](/lua/CopilotChat/config/providers.lua). -- [diff.lua](/lua/CopilotChat/diff.lua): This file manages the diff window. It - includes functions for creating, validating, showing, and restoring the diff - window. +- [constants.lua](/lua/CopilotChat/constants.lua): Shared constants (plugin + name, roles). -- [select.lua](/lua/CopilotChat/select.lua): This file contains functions for - selecting and processing different types of data such as visual selection, - unnamed register, whole buffer, current line, diagnostics, and git diff. +### Chat and UI -- [context.lua](/lua/CopilotChat/context.lua): This file is responsible for - building an outline for a buffer and finding items for a query. It uses spatial - distance and relatedness to rank data. +- [ui/chat.lua](/lua/CopilotChat/ui/chat.lua): Chat window management. + Creating, appending to, clearing, opening, closing, and focusing the chat + window. Handles fold expressions and section parsing. -- [actions.lua](/lua/CopilotChat/actions.lua): This file manages the actions - that can be performed. It includes functions for getting help actions, prompt - actions, and picking an action from a list of actions using `vim.ui.select`. +- [ui/overlay.lua](/lua/CopilotChat/ui/overlay.lua): Overlay buffer used for + displaying diff previews and other transient content. -- [tiktoken.lua](/lua/CopilotChat/tiktoken.lua): This file manages integration - with Tiktoken library and is used for counting tokens. It includes functions - for setting up Tiktoken, checking its availability, encoding prompts, and - counting prompts. +- [ui/spinner.lua](/lua/CopilotChat/ui/spinner.lua): Loading spinner indicator + for the chat window. -- [health.lua](/lua/CopilotChat/health.lua): This file checks the health of the - plugin by checking if commands exist, checking if Lua libraries are installed, - and checking if a Treesitter parsers are available. +### Features -- [spinner.lua](/lua/CopilotChat/spinner.lua): This file manages a spinner that - is used for indicating loading status in chat window. +- [prompts.lua](/lua/CopilotChat/prompts.lua): Prompt resolution, custom + instruction loading, system prompt building, and sticky/resource/tool + parsing from user input. -- [utils.lua](/lua/CopilotChat/utils.lua): This file contains utility functions - for creating classes, getting the log file path, checking if the current - version of Neovim is stable, and joining multiple async functions. +- [functions.lua](/lua/CopilotChat/functions.lua): Built-in functions/tools + exposed to the LLM (e.g., file editing, searching). -- [debuginfo.lua](/lua/CopilotChat/debuginfo.lua): This file is used for - creating `:CopilotChatDebugInfo` command. +- [resources.lua](/lua/CopilotChat/resources.lua): Resource handling for file + and URL content retrieval with caching. -### Integrations +- [completion.lua](/lua/CopilotChat/completion.lua): Completion source for the + chat window (`@tools`, `/prompts`, `#resources`, `$models`). -- [telescope.lua](/lua/CopilotChat/integrations/telescope.lua): This file - integrates the Telescope plugin with CopilotChat. It includes a function for - picking an action from a list of actions. +- [select.lua](/lua/CopilotChat/select.lua): Selection strategies for providing + context (visual selection, buffer, diagnostics, git diff, etc.). -- [fzflua.lua](/lua/CopilotChat/integrations/fzflua.lua): This file integrates - the fzf-lua plugin with CopilotChat. It includes a function for picking an - action from a list of actions. +- [tiktoken.lua](/lua/CopilotChat/tiktoken.lua): Token counting via native + tiktoken library. + +- [instructions/](/lua/CopilotChat/instructions/): System prompt templates + injected into LLM conversations (edit formats, tool use instructions, custom + instructions wrapper). + +### Utilities + +- [utils.lua](/lua/CopilotChat/utils.lua): General utility functions. + +- [utils/](/lua/CopilotChat/utils/): Utility modules + [class.lua](/lua/CopilotChat/utils/class.lua) (OOP helper), + [curl.lua](/lua/CopilotChat/utils/curl.lua) (HTTP requests), + [diff.lua](/lua/CopilotChat/utils/diff.lua) (unified diff parsing and application), + [files.lua](/lua/CopilotChat/utils/files.lua) (file I/O and filetype detection), + [notify.lua](/lua/CopilotChat/utils/notify.lua) (pub/sub notification system for status and message events) + [orderedmap.lua](/lua/CopilotChat/utils/orderedmap.lua) (insertion-ordered map), + [stringbuffer.lua](/lua/CopilotChat/utils/stringbuffer.lua) (efficient string concatenation). + +### Other + +- [health.lua](/lua/CopilotChat/health.lua): `:checkhealth` integration. + Verifies commands, libraries, and Treesitter parsers. diff --git a/README.md b/README.md index b2d80dc0..434cae00 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ https://github.com/user-attachments/assets/8cad5643-63b2-4641-a5c4-68bc313f20e6 CopilotChat.nvim brings GitHub Copilot Chat capabilities directly into Neovim with a focus on transparency and user control. - 🤖 **Multiple AI Models** - GitHub Copilot (including GPT-4o, Gemini 2.5 Pro, Claude 4 Sonnet, Claude 3.7 Sonnet, Claude 3.5 Sonnet, o3-mini, o4-mini) + custom providers (Ollama, Mistral.ai). The exact list of available models depends on your [GitHub Copilot settings](https://github.com/settings/copilot/features) and the models provided by GitHub's API. -- 🔧 **Tool Calling** - LLM can call workspace functions (file reading, git operations, search) with your explicit approval +- 🔧 **Tool Calling** - LLM can call workspace functions (file reading, git operations, search) with manual approval or automatic execution for trusted tools - 🔒 **Privacy First** - Only shares what you explicitly request - no background data collection - 📝 **Interactive Chat** - Interactive UI with completion, diffs, and quickfix integration - 🎯 **Smart Prompts** - Composable templates and sticky prompts for consistent context @@ -92,29 +92,13 @@ EOF # Core Concepts - **Resources** (`#`) - Add specific content (files, git diffs, URLs) to your prompt -- **Tools** (`@`) - Give LLM access to functions it can call with your approval +- **Tools** (`@`) - Give LLM access to functions it can call during the chat, with manual approval by default - **Sticky Prompts** (`> `) - Persist context across single chat session - **Models** (`$`) - Specify which AI model to use for the chat - **Prompts** (`/PromptName`) - Use predefined prompt templates for common tasks -## Examples - -```markdown -# Add specific file to context - -#file:src/main.lua - -# Give LLM access to workspace tools - -@copilot What files are in this project? - -# Sticky prompt that persists - -> #buffer:active -> You are a helpful coding assistant -``` - -When you use `@copilot`, the LLM can call functions like `bash`, `edit`, `file`, `glob`, `grep`, `gitdiff` etc. You'll see the proposed function call and can approve/reject it before execution. +> [!TIP] +> Press `` after typing `#` or `@` to see available options and auto-complete. This is the easiest way to discover what's available! # Usage @@ -136,54 +120,54 @@ When you use `@copilot`, the LLM can call functions like `bash`, `edit`, `file`, ## Chat Key Mappings -| Insert | Normal | Action | -| ------- | ------- | ------------------------------------------ | -| `` | - | Trigger/accept completion menu for tokens | -| `` | `q` | Close the chat window | -| `` | `` | Reset and clear the chat window | -| `` | `` | Submit the current prompt | -| - | `grr` | Toggle sticky prompt for line under cursor | -| `` | `` | Accept nearest diff | -| - | `gj` | Jump to section of nearest diff | -| - | `gqa` | Add all answers from chat to quickfix list | -| - | `gqd` | Add all diffs from chat to quickfix list | -| - | `gy` | Yank nearest diff to register | -| - | `gd` | Show diff between source and nearest diff | -| - | `gc` | Show info about current chat | -| - | `gh` | Show help message | - -> [!WARNING] -> Some plugins (e.g. `copilot.vim`) may also map common keys like `` in insert mode. -> To avoid conflicts, disable Copilot's default `` mapping with: +| Insert | Normal | Action | +| ------- | ------- | ---------------------------------------------------- | +| `` | - | **Autocomplete resources/files/options** (use this!) | +| `` | `q` | Close the chat window | +| `` | `` | Reset and clear the chat window | +| `` | `` | Submit the current prompt | +| `` | `` | Accept nearest diff | +| - | `gj` | Jump to section of nearest diff | +| - | `gqa` | Add all answers from chat to quickfix | +| - | `gqd` | Add all diffs from chat to quickfix | +| - | `gy` | Yank nearest diff to register | +| - | `gd` | Show diff between source and nearest diff | +| - | `gc` | Show info about current chat | +| - | `gh` | Show help message | + +**💡 Pro tip:** After typing `#`, `@`, `#buffer:`, or `#file:`, press `` to see available options. This is the fastest way to work! + +> [!NOTE] +> **Tab key not working?** Some plugins (e.g. `copilot.vim`) also map `` in insert mode. +> To fix conflicts, disable the other plugin's `` mapping: > > ```lua +> -- For copilot.vim > vim.g.copilot_no_tab_map = true > vim.keymap.set('i', '', 'copilot#Accept("\\")', { expr = true, replace_keycodes = false }) > ``` > -> You can also customize CopilotChat keymaps in your config. +> Or customize CopilotChat keymaps in your config. ## Predefined Functions All predefined functions belong to the `copilot` group. -| Function | Type | Description | Example Usage | -| ----------- | -------- | ------------------------------------------------------ | -------------------- | -| `bash` | Tool | Executes a bash command and returns output | `@copilot` only | -| `buffer` | Resource | Retrieves content from buffer(s) with diagnostics | `#buffer:active` | -| `clipboard` | Resource | Provides access to system clipboard content | `#clipboard` | -| `edit` | Tool | Applies a unified diff to a file | `@copilot` only | -| `file` | Resource | Reads content from a specified file path | `#file:path/to/file` | -| `gitdiff` | Resource | Retrieves git diff information | `#gitdiff:staged` | -| `glob` | Resource | Lists filenames matching a pattern in workspace | `#glob:**/*.lua` | -| `grep` | Resource | Searches for a pattern across files in workspace | `#grep:TODO` | -| `selection` | Resource | Includes the current visual selection with diagnostics | `#selection` | -| `url` | Resource | Fetches content from a specified URL | `#url:https://...` | - -**Type Legend:** - -- **Resource**: Can be used manually via `#function` syntax -- **Tool**: Can only be called by LLM via `@copilot` (for safety/complexity reasons) +| Function | Manual `#...` | Description | Available Options | +| ----------- | ------------- | ------------------------------------------------------ | --------------------------------------------------------------------- | +| `bash` | No | Executes a bash command and returns output | Tool-only (use `@copilot`) | +| `buffer` | Yes | Retrieves content from buffer(s) with diagnostics | `active`, `visible`, `listed`, `quickfix`, buffer number, or filename | +| `clipboard` | Yes | Provides access to system clipboard content | No options | +| `edit` | No | Applies a unified diff to a file | Tool-only (use `@copilot`) | +| `file` | Yes | Reads content from a specified file path | Any file path (use `` for completion) | +| `gitdiff` | Yes | Retrieves git diff information | `unstaged` (default), `staged`, or commit SHA | +| `glob` | Yes | Lists filenames matching a pattern in workspace | Any glob pattern (default: `**/*`) | +| `grep` | Yes | Searches for a pattern across files in workspace | Any search pattern | +| `selection` | Yes | Includes the current visual selection with diagnostics | No options | +| `url` | Yes | Fetches content from a specified URL | Any HTTPS URL | + +- **`#`** - Embeds output directly in your message (e.g., `#buffer:listed`, `#file:src/main.lua`) +- **`@`** - Makes function(s) available for LLM to call when needed (e.g., `@copilot`, `@file`) ## Predefined Prompts @@ -197,6 +181,55 @@ All predefined functions belong to the `copilot` group. | `Tests` | Generate tests for selected code | | `Commit` | Generate commit message with commitizen convention from staged changes | +## Resource Usage + +```markdown +# Current buffer + +#buffer:active + +# All open buffers (replaces old #buffers) + +#buffer:listed + +# All visible buffers + +#buffer:visible + +# Specific file + +#file:src/main.lua + +# Git changes + +#gitdiff:staged + +# URL content + +#url:https://example.com/docs +``` + +## Tool Usage + +When you use `@copilot`, the LLM can call functions from the `copilot` group such as `bash`, `edit`, `file`, `glob`, `grep`, and `gitdiff`. + +```markdown +# Give LLM access to workspace tools + +@copilot What files are in this project? + +# Sticky context with tools + +> #buffer:listed +> @copilot +> Refactor the authentication code +``` + +By default, tool calls require manual approval. Configure `trusted_tools` to automatically run specific tools (see [Functions](#functions)). + +> [!WARNING] +> `trusted_tools = true` allows the model to run every enabled tool without asking. Only use it if you fully trust the tool set and workspace. + # Configuration For all available configuration options, see [`lua/CopilotChat/config.lua`](lua/CopilotChat/config.lua). @@ -207,8 +240,9 @@ Most users only need to configure a few options: ```lua { - model = 'gpt-4.1', -- AI model to use + model = 'gpt-5-mini', -- AI model to use temperature = 0.1, -- Lower = focused, higher = creative + trusted_tools = nil, -- Require approval for all tool calls window = { layout = 'vertical', -- 'vertical', 'horizontal', 'float' width = 0.5, -- 50% of screen width @@ -241,12 +275,14 @@ Most users only need to configure a few options: } ``` +`window.layout` also supports `'replace'` to reuse the current window. + ## Buffer Behavior ```lua -- Auto-command to customize chat buffer behavior vim.api.nvim_create_autocmd('BufEnter', { - pattern = 'copilot-*', + pattern = 'copilot-chat', callback = function() vim.opt_local.relativenumber = false vim.opt_local.number = false @@ -275,9 +311,10 @@ Types of copilot highlights: - `CopilotChatResource` - Resource highlight in chat buffer (e.g. `#file`, `#gitdiff`) - `CopilotChatTool` - Tool call highlight in chat buffer (e.g. `@copilot`) - `CopilotChatPrompt` - Prompt highlight in chat buffer (e.g. `/Explain`, `/Review`) -- `CopilotChatModel` - Model highlight in chat buffer (e.g. `$gpt-4.1`) +- `CopilotChatModel` - Model highlight in chat buffer (e.g. `$gpt-5-mini`) - `CopilotChatUri` - URI highlight in chat buffer (e.g. `##https://...`) - `CopilotChatAnnotation` - Annotation highlight in chat buffer (file headers, tool call headers, tool call body) +- `CopilotChatAnnotationHeader` - Annotation header highlight in chat buffer ## Prompts @@ -304,14 +341,46 @@ Define your own prompts in the configuration: ## Functions +Use `trusted_tools` to control which tool calls are executed automatically: + +```lua +{ + trusted_tools = nil, -- default: require approval for all tool calls + + -- trust all functions in a group + -- trusted_tools = 'copilot', + + -- trust specific functions by name or groups by name + -- trusted_tools = { 'file', 'glob', 'grep' }, + + -- trust every enabled tool call + -- trusted_tools = true, +} +``` + +**How tool trust works:** + +A tool is trusted when any of these match: + +- Its function definition sets `trusted = true` +- Its function name appears in `trusted_tools` +- Its function group appears in `trusted_tools` +- `trusted_tools = true` + +**Recommended setup:** Trust read-only functions like `file`, `glob`, or `grep` for a smoother workflow without compromising safety. + +> [!WARNING] +> Trusted tools run without asking for confirmation. Be especially careful with tools like `bash` and `edit`, which can change your workspace. + Define your own functions in the configuration with input handling and schema: ```lua { functions = { birthday = { - description = "Retrieves birthday information for a person", - uri = "birthday://{name}", + description = 'Retrieves birthday information for a person', + uri = 'birthday://{name}', + trusted = false, schema = { type = 'object', required = { 'name' }, @@ -329,14 +398,16 @@ Define your own functions in the configuration with input handling and schema: uri = 'birthday://' .. input.name, mimetype = 'text/plain', data = input.name .. ' birthday info', - } + }, } - end - } + end, + }, } } ``` +If a function has a `uri`, it can be used manually with `#birthday:Alice`. Functions without a `uri` are tool-only and can only be called by the model. + ## Providers Add custom AI providers: @@ -345,9 +416,9 @@ Add custom AI providers: { providers = { my_provider = { - get_url = function(opts) return "https://api.example.com/chat" end, - get_headers = function() return { ["Authorization"] = "Bearer " .. api_key } end, - get_models = function() return { { id = "gpt-4.1", name = "GPT-4.1 model" } } end, + get_url = function(opts) return 'https://api.example.com/chat' end, + get_headers = function() return { ['Authorization'] = 'Bearer ' .. api_key } end, + get_models = function() return { { id = 'gpt-5-mini', name = 'GPT-5 mini model' } } end, prepare_input = require('CopilotChat.config.providers').copilot.prepare_input, prepare_output = require('CopilotChat.config.providers').copilot.prepare_output, } @@ -363,7 +434,7 @@ Add custom AI providers: disabled?: boolean, -- Optional: Extra info about the provider displayed in info panel - get_info?(): string[] + get_info?(headers: table): string[] -- Optional: Get extra request headers with optional expiration time get_headers?(): table, number?, @@ -379,24 +450,26 @@ Add custom AI providers: -- Optional: Get available models get_models?(headers: table): table, + + -- Optional: Resolve a user-facing model id to a provider model id + resolve_model?(headers: table, model: string): string, } ``` **Built-in providers:** - `copilot` - GitHub Copilot (default) -- `github_models` - GitHub Marketplace models (disabled by default) +- `github_models` - GitHub Models (disabled by default) # API Reference ## Core ```lua -local chat = require("CopilotChat") +local chat = require('CopilotChat') -- Basic Chat Functions chat.ask(prompt, config) -- Ask a question with optional config -chat.response() -- Get the last response text -- Window Management chat.open(config) -- Open chat window with optional config @@ -423,7 +496,7 @@ chat.log_level(level) -- Set log level (debug, info, etc.) You can also access the chat window UI methods through the `chat.chat` object: ```lua -local window = require("CopilotChat").chat +local window = require('CopilotChat').chat -- Chat UI State window:visible() -- Check if chat window is visible @@ -442,8 +515,8 @@ window:start() -- Start writing to chat window window:finish() -- Finish writing to chat window -- Source Management -window.get_source() -- Get the current source buffer and window -window.set_source(winnr) -- Set the source window +window:get_source() -- Get the current source buffer and window +window:set_source(winnr) -- Set the source window -- Navigation window:follow() -- Move cursor to end of chat content @@ -456,10 +529,11 @@ window:overlay(opts) -- Show overlay with specified options ## Prompt parser ```lua -local parser = require("CopilotChat.prompts") +local parser = require('CopilotChat.prompts') parser.resolve_prompt() -- Resolve prompt references -parser.resolve_tools() -- Resolve tools that are available for automatic use by LLM +parser.resolve_tools() -- Resolve tools shared with the model via @... +parser.resolve_functions() -- Resolve manual function/resource references via #... parser.resolve_model() -- Resolve model from prompt (WARN: async, requires plenary.async.run) ``` @@ -467,22 +541,26 @@ parser.resolve_model() -- Resolve model from prompt (WARN: async, requi ```lua -- Open chat, ask a question and handle response -require("CopilotChat").open() -require("CopilotChat").ask("#buffer Explain this code", { +require('CopilotChat').open() +require('CopilotChat').ask('#buffer Explain this code', { callback = function(response) - vim.notify("Got response: " .. response:sub(1, 50) .. "...") - return response + vim.notify('Got response: ' .. vim.trim(response.content):sub(1, 50) .. '...') end, }) -- Save and load chat history -require("CopilotChat").save("my_debugging_session") -require("CopilotChat").load("my_debugging_session") +require('CopilotChat').save('my_debugging_session') +require('CopilotChat').load('my_debugging_session') -- Use custom sticky and model -require("CopilotChat").ask("How can I optimize this?", { - model = "gpt-4.1", - sticky = {"#buffer", "#gitdiff:staged"} +require('CopilotChat').ask('How can I optimize this?', { + model = 'gpt-5-mini', + sticky = { '#buffer', '#gitdiff:staged' }, +}) + +-- Automatically trust a small read-only tool set +require('CopilotChat').setup({ + trusted_tools = { 'file', 'glob', 'grep' }, }) ``` @@ -513,6 +591,12 @@ To run tests: make test ``` +To run the same formatting check as CI: + +```bash +stylua --check . +``` + ## Contributing 1. Fork the repository @@ -523,14 +607,6 @@ make test See [CONTRIBUTING.md](/CONTRIBUTING.md) for detailed guidelines. -# Acknowledgments - -## diff-match-patch - -CopilotChat.nvim includes [diff-match-patch (Lua port)](https://github.com/google/diff-match-patch) for diffing and patching functionality. -Copyright 2018 The diff-match-patch Authors. -Licensed under the Apache License 2.0. - # Contributors Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): @@ -625,6 +701,18 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d Samiul Islam
Samiul Islam

💻 Rui Costa
Rui Costa

💻 CTCHEN
CTCHEN

💻 + Tobias Wölfel
Tobias Wölfel

💻 + Alexander Garcia
Alexander Garcia

💻 + Max Kharandziuk
Max Kharandziuk

💻 + Xinyu Xiang
Xinyu Xiang

💻 + + + junqizhang
junqizhang

💻 + Calum Lynch
Calum Lynch

💻 + sirjls
sirjls

💻 + Vladimir Kolchurin
Vladimir Kolchurin

💻 + RoseSecurity
RoseSecurity

📖 💻 + Abhraneel Mukherjee
Abhraneel Mukherjee

💻 diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index 729d2e4b..fda50bbd 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,5 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 October 24 +*CopilotChat.txt* + For NVIM v0.8.0 Last change: 2026 June 17 ============================================================================== Table of Contents *CopilotChat-table-of-contents* @@ -10,12 +11,13 @@ Table of Contents *CopilotChat-table-of-contents* - lazy.nvim |CopilotChat-lazy.nvim| - vim-plug |CopilotChat-vim-plug| 2. Core Concepts |CopilotChat-core-concepts| - - Examples |CopilotChat-examples| 3. Usage |CopilotChat-usage| - Commands |CopilotChat-commands| - Chat Key Mappings |CopilotChat-chat-key-mappings| - Predefined Functions |CopilotChat-predefined-functions| - Predefined Prompts |CopilotChat-predefined-prompts| + - Resource Usage |CopilotChat-resource-usage| + - Tool Usage |CopilotChat-tool-usage| 4. Configuration |CopilotChat-configuration| - Quick Setup |CopilotChat-quick-setup| - Window & Appearance |CopilotChat-window-&-appearance| @@ -32,17 +34,15 @@ Table of Contents *CopilotChat-table-of-contents* 6. Development |CopilotChat-development| - Setup |CopilotChat-setup| - Contributing |CopilotChat-contributing| -7. Acknowledgments |CopilotChat-acknowledgments| - - diff-match-patch |CopilotChat-diff-match-patch| -8. Contributors |CopilotChat-contributors| -9. Stargazers |CopilotChat-stargazers| -10. Links |CopilotChat-links| +7. Contributors |CopilotChat-contributors| +8. Stargazers |CopilotChat-stargazers| +9. Links |CopilotChat-links| CopilotChat.nvim brings GitHub Copilot Chat capabilities directly into Neovim with a focus on transparency and user control. - 🤖 **Multiple AI Models** - GitHub Copilot (including GPT-4o, Gemini 2.5 Pro, Claude 4 Sonnet, Claude 3.7 Sonnet, Claude 3.5 Sonnet, o3-mini, o4-mini) + custom providers (Ollama, Mistral.ai). The exact list of available models depends on your GitHub Copilot settings and the models provided by GitHub’s API. -- 🔧 **Tool Calling** - LLM can call workspace functions (file reading, git operations, search) with your explicit approval +- 🔧 **Tool Calling** - LLM can call workspace functions (file reading, git operations, search) with manual approval or automatic execution for trusted tools - 🔒 **Privacy First** - Only shares what you explicitly request - no background data collection - 📝 **Interactive Chat** - Interactive UI with completion, diffs, and quickfix integration - 🎯 **Smart Prompts** - Composable templates and sticky prompts for consistent context @@ -127,33 +127,14 @@ VIM-PLUG *CopilotChat-vim-plug* 2. Core Concepts *CopilotChat-core-concepts* - **Resources** (`#`) - Add specific content (files, git diffs, URLs) to your prompt -- **Tools** (`@`) - Give LLM access to functions it can call with your approval +- **Tools** (`@`) - Give LLM access to functions it can call during the chat, with manual approval by default - **Sticky Prompts** (`> `) - Persist context across single chat session - **Models** (`$`) - Specify which AI model to use for the chat - **Prompts** (`/PromptName`) - Use predefined prompt templates for common tasks -EXAMPLES *CopilotChat-examples* - ->markdown - # Add specific file to context - - #file:src/main.lua - - # Give LLM access to workspace tools - - @copilot What files are in this project? - - # Sticky prompt that persists - - > #buffer:active - > You are a helpful coding assistant -< - -When you use `@copilot`, the LLM can call functions like `bash`, `edit`, -`file`, `glob`, `grep`, `gitdiff` etc. You’ll see the proposed function call -and can approve/reject it before execution. - + [!TIP] Press `` after typing `#` or `@` to see available options and + auto-complete. This is the easiest way to discover what’s available! ============================================================================== 3. Usage *CopilotChat-usage* @@ -177,68 +158,89 @@ COMMANDS *CopilotChat-commands* CHAT KEY MAPPINGS *CopilotChat-chat-key-mappings* + ------------------------------------------------------------------------- Insert Normal Action - -------- -------- -------------------------------------------- - - Trigger/accept completion menu for tokens + -------- -------- ------------------------------------------------------- + - Autocomplete resources/files/options (use this!) + q Close the chat window + Reset and clear the chat window + Submit the current prompt - - grr Toggle sticky prompt for line under cursor + Accept nearest diff + - gj Jump to section of nearest diff - - gqa Add all answers from chat to quickfix list - - gqd Add all diffs from chat to quickfix list + + - gqa Add all answers from chat to quickfix + + - gqd Add all diffs from chat to quickfix + - gy Yank nearest diff to register + - gd Show diff between source and nearest diff + - gc Show info about current chat + - gh Show help message + ------------------------------------------------------------------------- +**💡 Pro tip:** After typing `#`, `@`, `#buffer:`, or `#file:`, press `` +to see available options. This is the fastest way to work! - [!WARNING] Some plugins (e.g. `copilot.vim`) may also map common keys like - `` in insert mode. To avoid conflicts, disable Copilot’s default `` - mapping with: + + [!NOTE] **Tab key not working?** Some plugins (e.g. `copilot.vim`) also map + `` in insert mode. To fix conflicts, disable the other plugin’s `` + mapping: >lua + -- For copilot.vim vim.g.copilot_no_tab_map = true vim.keymap.set('i', '', 'copilot#Accept("\\")', { expr = true, replace_keycodes = false }) < - You can also customize CopilotChat keymaps in your config. + Or customize CopilotChat keymaps in your config. PREDEFINED FUNCTIONS *CopilotChat-predefined-functions* All predefined functions belong to the `copilot` group. - ------------------------------------------------------------------------------------- - Function Type Description Example Usage - ----------- ---------- ----------------------------------------- -------------------- - bash Tool Executes a bash command and returns @copilot only - output - - buffer Resource Retrieves content from buffer(s) with #buffer:active - diagnostics + --------------------------------------------------------------------------------- + Function Manual Description Available Options + #... + ----------- -------- -------------------------- --------------------------------- + bash No Executes a bash command Tool-only (use @copilot) + and returns output - clipboard Resource Provides access to system clipboard #clipboard - content + buffer Yes Retrieves content from active, visible, listed, + buffer(s) with diagnostics quickfix, buffer number, or + filename - edit Tool Applies a unified diff to a file @copilot only + clipboard Yes Provides access to system No options + clipboard content - file Resource Reads content from a specified file path #file:path/to/file + edit No Applies a unified diff to Tool-only (use @copilot) + a file - gitdiff Resource Retrieves git diff information #gitdiff:staged + file Yes Reads content from a Any file path (use for + specified file path completion) - glob Resource Lists filenames matching a pattern in #glob:**/*.lua - workspace + gitdiff Yes Retrieves git diff unstaged (default), staged, or + information commit SHA - grep Resource Searches for a pattern across files in #grep:TODO - workspace + glob Yes Lists filenames matching a Any glob pattern (default: **/*) + pattern in workspace - selection Resource Includes the current visual selection #selection - with diagnostics + grep Yes Searches for a pattern Any search pattern + across files in workspace - url Resource Fetches content from a specified URL #url:https://... - ------------------------------------------------------------------------------------- -**Type Legend:** + selection Yes Includes the current No options + visual selection with + diagnostics -- **Resource**: Can be used manually via `#function` syntax -- **Tool**: Can only be called by LLM via `@copilot` (for safety/complexity reasons) + url Yes Fetches content from a Any HTTPS URL + specified URL + --------------------------------------------------------------------------------- +- **#** - Embeds output directly in your message (e.g., `#buffer:listed`, `#file:src/main.lua`) +- **@** - Makes function(s) available for LLM to call when needed (e.g., `@copilot`, `@file`) PREDEFINED PROMPTS *CopilotChat-predefined-prompts* @@ -262,6 +264,59 @@ PREDEFINED PROMPTS *CopilotChat-predefined-prompts* changes ------------------------------------------------------------------------- +RESOURCE USAGE *CopilotChat-resource-usage* + +>markdown + # Current buffer + + #buffer:active + + # All open buffers (replaces old #buffers) + + #buffer:listed + + # All visible buffers + + #buffer:visible + + # Specific file + + #file:src/main.lua + + # Git changes + + #gitdiff:staged + + # URL content + + #url:https://example.com/docs +< + + +TOOL USAGE *CopilotChat-tool-usage* + +When you use `@copilot`, the LLM can call functions from the `copilot` group +such as `bash`, `edit`, `file`, `glob`, `grep`, and `gitdiff`. + +>markdown + # Give LLM access to workspace tools + + @copilot What files are in this project? + + # Sticky context with tools + + > #buffer:listed + > @copilot + > Refactor the authentication code +< + +By default, tool calls require manual approval. Configure `trusted_tools` to +automatically run specific tools (see |CopilotChat-functions|). + + + [!WARNING] `trusted_tools = true` allows the model to run every enabled tool + without asking. Only use it if you fully trust the tool set and workspace. + ============================================================================== 4. Configuration *CopilotChat-configuration* @@ -275,8 +330,9 @@ Most users only need to configure a few options: >lua { - model = 'gpt-4.1', -- AI model to use + model = 'gpt-5-mini', -- AI model to use temperature = 0.1, -- Lower = focused, higher = creative + trusted_tools = nil, -- Require approval for all tool calls window = { layout = 'vertical', -- 'vertical', 'horizontal', 'float' width = 0.5, -- 50% of screen width @@ -310,13 +366,15 @@ WINDOW & APPEARANCE *CopilotChat-window-&-appearance* } < +`window.layout` also supports `'replace'` to reuse the current window. + BUFFER BEHAVIOR *CopilotChat-buffer-behavior* >lua -- Auto-command to customize chat buffer behavior vim.api.nvim_create_autocmd('BufEnter', { - pattern = 'copilot-*', + pattern = 'copilot-chat', callback = function() vim.opt_local.relativenumber = false vim.opt_local.number = false @@ -343,12 +401,13 @@ Types of copilot highlights: - `CopilotChatSelection` - Selection highlight in source buffer - `CopilotChatStatus` - Status and spinner in chat buffer - `CopilotChatHelp` - Help text in chat buffer -- `CopilotChatResource` - Resource highlight in chat buffer (e.g. `#file`, `#gitdiff`) -- `CopilotChatTool` - Tool call highlight in chat buffer (e.g. `@copilot`) -- `CopilotChatPrompt` - Prompt highlight in chat buffer (e.g. `/Explain`, `/Review`) -- `CopilotChatModel` - Model highlight in chat buffer (e.g. `$gpt-4.1`) -- `CopilotChatUri` - URI highlight in chat buffer (e.g. `##https://...`) +- `CopilotChatResource` - Resource highlight in chat buffer (e.g. `#file`, `#gitdiff`) +- `CopilotChatTool` - Tool call highlight in chat buffer (e.g. `@copilot`) +- `CopilotChatPrompt` - Prompt highlight in chat buffer (e.g. `/Explain`, `/Review`) +- `CopilotChatModel` - Model highlight in chat buffer (e.g. `$gpt-5-mini`) +- `CopilotChatUri` - URI highlight in chat buffer (e.g. `##https://...`) - `CopilotChatAnnotation` - Annotation highlight in chat buffer (file headers, tool call headers, tool call body) +- `CopilotChatAnnotationHeader` - Annotation header highlight in chat buffer PROMPTS *CopilotChat-prompts* @@ -377,14 +436,47 @@ Define your own prompts in the configuration: FUNCTIONS *CopilotChat-functions* +Use `trusted_tools` to control which tool calls are executed automatically: + +>lua + { + trusted_tools = nil, -- default: require approval for all tool calls + + -- trust all functions in a group + -- trusted_tools = 'copilot', + + -- trust specific functions by name or groups by name + -- trusted_tools = { 'file', 'glob', 'grep' }, + + -- trust every enabled tool call + -- trusted_tools = true, + } +< + +**How tool trust works:** + +A tool is trusted when any of these match: + +- Its function definition sets `trusted = true` +- Its function name appears in `trusted_tools` +- Its function group appears in `trusted_tools` +- `trusted_tools = true` + +**Recommended setup:** Trust read-only functions like `file`, `glob`, or `grep` +for a smoother workflow without compromising safety. + + + [!WARNING] Trusted tools run without asking for confirmation. Be especially + careful with tools like `bash` and `edit`, which can change your workspace. Define your own functions in the configuration with input handling and schema: >lua { functions = { birthday = { - description = "Retrieves birthday information for a person", - uri = "birthday://{name}", + description = 'Retrieves birthday information for a person', + uri = 'birthday://{name}', + trusted = false, schema = { type = 'object', required = { 'name' }, @@ -402,14 +494,17 @@ Define your own functions in the configuration with input handling and schema: uri = 'birthday://' .. input.name, mimetype = 'text/plain', data = input.name .. ' birthday info', - } + }, } - end - } + end, + }, } } < +If a function has a `uri`, it can be used manually with `#birthday:Alice`. +Functions without a `uri` are tool-only and can only be called by the model. + PROVIDERS *CopilotChat-providers* @@ -419,9 +514,9 @@ Add custom AI providers: { providers = { my_provider = { - get_url = function(opts) return "https://api.example.com/chat" end, - get_headers = function() return { ["Authorization"] = "Bearer " .. api_key } end, - get_models = function() return { { id = "gpt-4.1", name = "GPT-4.1 model" } } end, + get_url = function(opts) return 'https://api.example.com/chat' end, + get_headers = function() return { ['Authorization'] = 'Bearer ' .. api_key } end, + get_models = function() return { { id = 'gpt-5-mini', name = 'GPT-5 mini model' } } end, prepare_input = require('CopilotChat.config.providers').copilot.prepare_input, prepare_output = require('CopilotChat.config.providers').copilot.prepare_output, } @@ -437,7 +532,7 @@ Add custom AI providers: disabled?: boolean, -- Optional: Extra info about the provider displayed in info panel - get_info?(): string[] + get_info?(headers: table): string[] -- Optional: Get extra request headers with optional expiration time get_headers?(): table, number?, @@ -453,13 +548,16 @@ Add custom AI providers: -- Optional: Get available models get_models?(headers: table): table, + + -- Optional: Resolve a user-facing model id to a provider model id + resolve_model?(headers: table, model: string): string, } < **Built-in providers:** - `copilot` - GitHub Copilot (default) -- `github_models` - GitHub Marketplace models (disabled by default) +- `github_models` - GitHub Models (disabled by default) ============================================================================== @@ -469,11 +567,10 @@ Add custom AI providers: CORE *CopilotChat-core* >lua - local chat = require("CopilotChat") + local chat = require('CopilotChat') -- Basic Chat Functions chat.ask(prompt, config) -- Ask a question with optional config - chat.response() -- Get the last response text -- Window Management chat.open(config) -- Open chat window with optional config @@ -501,7 +598,7 @@ CHAT WINDOW *CopilotChat-chat-window* You can also access the chat window UI methods through the `chat.chat` object: >lua - local window = require("CopilotChat").chat + local window = require('CopilotChat').chat -- Chat UI State window:visible() -- Check if chat window is visible @@ -520,8 +617,8 @@ You can also access the chat window UI methods through the `chat.chat` object: window:finish() -- Finish writing to chat window -- Source Management - window.get_source() -- Get the current source buffer and window - window.set_source(winnr) -- Set the source window + window:get_source() -- Get the current source buffer and window + window:set_source(winnr) -- Set the source window -- Navigation window:follow() -- Move cursor to end of chat content @@ -535,10 +632,11 @@ You can also access the chat window UI methods through the `chat.chat` object: PROMPT PARSER *CopilotChat-prompt-parser* >lua - local parser = require("CopilotChat.prompts") + local parser = require('CopilotChat.prompts') parser.resolve_prompt() -- Resolve prompt references - parser.resolve_tools() -- Resolve tools that are available for automatic use by LLM + parser.resolve_tools() -- Resolve tools shared with the model via @... + parser.resolve_functions() -- Resolve manual function/resource references via #... parser.resolve_model() -- Resolve model from prompt (WARN: async, requires plenary.async.run) < @@ -547,22 +645,26 @@ EXAMPLE USAGE *CopilotChat-example-usage* >lua -- Open chat, ask a question and handle response - require("CopilotChat").open() - require("CopilotChat").ask("#buffer Explain this code", { + require('CopilotChat').open() + require('CopilotChat').ask('#buffer Explain this code', { callback = function(response) - vim.notify("Got response: " .. response:sub(1, 50) .. "...") - return response + vim.notify('Got response: ' .. vim.trim(response.content):sub(1, 50) .. '...') end, }) -- Save and load chat history - require("CopilotChat").save("my_debugging_session") - require("CopilotChat").load("my_debugging_session") + require('CopilotChat').save('my_debugging_session') + require('CopilotChat').load('my_debugging_session') -- Use custom sticky and model - require("CopilotChat").ask("How can I optimize this?", { - model = "gpt-4.1", - sticky = {"#buffer", "#gitdiff:staged"} + require('CopilotChat').ask('How can I optimize this?', { + model = 'gpt-5-mini', + sticky = { '#buffer', '#gitdiff:staged' }, + }) + + -- Automatically trust a small read-only tool set + require('CopilotChat').setup({ + trusted_tools = { 'file', 'glob', 'grep' }, }) < @@ -597,6 +699,12 @@ To run tests: make test < +To run the same formatting check as CI: + +>bash + stylua --check . +< + CONTRIBUTING *CopilotChat-contributing* @@ -610,35 +718,23 @@ See CONTRIBUTING.md for detailed guidelines. ============================================================================== -7. Acknowledgments *CopilotChat-acknowledgments* - - -DIFF-MATCH-PATCH *CopilotChat-diff-match-patch* - -CopilotChat.nvim includes diff-match-patch (Lua port) - for diffing and patching -functionality. Copyright 2018 The diff-match-patch Authors. Licensed under the -Apache License 2.0. - - -============================================================================== -8. Contributors *CopilotChat-contributors* +7. Contributors *CopilotChat-contributors* Thanks goes to these wonderful people (emoji key ): -gptlang💻 📖Dung Duc Huynh (Kaka)💻 📖Ahmed Haracic💻Trí Thiện Nguyễn💻He Zhizhou💻Guruprakash Rajakkannu💻kristofka💻PostCyberPunk📖Katsuhiko Nishimra💻Erno Hopearuoho💻Shaun Garwood💻neutrinoA4💻 📖Jack Muratore💻Adriel Velazquez💻 📖Tomas Slusny💻 📖Nisal📖Tobias Gårdhus📖Petr Dlouhý📖Dylan Madisetti💻Aaron Weisberg💻 📖Jose Tlacuilo💻 📖Kevin Traver💻 📖dTry💻Arata Furukawa💻Ling💻Ivan Frolov💻Folke Lemaitre💻 📖GitMurf💻Dmitrii Lipin💻jinzhongjia📖guill💻Sjon-Paul Brown💻Renzo Mondragón💻 📖fjchen7💻Radosław Woźniak💻JakubPecenka💻thomastthai📖Tomáš Janoušek💻Toddneal Stallworth📖Sergey Alexandrov💻Léopold Mebazaa💻JunKi Jin💻abdennourzahaf📖Josiah💻Tony Fischer💻 📖Kohei Wada💻Sebastian Yaghoubi📖johncming💻Rokas Brazdžionis💻Sola📖 💻Mani Chandra💻Nischal Basuti📖Teo Ljungberg💻Joe Price💻Yufan You📖 💻Manish Kumar💻Anton Ždanov📖 💻Fredrik Averpil💻Aaron D Borden💻Md. Iftakhar Awal Chowdhury💻 📖Danilo Horta💻Mihamina Rakotomandimby📖 💻Ajmal S💻Samiul Islam💻Rui Costa💻CTCHEN💻This project follows the all-contributors +gptlang💻 📖Dung Duc Huynh (Kaka)💻 📖Ahmed Haracic💻Trí Thiện Nguyễn💻He Zhizhou💻Guruprakash Rajakkannu💻kristofka💻PostCyberPunk📖Katsuhiko Nishimra💻Erno Hopearuoho💻Shaun Garwood💻neutrinoA4💻 📖Jack Muratore💻Adriel Velazquez💻 📖Tomas Slusny💻 📖Nisal📖Tobias Gårdhus📖Petr Dlouhý📖Dylan Madisetti💻Aaron Weisberg💻 📖Jose Tlacuilo💻 📖Kevin Traver💻 📖dTry💻Arata Furukawa💻Ling💻Ivan Frolov💻Folke Lemaitre💻 📖GitMurf💻Dmitrii Lipin💻jinzhongjia📖guill💻Sjon-Paul Brown💻Renzo Mondragón💻 📖fjchen7💻Radosław Woźniak💻JakubPecenka💻thomastthai📖Tomáš Janoušek💻Toddneal Stallworth📖Sergey Alexandrov💻Léopold Mebazaa💻JunKi Jin💻abdennourzahaf📖Josiah💻Tony Fischer💻 📖Kohei Wada💻Sebastian Yaghoubi📖johncming💻Rokas Brazdžionis💻Sola📖 💻Mani Chandra💻Nischal Basuti📖Teo Ljungberg💻Joe Price💻Yufan You📖 💻Manish Kumar💻Anton Ždanov📖 💻Fredrik Averpil💻Aaron D Borden💻Md. Iftakhar Awal Chowdhury💻 📖Danilo Horta💻Mihamina Rakotomandimby📖 💻Ajmal S💻Samiul Islam💻Rui Costa💻CTCHEN💻Tobias Wölfel💻Alexander Garcia💻Max Kharandziuk💻Xinyu Xiang💻junqizhang💻Calum Lynch💻sirjls💻Vladimir Kolchurin💻RoseSecurity📖 💻Abhraneel Mukherjee💻This project follows the all-contributors specification. Contributions of any kind are welcome! ============================================================================== -9. Stargazers *CopilotChat-stargazers* +8. Stargazers *CopilotChat-stargazers* ============================================================================== -10. Links *CopilotChat-links* +9. Links *CopilotChat-links* 1. *Stargazers over time*: https://starchart.cc/CopilotC-Nvim/CopilotChat.nvim.svg?variant=adaptive diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index 93e1c91d..7bbc65d8 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -55,7 +55,7 @@ local log = require('plenary.log') local constants = require('CopilotChat.constants') -local notify = require('CopilotChat.notify') +local notify = require('CopilotChat.utils.notify') local tiktoken = require('CopilotChat.tiktoken') local utils = require('CopilotChat.utils') local curl = require('CopilotChat.utils.curl') @@ -65,7 +65,7 @@ local orderedmap = require('CopilotChat.utils.orderedmap') local stringbuffer = require('CopilotChat.utils.stringbuffer') --- Constants -local RESOURCE_SHORT_FORMAT = '# %s\n```%s start_line=% end_line=%s\n%s\n```' +local RESOURCE_SHORT_FORMAT = '# %s\n```%s start_line=%s end_line=%s\n%s\n```' local RESOURCE_LONG_FORMAT = '# %s\n```%s path=%s start_line=%s end_line=%s\n%s\n```' local CACHE_TTL = 300 -- 5 minutes @@ -319,6 +319,16 @@ function Client:ask(opts) error('Provider not found: ' .. provider_name) end + if provider.resolve_model then + local headers = self:authenticate(provider_name) + local resolved_model = provider.resolve_model(headers, opts.model) + opts.model = resolved_model + model_config = models[opts.model] + if not model_config then + error('Resolved model not found: ' .. opts.model) + end + end + local options = { model = vim.tbl_extend('force', model_config, { id = opts.model:gsub(':' .. provider_name .. '$', ''), @@ -389,6 +399,7 @@ function Client:ask(opts) local errored = nil local finished = false local token_count = 0 + local out_model = nil local response_content_buffer = stringbuffer() local response_reasoning_buffer = stringbuffer() @@ -434,11 +445,22 @@ function Client:ask(opts) if out.tool_calls then for _, tool_call in ipairs(out.tool_calls) do - local val = tool_calls:get(tool_call.index) - if not val then - tool_calls:set(tool_call.index, tool_call) + local key = tostring(tool_call.index or tool_call.id or tool_call.name or #tool_calls:values() + 1) + local existing = tool_calls:get(key) + + if not existing then + tool_calls:set(key, tool_call) else - val.arguments = val.arguments .. tool_call.arguments + existing.arguments = existing.arguments .. tool_call.arguments + if tool_call.id then + existing.id = tool_call.id + end + if tool_call.index then + existing.index = tool_call.index + end + if tool_call.name then + existing.name = tool_call.name + end end end end @@ -451,6 +473,10 @@ function Client:ask(opts) response_reasoning_buffer:put(out.reasoning) end + if out.model then + out_model = out.model + end + if opts.on_progress then opts.on_progress({ role = constants.ROLE.ASSISTANT, @@ -511,7 +537,14 @@ function Client:ask(opts) end local headers = self:authenticate(provider_name) - local request = provider.prepare_input(generate_ask_request(opts.system_prompt, history, generated_messages), options) + + local request, extra_headers = + provider.prepare_input(generate_ask_request(opts.system_prompt, history, generated_messages), options) + + if extra_headers then + headers = vim.tbl_extend('force', headers, extra_headers) + end + local is_stream = request.stream local args = { @@ -551,12 +584,10 @@ function Client:ask(opts) end error(error_msg) - return end if errored then error(errored) - return end local response_text = response_content_buffer:tostring() @@ -576,12 +607,18 @@ function Client:ask(opts) response_reasoning = response_reasoning_buffer:tostring() end + -- Filter out tool calls that don't have names (streaming deltas used only for accumulation) + local final_tool_calls = vim.tbl_filter(function(tc) + return tc.name ~= nil + end, tool_calls:values()) + return { message = { role = constants.ROLE.ASSISTANT, content = response_text, reasoning = response_reasoning, - tool_calls = #tool_calls:values() > 0 and tool_calls:values() or nil, + tool_calls = #final_tool_calls > 0 and final_tool_calls or nil, + model = out_model, }, token_count = token_count, token_max_count = max_tokens, diff --git a/lua/CopilotChat/config.lua b/lua/CopilotChat/config.lua index b78ddf61..96c584f3 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 trusted_tools boolean|string|table|nil ---@field diff 'block'|'unified'? ---@field language string? ---@field temperature number? @@ -43,6 +44,7 @@ ---@field log_level 'trace'|'debug'|'info'|'warn'|'error'|'fatal'? ---@field proxy string? ---@field allow_insecure boolean? +---@field instruction_files table? ---@field selection 'visual'|'unnamed'|nil ---@field chat_autocomplete boolean? ---@field log_path string? @@ -59,10 +61,11 @@ return { system_prompt = require('CopilotChat.config.prompts').COPILOT_INSTRUCTIONS.system_prompt, -- System prompt to use (can be specified manually in prompt via /). - model = 'gpt-4.1', -- Default model to use, see ':CopilotChatModels' for available models (can be specified manually in prompt via $). + model = 'gpt-5-mini', -- Default model to use, see ':CopilotChatModels' for available models (can be specified manually in prompt via $). 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 >). + trusted_tools = nil, -- Trust tool calls from specific functions or groups, or all trusted tools when true (e.g., {'buffer', 'file'} or 'copilot'). diff = 'block', -- Default diff format to use, 'block' or 'unified'. language = 'English', -- Default language to use for answers @@ -105,6 +108,12 @@ return { proxy = nil, -- [protocol://]host[:port] Use this proxy allow_insecure = false, -- Allow insecure server connections + -- Instruction files to look for in current working directory + instruction_files = { + '.github/copilot-instructions.md', + 'AGENTS.md', + }, + selection = 'visual', -- Selection source chat_autocomplete = true, -- Enable chat autocompletion (when disabled, requires manual `mappings.complete` trigger) diff --git a/lua/CopilotChat/config/functions.lua b/lua/CopilotChat/config/functions.lua index 92226594..0ec22b10 100644 --- a/lua/CopilotChat/config/functions.lua +++ b/lua/CopilotChat/config/functions.lua @@ -44,6 +44,7 @@ end ---@field description string? ---@field schema table? ---@field group string? +---@field trusted boolean? ---@field uri string? ---@field resolve fun(input: table, source: CopilotChat.ui.chat.Source):CopilotChat.client.Resource[] @@ -64,6 +65,7 @@ return { enum = function(source) return files.glob(source.cwd(), { max_count = 0, + hidden = true, }) end, }, @@ -150,7 +152,7 @@ return { local name = vim.api.nvim_buf_get_name(buf) if name and name ~= '' then local display_name = vim.fn.fnamemodify(name, ':~:.') - table.insert(opts, { display = display_name, value = name }) + table.insert(opts, { display = display_name, value = tostring(buf) }) end end end @@ -247,7 +249,7 @@ return { { uri = 'neovim://selection', name = selection.filename, - mimetype = files.mimetype_to_filetype(selection.filetype), + mimetype = files.filetype_to_mimetype(selection.filetype), data = data, annotations = { start_line = selection.start_line, diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index fb4b6388..c3028f8a 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -14,23 +14,19 @@ local function prepare_diff_buffer(filename, source) filename = vim.api.nvim_buf_get_name(source.bufnr) end + -- Try to find matching buffer first 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), filename) then - diff_bufnr = buf - break - end + for _, buf in ipairs(vim.api.nvim_list_bufs()) do + if files.filename_same(vim.api.nvim_buf_get_name(buf), filename) then + diff_bufnr = buf + break end + end - -- If still not found, create a new buffer - if not diff_bufnr then - diff_bufnr = vim.fn.bufadd(filename) - vim.fn.bufload(diff_bufnr) - end + -- If not found, create a new buffer + if not diff_bufnr then + diff_bufnr = vim.fn.bufadd(filename) + vim.fn.bufload(diff_bufnr) end -- If source exists, update it to point to the diff buffer @@ -243,10 +239,10 @@ return { }) end end - - vim.fn.setqflist(items) - vim.cmd('copen') end + + vim.fn.setqflist(items) + vim.cmd('copen') end, }, @@ -294,7 +290,6 @@ return { async.run(function() local config, prompt = prompts.resolve_prompt(message.content) local system_prompt = config.system_prompt - local resolved_resources = prompts.resolve_functions(prompt, config) local selected_tools = prompts.resolve_tools(prompt, config) local selected_model = prompts.resolve_model(prompt, config) local infos = client:info() @@ -361,28 +356,6 @@ return { table.insert(lines, '') end - if not utils.empty(resolved_resources) then - table.insert(lines, '**Resources**') - table.insert(lines, '') - end - - for _, resource in ipairs(resolved_resources) do - local resource_lines = vim.split(resource.data, '\n') - local preview = vim.list_slice(resource_lines, 1, math.min(10, #resource_lines)) - local header = string.format('**%s** (%s lines)', resource.name or resource.uri, #resource_lines) - if #resource_lines > 10 then - header = header .. ' (truncated)' - end - - table.insert(lines, header) - table.insert(lines, '```' .. files.mimetype_to_filetype(resource.mimetype)) - for _, line in ipairs(preview) do - table.insert(lines, line) - end - table.insert(lines, '```') - table.insert(lines, '') - end - chat:overlay({ text = vim.trim(table.concat(lines, '\n')) .. '\n', }) diff --git a/lua/CopilotChat/config/providers.lua b/lua/CopilotChat/config/providers.lua index 5b4e95f9..ea5eea71 100644 --- a/lua/CopilotChat/config/providers.lua +++ b/lua/CopilotChat/config/providers.lua @@ -1,7 +1,7 @@ local log = require('plenary.log') local plenary_utils = require('plenary.async.util') local constants = require('CopilotChat.constants') -local notify = require('CopilotChat.notify') +local notify = require('CopilotChat.utils.notify') local utils = require('CopilotChat.utils') local curl = require('CopilotChat.utils.curl') local files = require('CopilotChat.utils.files') @@ -183,7 +183,7 @@ local function get_github_models_token(tag) end -- loading token from gh cli if available - if vim.fn.executable('gh') == 0 then + if vim.fn.executable('gh') == 1 then local result = utils.system({ 'gh', 'auth', 'token', '-h', 'github.com' }) if result and result.code == 0 and result.stdout then local gh_token = vim.trim(result.stdout) @@ -196,34 +196,320 @@ local function get_github_models_token(tag) return github_device_flow(tag, '178c6fc778ccc68e1d6a', 'read:user copilot') end ---- Helper function to extract text content from Responses API output parts ----@param parts table Array of content parts from Responses API +--- Resolve the Copilot API base URL from token endpoint response. +--- Falls back to the default api.githubcopilot.com if no business endpoint is found. +---@param token_body table The decoded JSON body from the token endpoint +---@return string base_url The base URL (no trailing slash) +local function resolve_copilot_base_url(token_body) + -- The token response may include an `endpoints` table with an `api` field + -- pointing to the correct base URL for business/enterprise accounts, + -- e.g. https://api.business.githubcopilot.com + if token_body and token_body.endpoints and token_body.endpoints.api then + local url = token_body.endpoints.api + -- Strip trailing slash if present + return url:gsub('/$', '') + end + return 'https://api.githubcopilot.com' +end + +--- Prepare input for Responses API +---@param inputs CopilotChat.client.Message[] +---@param opts CopilotChat.config.providers.Options +---@return table +local function prepare_responses_input(inputs, opts) + local instructions = nil + local input_messages = {} + + for _, msg in ipairs(inputs) do + if msg.role == constants.ROLE.SYSTEM then + instructions = instructions and (instructions .. '\n\n' .. msg.content) or msg.content + elseif msg.role == constants.ROLE.TOOL then + table.insert(input_messages, { + type = 'function_call_output', + call_id = msg.tool_call_id, + output = msg.content, + }) + else + table.insert(input_messages, { + role = msg.role, + content = msg.content, + }) + + if msg.tool_calls then + for _, tool_call in ipairs(msg.tool_calls) do + table.insert(input_messages, { + type = 'function_call', + call_id = tool_call.id, + name = tool_call.name, + arguments = tool_call.arguments or '', + }) + end + end + end + end + + local out = { + model = opts.model.id, + stream = opts.model.streaming ~= false, + input = input_messages, + } + + if instructions then + out.instructions = instructions + end + + if opts.tools and opts.model.tools then + out.tools = vim.tbl_map(function(tool) + return { + type = 'function', + name = tool.name, + description = tool.description, + parameters = tool.schema, + } + end, opts.tools) + end + + return out +end + +--- Prepare input for Chat Completions API +---@param inputs CopilotChat.client.Message[] +---@param opts CopilotChat.config.providers.Options +---@return table +local function prepare_chat_input(inputs, opts) + local is_o1 = vim.startswith(opts.model.id, 'o1') + local is_codex = opts.model.id:find('codex') ~= nil + + inputs = vim.tbl_map(function(input) + local output = { + role = (is_o1 and input.role == constants.ROLE.SYSTEM) and constants.ROLE.USER or input.role, + content = input.content, + } + + if input.tool_call_id then + output.tool_call_id = input.tool_call_id + end + + if input.tool_calls then + output.tool_calls = vim.tbl_map(function(tool_call) + return { + id = tool_call.id, + type = 'function', + ['function'] = { + name = tool_call.name, + arguments = tool_call.arguments or nil, + }, + } + end, input.tool_calls) + end + + return output + end, inputs) + + local out = { + messages = inputs, + model = opts.model.id, + stream = opts.model.streaming or false, + } + + if opts.tools and opts.model.tools then + out.tools = vim.tbl_map(function(tool) + return { + type = 'function', + ['function'] = { + name = tool.name, + description = tool.description, + parameters = tool.schema, + }, + } + end, opts.tools) + end + + if not is_o1 and not is_codex then + out.n = 1 + out.top_p = 1 + out.temperature = opts.temperature + end + + if opts.model.max_output_tokens then + out.max_tokens = opts.model.max_output_tokens + end + + return out +end +---@param parts table Array of content parts ---@return string The concatenated text content local function extract_text_from_parts(parts) - local content = '' if not parts or type(parts) ~= 'table' then - return content + return '' end + local content = '' for _, part in ipairs(parts) do - if type(part) == 'table' then - -- Handle different content types from Responses API - if part.type == 'output_text' or part.type == 'text' then + if type(part) == 'string' then + content = content .. part + elseif type(part) == 'table' then + -- Responses API: parts have type field + if part.type == 'text' or part.type == 'output_text' or part.type == 'input_text' then content = content .. (part.text or '') - elseif part.output_text then - -- Handle nested output_text - if type(part.output_text) == 'string' then - content = content .. part.output_text - elseif type(part.output_text) == 'table' and part.output_text.text then - content = content .. part.output_text.text + -- Fallback for simpler structures + elseif part.text then + content = content .. part.text + end + end + end + return content +end + +--- Parse Responses API output (both streaming and non-streaming) +---@param output table Raw API response +---@return CopilotChat.config.providers.Output +local function prepare_responses_output(output) + local content = '' + local reasoning = '' + local finish_reason = nil + local total_tokens = nil + local tool_calls = {} + local model = nil + + -- Handle errors + local error_msg = output.error or (output.response and output.response.error) + if error_msg then + if type(error_msg) == 'table' then + error_msg = error_msg.message or vim.inspect(error_msg) + end + return { + content = '', + reasoning = '', + finish_reason = 'error: ' .. tostring(error_msg), + total_tokens = nil, + tool_calls = {}, + model = nil, + } + end + + -- Handle streaming events + if output.type then + if output.type == 'response.output_text.delta' then + -- Streaming text delta + if output.delta and type(output.delta) == 'string' then + content = output.delta + elseif output.delta and output.delta.text then + content = output.delta.text + end + elseif output.type == 'response.output_item.done' then + local item = output.item + if item and item.type == 'function_call' then + table.insert(tool_calls, { + id = item.call_id, + index = output.output_index, + name = item.name, + arguments = item.arguments or '', + }) + end + elseif output.type == 'response.completed' or output.type == 'response.done' then + local response = output.response + if response then + if response.reasoning and response.reasoning.summary then + reasoning = response.reasoning.summary end + if response.usage then + total_tokens = response.usage.total_tokens + end + if response.model then + model = response.model + end + finish_reason = 'stop' end - elseif type(part) == 'string' then - content = content .. part + elseif output.type == 'response.failed' then + finish_reason = 'error: ' .. (output.error and output.error.message or 'unknown error') + end + -- Handle non-streaming response + elseif output.response then + local response = output.response + if response.output and #response.output > 0 then + for _, msg in ipairs(response.output) do + if msg.content then + content = content .. extract_text_from_parts(msg.content) + end + if msg.tool_calls then + for i, tool_call in ipairs(msg.tool_calls) do + table.insert(tool_calls, { + id = tool_call.call_id, + index = i, + name = tool_call.name, + arguments = tool_call.arguments or '', + }) + end + end + end + end + if response.reasoning and response.reasoning.summary then + reasoning = response.reasoning.summary + end + if response.usage then + total_tokens = response.usage.total_tokens end + if response.model then + model = response.model + end + finish_reason = response.status == 'completed' and 'stop' or nil end - return content + return { + content = content, + reasoning = reasoning, + finish_reason = finish_reason, + total_tokens = total_tokens, + tool_calls = tool_calls, + model = model, + } +end + +--- Parse Chat Completions API output (both streaming and non-streaming) +---@param output table Raw API response +---@return CopilotChat.config.providers.Output +local function prepare_chat_output(output) + local tool_calls = {} + + local choice + if output.choices and #output.choices > 0 then + for _, c in ipairs(output.choices) do + local message = c.message or c.delta + if message and message.tool_calls then + for i, tool_call in ipairs(message.tool_calls) do + local fn = tool_call['function'] + if fn then + table.insert(tool_calls, { + id = tool_call.id, + index = tool_call.index or i, + name = fn.name, + arguments = fn.arguments or '', + }) + end + end + end + end + choice = output.choices[1] + else + choice = output + end + + local message = choice.message or choice.delta + local content = message and message.content + local reasoning = message and (message.reasoning or message.reasoning_content) + local usage = choice.usage and choice.usage.total_tokens or output.usage and output.usage.total_tokens + local finish_reason = choice.finish_reason or choice.done_reason or output.finish_reason or output.done_reason + local model = choice.model or output.model + + return { + content = content, + reasoning = reasoning, + finish_reason = finish_reason, + total_tokens = usage, + tool_calls = tool_calls, + model = model, + } end ---@class CopilotChat.config.providers.Options @@ -237,13 +523,15 @@ end ---@field finish_reason string? ---@field total_tokens number? ---@field tool_calls table +---@field model string? ---@class CopilotChat.config.providers.Provider ---@field disabled nil|boolean ---@field get_headers nil|fun():table,number? ---@field get_info nil|fun(headers:table):string[] ---@field get_models nil|fun(headers:table):table ----@field prepare_input nil|fun(inputs:table, opts:CopilotChat.config.providers.Options):table +---@field resolve_model nil|fun(headers:table, model: string):string +---@field prepare_input nil|fun(inputs:CopilotChat.client.Message[], opts:CopilotChat.config.providers.Options):table,table? ---@field prepare_output nil|fun(output:table, opts:CopilotChat.config.providers.Options):CopilotChat.config.providers.Output ---@field get_url nil|fun(opts:CopilotChat.config.providers.Options):string @@ -263,11 +551,19 @@ M.copilot = { error(err) end + -- Resolve the base URL from the token response so that business/enterprise + -- accounts using *.business.githubcopilot.com are handled automatically. + local base_url = resolve_copilot_base_url(response.body) + return { ['Authorization'] = 'Bearer ' .. response.body.token, ['Editor-Version'] = EDITOR_VERSION, ['Editor-Plugin-Version'] = 'CopilotChat.nvim/*', ['Copilot-Integration-Id'] = 'vscode-chat', + ['x-github-api-version'] = '2025-10-01', + -- Store the resolved base URL in a custom header so that get_models, + -- resolve_model, and get_url can read it without making another request. + ['x-copilot-base-url'] = base_url, }, response.body.expires_at end, @@ -323,9 +619,16 @@ M.copilot = { end, get_models = function(headers) - local response, err = curl.get('https://api.githubcopilot.com/models', { + -- Use the resolved base URL carried in the custom header, falling back to + -- the default if it is absent (e.g. during tests or manual calls). + local base_url = headers['x-copilot-base-url'] or 'https://api.githubcopilot.com' + + -- Build request headers without our internal routing header. + local request_headers = vim.tbl_extend('force', headers, { ['x-copilot-base-url'] = nil }) + + local response, err = curl.get(base_url .. '/models', { json_response = true, - headers = headers, + headers = request_headers, }) if err then @@ -352,7 +655,11 @@ M.copilot = { tools = model.capabilities.supports.tool_calls, policy = not model['policy'] or model['policy']['state'] == 'enabled', version = model.version, + multiplier = model.billing and model.billing.multiplier or nil, use_responses = use_responses, + -- Carry the base URL into the model so get_url and resolve_model + -- can use it without needing access to the headers again. + base_url = base_url, } end) :totable() @@ -368,368 +675,82 @@ M.copilot = { for _, model in ipairs(models) do if not model.policy then - pcall(curl.post, 'https://api.githubcopilot.com/models/' .. model.id .. '/policy', { - headers = headers, + pcall(curl.post, base_url .. '/models/' .. model.id .. '/policy', { + headers = request_headers, json_request = true, body = { state = 'enabled' }, }) end end + -- Auto model selector + table.insert(models, { + id = 'auto', + name = 'Auto (Copilot)', + description = 'Auto selects the best model for your request.', + base_url = base_url, + }) + return models end, - prepare_input = function(inputs, opts) - local is_o1 = vim.startswith(opts.model.id, 'o1') - - -- Check if this model uses the Responses API - if opts.model.use_responses then - -- Prepare input for Responses API - local instructions = nil - local input_messages = {} - - for _, msg in ipairs(inputs) do - if msg.role == constants.ROLE.SYSTEM then - -- Combine system messages as instructions - if instructions then - instructions = instructions .. '\n\n' .. msg.content - else - instructions = msg.content - end - else - -- Include the message in the input array - table.insert(input_messages, { - role = msg.role, - content = msg.content, - }) - end - end - - -- The Responses API expects the input field to be an array of message objects - local out = { - model = opts.model.id, - -- Always request streaming for Responses API (honor model.streaming or default to true) - stream = opts.model.streaming ~= false, - input = input_messages, - } - - -- Add instructions if we have any system messages - if instructions then - out.instructions = instructions - end - - -- Add tools for Responses API if available - if opts.tools and opts.model.tools then - out.tools = vim.tbl_map(function(tool) - return { - type = 'function', - ['function'] = { - name = tool.name, - description = tool.description, - parameters = tool.schema, - strict = true, - }, - } - end, opts.tools) - end - - -- Note: temperature is not supported by Responses API, so we don't include it - - return out + resolve_model = function(headers, model) + if model ~= 'auto' then + return model end - -- Original Chat Completion API logic - inputs = vim.tbl_map(function(input) - local output = { - role = input.role, - content = input.content, - } - - if is_o1 then - if input.role == constants.ROLE.SYSTEM then - output.role = constants.ROLE.USER - end - end - - if input.tool_call_id then - output.tool_call_id = input.tool_call_id - end - - if input.tool_calls then - output.tool_calls = vim.tbl_map(function(tool_call) - return { - id = tool_call.id, - type = 'function', - ['function'] = { - name = tool_call.name, - arguments = tool_call.arguments or nil, - }, - } - end, input.tool_calls) - end - - return output - end, inputs) + local base_url = headers['x-copilot-base-url'] or 'https://api.githubcopilot.com' + local request_headers = vim.tbl_extend('force', headers, { ['x-copilot-base-url'] = nil }) - local out = { - messages = inputs, - model = opts.model.id, - stream = opts.model.streaming or false, - } + local url = base_url .. '/models/session' + local response, err = curl.post(url, { + headers = request_headers, + body = { auto_mode = { model_hints = { 'auto' } } }, + json_response = true, + json_request = true, + }) - if opts.tools and opts.model.tools then - out.tools = vim.tbl_map(function(tool) - return { - type = 'function', - ['function'] = { - name = tool.name, - description = tool.description, - parameters = tool.schema, - }, - } - end, opts.tools) + if err then + error(err) end - if not is_o1 then - out.n = 1 - out.top_p = 1 - out.temperature = opts.temperature + return response.body.selected_model + end, + + prepare_input = function(inputs, opts) + local request + if opts.model.use_responses then + request = prepare_responses_input(inputs, opts) + else + request = prepare_chat_input(inputs, opts) end - if opts.model.max_output_tokens then - out.max_tokens = opts.model.max_output_tokens + if inputs and #inputs > 0 then + local last_msg = inputs[#inputs] + if last_msg.role == constants.ROLE.TOOL then + return request, { ['x-initiator'] = 'agent' } + end end - return out + return request end, prepare_output = function(output, opts) - -- Check if this model uses the Responses API if opts and opts.model and opts.model.use_responses then - -- Handle Responses API output format - local content = '' - local reasoning = '' - local finish_reason = nil - local total_tokens = 0 - local tool_calls = {} - - -- Check for error in response - if output.error then - -- Surface the error as a finish reason to stop processing - local error_msg = output.error - if type(error_msg) == 'table' then - error_msg = error_msg.message or vim.inspect(error_msg) - end - return { - content = '', - reasoning = '', - finish_reason = 'error: ' .. tostring(error_msg), - total_tokens = nil, - tool_calls = {}, - } - end - - if output.type then - -- This is a streaming response from Responses API - if output.type == 'response.created' or output.type == 'response.in_progress' then - -- In-progress events, we don't have content yet - return { - content = '', - reasoning = '', - finish_reason = nil, - total_tokens = nil, - tool_calls = {}, - } - elseif output.type == 'response.completed' then - -- Completed response: do NOT resend content here to avoid duplication. - -- Only signal finish and capture usage/reasoning. - local response = output.response - if response then - if response.reasoning and response.reasoning.summary then - reasoning = response.reasoning.summary - end - if response.usage then - total_tokens = response.usage.total_tokens - end - finish_reason = 'stop' - end - return { - content = '', - reasoning = reasoning, - finish_reason = finish_reason, - total_tokens = total_tokens, - tool_calls = {}, - } - elseif output.type == 'response.content.delta' or output.type == 'response.output_text.delta' then - -- Streaming content delta - if output.delta then - if type(output.delta) == 'string' then - content = output.delta - elseif type(output.delta) == 'table' then - if output.delta.content then - content = output.delta.content - elseif output.delta.output_text then - content = extract_text_from_parts({ output.delta.output_text }) - elseif output.delta.text then - content = output.delta.text - end - end - end - elseif output.type == 'response.delta' then - -- Handle response.delta with nested output_text - if output.delta and output.delta.output_text then - content = extract_text_from_parts({ output.delta.output_text }) - end - elseif output.type == 'response.content.done' or output.type == 'response.output_text.done' then - -- Terminal content event; keep streaming open until response.completed provides usage info - finish_reason = nil - elseif output.type == 'response.error' then - -- Handle error event - local error_msg = output.error - if type(error_msg) == 'table' then - error_msg = error_msg.message or vim.inspect(error_msg) - end - finish_reason = 'error: ' .. tostring(error_msg) - elseif output.type == 'response.tool_call.delta' then - -- Handle tool call delta events - if output.delta and output.delta.tool_calls then - for _, tool_call in ipairs(output.delta.tool_calls) do - local id = tool_call.id or ('tooluse_' .. (tool_call.index or 1)) - local existing_call = nil - for _, tc in ipairs(tool_calls) do - if tc.id == id then - existing_call = tc - break - end - end - if not existing_call then - table.insert(tool_calls, { - id = id, - index = tool_call.index or #tool_calls + 1, - name = tool_call.name or '', - arguments = tool_call.arguments or '', - }) - else - -- Append arguments - existing_call.arguments = existing_call.arguments .. (tool_call.arguments or '') - end - end - end - end - elseif output.response then - -- Non-streaming response or final response - local response = output.response - - -- Check for error in the response object - if response.error then - local error_msg = response.error - if type(error_msg) == 'table' then - error_msg = error_msg.message or vim.inspect(error_msg) - end - return { - content = '', - reasoning = '', - finish_reason = 'error: ' .. tostring(error_msg), - total_tokens = nil, - tool_calls = {}, - } - end - - if response.output and #response.output > 0 then - for _, msg in ipairs(response.output) do - if msg.content and #msg.content > 0 then - content = content .. extract_text_from_parts(msg.content) - end - -- Extract tool calls from output messages - if msg.tool_calls then - for i, tool_call in ipairs(msg.tool_calls) do - local id = tool_call.id or ('tooluse_' .. i) - table.insert(tool_calls, { - id = id, - index = tool_call.index or i, - name = tool_call.name or '', - arguments = tool_call.arguments or '', - }) - end - end - end - end - - if response.reasoning and response.reasoning.summary then - reasoning = response.reasoning.summary - end - - if response.usage then - total_tokens = response.usage.total_tokens - end - - finish_reason = response.status == 'completed' and 'stop' or nil - end - - return { - content = content, - reasoning = reasoning, - finish_reason = finish_reason, - total_tokens = total_tokens, - tool_calls = tool_calls, - } + return prepare_responses_output(output) end - - -- Original Chat Completion API logic - local tool_calls = {} - - local choice - if output.choices and #output.choices > 0 then - for _, choice in ipairs(output.choices) do - local message = choice.message or choice.delta - if message and message.tool_calls then - for i, tool_call in ipairs(message.tool_calls) do - local fn = tool_call['function'] - if fn then - local index = tool_call.index or i - local id = utils.empty(tool_call.id) and ('tooluse_' .. index) or tool_call.id - table.insert(tool_calls, { - id = id, - index = index, - name = fn.name, - arguments = fn.arguments or '', - }) - end - end - end - end - - choice = output.choices[1] - else - choice = output - end - - local message = choice.message or choice.delta - local content = message and message.content - local reasoning = message and (message.reasoning or message.reasoning_content) - local usage = choice.usage and choice.usage.total_tokens - if not usage then - usage = output.usage and output.usage.total_tokens - end - local finish_reason = choice.finish_reason or choice.done_reason or output.finish_reason or output.done_reason - - return { - content = content, - reasoning = reasoning, - finish_reason = finish_reason, - total_tokens = usage, - tool_calls = tool_calls, - } + return prepare_chat_output(output) end, get_url = function(opts) - -- Check if this model uses the Responses API + -- Use the base URL stored on the model (populated by get_models), falling + -- back to the default for backwards compatibility. + local base_url = (opts and opts.model and opts.model.base_url) or 'https://api.githubcopilot.com' + if opts and opts.model and opts.model.use_responses then - return 'https://api.githubcopilot.com/responses' + return base_url .. '/responses' end - - -- Default to Chat Completion API - return 'https://api.githubcopilot.com/chat/completions' + return base_url .. '/chat/completions' end, } @@ -755,17 +776,15 @@ M.github_models = { return vim .iter(response.body) :map(function(model) - local max_output_tokens = model.limits.max_output_tokens - local max_input_tokens = model.limits.max_input_tokens return { id = model.id, name = model.name, - tokenizer = 'o200k_base', - max_input_tokens = max_input_tokens, - max_output_tokens = max_output_tokens, - streaming = vim.tbl_contains(model.capabilities, 'streaming'), - tools = vim.tbl_contains(model.capabilities, 'tool-calling'), - reasoning = vim.tbl_contains(model.capabilities, 'reasoning'), + tokenizer = 'o200k_base', -- GitHub Models doesn't expose tokenizer info + max_input_tokens = model.limits and model.limits.max_input_tokens, + max_output_tokens = model.limits and model.limits.max_output_tokens, + streaming = model.capabilities and vim.tbl_contains(model.capabilities, 'streaming') or false, + tools = model.capabilities and vim.tbl_contains(model.capabilities, 'tool-calling') or false, + reasoning = model.capabilities and vim.tbl_contains(model.capabilities, 'reasoning') or false, version = model.version, } end) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 212bcb55..feba3754 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -2,6 +2,7 @@ local async = require('plenary.async') local log = require('plenary.log') local client = require('CopilotChat.client') local constants = require('CopilotChat.constants') +local functions = require('CopilotChat.functions') local prompts = require('CopilotChat.prompts') local select = require('CopilotChat.select') local utils = require('CopilotChat.utils') @@ -30,6 +31,37 @@ local M = setmetatable({}, { end, }) +---@param config CopilotChat.config.Shared +---@param tool_name string +---@return boolean +local function is_trusted_tool(config, tool_name) + local tool_spec = config.functions[tool_name] + if not tool_spec then + return false + end + + if tool_spec.trusted then + return true + end + + local trusted_tools = config.trusted_tools + if trusted_tools == true then + return true + end + + for _, trusted_pattern in ipairs(utils.to_table(trusted_tools)) do + if tool_name == trusted_pattern then + return true + end + + if tool_spec.group == trusted_pattern then + return true + end + end + + return false +end + --- Process sticky values from prompt and config --- Extracts stickies from prompt, adds config-based stickies, stores them, returns clean prompt ---@param prompt string @@ -54,8 +86,12 @@ local function process_sticky(prompt, config) end -- Find sticky lines in new prompt to remove them + in_code_block = false for i, line in ipairs(lines) do - if vim.startswith(line, '> ') then + if line:match('^```') then + in_code_block = not in_code_block + end + if vim.startswith(line, '> ') and not in_code_block then table.insert(sticky_indices, i) end end @@ -112,7 +148,7 @@ end --- Finish writing to chat buffer. ---@param start_of_chat boolean? -local function finish(start_of_chat) +local function finish(start_of_chat, remaining_tool_calls) if start_of_chat then local sticky = {} if M.config.sticky then @@ -124,8 +160,11 @@ local function finish(start_of_chat) end local prompt_content = '' - local assistant_message = M.chat:get_message(constants.ROLE.ASSISTANT) - local tool_calls = assistant_message and assistant_message.tool_calls or {} + local tool_calls = remaining_tool_calls + if not tool_calls then + local assistant_message = M.chat:get_message(constants.ROLE.ASSISTANT) + tool_calls = assistant_message and assistant_message.tool_calls or {} + end local current_sticky = M.chat:get_sticky() if not utils.empty(current_sticky) then @@ -242,10 +281,18 @@ function M.open(config) local message = M.chat:get_message(constants.ROLE.USER) if message then local clean_prompt = process_sticky(message.content, config) + local stickies = M.chat:get_sticky() + local content = '' + if not vim.tbl_isempty(stickies) then + content = '\n> ' .. table.concat(stickies, '\n> ') .. '\n\n' + end if clean_prompt and clean_prompt ~= '' then + content = content .. clean_prompt + end + if content ~= '' then M.chat:add_message({ role = constants.ROLE.USER, - content = '\n> ' .. table.concat(M.chat:get_sticky(), '\n> ') .. '\n\n' .. clean_prompt, + content = content, }, true) end end @@ -296,6 +343,7 @@ function M.select_model() streaming = model.streaming, tools = model.tools, reasoning = model.reasoning, + multiplier = model.multiplier, selected = model.id == M.config.model, } end, models) @@ -311,6 +359,9 @@ function M.select_model() out = '* ' .. out end + if item.multiplier ~= nil then + table.insert(indicators, 'x' .. tostring(item.multiplier)) + end if item.provider then table.insert(indicators, item.provider) end @@ -341,8 +392,8 @@ end --- Select a prompt template to use. ---@param config CopilotChat.config.Shared? function M.select_prompt(config) - local prompts = prompts.list_prompts() - local keys = vim.tbl_keys(prompts) + local prompt_list = prompts.list_prompts() + local keys = vim.tbl_keys(prompt_list) table.sort(keys) local choices = vim @@ -350,8 +401,8 @@ function M.select_prompt(config) :map(function(name) return { name = name, - description = prompts[name].description, - prompt = prompts[name].prompt, + description = prompt_list[name].description, + prompt = prompt_list[name].prompt, } end) :filter(function(choice) @@ -366,7 +417,7 @@ function M.select_prompt(config) end, }, function(choice) if choice then - M.ask(prompts[choice.name].prompt, vim.tbl_extend('force', prompts[choice.name], config or {})) + M.ask(prompt_list[choice.name].prompt, vim.tbl_extend('force', prompt_list[choice.name], config or {})) end end) end @@ -418,16 +469,9 @@ function M.ask(prompt, config) config, prompt = prompts.resolve_prompt(prompt, config) local system_prompt = config.system_prompt or '' local selected_tools, prompt = prompts.resolve_tools(prompt, config) - local resolved_resources, resolved_tools, resolved_stickies, prompt = prompts.resolve_functions(prompt, config) + local resolved_resources, resolved_tools, prompt = prompts.resolve_functions(prompt, config) local selected_model, prompt = prompts.resolve_model(prompt, config) - -- Store resolved stickies to chat - local current_sticky = M.chat:get_sticky() - for _, sticky in ipairs(resolved_stickies) do - table.insert(current_sticky, sticky) - end - M.chat:set_sticky(current_sticky) - prompt = vim.trim(prompt) if not config.headless then @@ -534,6 +578,94 @@ function M.ask(prompt, config) M.chat:add_message(response, true) M.chat.token_count = token_count M.chat.token_max_count = token_max_count + + -- Execute trusted tool calls automatically + if response.tool_calls and #response.tool_calls > 0 then + local trusted_tool_calls = {} + local untrusted_tool_calls = {} + + for _, tool_call in ipairs(response.tool_calls) do + if is_trusted_tool(config, tool_call.name) then + table.insert(trusted_tool_calls, tool_call) + else + table.insert(untrusted_tool_calls, tool_call) + end + end + + if #trusted_tool_calls > 0 then + async.run(handle_error(config, function() + local trusted_tool_results = {} + local source = M.chat:get_source() + + for _, tool_call in ipairs(trusted_tool_calls) do + local input = {} + if not utils.empty(tool_call.arguments) then + input = utils.json_decode(tool_call.arguments) + end + + local ok, output = prompts.execute_tool_call(tool_call.name, input, config, source) + local result = prompts.format_tool_output(ok, output) + + table.insert(trusted_tool_results, { + id = tool_call.id, + result = result, + }) + end + + if not utils.empty(trusted_tool_results) then + utils.schedule_main() + for _, tool in ipairs(trusted_tool_results) do + M.chat:add_message({ + id = tool.id, + role = constants.ROLE.TOOL, + tool_call_id = tool.id, + content = '\n' .. tool.result .. '\n', + }) + end + + if #untrusted_tool_calls > 0 then + finish(nil, untrusted_tool_calls) + else + local continue_response = client:ask({ + headless = config.headless, + history = M.chat:get_messages(), + resources = resolved_resources, + tools = selected_tools, + system_prompt = system_prompt, + model = selected_model, + temperature = config.temperature, + on_progress = vim.schedule_wrap(function(message) + if not config.headless then + M.chat:add_message(message) + end + end), + }) + + if continue_response then + local continue_message = continue_response.message + continue_message.content = vim.trim(continue_message.content) + if utils.empty(continue_message.content) then + continue_message.content = '' + else + continue_message.content = '\n' .. continue_message.content .. '\n' + end + + utils.schedule_main() + M.chat:add_message(continue_message, true) + M.chat.token_count = continue_response.token_count + M.chat.token_max_count = continue_response.token_max_count + end + + finish() + end + else + finish() + end + end)) + return + end + end + finish() end end)) @@ -688,6 +820,7 @@ function M.setup(config) end) -- Initialize chat + require('CopilotChat.utils.notify').clear() if M.chat then M.chat:close() M.chat:delete() diff --git a/lua/CopilotChat/prompts.lua b/lua/CopilotChat/prompts.lua index 21eb6cd4..7c4e60ce 100644 --- a/lua/CopilotChat/prompts.lua +++ b/lua/CopilotChat/prompts.lua @@ -1,8 +1,9 @@ local client = require('CopilotChat.client') local constants = require('CopilotChat.constants') local functions = require('CopilotChat.functions') -local notify = require('CopilotChat.notify') +local notify = require('CopilotChat.utils.notify') local files = require('CopilotChat.utils.files') +local orderedmap = require('CopilotChat.utils.orderedmap') local utils = require('CopilotChat.utils') local WORD = '([^%s:]+)' @@ -12,16 +13,22 @@ local WORD_WITH_INPUT_UNQUOTED = WORD .. ':?([^%s`]*)' --- Find custom instructions in the current working directory. ---@param cwd string +---@param config CopilotChat.config.Config ---@return table -local function find_custom_instructions(cwd) +local function find_custom_instructions(cwd, config) local out = {} - local copilot_instructions_path = vim.fs.joinpath(cwd, '.github', 'copilot-instructions.md') - local copilot_instructions = files.read_file(copilot_instructions_path) - if copilot_instructions then - table.insert(out, { - filename = copilot_instructions_path, - content = vim.trim(copilot_instructions), - }) + local files_to_check = {} + for _, relpath in ipairs(config.instruction_files or {}) do + table.insert(files_to_check, vim.fs.joinpath(cwd, relpath)) + end + for _, path in ipairs(files_to_check) do + local content = files.read_file(path) + if content then + table.insert(out, { + filename = path, + content = vim.trim(content), + }) + end end return out end @@ -60,7 +67,7 @@ function M.resolve_tools(prompt, config) tools[tool.name] = tool end - local enabled_tools = {} + local enabled_tools = orderedmap() local tool_matches = utils.to_table(config.tools) -- Check for @tool pattern to find enabled tools @@ -76,18 +83,75 @@ function M.resolve_tools(prompt, config) for _, match in ipairs(tool_matches) do for name, tool in pairs(config.functions) do if name == match or tool.group == match then - table.insert(enabled_tools, tools[name]) + enabled_tools:set(name, tools[name]) end end end - return enabled_tools, prompt + return enabled_tools:values(), prompt +end + +--- Execute a tool call and return the raw output. +---@param name string Tool name +---@param input table|string Input arguments +---@param config CopilotChat.config.Shared +---@param source CopilotChat.client.Source +---@return boolean ok +---@return any output +---@async +function M.execute_tool_call(name, input, config, source) + local tool = config.functions[name] + if not tool or not tool.resolve then + return false, 'Tool not found: ' .. name + end + + local schema = nil + for _, t in ipairs(functions.parse_tools(config.functions)) do + if t.name == name then + schema = t.schema + break + end + end + + local ok, output + if config.stop_on_function_failure then + output = tool.resolve(functions.parse_input(input, schema), source) + ok = true + else + ok, output = pcall(tool.resolve, functions.parse_input(input, schema), source) + end + + return ok, output +end + +--- Format tool output as plain text. +---@param ok boolean +---@param output any +---@return string +function M.format_tool_output(ok, output) + local result = '' + if not ok then + result = utils.make_string(output) + elseif type(output) ~= 'table' then + result = utils.make_string(output) + else + for _, content in ipairs(output) do + if content then + local data = content.data or content.uri + if data then + result = result .. (utils.empty(result) and '' or '\n') .. data + end + end + end + end + + return result end --- Call and resolve function calls from the prompt. ---@param prompt string? ---@param config CopilotChat.config.Shared? ----@return table, table, table, string +---@return table, table, string ---@async function M.resolve_functions(prompt, config) config, prompt = M.resolve_prompt(prompt, config) @@ -95,11 +159,6 @@ function M.resolve_functions(prompt, config) local chat = require('CopilotChat').chat local source = chat:get_source() - local tools = {} - for _, tool in ipairs(functions.parse_tools(config.functions)) do - tools[tool.name] = tool - end - if config.resources then local resources = utils.to_table(config.resources) local lines = utils.split_lines(prompt) @@ -112,7 +171,6 @@ function M.resolve_functions(prompt, config) local resolved_resources = {} local resolved_tools = {} - local resolved_stickies = {} local tool_calls = {} utils.schedule_main() @@ -192,58 +250,51 @@ function M.resolve_functions(prompt, config) return nil end - local schema = tools[name] and tools[name].schema or nil - local ok, output - if config.stop_on_function_failure then - output = tool.resolve(functions.parse_input(input, schema), source) - ok = true - else - ok, output = pcall(tool.resolve, functions.parse_input(input, schema), source) + local ok, output = M.execute_tool_call(name, input, config, source) + + if tool_id then + table.insert(resolved_tools, { + id = tool_id, + result = M.format_tool_output(ok, output), + }) + + return '' end - local result = '' if not ok then - result = utils.make_string(output) - else - for _, content in ipairs(output) do - if content then - local content_out = nil - if content.uri then - if - not vim.tbl_contains(resolved_resources, function(resource) - return resource.uri == content.uri - end, { predicate = true }) - then - content_out = '##' .. content.uri - table.insert(resolved_resources, content) - end - - if tool_id then - table.insert(resolved_stickies, '##' .. content.uri) - end - else - content_out = content.data + return utils.make_string(output) + end + + if type(output) ~= 'table' then + return utils.make_string(output) + end + + local result = '' + for _, content in ipairs(output) do + if content then + local content_out = nil + if content.uri then + if + not vim.tbl_contains(resolved_resources, function(resource) + return resource.uri == content.uri + end, { predicate = true }) + then + content_out = '##' .. content.uri + table.insert(resolved_resources, content) end + else + content_out = content.data + end - if content_out then - if not utils.empty(result) then - result = result .. '\n' - end - result = result .. content_out + if content_out then + if not utils.empty(result) then + result = result .. '\n' end + result = result .. content_out end end end - if tool_id then - table.insert(resolved_tools, { - id = tool_id, - result = result, - }) - - return '' - end - return result end @@ -259,7 +310,7 @@ function M.resolve_functions(prompt, config) end end - return resolved_resources, resolved_tools, resolved_stickies, prompt + return resolved_resources, resolved_tools, prompt end --- Resolve the final prompt and config from prompt template. @@ -314,7 +365,7 @@ function M.resolve_prompt(prompt, config) end local custom_instructions = vim.trim(require('CopilotChat.instructions.custom_instructions')) - for _, instruction in ipairs(find_custom_instructions(source.cwd())) do + for _, instruction in ipairs(find_custom_instructions(source.cwd(), config)) do config.system_prompt = vim.trim(config.system_prompt) .. '\n' .. custom_instructions:gsub('{FILENAME}', instruction.filename):gsub('{CONTENT}', instruction.content) @@ -337,7 +388,7 @@ function M.resolve_prompt(prompt, config) config.system_prompt = config.system_prompt:gsub('{OS_NAME}', vim.uv.os_uname().sysname) config.system_prompt = config.system_prompt:gsub('{LANGUAGE}', config.language) - config.system_prompt = config.system_prompt:gsub('{DIR}', source.cwd()) + config.system_prompt = config.system_prompt:gsub('{DIR}', source.cwd) end return config, prompt diff --git a/lua/CopilotChat/resources.lua b/lua/CopilotChat/resources.lua index 57e12e33..22c97c4b 100644 --- a/lua/CopilotChat/resources.lua +++ b/lua/CopilotChat/resources.lua @@ -27,6 +27,10 @@ function M.get_file(filename) if not content or content == '' then return nil end + -- Simple binary detection: reject files with null bytes + if content:find('\0') then + return nil + end data = { content = content, _modified = modified, diff --git a/lua/CopilotChat/select.lua b/lua/CopilotChat/select.lua index 425bf2a5..84722e9d 100644 --- a/lua/CopilotChat/select.lua +++ b/lua/CopilotChat/select.lua @@ -1,10 +1,10 @@ ---@class CopilotChat.select.Selection ---@field content string ----@field start_line number ----@field end_line number +---@field start_line integer +---@field end_line integer ---@field filename string ---@field filetype string ----@field bufnr number +---@field bufnr integer local log = require('plenary.log') local utils = require('CopilotChat.utils') @@ -51,7 +51,7 @@ function M.marks() end --- Highlight selection in target buffer or clear it ----@param bufnr number +---@param bufnr integer ---@param clear boolean? function M.highlight(bufnr, clear) local selection_ns = vim.api.nvim_create_namespace('copilot-chat-selection') @@ -76,7 +76,7 @@ function M.highlight(bufnr, clear) end --- Get the selection from the target buffer ----@param bufnr number +---@param bufnr integer ---@return CopilotChat.select.Selection? function M.get(bufnr) if not utils.buf_valid(bufnr) then @@ -113,10 +113,10 @@ function M.get(bufnr) end --- Sets the selection to specific lines in buffer or clears it ----@param bufnr number ----@param winnr number? ----@param start_line number? ----@param end_line number? +---@param bufnr integer +---@param winnr integer? +---@param start_line integer? +---@param end_line integer? function M.set(bufnr, winnr, start_line, end_line) if not utils.buf_valid(bufnr) then return diff --git a/lua/CopilotChat/tiktoken.lua b/lua/CopilotChat/tiktoken.lua index abe1ce1d..f7ea0de7 100644 --- a/lua/CopilotChat/tiktoken.lua +++ b/lua/CopilotChat/tiktoken.lua @@ -1,4 +1,4 @@ -local notify = require('CopilotChat.notify') +local notify = require('CopilotChat.utils.notify') local utils = require('CopilotChat.utils') local curl = require('CopilotChat.utils.curl') local class = require('CopilotChat.utils.class') diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index 0d2e9bc5..dba7bfbd 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -1,7 +1,7 @@ local Overlay = require('CopilotChat.ui.overlay') local Spinner = require('CopilotChat.ui.spinner') local constants = require('CopilotChat.constants') -local notify = require('CopilotChat.notify') +local notify = require('CopilotChat.utils.notify') local utils = require('CopilotChat.utils') local class = require('CopilotChat.utils.class') local orderedmap = require('CopilotChat.utils.orderedmap') @@ -39,6 +39,7 @@ local function match_block_header(header) end local patterns = { + '^(%w+)%s+path=(.-)%s+start_line=(%d+)%s+end_line=(%d+)$', '^(%w+)%s+path=(%S+)%s+start_line=(%d+)%s+end_line=(%d+)$', '^(%w+)$', } @@ -98,21 +99,21 @@ end ---@field content string ---@class CopilotChat.ui.chat.Section ----@field start_line number ----@field end_line number ----@field blocks table +---@field start_line integer +---@field end_line integer +---@field blocks CopilotChat.ui.chat.Block[] ---@class CopilotChat.ui.chat.Message : CopilotChat.client.Message ---@field id string? ---@field section CopilotChat.ui.chat.Section? --- @class CopilotChat.ui.chat.Source ---- @field bufnr number? ---- @field winnr number? +--- @field bufnr integer? +--- @field winnr integer? --- @field cwd fun():string ---@class CopilotChat.ui.chat.Chat : CopilotChat.ui.overlay.Overlay ----@field winnr number? +---@field winnr integer? ---@field config CopilotChat.config.Shared ---@field token_count number? ---@field token_max_count number? @@ -124,7 +125,7 @@ end ---@field private chat_overlay CopilotChat.ui.overlay.Overlay ---@field private last_changedtick number? ---@field private source CopilotChat.ui.chat.Source ----@field private sticky table +---@field private sticky string[] local Chat = class(function(self, config, on_buf_create) Overlay.init(self, 'copilot-chat', utils.key_to_info('show_help', config.mappings.show_help), on_buf_create) @@ -160,7 +161,7 @@ local Chat = class(function(self, config, on_buf_create) function(bufnr) vim.keymap.set('n', config.mappings.close.normal, function() self.chat_overlay:restore(self.winnr, self.bufnr) - end) + end, { buffer = bufnr }) vim.api.nvim_create_autocmd({ 'BufHidden', 'BufDelete' }, { buffer = bufnr, @@ -242,7 +243,7 @@ function Chat:get_block(role, cursor) end --- Get list of all chat messages ----@return table +---@return CopilotChat.ui.chat.Message[] function Chat:get_messages() self:parse() return self.messages:values() @@ -268,7 +269,12 @@ function Chat:get_message(role, cursor) for _, message in ipairs(messages) do local section = message.section local matches_role = not role or message.role == role - if matches_role and section.start_line <= cursor_line and section.start_line > max_line_below_cursor then + if + matches_role + and section + and section.start_line <= cursor_line + and section.start_line > max_line_below_cursor + then max_line_below_cursor = section.start_line closest_message = message end @@ -287,13 +293,13 @@ function Chat:get_message(role, cursor) end --- Get the current sticky array. ----@return table +---@return string[] function Chat:get_sticky() return self.sticky end --- Set the sticky array. ----@param sticky table +---@param sticky string[] function Chat:set_sticky(sticky) self.sticky = sticky end @@ -604,7 +610,7 @@ function Chat:create() end end) - vim.api.nvim_create_autocmd({ 'TextChanged', 'InsertLeave' }, { + vim.api.nvim_create_autocmd({ 'TextChanged', 'InsertEnter', 'InsertLeave' }, { buffer = bufnr, callback = function() utils.debounce('chat-parse-' .. bufnr, function() @@ -734,7 +740,7 @@ function Chat:parse() message.content = vim.trim(table.concat(message.content, '\n')) if message.section then for _, block in ipairs(message.section.blocks) do - block.content = vim.trim(table.concat(block.content, '\n')) + block.content = table.concat(block.content, '\n') end end @@ -770,6 +776,9 @@ function Chat:render() -- Overlay section header with nice display local header_value = self.headers[message.role] local header_line = message.section.start_line - 2 + if message.model then + header_value = header_value .. ' (' .. message.model .. ')' + end vim.api.nvim_buf_set_extmark(self.bufnr, highlight_ns, header_line, 0, { conceal = '', @@ -859,10 +868,10 @@ function Chat:render() -- Highlight tools in the last user message local assistant_msg = self:get_message(constants.ROLE.ASSISTANT) if assistant_msg and assistant_msg.tool_calls and #assistant_msg.tool_calls > 0 then - for i, line in ipairs(utils.split_lines(message.content)) do + for j, line in ipairs(utils.split_lines(message.content)) do for _, tool_call in ipairs(assistant_msg.tool_calls) do if line:match(string.format('#%s:%s', tool_call.name, vim.pesc(tool_call.id))) then - local l = message.section.start_line + i + local l = message.section.start_line + j vim.api.nvim_buf_add_highlight(self.bufnr, highlight_ns, 'CopilotChatAnnotationHeader', l, 0, #line) if not utils.empty(tool_call.arguments) then vim.api.nvim_buf_set_extmark(self.bufnr, highlight_ns, l, 0, { diff --git a/lua/CopilotChat/ui/overlay.lua b/lua/CopilotChat/ui/overlay.lua index 547394b8..ace646c4 100644 --- a/lua/CopilotChat/ui/overlay.lua +++ b/lua/CopilotChat/ui/overlay.lua @@ -2,7 +2,7 @@ local utils = require('CopilotChat.utils') local class = require('CopilotChat.utils.class') ---@class CopilotChat.ui.overlay.Overlay : Class ----@field bufnr number? +---@field bufnr integer? ---@field protected name string ---@field protected help string ---@field private cursor integer[]? @@ -23,11 +23,11 @@ end) --- Show the overlay buffer ---@param text string ----@param winnr number +---@param winnr integer ---@param filetype? string ---@param syntax string? ----@param on_show? fun(bufnr: number) ----@param on_hide? fun(bufnr: number) +---@param on_show? fun(bufnr: integer) +---@param on_hide? fun(bufnr: integer) function Overlay:show(text, winnr, filetype, syntax, on_show, on_hide) if not text or text == '' then return @@ -75,7 +75,7 @@ function Overlay:delete() end --- Create the overlay buffer ----@return number +---@return integer ---@protected function Overlay:create() local bufnr = vim.api.nvim_create_buf(false, true) @@ -116,6 +116,10 @@ function Overlay:restore(winnr, bufnr) self.on_hide(self.bufnr) end + if not vim.api.nvim_win_is_valid(winnr) then + return + end + vim.api.nvim_win_set_buf(winnr, bufnr) if self.cursor then @@ -124,7 +128,9 @@ function Overlay:restore(winnr, bufnr) -- Manually trigger BufEnter event as nvim_win_set_buf does not trigger it vim.schedule(function() - vim.cmd(string.format('doautocmd BufEnter %s', bufnr)) + if vim.api.nvim_buf_is_valid(bufnr) then + vim.api.nvim_exec_autocmds('BufEnter', { buffer = bufnr }) + end end) end diff --git a/lua/CopilotChat/ui/spinner.lua b/lua/CopilotChat/ui/spinner.lua index 44c77b80..06091a16 100644 --- a/lua/CopilotChat/ui/spinner.lua +++ b/lua/CopilotChat/ui/spinner.lua @@ -1,4 +1,4 @@ -local notify = require('CopilotChat.notify') +local notify = require('CopilotChat.utils.notify') local utils = require('CopilotChat.utils') local class = require('CopilotChat.utils.class') diff --git a/lua/CopilotChat/utils/curl.lua b/lua/CopilotChat/utils/curl.lua index 87e9b89d..2c2cf60e 100644 --- a/lua/CopilotChat/utils/curl.lua +++ b/lua/CopilotChat/utils/curl.lua @@ -48,7 +48,10 @@ M.get = async.wrap(function(url, opts, callback) args.callback = function(response) log.debug('GET response:', response) - if response and not vim.startswith(tostring(response.status), '20') then + -- HTTP status codes: 1xx (informational), 2xx (success) + -- Status 100 (Continue) is common with streaming responses + local status_str = tostring(response.status) + if response and not vim.startswith(status_str, '1') and not vim.startswith(status_str, '20') then callback(response, response.body) return end @@ -96,7 +99,10 @@ M.post = async.wrap(function(url, opts, callback) log.debug('Failed to remove temp file:', temp_file_path, err) end end - if response and not vim.startswith(tostring(response.status), '20') then + -- HTTP status codes: 1xx (informational), 2xx (success) + -- Status 100 (Continue) is common with streaming responses + local status_str = tostring(response.status) + if response and not vim.startswith(status_str, '1') and not vim.startswith(status_str, '20') then callback(response, response.body) return end diff --git a/lua/CopilotChat/utils/diff.lua b/lua/CopilotChat/utils/diff.lua index 17eb3091..6a2384a6 100644 --- a/lua/CopilotChat/utils/diff.lua +++ b/lua/CopilotChat/utils/diff.lua @@ -16,9 +16,9 @@ local function parse_hunks(diff_text) local start_old, len_old, start_new, len_new = line:match('@@%s%-(%d+),?(%d*)%s%+(%d+),?(%d*)%s@@') current_hunk = { start_old = tonumber(start_old), - len_old = tonumber(len_old) or 1, + len_old = len_old == '' and 1 or tonumber(len_old), start_new = tonumber(start_new), - len_new = tonumber(len_new) or 1, + len_new = len_new == '' and 1 or tonumber(len_new), old_snippet = {}, new_snippet = {}, } @@ -40,80 +40,144 @@ local function parse_hunks(diff_text) return hunks end ---- Apply a single hunk to content, with fallback/context logic +--- Try to match old_snippet in lines starting at approximate start_line +---@param lines table +---@param old_snippet table +---@param approx_start number +---@param search_range number +---@return number? matched_start +local function find_best_match(lines, old_snippet, approx_start, search_range) + local best_idx, best_score = nil, -1 + local old_len = #old_snippet + + if old_len == 0 then + return approx_start + end + + local min_start = math.max(1, approx_start - search_range) + local max_start = math.min(#lines - old_len + 1, approx_start + search_range) + + for start_idx = min_start, max_start do + local score = 0 + for i = 1, old_len do + if vim.trim(lines[start_idx + i - 1] or '') == vim.trim(old_snippet[i] or '') then + score = score + 1 + end + end + + if score > best_score then + best_score = score + best_idx = start_idx + end + + if score == old_len then + return best_idx + end + end + + if best_score >= math.ceil(old_len * 0.8) then + return best_idx + end + + return nil +end + +--- Apply a single hunk to content ---@param hunk table ---@param content string ---@return string patched_content, boolean applied_cleanly local function apply_hunk(hunk, content) - local dmp = require('CopilotChat.vendor.diff_match_patch') - local patch = dmp.patch_make(table.concat(hunk.old_snippet, '\n'), table.concat(hunk.new_snippet, '\n')) + local lines = vim.split(content, '\n') + local start_idx = hunk.start_old - -- First try: direct application - local patched, results = dmp.patch_apply(patch, content) - if not vim.tbl_contains(results, false) then - return patched, true + -- Handle insertions (len_old == 0) + if hunk.len_old == 0 then + -- For insertions, start_old indicates where to insert + -- start_old = 0 means insert at beginning + -- start_old = n means insert after line n + if start_idx == 0 then + start_idx = 1 + else + start_idx = start_idx + 1 + end + local new_lines = vim.list_slice(lines, 1, start_idx - 1) + vim.list_extend(new_lines, hunk.new_snippet) + vim.list_extend(new_lines, lines, start_idx, #lines) + -- Insertions are always applied cleanly if we reach this point + return table.concat(new_lines, '\n'), true end - -- Fallback: direct replacement - local lines = vim.split(content, '\n') - local insert_idx = hunk.start_old or 1 - if not hunk.start_old then - -- No starting point, try to find best match - local match_idx, best_score = nil, -1 - local context_lines = vim.tbl_filter(function(line) - return line and line ~= '' - end, hunk.old_snippet) - local context_len = #context_lines - if context_len > 0 then - for i = 1, #lines - context_len + 1 do - local score = 0 - for j = 1, context_len do - if vim.trim(lines[i + j - 1] or '') == vim.trim(context_lines[j] or '') then - score = score + 1 - end - end - if score > best_score then - best_score = score - match_idx = i - end - end + -- Handle replacements and deletions (len_old > 0) + -- If we have a start line hint, try to find best match within +/- 2 lines + if start_idx and start_idx > 0 and start_idx <= #lines then + local match_idx = find_best_match(lines, hunk.old_snippet, start_idx, 2) + if match_idx then + start_idx = match_idx end - if best_score > 0 and match_idx then - insert_idx = match_idx + else + -- No valid start line, search for best match in whole content + local match_idx = find_best_match(lines, hunk.old_snippet, 1, #lines) + if match_idx then + start_idx = match_idx + else + start_idx = 1 end end - local start_idx = insert_idx - local end_idx = insert_idx + #hunk.old_snippet + -- Replace old lines with new lines + local end_idx = start_idx + #hunk.old_snippet - 1 local new_lines = vim.list_slice(lines, 1, start_idx - 1) vim.list_extend(new_lines, hunk.new_snippet) vim.list_extend(new_lines, lines, end_idx + 1, #lines) - return table.concat(new_lines, '\n'), false + + -- Check if we matched exactly at the hinted position + local applied_cleanly = find_best_match(lines, hunk.old_snippet, hunk.start_old or start_idx, 0) == start_idx + return table.concat(new_lines, '\n'), applied_cleanly end --- Apply unified diff to a table of lines and return new lines ---@param diff_text string ---@param original_content string ----@return table, boolean, integer, integer +---@return string[], boolean, integer?, integer? function M.apply_unified_diff(diff_text, original_content) local hunks = parse_hunks(diff_text) local new_content = original_content local applied = false + local offset = 0 -- Track cumulative line offset from previous hunks + for _, hunk in ipairs(hunks) do - local patched, ok = apply_hunk(hunk, new_content) + -- Adjust hunk start position based on accumulated offset + local adjusted_hunk = vim.deepcopy(hunk) + if adjusted_hunk.start_old then + adjusted_hunk.start_old = hunk.start_old + offset + end + + local patched, ok = apply_hunk(adjusted_hunk, new_content) new_content = patched applied = applied or ok + + -- Update offset: (new lines added) - (old lines removed) + offset = offset + (#hunk.new_snippet - #hunk.old_snippet) end - local original_lines = vim.split(original_content, '\n', { trimempty = true }) + local new_lines = vim.split(new_content, '\n', { trimempty = true }) + local diff_hunks = vim.diff( + original_content, + new_content, + { algorithm = 'myers', ctxlen = 10, interhunkctxlen = 10, ignore_whitespace_change = true, result_type = 'indices' } + ) + if not diff_hunks or #diff_hunks == 0 then + return new_lines, applied, nil, nil + end local first, last - local max_len = math.max(#original_lines, #new_lines) - for i = 1, max_len do - if original_lines[i] ~= new_lines[i] then - if not first then - first = i - end - last = i + for _, hunk in ipairs(diff_hunks) do + local hunk_start = hunk[1] + local hunk_end = hunk[1] + hunk[2] - 1 + if not first or hunk_start < first then + first = hunk_start + end + if not last or hunk_end > last then + last = hunk_end end end return new_lines, applied, first, last @@ -129,7 +193,7 @@ function M.get_diff(block, lines) return block.content, content end - local patched_lines = vim.split(block.content, '\n') + local patched_lines = vim.split(block.content, '\n', { trimempty = true }) local start_idx = block.header.start_line local end_idx = block.header.end_line local original_lines = lines diff --git a/lua/CopilotChat/utils/files.lua b/lua/CopilotChat/utils/files.lua index 28184585..24b7b003 100644 --- a/lua/CopilotChat/utils/files.lua +++ b/lua/CopilotChat/utils/files.lua @@ -9,23 +9,11 @@ M.scan_args = { } local function filter_files(files, max_count) - local filetype = require('plenary.filetype') - + -- Filter out empty entries files = vim.tbl_filter(function(file) - if file == nil or file == '' then - return false - end - - local ft = filetype.detect(file, { - fs_access = false, - }) - - if ft == '' or not ft then - return false - end - - return true + return file ~= nil and file ~= '' end, files) + if max_count and max_count > 0 then files = vim.list_slice(files, 1, max_count) end @@ -176,7 +164,7 @@ M.grep = async.wrap(function(path, opts, callback) if opts.pattern then table.insert(cmd, '-e') - table.insert(cmd, "'" .. opts.pattern .. "'") + table.insert(cmd, opts.pattern) end elseif vim.fn.executable('grep') == 1 then table.insert(cmd, 'grep') @@ -184,7 +172,7 @@ M.grep = async.wrap(function(path, opts, callback) if opts.pattern then table.insert(cmd, '-e') - table.insert(cmd, "'" .. opts.pattern .. "'") + table.insert(cmd, opts.pattern) end end @@ -268,7 +256,13 @@ function M.filetype(filename) }) if ft == '' or not ft and not vim.in_fast_event() then - return vim.filetype.match({ filename = filename }) + ft = vim.filetype.match({ filename = filename }) + end + + -- If filetype still not detected, default to 'text' + -- Let content validation handle whether it's actually readable + if not ft or ft == '' then + return 'text' end return ft diff --git a/lua/CopilotChat/notify.lua b/lua/CopilotChat/utils/notify.lua similarity index 91% rename from lua/CopilotChat/notify.lua rename to lua/CopilotChat/utils/notify.lua index 99aa499a..b15b209a 100644 --- a/lua/CopilotChat/notify.lua +++ b/lua/CopilotChat/utils/notify.lua @@ -32,4 +32,9 @@ function M.listen(event_name, callback) table.insert(M.listeners[event_name], callback) end +--- Clear all listeners +function M.clear() + M.listeners = {} +end + return M diff --git a/lua/CopilotChat/vendor/diff_match_patch.lua b/lua/CopilotChat/vendor/diff_match_patch.lua deleted file mode 100644 index b2c397d0..00000000 --- a/lua/CopilotChat/vendor/diff_match_patch.lua +++ /dev/null @@ -1,2085 +0,0 @@ ---[[ -* Diff Match and Patch -* Copyright 2018 The diff-match-patch Authors. -* https://github.com/google/diff-match-patch -* -* Based on the JavaScript implementation by Neil Fraser. -* Ported to Lua by Duncan Cross. -* -* Licensed under the Apache License, Version 2.0 (the "License"); -* you may not use this file except in compliance with the License. -* You may obtain a copy of the License at -* -* http://www.apache.org/licenses/LICENSE-2.0 -* -* Unless required by applicable law or agreed to in writing, software -* distributed under the License is distributed on an "AS IS" BASIS, -* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -* See the License for the specific language governing permissions and -* limitations under the License. ---]] - -local bit = require('bit') -local band, bor, lshift = bit.band, bit.bor, bit.lshift -local type, setmetatable, ipairs, select = type, setmetatable, ipairs, select -local unpack, tonumber, error = unpack, tonumber, error -local strsub, strbyte, strchar, gmatch, gsub = string.sub, string.byte, string.char, string.gmatch, string.gsub -local strmatch, strfind, strformat = string.match, string.find, string.format -local tinsert, tremove, tconcat = table.insert, table.remove, table.concat -local max, min, floor, ceil, abs = math.max, math.min, math.floor, math.ceil, math.abs -local clock = os.clock - --- Utility functions. - -local percentEncode_pattern = "[^A-Za-z0-9%-=;',./~!@#$%&*%(%)_%+ %?]" -local function percentEncode_replace(v) - return strformat('%%%02X', strbyte(v)) -end - -local function indexOf(a, b, start) - if #b == 0 then - return nil - end - return strfind(a, b, start, true) -end - -local htmlEncode_pattern = '[&<>\n]' -local htmlEncode_replace = { - ['&'] = '&', - ['<'] = '<', - ['>'] = '>', - ['\n'] = '¶
', -} - --- Public API Functions --- (Exported at the end of the script) - -local diff_main, diff_cleanupSemantic, diff_cleanupEfficiency, diff_levenshtein, diff_prettyHtml - -local match_main - -local patch_make, patch_toText, patch_fromText, patch_apply - ---[[ -* The data structure representing a diff is an array of tuples: -* {{DIFF_DELETE, 'Hello'}, {DIFF_INSERT, 'Goodbye'}, {DIFF_EQUAL, ' world.'}} -* which means: delete 'Hello', add 'Goodbye' and keep ' world.' ---]] -local DIFF_DELETE = -1 -local DIFF_INSERT = 1 -local DIFF_EQUAL = 0 - --- Number of seconds to map a diff before giving up (0 for infinity). -local Diff_Timeout = 1.0 --- Cost of an empty edit operation in terms of edit characters. -local Diff_EditCost = 4 --- At what point is no match declared (0.0 = perfection, 1.0 = very loose). -local Match_Threshold = 0.5 --- How far to search for a match (0 = exact location, 1000+ = broad match). --- A match this many characters away from the expected location will add --- 1.0 to the score (0.0 is a perfect match). -local Match_Distance = 1000 --- When deleting a large block of text (over ~64 characters), how close do --- the contents have to be to match the expected contents. (0.0 = perfection, --- 1.0 = very loose). Note that Match_Threshold controls how closely the --- end points of a delete need to match. -local Patch_DeleteThreshold = 0.5 --- Chunk size for context length. -local Patch_Margin = 4 --- The number of bits in an int. -local Match_MaxBits = 32 - -function settings(new) - if new then - Diff_Timeout = new.Diff_Timeout or Diff_Timeout - Diff_EditCost = new.Diff_EditCost or Diff_EditCost - Match_Threshold = new.Match_Threshold or Match_Threshold - Match_Distance = new.Match_Distance or Match_Distance - Patch_DeleteThreshold = new.Patch_DeleteThreshold or Patch_DeleteThreshold - Patch_Margin = new.Patch_Margin or Patch_Margin - Match_MaxBits = new.Match_MaxBits or Match_MaxBits - else - return { - Diff_Timeout = Diff_Timeout, - Diff_EditCost = Diff_EditCost, - Match_Threshold = Match_Threshold, - Match_Distance = Match_Distance, - Patch_DeleteThreshold = Patch_DeleteThreshold, - Patch_Margin = Patch_Margin, - Match_MaxBits = Match_MaxBits, - } - end -end - --- --------------------------------------------------------------------------- --- DIFF API --- --------------------------------------------------------------------------- - --- The private diff functions -local _diff_compute, _diff_bisect, _diff_halfMatchI, _diff_halfMatch, _diff_cleanupSemanticScore, _diff_cleanupSemanticLossless, _diff_cleanupMerge, _diff_commonPrefix, _diff_commonSuffix, _diff_commonOverlap, _diff_xIndex, _diff_text1, _diff_text2, _diff_toDelta, _diff_fromDelta - ---[[ -* Find the differences between two texts. Simplifies the problem by stripping -* any common prefix or suffix off the texts before diffing. -* @param {string} text1 Old string to be diffed. -* @param {string} text2 New string to be diffed. -* @param {boolean} opt_checklines Has no effect in Lua. -* @param {number} opt_deadline Optional time when the diff should be complete -* by. Used internally for recursive calls. Users should set DiffTimeout -* instead. -* @return {Array.>} Array of diff tuples. ---]] -function diff_main(text1, text2, opt_checklines, opt_deadline) - -- Set a deadline by which time the diff must be complete. - if opt_deadline == nil then - if Diff_Timeout <= 0 then - opt_deadline = 2 ^ 31 - else - opt_deadline = clock() + Diff_Timeout - end - end - local deadline = opt_deadline - - -- Check for null inputs. - if text1 == nil or text1 == nil then - error('Null inputs. (diff_main)') - end - - -- Check for equality (speedup). - if text1 == text2 then - if #text1 > 0 then - return { { DIFF_EQUAL, text1 } } - end - return {} - end - - -- LUANOTE: Due to the lack of Unicode support, Lua is incapable of - -- implementing the line-mode speedup. - local checklines = false - - -- Trim off common prefix (speedup). - local commonlength = _diff_commonPrefix(text1, text2) - local commonprefix - if commonlength > 0 then - commonprefix = strsub(text1, 1, commonlength) - text1 = strsub(text1, commonlength + 1) - text2 = strsub(text2, commonlength + 1) - end - - -- Trim off common suffix (speedup). - commonlength = _diff_commonSuffix(text1, text2) - local commonsuffix - if commonlength > 0 then - commonsuffix = strsub(text1, -commonlength) - text1 = strsub(text1, 1, -commonlength - 1) - text2 = strsub(text2, 1, -commonlength - 1) - end - - -- Compute the diff on the middle block. - local diffs = _diff_compute(text1, text2, checklines, deadline) - - -- Restore the prefix and suffix. - if commonprefix then - tinsert(diffs, 1, { DIFF_EQUAL, commonprefix }) - end - if commonsuffix then - diffs[#diffs + 1] = { DIFF_EQUAL, commonsuffix } - end - - _diff_cleanupMerge(diffs) - return diffs -end - ---[[ -* Reduce the number of edits by eliminating semantically trivial equalities. -* @param {Array.>} diffs Array of diff tuples. ---]] -function diff_cleanupSemantic(diffs) - local changes = false - local equalities = {} -- Stack of indices where equalities are found. - local equalitiesLength = 0 -- Keeping our own length var is faster. - local lastEquality = nil - -- Always equal to diffs[equalities[equalitiesLength]][2] - local pointer = 1 -- Index of current position. - -- Number of characters that changed prior to the equality. - local length_insertions1 = 0 - local length_deletions1 = 0 - -- Number of characters that changed after the equality. - local length_insertions2 = 0 - local length_deletions2 = 0 - - while diffs[pointer] do - if diffs[pointer][1] == DIFF_EQUAL then -- Equality found. - equalitiesLength = equalitiesLength + 1 - equalities[equalitiesLength] = pointer - length_insertions1 = length_insertions2 - length_deletions1 = length_deletions2 - length_insertions2 = 0 - length_deletions2 = 0 - lastEquality = diffs[pointer][2] - else -- An insertion or deletion. - if diffs[pointer][1] == DIFF_INSERT then - length_insertions2 = length_insertions2 + #diffs[pointer][2] - else - length_deletions2 = length_deletions2 + #diffs[pointer][2] - end - -- Eliminate an equality that is smaller or equal to the edits on both - -- sides of it. - if - lastEquality - and (#lastEquality <= max(length_insertions1, length_deletions1)) - and (#lastEquality <= max(length_insertions2, length_deletions2)) - then - -- Duplicate record. - tinsert(diffs, equalities[equalitiesLength], { DIFF_DELETE, lastEquality }) - -- Change second copy to insert. - diffs[equalities[equalitiesLength] + 1][1] = DIFF_INSERT - -- Throw away the equality we just deleted. - equalitiesLength = equalitiesLength - 1 - -- Throw away the previous equality (it needs to be reevaluated). - equalitiesLength = equalitiesLength - 1 - pointer = (equalitiesLength > 0) and equalities[equalitiesLength] or 0 - length_insertions1, length_deletions1 = 0, 0 -- Reset the counters. - length_insertions2, length_deletions2 = 0, 0 - lastEquality = nil - changes = true - end - end - pointer = pointer + 1 - end - - -- Normalize the diff. - if changes then - _diff_cleanupMerge(diffs) - end - _diff_cleanupSemanticLossless(diffs) - - -- Find any overlaps between deletions and insertions. - -- e.g: abcxxxxxxdef - -- -> abcxxxdef - -- e.g: xxxabcdefxxx - -- -> defxxxabc - -- Only extract an overlap if it is as big as the edit ahead or behind it. - pointer = 2 - while diffs[pointer] do - if diffs[pointer - 1][1] == DIFF_DELETE and diffs[pointer][1] == DIFF_INSERT then - local deletion = diffs[pointer - 1][2] - local insertion = diffs[pointer][2] - local overlap_length1 = _diff_commonOverlap(deletion, insertion) - local overlap_length2 = _diff_commonOverlap(insertion, deletion) - if overlap_length1 >= overlap_length2 then - if overlap_length1 >= #deletion / 2 or overlap_length1 >= #insertion / 2 then - -- Overlap found. Insert an equality and trim the surrounding edits. - tinsert(diffs, pointer, { DIFF_EQUAL, strsub(insertion, 1, overlap_length1) }) - diffs[pointer - 1][2] = strsub(deletion, 1, #deletion - overlap_length1) - diffs[pointer + 1][2] = strsub(insertion, overlap_length1 + 1) - pointer = pointer + 1 - end - else - if overlap_length2 >= #deletion / 2 or overlap_length2 >= #insertion / 2 then - -- Reverse overlap found. - -- Insert an equality and swap and trim the surrounding edits. - tinsert(diffs, pointer, { DIFF_EQUAL, strsub(deletion, 1, overlap_length2) }) - diffs[pointer - 1] = { DIFF_INSERT, strsub(insertion, 1, #insertion - overlap_length2) } - diffs[pointer + 1] = { DIFF_DELETE, strsub(deletion, overlap_length2 + 1) } - pointer = pointer + 1 - end - end - pointer = pointer + 1 - end - pointer = pointer + 1 - end -end - ---[[ -* Reduce the number of edits by eliminating operationally trivial equalities. -* @param {Array.>} diffs Array of diff tuples. ---]] -function diff_cleanupEfficiency(diffs) - local changes = false - -- Stack of indices where equalities are found. - local equalities = {} - -- Keeping our own length var is faster. - local equalitiesLength = 0 - -- Always equal to diffs[equalities[equalitiesLength]][2] - local lastEquality = nil - -- Index of current position. - local pointer = 1 - - -- The following four are really booleans but are stored as numbers because - -- they are used at one point like this: - -- - -- (pre_ins + pre_del + post_ins + post_del) == 3 - -- - -- ...i.e. checking that 3 of them are true and 1 of them is false. - - -- Is there an insertion operation before the last equality. - local pre_ins = 0 - -- Is there a deletion operation before the last equality. - local pre_del = 0 - -- Is there an insertion operation after the last equality. - local post_ins = 0 - -- Is there a deletion operation after the last equality. - local post_del = 0 - - while diffs[pointer] do - if diffs[pointer][1] == DIFF_EQUAL then -- Equality found. - local diffText = diffs[pointer][2] - if (#diffText < Diff_EditCost) and (post_ins == 1 or post_del == 1) then - -- Candidate found. - equalitiesLength = equalitiesLength + 1 - equalities[equalitiesLength] = pointer - pre_ins, pre_del = post_ins, post_del - lastEquality = diffText - else - -- Not a candidate, and can never become one. - equalitiesLength = 0 - lastEquality = nil - end - post_ins, post_del = 0, 0 - else -- An insertion or deletion. - if diffs[pointer][1] == DIFF_DELETE then - post_del = 1 - else - post_ins = 1 - end - --[[ - * Five types to be split: - * ABXYCD - * AXCD - * ABXC - * AXCD - * ABXC - --]] - if - lastEquality - and ( - (pre_ins + pre_del + post_ins + post_del == 4) - or ((#lastEquality < Diff_EditCost / 2) and (pre_ins + pre_del + post_ins + post_del == 3)) - ) - then - -- Duplicate record. - tinsert(diffs, equalities[equalitiesLength], { DIFF_DELETE, lastEquality }) - -- Change second copy to insert. - diffs[equalities[equalitiesLength] + 1][1] = DIFF_INSERT - -- Throw away the equality we just deleted. - equalitiesLength = equalitiesLength - 1 - lastEquality = nil - if (pre_ins == 1) and (pre_del == 1) then - -- No changes made which could affect previous entry, keep going. - post_ins, post_del = 1, 1 - equalitiesLength = 0 - else - -- Throw away the previous equality. - equalitiesLength = equalitiesLength - 1 - pointer = (equalitiesLength > 0) and equalities[equalitiesLength] or 0 - post_ins, post_del = 0, 0 - end - changes = true - end - end - pointer = pointer + 1 - end - - if changes then - _diff_cleanupMerge(diffs) - end -end - ---[[ -* Compute the Levenshtein distance; the number of inserted, deleted or -* substituted characters. -* @param {Array.>} diffs Array of diff tuples. -* @return {number} Number of changes. ---]] -function diff_levenshtein(diffs) - local levenshtein = 0 - local insertions, deletions = 0, 0 - for x, diff in ipairs(diffs) do - local op, data = diff[1], diff[2] - if op == DIFF_INSERT then - insertions = insertions + #data - elseif op == DIFF_DELETE then - deletions = deletions + #data - elseif op == DIFF_EQUAL then - -- A deletion and an insertion is one substitution. - levenshtein = levenshtein + max(insertions, deletions) - insertions = 0 - deletions = 0 - end - end - levenshtein = levenshtein + max(insertions, deletions) - return levenshtein -end - ---[[ -* Convert a diff array into a pretty HTML report. -* @param {Array.>} diffs Array of diff tuples. -* @return {string} HTML representation. ---]] -function diff_prettyHtml(diffs) - local html = {} - for x, diff in ipairs(diffs) do - local op = diff[1] -- Operation (insert, delete, equal) - local data = diff[2] -- Text of change. - local text = gsub(data, htmlEncode_pattern, htmlEncode_replace) - if op == DIFF_INSERT then - html[x] = '' .. text .. '' - elseif op == DIFF_DELETE then - html[x] = '' .. text .. '' - elseif op == DIFF_EQUAL then - html[x] = '' .. text .. '' - end - end - return tconcat(html) -end - --- --------------------------------------------------------------------------- --- UNOFFICIAL/PRIVATE DIFF FUNCTIONS --- --------------------------------------------------------------------------- - ---[[ -* Find the differences between two texts. Assumes that the texts do not -* have any common prefix or suffix. -* @param {string} text1 Old string to be diffed. -* @param {string} text2 New string to be diffed. -* @param {boolean} checklines Has no effect in Lua. -* @param {number} deadline Time when the diff should be complete by. -* @return {Array.>} Array of diff tuples. -* @private ---]] -function _diff_compute(text1, text2, checklines, deadline) - if #text1 == 0 then - -- Just add some text (speedup). - return { { DIFF_INSERT, text2 } } - end - - if #text2 == 0 then - -- Just delete some text (speedup). - return { { DIFF_DELETE, text1 } } - end - - local diffs - - local longtext = (#text1 > #text2) and text1 or text2 - local shorttext = (#text1 > #text2) and text2 or text1 - local i = indexOf(longtext, shorttext) - - if i ~= nil then - -- Shorter text is inside the longer text (speedup). - diffs = { - { DIFF_INSERT, strsub(longtext, 1, i - 1) }, - { DIFF_EQUAL, shorttext }, - { DIFF_INSERT, strsub(longtext, i + #shorttext) }, - } - -- Swap insertions for deletions if diff is reversed. - if #text1 > #text2 then - diffs[1][1], diffs[3][1] = DIFF_DELETE, DIFF_DELETE - end - return diffs - end - - if #shorttext == 1 then - -- Single character string. - -- After the previous speedup, the character can't be an equality. - return { { DIFF_DELETE, text1 }, { DIFF_INSERT, text2 } } - end - - -- Check to see if the problem can be split in two. - do - local text1_a, text1_b, text2_a, text2_b, mid_common = _diff_halfMatch(text1, text2) - - if text1_a then - -- A half-match was found, sort out the return data. - -- Send both pairs off for separate processing. - local diffs_a = diff_main(text1_a, text2_a, checklines, deadline) - local diffs_b = diff_main(text1_b, text2_b, checklines, deadline) - -- Merge the results. - local diffs_a_len = #diffs_a - diffs = diffs_a - diffs[diffs_a_len + 1] = { DIFF_EQUAL, mid_common } - for i, b_diff in ipairs(diffs_b) do - diffs[diffs_a_len + 1 + i] = b_diff - end - return diffs - end - end - - return _diff_bisect(text1, text2, deadline) -end - ---[[ -* Find the 'middle snake' of a diff, split the problem in two -* and return the recursively constructed diff. -* See Myers 1986 paper: An O(ND) Difference Algorithm and Its Variations. -* @param {string} text1 Old string to be diffed. -* @param {string} text2 New string to be diffed. -* @param {number} deadline Time at which to bail if not yet complete. -* @return {Array.>} Array of diff tuples. -* @private ---]] -function _diff_bisect(text1, text2, deadline) - -- Cache the text lengths to prevent multiple calls. - local text1_length = #text1 - local text2_length = #text2 - local _sub, _element - local max_d = ceil((text1_length + text2_length) / 2) - local v_offset = max_d - local v_length = 2 * max_d - local v1 = {} - local v2 = {} - -- Setting all elements to -1 is faster in Lua than mixing integers and nil. - for x = 0, v_length - 1 do - v1[x] = -1 - v2[x] = -1 - end - v1[v_offset + 1] = 0 - v2[v_offset + 1] = 0 - local delta = text1_length - text2_length - -- If the total number of characters is odd, then - -- the front path will collide with the reverse path. - local front = (delta % 2 ~= 0) - -- Offsets for start and end of k loop. - -- Prevents mapping of space beyond the grid. - local k1start = 0 - local k1end = 0 - local k2start = 0 - local k2end = 0 - for d = 0, max_d - 1 do - -- Bail out if deadline is reached. - if clock() > deadline then - break - end - - -- Walk the front path one step. - for k1 = -d + k1start, d - k1end, 2 do - local k1_offset = v_offset + k1 - local x1 - if (k1 == -d) or ((k1 ~= d) and (v1[k1_offset - 1] < v1[k1_offset + 1])) then - x1 = v1[k1_offset + 1] - else - x1 = v1[k1_offset - 1] + 1 - end - local y1 = x1 - k1 - while (x1 <= text1_length) and (y1 <= text2_length) and (strsub(text1, x1, x1) == strsub(text2, y1, y1)) do - x1 = x1 + 1 - y1 = y1 + 1 - end - v1[k1_offset] = x1 - if x1 > text1_length + 1 then - -- Ran off the right of the graph. - k1end = k1end + 2 - elseif y1 > text2_length + 1 then - -- Ran off the bottom of the graph. - k1start = k1start + 2 - elseif front then - local k2_offset = v_offset + delta - k1 - if k2_offset >= 0 and k2_offset < v_length and v2[k2_offset] ~= -1 then - -- Mirror x2 onto top-left coordinate system. - local x2 = text1_length - v2[k2_offset] + 1 - if x1 > x2 then - -- Overlap detected. - return _diff_bisectSplit(text1, text2, x1, y1, deadline) - end - end - end - end - - -- Walk the reverse path one step. - for k2 = -d + k2start, d - k2end, 2 do - local k2_offset = v_offset + k2 - local x2 - if (k2 == -d) or ((k2 ~= d) and (v2[k2_offset - 1] < v2[k2_offset + 1])) then - x2 = v2[k2_offset + 1] - else - x2 = v2[k2_offset - 1] + 1 - end - local y2 = x2 - k2 - while (x2 <= text1_length) and (y2 <= text2_length) and (strsub(text1, -x2, -x2) == strsub(text2, -y2, -y2)) do - x2 = x2 + 1 - y2 = y2 + 1 - end - v2[k2_offset] = x2 - if x2 > text1_length + 1 then - -- Ran off the left of the graph. - k2end = k2end + 2 - elseif y2 > text2_length + 1 then - -- Ran off the top of the graph. - k2start = k2start + 2 - elseif not front then - local k1_offset = v_offset + delta - k2 - if k1_offset >= 0 and k1_offset < v_length and v1[k1_offset] ~= -1 then - local x1 = v1[k1_offset] - local y1 = v_offset + x1 - k1_offset - -- Mirror x2 onto top-left coordinate system. - x2 = text1_length - x2 + 1 - if x1 > x2 then - -- Overlap detected. - return _diff_bisectSplit(text1, text2, x1, y1, deadline) - end - end - end - end - end - -- Diff took too long and hit the deadline or - -- number of diffs equals number of characters, no commonality at all. - return { { DIFF_DELETE, text1 }, { DIFF_INSERT, text2 } } -end - ---[[ - * Given the location of the 'middle snake', split the diff in two parts - * and recurse. - * @param {string} text1 Old string to be diffed. - * @param {string} text2 New string to be diffed. - * @param {number} x Index of split point in text1. - * @param {number} y Index of split point in text2. - * @param {number} deadline Time at which to bail if not yet complete. - * @return {Array.>} Array of diff tuples. - * @private ---]] -function _diff_bisectSplit(text1, text2, x, y, deadline) - local text1a = strsub(text1, 1, x - 1) - local text2a = strsub(text2, 1, y - 1) - local text1b = strsub(text1, x) - local text2b = strsub(text2, y) - - -- Compute both diffs serially. - local diffs = diff_main(text1a, text2a, false, deadline) - local diffsb = diff_main(text1b, text2b, false, deadline) - - local diffs_len = #diffs - for i, v in ipairs(diffsb) do - diffs[diffs_len + i] = v - end - return diffs -end - ---[[ -* Determine the common prefix of two strings. -* @param {string} text1 First string. -* @param {string} text2 Second string. -* @return {number} The number of characters common to the start of each -* string. ---]] -function _diff_commonPrefix(text1, text2) - -- Quick check for common null cases. - if (#text1 == 0) or (#text2 == 0) or (strbyte(text1, 1) ~= strbyte(text2, 1)) then - return 0 - end - -- Binary search. - -- Performance analysis: https://neil.fraser.name/news/2007/10/09/ - local pointermin = 1 - local pointermax = min(#text1, #text2) - local pointermid = pointermax - local pointerstart = 1 - while pointermin < pointermid do - if strsub(text1, pointerstart, pointermid) == strsub(text2, pointerstart, pointermid) then - pointermin = pointermid - pointerstart = pointermin - else - pointermax = pointermid - end - pointermid = floor(pointermin + (pointermax - pointermin) / 2) - end - return pointermid -end - ---[[ -* Determine the common suffix of two strings. -* @param {string} text1 First string. -* @param {string} text2 Second string. -* @return {number} The number of characters common to the end of each string. ---]] -function _diff_commonSuffix(text1, text2) - -- Quick check for common null cases. - if (#text1 == 0) or (#text2 == 0) or (strbyte(text1, -1) ~= strbyte(text2, -1)) then - return 0 - end - -- Binary search. - -- Performance analysis: https://neil.fraser.name/news/2007/10/09/ - local pointermin = 1 - local pointermax = min(#text1, #text2) - local pointermid = pointermax - local pointerend = 1 - while pointermin < pointermid do - if strsub(text1, -pointermid, -pointerend) == strsub(text2, -pointermid, -pointerend) then - pointermin = pointermid - pointerend = pointermin - else - pointermax = pointermid - end - pointermid = floor(pointermin + (pointermax - pointermin) / 2) - end - return pointermid -end - ---[[ -* Determine if the suffix of one string is the prefix of another. -* @param {string} text1 First string. -* @param {string} text2 Second string. -* @return {number} The number of characters common to the end of the first -* string and the start of the second string. -* @private ---]] -function _diff_commonOverlap(text1, text2) - -- Cache the text lengths to prevent multiple calls. - local text1_length = #text1 - local text2_length = #text2 - -- Eliminate the null case. - if text1_length == 0 or text2_length == 0 then - return 0 - end - -- Truncate the longer string. - if text1_length > text2_length then - text1 = strsub(text1, text1_length - text2_length + 1) - elseif text1_length < text2_length then - text2 = strsub(text2, 1, text1_length) - end - local text_length = min(text1_length, text2_length) - -- Quick check for the worst case. - if text1 == text2 then - return text_length - end - - -- Start by looking for a single character match - -- and increase length until no match is found. - -- Performance analysis: https://neil.fraser.name/news/2010/11/04/ - local best = 0 - local length = 1 - while true do - local pattern = strsub(text1, text_length - length + 1) - local found = strfind(text2, pattern, 1, true) - if found == nil then - return best - end - length = length + found - 1 - if found == 1 or strsub(text1, text_length - length + 1) == strsub(text2, 1, length) then - best = length - length = length + 1 - end - end -end - ---[[ -* Does a substring of shorttext exist within longtext such that the substring -* is at least half the length of longtext? -* This speedup can produce non-minimal diffs. -* Closure, but does not reference any external variables. -* @param {string} longtext Longer string. -* @param {string} shorttext Shorter string. -* @param {number} i Start index of quarter length substring within longtext. -* @return {?Array.} Five element Array, containing the prefix of -* longtext, the suffix of longtext, the prefix of shorttext, the suffix -* of shorttext and the common middle. Or nil if there was no match. -* @private ---]] -function _diff_halfMatchI(longtext, shorttext, i) - -- Start with a 1/4 length substring at position i as a seed. - local seed = strsub(longtext, i, i + floor(#longtext / 4)) - local j = 0 -- LUANOTE: do not change to 1, was originally -1 - local best_common = '' - local best_longtext_a, best_longtext_b, best_shorttext_a, best_shorttext_b - while true do - j = indexOf(shorttext, seed, j + 1) - if j == nil then - break - end - local prefixLength = _diff_commonPrefix(strsub(longtext, i), strsub(shorttext, j)) - local suffixLength = _diff_commonSuffix(strsub(longtext, 1, i - 1), strsub(shorttext, 1, j - 1)) - if #best_common < suffixLength + prefixLength then - best_common = strsub(shorttext, j - suffixLength, j - 1) .. strsub(shorttext, j, j + prefixLength - 1) - best_longtext_a = strsub(longtext, 1, i - suffixLength - 1) - best_longtext_b = strsub(longtext, i + prefixLength) - best_shorttext_a = strsub(shorttext, 1, j - suffixLength - 1) - best_shorttext_b = strsub(shorttext, j + prefixLength) - end - end - if #best_common * 2 >= #longtext then - return { best_longtext_a, best_longtext_b, best_shorttext_a, best_shorttext_b, best_common } - else - return nil - end -end - ---[[ -* Do the two texts share a substring which is at least half the length of the -* longer text? -* @param {string} text1 First string. -* @param {string} text2 Second string. -* @return {?Array.} Five element Array, containing the prefix of -* text1, the suffix of text1, the prefix of text2, the suffix of -* text2 and the common middle. Or nil if there was no match. -* @private ---]] -function _diff_halfMatch(text1, text2) - if Diff_Timeout <= 0 then - -- Don't risk returning a non-optimal diff if we have unlimited time. - return nil - end - local longtext = (#text1 > #text2) and text1 or text2 - local shorttext = (#text1 > #text2) and text2 or text1 - if (#longtext < 4) or (#shorttext * 2 < #longtext) then - return nil -- Pointless. - end - - -- First check if the second quarter is the seed for a half-match. - local hm1 = _diff_halfMatchI(longtext, shorttext, ceil(#longtext / 4)) - -- Check again based on the third quarter. - local hm2 = _diff_halfMatchI(longtext, shorttext, ceil(#longtext / 2)) - local hm - if not hm1 and not hm2 then - return nil - elseif not hm2 then - hm = hm1 - elseif not hm1 then - hm = hm2 - else - -- Both matched. Select the longest. - hm = (#hm1[5] > #hm2[5]) and hm1 or hm2 - end - - -- A half-match was found, sort out the return data. - local text1_a, text1_b, text2_a, text2_b - if #text1 > #text2 then - text1_a, text1_b = hm[1], hm[2] - text2_a, text2_b = hm[3], hm[4] - else - text2_a, text2_b = hm[1], hm[2] - text1_a, text1_b = hm[3], hm[4] - end - local mid_common = hm[5] - return text1_a, text1_b, text2_a, text2_b, mid_common -end - ---[[ -* Given two strings, compute a score representing whether the internal -* boundary falls on logical boundaries. -* Scores range from 6 (best) to 0 (worst). -* @param {string} one First string. -* @param {string} two Second string. -* @return {number} The score. -* @private ---]] -function _diff_cleanupSemanticScore(one, two) - if (#one == 0) or (#two == 0) then - -- Edges are the best. - return 6 - end - - -- Each port of this function behaves slightly differently due to - -- subtle differences in each language's definition of things like - -- 'whitespace'. Since this function's purpose is largely cosmetic, - -- the choice has been made to use each language's native features - -- rather than force total conformity. - local char1 = strsub(one, -1) - local char2 = strsub(two, 1, 1) - local nonAlphaNumeric1 = strmatch(char1, '%W') - local nonAlphaNumeric2 = strmatch(char2, '%W') - local whitespace1 = nonAlphaNumeric1 and strmatch(char1, '%s') - local whitespace2 = nonAlphaNumeric2 and strmatch(char2, '%s') - local lineBreak1 = whitespace1 and strmatch(char1, '%c') - local lineBreak2 = whitespace2 and strmatch(char2, '%c') - local blankLine1 = lineBreak1 and strmatch(one, '\n\r?\n$') - local blankLine2 = lineBreak2 and strmatch(two, '^\r?\n\r?\n') - - if blankLine1 or blankLine2 then - -- Five points for blank lines. - return 5 - elseif lineBreak1 or lineBreak2 then - -- Four points for line breaks. - return 4 - elseif nonAlphaNumeric1 and not whitespace1 and whitespace2 then - -- Three points for end of sentences. - return 3 - elseif whitespace1 or whitespace2 then - -- Two points for whitespace. - return 2 - elseif nonAlphaNumeric1 or nonAlphaNumeric2 then - -- One point for non-alphanumeric. - return 1 - end - return 0 -end - ---[[ -* Look for single edits surrounded on both sides by equalities -* which can be shifted sideways to align the edit to a word boundary. -* e.g: The cat came. -> The cat came. -* @param {Array.>} diffs Array of diff tuples. ---]] -function _diff_cleanupSemanticLossless(diffs) - local pointer = 2 - -- Intentionally ignore the first and last element (don't need checking). - while diffs[pointer + 1] do - local prevDiff, nextDiff = diffs[pointer - 1], diffs[pointer + 1] - if (prevDiff[1] == DIFF_EQUAL) and (nextDiff[1] == DIFF_EQUAL) then - -- This is a single edit surrounded by equalities. - local diff = diffs[pointer] - - local equality1 = prevDiff[2] - local edit = diff[2] - local equality2 = nextDiff[2] - - -- First, shift the edit as far left as possible. - local commonOffset = _diff_commonSuffix(equality1, edit) - if commonOffset > 0 then - local commonString = strsub(edit, -commonOffset) - equality1 = strsub(equality1, 1, -commonOffset - 1) - edit = commonString .. strsub(edit, 1, -commonOffset - 1) - equality2 = commonString .. equality2 - end - - -- Second, step character by character right, looking for the best fit. - local bestEquality1 = equality1 - local bestEdit = edit - local bestEquality2 = equality2 - local bestScore = _diff_cleanupSemanticScore(equality1, edit) + _diff_cleanupSemanticScore(edit, equality2) - - while strbyte(edit, 1) == strbyte(equality2, 1) do - equality1 = equality1 .. strsub(edit, 1, 1) - edit = strsub(edit, 2) .. strsub(equality2, 1, 1) - equality2 = strsub(equality2, 2) - local score = _diff_cleanupSemanticScore(equality1, edit) + _diff_cleanupSemanticScore(edit, equality2) - -- The >= encourages trailing rather than leading whitespace on edits. - if score >= bestScore then - bestScore = score - bestEquality1 = equality1 - bestEdit = edit - bestEquality2 = equality2 - end - end - if prevDiff[2] ~= bestEquality1 then - -- We have an improvement, save it back to the diff. - if #bestEquality1 > 0 then - diffs[pointer - 1][2] = bestEquality1 - else - tremove(diffs, pointer - 1) - pointer = pointer - 1 - end - diffs[pointer][2] = bestEdit - if #bestEquality2 > 0 then - diffs[pointer + 1][2] = bestEquality2 - else - tremove(diffs, pointer + 1, 1) - pointer = pointer - 1 - end - end - end - pointer = pointer + 1 - end -end - ---[[ -* Reorder and merge like edit sections. Merge equalities. -* Any edit section can move as long as it doesn't cross an equality. -* @param {Array.>} diffs Array of diff tuples. ---]] -function _diff_cleanupMerge(diffs) - diffs[#diffs + 1] = { DIFF_EQUAL, '' } -- Add a dummy entry at the end. - local pointer = 1 - local count_delete, count_insert = 0, 0 - local text_delete, text_insert = '', '' - local commonlength - while diffs[pointer] do - local diff_type = diffs[pointer][1] - if diff_type == DIFF_INSERT then - count_insert = count_insert + 1 - text_insert = text_insert .. diffs[pointer][2] - pointer = pointer + 1 - elseif diff_type == DIFF_DELETE then - count_delete = count_delete + 1 - text_delete = text_delete .. diffs[pointer][2] - pointer = pointer + 1 - elseif diff_type == DIFF_EQUAL then - -- Upon reaching an equality, check for prior redundancies. - if count_delete + count_insert > 1 then - if (count_delete > 0) and (count_insert > 0) then - -- Factor out any common prefixies. - commonlength = _diff_commonPrefix(text_insert, text_delete) - if commonlength > 0 then - local back_pointer = pointer - count_delete - count_insert - if (back_pointer > 1) and (diffs[back_pointer - 1][1] == DIFF_EQUAL) then - diffs[back_pointer - 1][2] = diffs[back_pointer - 1][2] .. strsub(text_insert, 1, commonlength) - else - tinsert(diffs, 1, { DIFF_EQUAL, strsub(text_insert, 1, commonlength) }) - pointer = pointer + 1 - end - text_insert = strsub(text_insert, commonlength + 1) - text_delete = strsub(text_delete, commonlength + 1) - end - -- Factor out any common suffixies. - commonlength = _diff_commonSuffix(text_insert, text_delete) - if commonlength ~= 0 then - diffs[pointer][2] = strsub(text_insert, -commonlength) .. diffs[pointer][2] - text_insert = strsub(text_insert, 1, -commonlength - 1) - text_delete = strsub(text_delete, 1, -commonlength - 1) - end - end - -- Delete the offending records and add the merged ones. - pointer = pointer - count_delete - count_insert - for i = 1, count_delete + count_insert do - tremove(diffs, pointer) - end - if #text_delete > 0 then - tinsert(diffs, pointer, { DIFF_DELETE, text_delete }) - pointer = pointer + 1 - end - if #text_insert > 0 then - tinsert(diffs, pointer, { DIFF_INSERT, text_insert }) - pointer = pointer + 1 - end - pointer = pointer + 1 - elseif (pointer > 1) and (diffs[pointer - 1][1] == DIFF_EQUAL) then - -- Merge this equality with the previous one. - diffs[pointer - 1][2] = diffs[pointer - 1][2] .. diffs[pointer][2] - tremove(diffs, pointer) - else - pointer = pointer + 1 - end - count_insert, count_delete = 0, 0 - text_delete, text_insert = '', '' - end - end - if diffs[#diffs][2] == '' then - diffs[#diffs] = nil -- Remove the dummy entry at the end. - end - - -- Second pass: look for single edits surrounded on both sides by equalities - -- which can be shifted sideways to eliminate an equality. - -- e.g: ABAC -> ABAC - local changes = false - pointer = 2 - -- Intentionally ignore the first and last element (don't need checking). - while pointer < #diffs do - local prevDiff, nextDiff = diffs[pointer - 1], diffs[pointer + 1] - if (prevDiff[1] == DIFF_EQUAL) and (nextDiff[1] == DIFF_EQUAL) then - -- This is a single edit surrounded by equalities. - local diff = diffs[pointer] - local currentText = diff[2] - local prevText = prevDiff[2] - local nextText = nextDiff[2] - if #prevText == 0 then - tremove(diffs, pointer - 1) - changes = true - elseif strsub(currentText, -#prevText) == prevText then - -- Shift the edit over the previous equality. - diff[2] = prevText .. strsub(currentText, 1, -#prevText - 1) - nextDiff[2] = prevText .. nextDiff[2] - tremove(diffs, pointer - 1) - changes = true - elseif strsub(currentText, 1, #nextText) == nextText then - -- Shift the edit over the next equality. - prevDiff[2] = prevText .. nextText - diff[2] = strsub(currentText, #nextText + 1) .. nextText - tremove(diffs, pointer + 1) - changes = true - end - end - pointer = pointer + 1 - end - -- If shifts were made, the diff needs reordering and another shift sweep. - if changes then - -- LUANOTE: no return value, but necessary to use 'return' to get - -- tail calls. - return _diff_cleanupMerge(diffs) - end -end - ---[[ -* loc is a location in text1, compute and return the equivalent location in -* text2. -* e.g. 'The cat' vs 'The big cat', 1->1, 5->8 -* @param {Array.>} diffs Array of diff tuples. -* @param {number} loc Location within text1. -* @return {number} Location within text2. ---]] -function _diff_xIndex(diffs, loc) - local chars1 = 1 - local chars2 = 1 - local last_chars1 = 1 - local last_chars2 = 1 - local x - for _x, diff in ipairs(diffs) do - x = _x - if diff[1] ~= DIFF_INSERT then -- Equality or deletion. - chars1 = chars1 + #diff[2] - end - if diff[1] ~= DIFF_DELETE then -- Equality or insertion. - chars2 = chars2 + #diff[2] - end - if chars1 > loc then -- Overshot the location. - break - end - last_chars1 = chars1 - last_chars2 = chars2 - end - -- Was the location deleted? - if diffs[x + 1] and (diffs[x][1] == DIFF_DELETE) then - return last_chars2 - end - -- Add the remaining character length. - return last_chars2 + (loc - last_chars1) -end - ---[[ -* Compute and return the source text (all equalities and deletions). -* @param {Array.>} diffs Array of diff tuples. -* @return {string} Source text. ---]] -function _diff_text1(diffs) - local text = {} - for x, diff in ipairs(diffs) do - if diff[1] ~= DIFF_INSERT then - text[#text + 1] = diff[2] - end - end - return tconcat(text) -end - ---[[ -* Compute and return the destination text (all equalities and insertions). -* @param {Array.>} diffs Array of diff tuples. -* @return {string} Destination text. ---]] -function _diff_text2(diffs) - local text = {} - for x, diff in ipairs(diffs) do - if diff[1] ~= DIFF_DELETE then - text[#text + 1] = diff[2] - end - end - return tconcat(text) -end - ---[[ -* Crush the diff into an encoded string which describes the operations -* required to transform text1 into text2. -* E.g. =3\t-2\t+ing -> Keep 3 chars, delete 2 chars, insert 'ing'. -* Operations are tab-separated. Inserted text is escaped using %xx notation. -* @param {Array.>} diffs Array of diff tuples. -* @return {string} Delta text. ---]] -function _diff_toDelta(diffs) - local text = {} - for x, diff in ipairs(diffs) do - local op, data = diff[1], diff[2] - if op == DIFF_INSERT then - text[x] = '+' .. gsub(data, percentEncode_pattern, percentEncode_replace) - elseif op == DIFF_DELETE then - text[x] = '-' .. #data - elseif op == DIFF_EQUAL then - text[x] = '=' .. #data - end - end - return tconcat(text, '\t') -end - ---[[ -* Given the original text1, and an encoded string which describes the -* operations required to transform text1 into text2, compute the full diff. -* @param {string} text1 Source string for the diff. -* @param {string} delta Delta text. -* @return {Array.>} Array of diff tuples. -* @throws {Errorend If invalid input. ---]] -function _diff_fromDelta(text1, delta) - local diffs = {} - local diffsLength = 0 -- Keeping our own length var is faster - local pointer = 1 -- Cursor in text1 - for token in gmatch(delta, '[^\t]+') do - -- Each token begins with a one character parameter which specifies the - -- operation of this token (delete, insert, equality). - local tokenchar, param = strsub(token, 1, 1), strsub(token, 2) - if tokenchar == '+' then - local invalidDecode = false - local decoded = gsub(param, '%%(.?.?)', function(c) - local n = tonumber(c, 16) - if (#c ~= 2) or (n == nil) then - invalidDecode = true - return '' - end - return strchar(n) - end) - if invalidDecode then - -- Malformed URI sequence. - error('Illegal escape in _diff_fromDelta: ' .. param) - end - diffsLength = diffsLength + 1 - diffs[diffsLength] = { DIFF_INSERT, decoded } - elseif (tokenchar == '-') or (tokenchar == '=') then - local n = tonumber(param) - if (n == nil) or (n < 0) then - error('Invalid number in _diff_fromDelta: ' .. param) - end - local text = strsub(text1, pointer, pointer + n - 1) - pointer = pointer + n - if tokenchar == '=' then - diffsLength = diffsLength + 1 - diffs[diffsLength] = { DIFF_EQUAL, text } - else - diffsLength = diffsLength + 1 - diffs[diffsLength] = { DIFF_DELETE, text } - end - else - error('Invalid diff operation in _diff_fromDelta: ' .. token) - end - end - if pointer ~= #text1 + 1 then - error('Delta length (' .. (pointer - 1) .. ') does not equal source text length (' .. #text1 .. ').') - end - return diffs -end - --- --------------------------------------------------------------------------- --- MATCH API --- --------------------------------------------------------------------------- - -local _match_bitap, _match_alphabet - ---[[ -* Locate the best instance of 'pattern' in 'text' near 'loc'. -* @param {string} text The text to search. -* @param {string} pattern The pattern to search for. -* @param {number} loc The location to search around. -* @return {number} Best match index or -1. ---]] -function match_main(text, pattern, loc) - -- Check for null inputs. - if text == nil or pattern == nil or loc == nil then - error('Null inputs. (match_main)') - end - - if text == pattern then - -- Shortcut (potentially not guaranteed by the algorithm) - return 1 - elseif #text == 0 then - -- Nothing to match. - return -1 - end - loc = max(1, min(loc, #text)) - if strsub(text, loc, loc + #pattern - 1) == pattern then - -- Perfect match at the perfect spot! (Includes case of null pattern) - return loc - else - -- Do a fuzzy compare. - return _match_bitap(text, pattern, loc) - end -end - --- --------------------------------------------------------------------------- --- UNOFFICIAL/PRIVATE MATCH FUNCTIONS --- --------------------------------------------------------------------------- - ---[[ -* Initialise the alphabet for the Bitap algorithm. -* @param {string} pattern The text to encode. -* @return {Object} Hash of character locations. -* @private ---]] -function _match_alphabet(pattern) - local s = {} - local i = 0 - for c in gmatch(pattern, '.') do - s[c] = bor(s[c] or 0, lshift(1, #pattern - i - 1)) - i = i + 1 - end - return s -end - ---[[ -* Locate the best instance of 'pattern' in 'text' near 'loc' using the -* Bitap algorithm. -* @param {string} text The text to search. -* @param {string} pattern The pattern to search for. -* @param {number} loc The location to search around. -* @return {number} Best match index or -1. -* @private ---]] -function _match_bitap(text, pattern, loc) - if #pattern > Match_MaxBits then - error('Pattern too long.') - end - - -- Initialise the alphabet. - local s = _match_alphabet(pattern) - - --[[ - * Compute and return the score for a match with e errors and x location. - * Accesses loc and pattern through being a closure. - * @param {number} e Number of errors in match. - * @param {number} x Location of match. - * @return {number} Overall score for match (0.0 = good, 1.0 = bad). - * @private - --]] - local function _match_bitapScore(e, x) - local accuracy = e / #pattern - local proximity = abs(loc - x) - if Match_Distance == 0 then - -- Dodge divide by zero error. - return (proximity == 0) and 1 or accuracy - end - return accuracy + (proximity / Match_Distance) - end - - -- Highest score beyond which we give up. - local score_threshold = Match_Threshold - -- Is there a nearby exact match? (speedup) - local best_loc = indexOf(text, pattern, loc) - if best_loc then - score_threshold = min(_match_bitapScore(0, best_loc), score_threshold) - -- LUANOTE: Ideally we'd also check from the other direction, but Lua - -- doesn't have an efficent lastIndexOf function. - end - - -- Initialise the bit arrays. - local matchmask = lshift(1, #pattern - 1) - best_loc = -1 - - local bin_min, bin_mid - local bin_max = #pattern + #text - local last_rd - for d = 0, #pattern - 1, 1 do - -- Scan for the best match; each iteration allows for one more error. - -- Run a binary search to determine how far from 'loc' we can stray at this - -- error level. - bin_min = 0 - bin_mid = bin_max - while bin_min < bin_mid do - if _match_bitapScore(d, loc + bin_mid) <= score_threshold then - bin_min = bin_mid - else - bin_max = bin_mid - end - bin_mid = floor(bin_min + (bin_max - bin_min) / 2) - end - -- Use the result from this iteration as the maximum for the next. - bin_max = bin_mid - local start = max(1, loc - bin_mid + 1) - local finish = min(loc + bin_mid, #text) + #pattern - - local rd = {} - for j = start, finish do - rd[j] = 0 - end - rd[finish + 1] = lshift(1, d) - 1 - for j = finish, start, -1 do - local charMatch = s[strsub(text, j - 1, j - 1)] or 0 - if d == 0 then -- First pass: exact match. - rd[j] = band(bor((rd[j + 1] * 2), 1), charMatch) - else - -- Subsequent passes: fuzzy match. - -- Functions instead of operators make this hella messy. - rd[j] = bor( - band(bor(lshift(rd[j + 1], 1), 1), charMatch), - bor(bor(lshift(bor(last_rd[j + 1], last_rd[j]), 1), 1), last_rd[j + 1]) - ) - end - if band(rd[j], matchmask) ~= 0 then - local score = _match_bitapScore(d, j - 1) - -- This match will almost certainly be better than any existing match. - -- But check anyway. - if score <= score_threshold then - -- Told you so. - score_threshold = score - best_loc = j - 1 - if best_loc > loc then - -- When passing loc, don't exceed our current distance from loc. - start = max(1, loc * 2 - best_loc) - else - -- Already passed loc, downhill from here on in. - break - end - end - end - end - -- No hope for a (better) match at greater error levels. - if _match_bitapScore(d + 1, loc) > score_threshold then - break - end - last_rd = rd - end - return best_loc -end - --- ----------------------------------------------------------------------------- --- PATCH API --- ----------------------------------------------------------------------------- - -local _patch_addContext, _patch_deepCopy, _patch_addPadding, _patch_splitMax, _patch_appendText, _new_patch_obj - ---[[ -* Compute a list of patches to turn text1 into text2. -* Use diffs if provided, otherwise compute it ourselves. -* There are four ways to call this function, depending on what data is -* available to the caller: -* Method 1: -* a = text1, b = text2 -* Method 2: -* a = diffs -* Method 3 (optimal): -* a = text1, b = diffs -* Method 4 (deprecated, use method 3): -* a = text1, b = text2, c = diffs -* -* @param {string|Array.>} a text1 (methods 1,3,4) or -* Array of diff tuples for text1 to text2 (method 2). -* @param {string|Array.>} opt_b text2 (methods 1,4) or -* Array of diff tuples for text1 to text2 (method 3) or undefined (method 2). -* @param {string|Array.>} opt_c Array of diff tuples for -* text1 to text2 (method 4) or undefined (methods 1,2,3). -* @return {Array.<_new_patch_obj>} Array of patch objects. ---]] -function patch_make(a, opt_b, opt_c) - local text1, diffs - local type_a, type_b, type_c = type(a), type(opt_b), type(opt_c) - if (type_a == 'string') and (type_b == 'string') and (type_c == 'nil') then - -- Method 1: text1, text2 - -- Compute diffs from text1 and text2. - text1 = a - diffs = diff_main(text1, opt_b, true) - if #diffs > 2 then - diff_cleanupSemantic(diffs) - diff_cleanupEfficiency(diffs) - end - elseif (type_a == 'table') and (type_b == 'nil') and (type_c == 'nil') then - -- Method 2: diffs - -- Compute text1 from diffs. - diffs = a - text1 = _diff_text1(diffs) - elseif (type_a == 'string') and (type_b == 'table') and (type_c == 'nil') then - -- Method 3: text1, diffs - text1 = a - diffs = opt_b - elseif (type_a == 'string') and (type_b == 'string') and (type_c == 'table') then - -- Method 4: text1, text2, diffs - -- text2 is not used. - text1 = a - diffs = opt_c - else - error('Unknown call format to patch_make.') - end - - if diffs[1] == nil then - return {} -- Get rid of the null case. - end - - local patches = {} - local patch = _new_patch_obj() - local patchDiffLength = 0 -- Keeping our own length var is faster. - local char_count1 = 0 -- Number of characters into the text1 string. - local char_count2 = 0 -- Number of characters into the text2 string. - -- Start with text1 (prepatch_text) and apply the diffs until we arrive at - -- text2 (postpatch_text). We recreate the patches one by one to determine - -- context info. - local prepatch_text, postpatch_text = text1, text1 - for x, diff in ipairs(diffs) do - local diff_type, diff_text = diff[1], diff[2] - - if (patchDiffLength == 0) and (diff_type ~= DIFF_EQUAL) then - -- A new patch starts here. - patch.start1 = char_count1 + 1 - patch.start2 = char_count2 + 1 - end - - if diff_type == DIFF_INSERT then - patchDiffLength = patchDiffLength + 1 - patch.diffs[patchDiffLength] = diff - patch.length2 = patch.length2 + #diff_text - postpatch_text = strsub(postpatch_text, 1, char_count2) .. diff_text .. strsub(postpatch_text, char_count2 + 1) - elseif diff_type == DIFF_DELETE then - patch.length1 = patch.length1 + #diff_text - patchDiffLength = patchDiffLength + 1 - patch.diffs[patchDiffLength] = diff - postpatch_text = strsub(postpatch_text, 1, char_count2) .. strsub(postpatch_text, char_count2 + #diff_text + 1) - elseif diff_type == DIFF_EQUAL then - if (#diff_text <= Patch_Margin * 2) and (patchDiffLength ~= 0) and (#diffs ~= x) then - -- Small equality inside a patch. - patchDiffLength = patchDiffLength + 1 - patch.diffs[patchDiffLength] = diff - patch.length1 = patch.length1 + #diff_text - patch.length2 = patch.length2 + #diff_text - elseif #diff_text >= Patch_Margin * 2 then - -- Time for a new patch. - if patchDiffLength ~= 0 then - _patch_addContext(patch, prepatch_text) - patches[#patches + 1] = patch - patch = _new_patch_obj() - patchDiffLength = 0 - -- Unlike Unidiff, our patch lists have a rolling context. - -- https://github.com/google/diff-match-patch/wiki/Unidiff - -- Update prepatch text & pos to reflect the application of the - -- just completed patch. - prepatch_text = postpatch_text - char_count1 = char_count2 - end - end - end - - -- Update the current character count. - if diff_type ~= DIFF_INSERT then - char_count1 = char_count1 + #diff_text - end - if diff_type ~= DIFF_DELETE then - char_count2 = char_count2 + #diff_text - end - end - - -- Pick up the leftover patch if not empty. - if patchDiffLength > 0 then - _patch_addContext(patch, prepatch_text) - patches[#patches + 1] = patch - end - - return patches -end - ---[[ -* Merge a set of patches onto the text. Return a patched text, as well -* as a list of true/false values indicating which patches were applied. -* @param {Array.<_new_patch_obj>} patches Array of patch objects. -* @param {string} text Old text. -* @return {Array.>} Two return values, the -* new text and an array of boolean values. ---]] -function patch_apply(patches, text) - if patches[1] == nil then - return text, {} - end - - -- Deep copy the patches so that no changes are made to originals. - patches = _patch_deepCopy(patches) - - local nullPadding = _patch_addPadding(patches) - text = nullPadding .. text .. nullPadding - - _patch_splitMax(patches) - -- delta keeps track of the offset between the expected and actual location - -- of the previous patch. If there are patches expected at positions 10 and - -- 20, but the first patch was found at 12, delta is 2 and the second patch - -- has an effective expected position of 22. - local delta = 0 - local results = {} - for x, patch in ipairs(patches) do - local expected_loc = patch.start2 + delta - local text1 = _diff_text1(patch.diffs) - local start_loc - local end_loc = -1 - if #text1 > Match_MaxBits then - -- _patch_splitMax will only provide an oversized pattern in - -- the case of a monster delete. - start_loc = match_main(text, strsub(text1, 1, Match_MaxBits), expected_loc) - if start_loc ~= -1 then - end_loc = match_main(text, strsub(text1, -Match_MaxBits), expected_loc + #text1 - Match_MaxBits) - if end_loc == -1 or start_loc >= end_loc then - -- Can't find valid trailing context. Drop this patch. - start_loc = -1 - end - end - else - start_loc = match_main(text, text1, expected_loc) - end - if start_loc == -1 then - -- No match found. :( - results[x] = false - -- Subtract the delta for this failed patch from subsequent patches. - delta = delta - patch.length2 - patch.length1 - else - -- Found a match. :) - results[x] = true - delta = start_loc - expected_loc - local text2 - if end_loc == -1 then - text2 = strsub(text, start_loc, start_loc + #text1 - 1) - else - text2 = strsub(text, start_loc, end_loc + Match_MaxBits - 1) - end - if text1 == text2 then - -- Perfect match, just shove the replacement text in. - text = strsub(text, 1, start_loc - 1) .. _diff_text2(patch.diffs) .. strsub(text, start_loc + #text1) - else - -- Imperfect match. Run a diff to get a framework of equivalent - -- indices. - local diffs = diff_main(text1, text2, false) - if (#text1 > Match_MaxBits) and (diff_levenshtein(diffs) / #text1 > Patch_DeleteThreshold) then - -- The end points match, but the content is unacceptably bad. - results[x] = false - else - _diff_cleanupSemanticLossless(diffs) - local index1 = 1 - local index2 - for y, mod in ipairs(patch.diffs) do - if mod[1] ~= DIFF_EQUAL then - index2 = _diff_xIndex(diffs, index1) - end - if mod[1] == DIFF_INSERT then - text = strsub(text, 1, start_loc + index2 - 2) .. mod[2] .. strsub(text, start_loc + index2 - 1) - elseif mod[1] == DIFF_DELETE then - text = strsub(text, 1, start_loc + index2 - 2) - .. strsub(text, start_loc + _diff_xIndex(diffs, index1 + #mod[2] - 1)) - end - if mod[1] ~= DIFF_DELETE then - index1 = index1 + #mod[2] - end - end - end - end - end - end - -- Strip the padding off. - text = strsub(text, #nullPadding + 1, -#nullPadding - 1) - return text, results -end - ---[[ -* Take a list of patches and return a textual representation. -* @param {Array.<_new_patch_obj>} patches Array of patch objects. -* @return {string} Text representation of patches. ---]] -function patch_toText(patches) - local text = {} - for x, patch in ipairs(patches) do - _patch_appendText(patch, text) - end - return tconcat(text) -end - ---[[ -* Parse a textual representation of patches and return a list of patch objects. -* @param {string} textline Text representation of patches. -* @return {Array.<_new_patch_obj>} Array of patch objects. -* @throws {Error} If invalid input. ---]] -function patch_fromText(textline) - local patches = {} - if #textline == 0 then - return patches - end - local text = {} - for line in gmatch(textline, '([^\n]*)') do - text[#text + 1] = line - end - local textPointer = 1 - while textPointer <= #text do - local start1, length1, start2, length2 = strmatch(text[textPointer], '^@@ %-(%d+),?(%d*) %+(%d+),?(%d*) @@$') - if start1 == nil then - error('Invalid patch string: "' .. text[textPointer] .. '"') - end - local patch = _new_patch_obj() - patches[#patches + 1] = patch - - start1 = tonumber(start1) - length1 = tonumber(length1) or 1 - if length1 == 0 then - start1 = start1 + 1 - end - patch.start1 = start1 - patch.length1 = length1 - - start2 = tonumber(start2) - length2 = tonumber(length2) or 1 - if length2 == 0 then - start2 = start2 + 1 - end - patch.start2 = start2 - patch.length2 = length2 - - textPointer = textPointer + 1 - - while true do - local line = text[textPointer] - if line == nil then - break - end - local sign - sign, line = strsub(line, 1, 1), strsub(line, 2) - - local invalidDecode = false - local decoded = gsub(line, '%%(.?.?)', function(c) - local n = tonumber(c, 16) - if (#c ~= 2) or (n == nil) then - invalidDecode = true - return '' - end - return strchar(n) - end) - if invalidDecode then - -- Malformed URI sequence. - error('Illegal escape in patch_fromText: ' .. line) - end - - line = decoded - - if sign == '-' then - -- Deletion. - patch.diffs[#patch.diffs + 1] = { DIFF_DELETE, line } - elseif sign == '+' then - -- Insertion. - patch.diffs[#patch.diffs + 1] = { DIFF_INSERT, line } - elseif sign == ' ' then - -- Minor equality. - patch.diffs[#patch.diffs + 1] = { DIFF_EQUAL, line } - elseif sign == '@' then - -- Start of next patch. - break - elseif sign == '' then - -- Blank line? Whatever. - else - -- WTF? - error('Invalid patch mode "' .. sign .. '" in: ' .. line) - end - textPointer = textPointer + 1 - end - end - return patches -end - --- --------------------------------------------------------------------------- --- UNOFFICIAL/PRIVATE PATCH FUNCTIONS --- --------------------------------------------------------------------------- - -local patch_meta = { - __tostring = function(patch) - local buf = {} - _patch_appendText(patch, buf) - return tconcat(buf) - end, -} - ---[[ -* Class representing one patch operation. -* @constructor ---]] -function _new_patch_obj() - return setmetatable({ - --[[ @type {Array.>} ]] - diffs = {}, - --[[ @type {?number} ]] - start1 = 1, -- nil; - --[[ @type {?number} ]] - start2 = 1, -- nil; - --[[ @type {number} ]] - length1 = 0, - --[[ @type {number} ]] - length2 = 0, - }, patch_meta) -end - ---[[ -* Increase the context until it is unique, -* but don't let the pattern expand beyond Match_MaxBits. -* @param {_new_patch_obj} patch The patch to grow. -* @param {string} text Source text. -* @private ---]] -function _patch_addContext(patch, text) - if #text == 0 then - return - end - local pattern = strsub(text, patch.start2, patch.start2 + patch.length1 - 1) - local padding = 0 - - -- LUANOTE: Lua's lack of a lastIndexOf function results in slightly - -- different logic here than in other language ports. - -- Look for the first two matches of pattern in text. If two are found, - -- increase the pattern length. - local firstMatch = indexOf(text, pattern) - local secondMatch = nil - if firstMatch ~= nil then - secondMatch = indexOf(text, pattern, firstMatch + 1) - end - while (#pattern == 0 or secondMatch ~= nil) and (#pattern < Match_MaxBits - Patch_Margin - Patch_Margin) do - padding = padding + Patch_Margin - pattern = strsub(text, max(1, patch.start2 - padding), patch.start2 + patch.length1 - 1 + padding) - firstMatch = indexOf(text, pattern) - if firstMatch ~= nil then - secondMatch = indexOf(text, pattern, firstMatch + 1) - else - secondMatch = nil - end - end - -- Add one chunk for good luck. - padding = padding + Patch_Margin - - -- Add the prefix. - local prefix = strsub(text, max(1, patch.start2 - padding), patch.start2 - 1) - if #prefix > 0 then - tinsert(patch.diffs, 1, { DIFF_EQUAL, prefix }) - end - -- Add the suffix. - local suffix = strsub(text, patch.start2 + patch.length1, patch.start2 + patch.length1 - 1 + padding) - if #suffix > 0 then - patch.diffs[#patch.diffs + 1] = { DIFF_EQUAL, suffix } - end - - -- Roll back the start points. - patch.start1 = patch.start1 - #prefix - patch.start2 = patch.start2 - #prefix - -- Extend the lengths. - patch.length1 = patch.length1 + #prefix + #suffix - patch.length2 = patch.length2 + #prefix + #suffix -end - ---[[ -* Given an array of patches, return another array that is identical. -* @param {Array.<_new_patch_obj>} patches Array of patch objects. -* @return {Array.<_new_patch_obj>} Array of patch objects. ---]] -function _patch_deepCopy(patches) - local patchesCopy = {} - for x, patch in ipairs(patches) do - local patchCopy = _new_patch_obj() - local diffsCopy = {} - for i, diff in ipairs(patch.diffs) do - diffsCopy[i] = { diff[1], diff[2] } - end - patchCopy.diffs = diffsCopy - patchCopy.start1 = patch.start1 - patchCopy.start2 = patch.start2 - patchCopy.length1 = patch.length1 - patchCopy.length2 = patch.length2 - patchesCopy[x] = patchCopy - end - return patchesCopy -end - ---[[ -* Add some padding on text start and end so that edges can match something. -* Intended to be called only from within patch_apply. -* @param {Array.<_new_patch_obj>} patches Array of patch objects. -* @return {string} The padding string added to each side. ---]] -function _patch_addPadding(patches) - local paddingLength = Patch_Margin - local nullPadding = '' - for x = 1, paddingLength do - nullPadding = nullPadding .. strchar(x) - end - - -- Bump all the patches forward. - for x, patch in ipairs(patches) do - patch.start1 = patch.start1 + paddingLength - patch.start2 = patch.start2 + paddingLength - end - - -- Add some padding on start of first diff. - local patch = patches[1] - local diffs = patch.diffs - local firstDiff = diffs[1] - if (firstDiff == nil) or (firstDiff[1] ~= DIFF_EQUAL) then - -- Add nullPadding equality. - tinsert(diffs, 1, { DIFF_EQUAL, nullPadding }) - patch.start1 = patch.start1 - paddingLength -- Should be 0. - patch.start2 = patch.start2 - paddingLength -- Should be 0. - patch.length1 = patch.length1 + paddingLength - patch.length2 = patch.length2 + paddingLength - elseif paddingLength > #firstDiff[2] then - -- Grow first equality. - local extraLength = paddingLength - #firstDiff[2] - firstDiff[2] = strsub(nullPadding, #firstDiff[2] + 1) .. firstDiff[2] - patch.start1 = patch.start1 - extraLength - patch.start2 = patch.start2 - extraLength - patch.length1 = patch.length1 + extraLength - patch.length2 = patch.length2 + extraLength - end - - -- Add some padding on end of last diff. - patch = patches[#patches] - diffs = patch.diffs - local lastDiff = diffs[#diffs] - if (lastDiff == nil) or (lastDiff[1] ~= DIFF_EQUAL) then - -- Add nullPadding equality. - diffs[#diffs + 1] = { DIFF_EQUAL, nullPadding } - patch.length1 = patch.length1 + paddingLength - patch.length2 = patch.length2 + paddingLength - elseif paddingLength > #lastDiff[2] then - -- Grow last equality. - local extraLength = paddingLength - #lastDiff[2] - lastDiff[2] = lastDiff[2] .. strsub(nullPadding, 1, extraLength) - patch.length1 = patch.length1 + extraLength - patch.length2 = patch.length2 + extraLength - end - - return nullPadding -end - ---[[ -* Look through the patches and break up any which are longer than the maximum -* limit of the match algorithm. -* Intended to be called only from within patch_apply. -* @param {Array.<_new_patch_obj>} patches Array of patch objects. ---]] -function _patch_splitMax(patches) - local patch_size = Match_MaxBits - local x = 1 - while true do - local patch = patches[x] - if patch == nil then - return - end - if patch.length1 > patch_size then - local bigpatch = patch - -- Remove the big old patch. - tremove(patches, x) - x = x - 1 - local start1 = bigpatch.start1 - local start2 = bigpatch.start2 - local precontext = '' - while bigpatch.diffs[1] do - -- Create one of several smaller patches. - local patch = _new_patch_obj() - local empty = true - patch.start1 = start1 - #precontext - patch.start2 = start2 - #precontext - if precontext ~= '' then - patch.length1, patch.length2 = #precontext, #precontext - patch.diffs[#patch.diffs + 1] = { DIFF_EQUAL, precontext } - end - while bigpatch.diffs[1] and (patch.length1 < patch_size - Patch_Margin) do - local diff_type = bigpatch.diffs[1][1] - local diff_text = bigpatch.diffs[1][2] - if diff_type == DIFF_INSERT then - -- Insertions are harmless. - patch.length2 = patch.length2 + #diff_text - start2 = start2 + #diff_text - patch.diffs[#patch.diffs + 1] = bigpatch.diffs[1] - tremove(bigpatch.diffs, 1) - empty = false - elseif - (diff_type == DIFF_DELETE) - and (#patch.diffs == 1) - and (patch.diffs[1][1] == DIFF_EQUAL) - and (#diff_text > 2 * patch_size) - then - -- This is a large deletion. Let it pass in one chunk. - patch.length1 = patch.length1 + #diff_text - start1 = start1 + #diff_text - empty = false - patch.diffs[#patch.diffs + 1] = { diff_type, diff_text } - tremove(bigpatch.diffs, 1) - else - -- Deletion or equality. - -- Only take as much as we can stomach. - diff_text = strsub(diff_text, 1, patch_size - patch.length1 - Patch_Margin) - patch.length1 = patch.length1 + #diff_text - start1 = start1 + #diff_text - if diff_type == DIFF_EQUAL then - patch.length2 = patch.length2 + #diff_text - start2 = start2 + #diff_text - else - empty = false - end - patch.diffs[#patch.diffs + 1] = { diff_type, diff_text } - if diff_text == bigpatch.diffs[1][2] then - tremove(bigpatch.diffs, 1) - else - bigpatch.diffs[1][2] = strsub(bigpatch.diffs[1][2], #diff_text + 1) - end - end - end - -- Compute the head context for the next patch. - precontext = _diff_text2(patch.diffs) - precontext = strsub(precontext, -Patch_Margin) - -- Append the end context for this patch. - local postcontext = strsub(_diff_text1(bigpatch.diffs), 1, Patch_Margin) - if postcontext ~= '' then - patch.length1 = patch.length1 + #postcontext - patch.length2 = patch.length2 + #postcontext - if patch.diffs[1] and (patch.diffs[#patch.diffs][1] == DIFF_EQUAL) then - patch.diffs[#patch.diffs][2] = patch.diffs[#patch.diffs][2] .. postcontext - else - patch.diffs[#patch.diffs + 1] = { DIFF_EQUAL, postcontext } - end - end - if not empty then - x = x + 1 - tinsert(patches, x, patch) - end - end - end - x = x + 1 - end -end - ---[[ -* Emulate GNU diff's format. -* Header: @@ -382,8 +481,9 @@ -* @return {string} The GNU diff string. ---]] -function _patch_appendText(patch, text) - local coords1, coords2 - local length1, length2 = patch.length1, patch.length2 - local start1, start2 = patch.start1, patch.start2 - local diffs = patch.diffs - - if length1 == 1 then - coords1 = start1 - else - coords1 = ((length1 == 0) and (start1 - 1) or start1) .. ',' .. length1 - end - - if length2 == 1 then - coords2 = start2 - else - coords2 = ((length2 == 0) and (start2 - 1) or start2) .. ',' .. length2 - end - text[#text + 1] = '@@ -' .. coords1 .. ' +' .. coords2 .. ' @@\n' - - local op - -- Escape the body of the patch with %xx notation. - for x, diff in ipairs(patch.diffs) do - local diff_type = diff[1] - if diff_type == DIFF_INSERT then - op = '+' - elseif diff_type == DIFF_DELETE then - op = '-' - elseif diff_type == DIFF_EQUAL then - op = ' ' - end - text[#text + 1] = op .. gsub(diffs[x][2], percentEncode_pattern, percentEncode_replace) .. '\n' - end - - return text -end - --- Expose the API -local _M = {} - -_M.DIFF_DELETE = DIFF_DELETE -_M.DIFF_INSERT = DIFF_INSERT -_M.DIFF_EQUAL = DIFF_EQUAL - -_M.diff_main = diff_main -_M.diff_cleanupSemantic = diff_cleanupSemantic -_M.diff_cleanupEfficiency = diff_cleanupEfficiency -_M.diff_levenshtein = diff_levenshtein -_M.diff_prettyHtml = diff_prettyHtml - -_M.match_main = match_main - -_M.patch_make = patch_make -_M.patch_toText = patch_toText -_M.patch_fromText = patch_fromText -_M.patch_apply = patch_apply - --- Expose some non-API functions as well, for testing purposes etc. -_M.diff_commonPrefix = _diff_commonPrefix -_M.diff_commonSuffix = _diff_commonSuffix -_M.diff_commonOverlap = _diff_commonOverlap -_M.diff_halfMatch = _diff_halfMatch -_M.diff_bisect = _diff_bisect -_M.diff_cleanupMerge = _diff_cleanupMerge -_M.diff_cleanupSemanticLossless = _diff_cleanupSemanticLossless -_M.diff_text1 = _diff_text1 -_M.diff_text2 = _diff_text2 -_M.diff_toDelta = _diff_toDelta -_M.diff_fromDelta = _diff_fromDelta -_M.diff_xIndex = _diff_xIndex -_M.match_alphabet = _match_alphabet -_M.match_bitap = _match_bitap -_M.new_patch_obj = _new_patch_obj -_M.patch_addContext = _patch_addContext -_M.patch_splitMax = _patch_splitMax -_M.patch_addPadding = _patch_addPadding -_M.settings = settings - -return _M diff --git a/tests/diff_spec.lua b/tests/diff_spec.lua index 58e2f4c9..bfaa19a4 100644 --- a/tests/diff_spec.lua +++ b/tests/diff_spec.lua @@ -229,4 +229,815 @@ describe('CopilotChat.utils.diff', function() assert.is_true(applied) assert.are.same({ 'newstart', 'context' }, result) end) + + it('may confuse similar variable names', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ -1,2 +1,2 @@ +-local x = 1 ++local x = 10 +]] + local original = { + 'local x = 1', + 'local y = 2', + 'local x = 3', + 'local z = 4', + } + local result, applied = diff.apply_unified_diff(diff_text, table.concat(original, '\n')) + assert.is_true(applied) + assert.are.same({ + 'local x = 10', + 'local y = 2', + 'local x = 3', + 'local z = 4', + }, result) + end) + + it('may match wrong substring with partial matches', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ -1,2 +1,2 @@ +-old_value ++new_value +]] + local original = { + 'value', + 'old_value', + 'very_old_value', + } + local result, applied = diff.apply_unified_diff(diff_text, table.concat(original, '\n')) + assert.is_false(applied) -- not applied cleanly, but adjusted + assert.are.same({ + 'value', + 'new_value', + 'very_old_value', + }, result) + end) + + it('may apply to wrong instance of identical boilerplate code', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ -1,3 +1,3 @@ + return { +- status = "old" ++ status = "new" +]] + local original = { + 'return {', + ' status = "old"', + '}', + 'return {', + ' status = "old"', + '}', + } + local result, applied = diff.apply_unified_diff(diff_text, table.concat(original, '\n')) + assert.is_true(applied) + assert.are.same({ + 'return {', + ' status = "new"', + '}', + 'return {', + ' status = "old"', + '}', + }, result) + end) + + it('allows adding at very start with zero original lines', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ -0,0 +1,2 @@ ++first ++second +]] + local original = { 'x', 'y' } + local original_content = table.concat(original, '\n') + local result, applied = diff.apply_unified_diff(diff_text, original_content) + assert.is_true(applied) + assert.are.same({ 'first', 'second', 'x', 'y' }, result) + end) + + it('handles insertion at end without context', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ -3,0 +4,2 @@ ++new1 ++new2 +]] + local original = { 'a', 'b', 'c' } + local original_content = table.concat(original, '\n') + local result, applied = diff.apply_unified_diff(diff_text, original_content) + assert.is_true(applied) + assert.are.same({ 'a', 'b', 'c', 'new1', 'new2' }, result) + end) + + it('supports multiple adjacent hunks modifying contiguous lines', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ -1,1 +1,1 @@ +-a ++x +@@ -2,1 +2,1 @@ +-b ++y +]] + local original = { 'a', 'b', 'c' } + local original_content = table.concat(original, '\n') + local result, applied = diff.apply_unified_diff(diff_text, original_content) + assert.is_true(applied) + assert.are.same({ 'x', 'y', 'c' }, result) + end) + + it('handles diff with trailing newline missing in original', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ -1,1 +1,1 @@ +-old ++new +]] + local original_content = 'old' -- no trailing newline + local result, applied = diff.apply_unified_diff(diff_text, original_content) + assert.is_true(applied) + assert.are.same({ 'new' }, result) + end) + + it('handles diff ending without newline on addition lines', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ -1,1 +1,2 @@ + old ++new]] + local original = { 'old' } + local original_content = table.concat(original, '\n') + local result, applied = diff.apply_unified_diff(diff_text, original_content) + assert.is_true(applied) + assert.are.same({ 'old', 'new' }, result) + end) + + it('handles hunks with zero-context lines around changes', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ -2,0 +3,1 @@ ++added +]] + local original = { 'a', 'b', 'c' } + local original_content = table.concat(original, '\n') + local result, applied = diff.apply_unified_diff(diff_text, original_content) + assert.is_true(applied) + assert.are.same({ 'a', 'b', 'added', 'c' }, result) + end) + + it('handles insertion of identical-to-context line', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ -1,1 +1,2 @@ + context ++context +]] + local original = { 'context', 'other' } + local result, applied = diff.apply_unified_diff(diff_text, table.concat(original, '\n')) + assert.is_true(applied) + assert.are.same({ 'context', 'context', 'other' }, result) + end) + + it('rejects hunk with wrong header lengths', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ -1,3 +1,3 @@ + context +-old ++new +]] + local original = { 'context' } + local result, applied = diff.apply_unified_diff(diff_text, table.concat(original, '\n')) + -- Fuzzy matching may still apply despite wrong header lengths + assert.is_not_nil(result) + end) + + it('handles CRLF original with unix diff', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ -1,1 +1,1 @@ +-old ++new +]] + local original_content = 'old\r\n' + local result, applied = diff.apply_unified_diff(diff_text, original_content) + assert.is_true(applied) + assert.is_not_nil(result) + assert.is_true(#result >= 1) + end) + + it('handles large insertion with no context', function() + local lines = {} + for i = 1, 10 do + table.insert(lines, '+line' .. i) + end + local diff_text = '--- a/foo.txt\n+++ b/foo.txt\n@@ -4,0 +5,10 @@\n' .. table.concat(lines, '\n') .. '\n' + local original = { 'a', 'b', 'c', 'd', 'e' } + local result, applied = diff.apply_unified_diff(diff_text, table.concat(original, '\n')) + assert.is_true(applied) + local expected = { 'a', 'b', 'c', 'd' } + for i = 1, 10 do + table.insert(expected, 'line' .. i) + end + table.insert(expected, 'e') + assert.are.same(expected, result) + end) + + it('rejects mismatched deletion ranges', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ -1,3 +0,0 @@ +-old1 +-old2 +-old3 +]] + local original = { 'single' } + local result, applied = diff.apply_unified_diff(diff_text, table.concat(original, '\n')) + -- Fuzzy matching may apply the deletion despite mismatch + assert.is_not_nil(result) + end) + + it('handles mixed operations in one hunk', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ -1,5 +1,4 @@ + context1 +-old + unchanged +-old2 ++new2 + context2 +]] + local original = { 'context1', 'old', 'unchanged', 'old2', 'context2' } + local result, applied = diff.apply_unified_diff(diff_text, table.concat(original, '\n')) + assert.is_true(applied) + assert.are.same({ 'context1', 'unchanged', 'new2', 'context2' }, result) + end) + + it('handles leading tabs/spaces inside context lines', function() + local diff_text = [[ +--- a/x ++++ b/x +@@ -1,2 +1,2 @@ + indented +-old ++new +]] + local original = { '\tindented', 'old' } + local result, applied = diff.apply_unified_diff(diff_text, table.concat(original, '\n')) + assert.is_true(applied) + assert.are.same({ '\tindented', 'new' }, result) + end) + + it('respects diff markers even if content begins with + or -', function() + local diff_text = [[ +--- a/x ++++ b/x +@@ -1,2 +1,2 @@ +-+literalplus +--literalminus +++literalplus +++literalminus +]] + local original = { '+literalplus', '-literalminus' } + local result, applied = diff.apply_unified_diff(diff_text, table.concat(original, '\n')) + assert.is_true(applied) + assert.are.same({ '+literalplus', '+literalminus' }, result) + end) + + it('applies diff despite slight context mismatch with fuzzy matching', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ -1,3 +1,3 @@ + slightly different context +-old ++new +]] + local original = { 'context', 'old', 'other' } + local result, applied = diff.apply_unified_diff(diff_text, table.concat(original, '\n')) + -- Fuzzy matching will replace context lines that don't match + assert.are.same({ 'slightly different context', 'new', 'other' }, result) + end) + + it('applies even when context is completely wrong due to fuzzy matching', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ -1,3 +1,3 @@ + totally wrong line + another wrong line +-old ++new +]] + local original = { 'context1', 'context2', 'old' } + local result, applied = diff.apply_unified_diff(diff_text, table.concat(original, '\n')) + -- Fuzzy matching will replace all old_snippet lines (including wrong context) with new_snippet + assert.are.same({ 'totally wrong line', 'another wrong line', 'new' }, result) + end) + + it('applies with partial context match', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ -2,3 +2,3 @@ + matching +-old ++new +]] + local original = { 'first', 'matching', 'old', 'last' } + local result, applied = diff.apply_unified_diff(diff_text, table.concat(original, '\n')) + assert.is_true(applied) + assert.are.same({ 'first', 'matching', 'new', 'last' }, result) + end) + + it('handles context with extra lines not in original', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ -1,5 +1,5 @@ + context1 + context2 + context3 +-old ++new +]] + local original = { 'context1', 'old' } + local result, applied = diff.apply_unified_diff(diff_text, table.concat(original, '\n')) + -- Should fail or apply with fuzzy matching + assert.is_not_nil(result) + end) + + it('fails when deletion target does not exist', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ -1,2 +1,1 @@ + context +-nonexistent +]] + local original = { 'context', 'actual' } + local result, applied = diff.apply_unified_diff(diff_text, table.concat(original, '\n')) + -- Fuzzy matching might still apply or fail + assert.is_not_nil(result) + end) + + it('applies when context lines are in different order', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ -1,3 +1,3 @@ + line2 + line1 +-old ++new +]] + local original = { 'line1', 'line2', 'old' } + local result, applied = diff.apply_unified_diff(diff_text, table.concat(original, '\n')) + -- Fuzzy matching should handle reordered context + assert.is_not_nil(result) + end) + + it('adds max_retry_time and cumulative retry logic', function() + local diff_text = [[ +--- original.py ++++ modified.py +@@ -24,6 +24,7 @@ + import time + + retry_statuses = {HTTPStatus.TOO_MANY_REQUESTS, 502, 503, 504} ++ max_retry_time = 120 # Maximum cumulative retry time in seconds + retry_exceptions = ( + httpx.ReadTimeout, + httpx.ConnectTimeout, +@@ -34,6 +35,7 @@ + def deco(fn): + def wrapped(*args, **kwargs): + last_exc = None ++ total_retry_time = 0 # Track cumulative retry time + for attempt in range(retries): + try: + resp = fn(*args, **kwargs) +@@ -43,6 +45,9 @@ + delay = min(max_backoff, backoff * (2**attempt)) * ( + 1 + random.random() * 0.25 + ) ++ if total_retry_time + delay > max_retry_time: ++ raise TimeoutError("Exceeded maximum retry time of 120 seconds") ++ total_retry_time += delay + time.sleep(delay) + continue + +@@ -59,6 +64,9 @@ + delay = min(max_backoff, backoff * (2**attempt)) * ( + 1 + random.random() * 0.25 + ) ++ if total_retry_time + delay > max_retry_time: ++ raise TimeoutError("Exceeded maximum retry time of 120 seconds") ++ total_retry_time += delay + time.sleep(delay) + continue +]] + local original = [[ +import base64 +import json +import logging +import os +import random +from datetime import datetime, time +from http import HTTPStatus + +import geojson +import httpx +from cachetools import TTLCache, cached +from geopy.distance import geodesic +from shapely.geometry import MultiPolygon, Polygon, shape + +logger = logging.getLogger(__name__) + +httpx_client = httpx.Client( + timeout=10.0, + limits=httpx.Limits(max_keepalive_connections=20, max_connections=100), +) + + +def retry_request(retries=10, backoff=1, max_backoff=40.0): + import time + + retry_statuses = {HTTPStatus.TOO_MANY_REQUESTS, 502, 503, 504} + retry_exceptions = ( + httpx.ReadTimeout, + httpx.ConnectTimeout, + httpx.NetworkError, # includes transient connection errors + httpx.RemoteProtocolError, + ) + + def deco(fn): + def wrapped(*args, **kwargs): + last_exc = None + for attempt in range(retries): + try: + resp = fn(*args, **kwargs) + except retry_exceptions as exc: + last_exc = exc + # backoff and retry + delay = min(max_backoff, backoff * (2**attempt)) * ( + 1 + random.random() * 0.25 + ) + time.sleep(delay) + continue + + # Retry on selected HTTP status + if resp.status_code in retry_statuses: + # honor Retry-After if present + ra = resp.headers.get("Retry-After") + if ra: + try: + delay = min(max_backoff, float(ra)) + except ValueError: + delay = min(max_backoff, backoff * (2**attempt)) + else: + delay = min(max_backoff, backoff * (2**attempt)) * ( + 1 + random.random() * 0.25 + ) + time.sleep(delay) + continue + + return resp + + if last_exc: + raise last_exc + return resp + + return wrapped + + return deco +]] + local expected = [[ +import base64 +import json +import logging +import os +import random +from datetime import datetime, time +from http import HTTPStatus + +import geojson +import httpx +from cachetools import TTLCache, cached +from geopy.distance import geodesic +from shapely.geometry import MultiPolygon, Polygon, shape + +logger = logging.getLogger(__name__) + +httpx_client = httpx.Client( + timeout=10.0, + limits=httpx.Limits(max_keepalive_connections=20, max_connections=100), +) + + +def retry_request(retries=10, backoff=1, max_backoff=40.0): + import time + + retry_statuses = {HTTPStatus.TOO_MANY_REQUESTS, 502, 503, 504} + max_retry_time = 120 # Maximum cumulative retry time in seconds + retry_exceptions = ( + httpx.ReadTimeout, + httpx.ConnectTimeout, + httpx.NetworkError, # includes transient connection errors + httpx.RemoteProtocolError, + ) + + def deco(fn): + def wrapped(*args, **kwargs): + last_exc = None + total_retry_time = 0 # Track cumulative retry time + for attempt in range(retries): + try: + resp = fn(*args, **kwargs) + except retry_exceptions as exc: + last_exc = exc + # backoff and retry + delay = min(max_backoff, backoff * (2**attempt)) * ( + 1 + random.random() * 0.25 + ) + if total_retry_time + delay > max_retry_time: + raise TimeoutError("Exceeded maximum retry time of 120 seconds") + total_retry_time += delay + time.sleep(delay) + continue + + # Retry on selected HTTP status + if resp.status_code in retry_statuses: + # honor Retry-After if present + ra = resp.headers.get("Retry-After") + if ra: + try: + delay = min(max_backoff, float(ra)) + except ValueError: + delay = min(max_backoff, backoff * (2**attempt)) + else: + delay = min(max_backoff, backoff * (2**attempt)) * ( + 1 + random.random() * 0.25 + ) + if total_retry_time + delay > max_retry_time: + raise TimeoutError("Exceeded maximum retry time of 120 seconds") + total_retry_time += delay + time.sleep(delay) + continue + + return resp + + if last_exc: + raise last_exc + return resp + + return wrapped + + return deco +]] + local result, applied = diff.apply_unified_diff(diff_text, original) + local expected_lines = vim.split(expected, '\n', { trimempty = true }) + assert.are.same(expected_lines, result) + end) + + -- Tests for offset tracking in sequential hunk application + it('correctly applies offset when first hunk adds lines', function() + local diff_text = [[ +--- a/test.txt ++++ b/test.txt +@@ -1,2 +1,4 @@ + line1 ++added1 ++added2 + line2 +@@ -3,1 +5,1 @@ + line3 +]] + local original = { 'line1', 'line2', 'line3' } + local original_content = table.concat(original, '\n') + local result, applied = diff.apply_unified_diff(diff_text, original_content) + assert.is_true(applied) + assert.are.same({ 'line1', 'added1', 'added2', 'line2', 'line3' }, result) + end) + + it('correctly applies offset when first hunk removes lines', function() + local diff_text = [[ +--- a/test.txt ++++ b/test.txt +@@ -1,3 +1,1 @@ + line1 +-line2 +-line3 +@@ -4,1 +2,1 @@ + line4 +]] + local original = { 'line1', 'line2', 'line3', 'line4' } + local original_content = table.concat(original, '\n') + local result, applied = diff.apply_unified_diff(diff_text, original_content) + assert.is_true(applied) + assert.are.same({ 'line1', 'line4' }, result) + end) + + it('correctly tracks offset through multiple hunks with mixed add/remove', function() + local diff_text = [[ +--- a/test.txt ++++ b/test.txt +@@ -1,1 +1,2 @@ + a ++b +@@ -2,1 +3,1 @@ +-c ++C +@@ -3,1 +4,3 @@ + d ++e ++f +]] + local original = { 'a', 'c', 'd' } + local original_content = table.concat(original, '\n') + local result, applied = diff.apply_unified_diff(diff_text, original_content) + assert.is_true(applied) + assert.are.same({ 'a', 'b', 'C', 'd', 'e', 'f' }, result) + end) + + it('handles offset when hunks are far apart', function() + local diff_text = [[ +--- a/test.txt ++++ b/test.txt +@@ -2,1 +2,2 @@ + line2 ++inserted +@@ -10,1 +11,1 @@ +-line10 ++LINE10 +]] + local original = { + 'line1', + 'line2', + 'line3', + 'line4', + 'line5', + 'line6', + 'line7', + 'line8', + 'line9', + 'line10', + } + local original_content = table.concat(original, '\n') + local result, applied = diff.apply_unified_diff(diff_text, original_content) + assert.is_true(applied) + local expected = { + 'line1', + 'line2', + 'inserted', + 'line3', + 'line4', + 'line5', + 'line6', + 'line7', + 'line8', + 'line9', + 'LINE10', + } + assert.are.same(expected, result) + end) + + it('applies three consecutive hunks with positive offset accumulation', function() + local diff_text = [[ +--- a/test.txt ++++ b/test.txt +@@ -1,1 +1,2 @@ + a ++b +@@ -2,1 +3,2 @@ + c ++d +@@ -3,1 +5,2 @@ + e ++f +]] + local original = { 'a', 'c', 'e' } + local original_content = table.concat(original, '\n') + local result, applied = diff.apply_unified_diff(diff_text, original_content) + assert.is_true(applied) + assert.are.same({ 'a', 'b', 'c', 'd', 'e', 'f' }, result) + end) + + it('applies three consecutive hunks with negative offset accumulation', function() + local diff_text = [[ +--- a/test.txt ++++ b/test.txt +@@ -1,2 +1,1 @@ +-x + a +@@ -3,2 +2,1 @@ +-y + b +@@ -5,2 +3,1 @@ +-z + c +]] + local original = { 'x', 'a', 'y', 'b', 'z', 'c' } + local original_content = table.concat(original, '\n') + local result, applied = diff.apply_unified_diff(diff_text, original_content) + assert.is_true(applied) + assert.are.same({ 'a', 'b', 'c' }, result) + end) + + it('handles zero-offset hunks (replacements without size change)', function() + local diff_text = [[ +--- a/test.txt ++++ b/test.txt +@@ -1,1 +1,1 @@ +-old1 ++new1 +@@ -2,1 +2,1 @@ +-old2 ++new2 +@@ -3,1 +3,1 @@ +-old3 ++new3 +]] + local original = { 'old1', 'old2', 'old3' } + local original_content = table.concat(original, '\n') + local result, applied = diff.apply_unified_diff(diff_text, original_content) + assert.is_true(applied) + assert.are.same({ 'new1', 'new2', 'new3' }, result) + end) + + it('applies offset correctly when first hunk is pure insertion (len_old=0)', function() + local diff_text = [[ +--- a/test.txt ++++ b/test.txt +@@ -0,0 +1,2 @@ ++inserted1 ++inserted2 +@@ -1,1 +3,1 @@ + original +]] + local original = { 'original' } + local original_content = table.concat(original, '\n') + local result, applied = diff.apply_unified_diff(diff_text, original_content) + assert.is_true(applied) + assert.are.same({ 'inserted1', 'inserted2', 'original' }, result) + end) + + it('handles complex offset scenario with interleaved additions and deletions', function() + local diff_text = [[ +--- a/test.txt ++++ b/test.txt +@@ -1,2 +1,1 @@ +-delete1 + keep1 +@@ -3,1 +2,3 @@ + keep2 ++add1 ++add2 +@@ -4,2 +5,1 @@ +-delete2 + keep3 +]] + local original = { 'delete1', 'keep1', 'keep2', 'delete2', 'keep3' } + local original_content = table.concat(original, '\n') + local result, applied = diff.apply_unified_diff(diff_text, original_content) + assert.is_true(applied) + assert.are.same({ 'keep1', 'keep2', 'add1', 'add2', 'keep3' }, result) + end) + + it('offset tracking works with hunks that have context lines', function() + local diff_text = [[ +--- a/test.txt ++++ b/test.txt +@@ -1,3 +1,4 @@ + ctx1 + line1 ++inserted + ctx2 +@@ -5,2 +6,2 @@ + ctx3 +-line2 ++LINE2 +]] + local original = { 'ctx1', 'line1', 'ctx2', 'ctx3', 'line2' } + local original_content = table.concat(original, '\n') + local result, applied = diff.apply_unified_diff(diff_text, original_content) + assert.is_true(applied) + assert.are.same({ 'ctx1', 'line1', 'inserted', 'ctx2', 'ctx3', 'LINE2' }, result) + end) end) diff --git a/tests/notify_spec.lua b/tests/notify_spec.lua new file mode 100644 index 00000000..020c9391 --- /dev/null +++ b/tests/notify_spec.lua @@ -0,0 +1,126 @@ +local notify = require('CopilotChat.utils.notify') + +describe('CopilotChat.notify', function() + before_each(function() + -- Clear all listeners before each test + notify.clear() + end) + + describe('publish and listen', function() + it('calls listener when event is published', function() + local called = false + local received_data = nil + + notify.listen('test_event', function(data) + called = true + received_data = data + end) + + notify.publish('test_event', 'test_data') + + assert.is_true(called) + assert.equals('test_data', received_data) + end) + + it('supports multiple listeners for same event', function() + local count = 0 + + notify.listen('test_event', function(data) + count = count + 1 + end) + notify.listen('test_event', function(data) + count = count + 10 + end) + + notify.publish('test_event', 'data') + + assert.equals(11, count) + end) + + it('does not call listeners for different events', function() + local called = false + + notify.listen('event_a', function(data) + called = true + end) + + notify.publish('event_b', 'data') + + assert.is_false(called) + end) + + it('passes correct data to listeners', function() + local received = nil + + notify.listen('test_event', function(data) + received = data + end) + + notify.publish('test_event', { foo = 'bar', num = 123 }) + + assert.are.same({ foo = 'bar', num = 123 }, received) + end) + + it('handles nil and empty data', function() + local received = 'not_called' + + notify.listen('test_event', function(data) + received = data + end) + + notify.publish('test_event', nil) + assert.is_nil(received) + + notify.publish('test_event', '') + assert.equals('', received) + end) + + it('handles publishing to events with no listeners', function() + -- Should not error + assert.has_no.errors(function() + notify.publish('nonexistent_event', 'data') + end) + end) + end) + + describe('clear', function() + it('removes all listeners', function() + local called = false + + notify.listen('test_event', function(data) + called = true + end) + + notify.clear() + notify.publish('test_event', 'data') + + assert.is_false(called) + end) + + it('allows adding new listeners after clear', function() + local called = false + + notify.listen('test_event', function(data) + called = true + end) + notify.clear() + + notify.listen('test_event', function(data) + called = true + end) + notify.publish('test_event', 'data') + + assert.is_true(called) + end) + end) + + describe('constants', function() + it('defines STATUS constant', function() + assert.equals('status', notify.STATUS) + end) + + it('defines MESSAGE constant', function() + assert.equals('message', notify.MESSAGE) + end) + end) +end)