|
| 1 | +# translate.nvim |
| 2 | + |
| 3 | +Minimal translation plugin for Neovim with DeepL and Google Cloud Translation engines. |
| 4 | + |
| 5 | + |
| 6 | + |
| 7 | + |
| 8 | + |
| 9 | +## Features |
| 10 | + |
| 11 | +- Translate visual selections with a single keymap |
| 12 | +- DeepL and Google Cloud Translation API v2 support |
| 13 | +- Floating window output with centered engine title in border |
| 14 | +- Target language picker (`vim.ui.select`) per engine |
| 15 | +- Engine switcher with automatic target language normalization |
| 16 | +- Persist last-used engine and target language across sessions |
| 17 | +- Preserve original line structure: blank lines and leading indentation are kept intact |
| 18 | +- Parallel chunking for large selections (50 lines per request) |
| 19 | +- Race guard: rapid re-translations cancel stale in-flight requests |
| 20 | +- Security: API keys are passed via curl stdin (`--config -`), never as CLI arguments |
| 21 | + |
| 22 | +## Requirements |
| 23 | + |
| 24 | +- Neovim 0.10+ (`vim.system()` required) |
| 25 | +- `curl` in `$PATH` |
| 26 | +- At least one API key: |
| 27 | + - **DeepL**: `DEEPL_AUTH_KEY` environment variable or `setup({ api_key = "..." })` |
| 28 | + - **Google**: `GOOGLE_TRANSLATE_API_KEY` or `GOOGLE_API_KEY` environment variable, or `setup({ google_api_key = "..." })` |
| 29 | + |
| 30 | +## Installation |
| 31 | + |
| 32 | +### lazy.nvim |
| 33 | + |
| 34 | +```lua |
| 35 | +{ |
| 36 | + "addsalt1t/translate.nvim", |
| 37 | + opts = { |
| 38 | + api_key = vim.env.DEEPL_AUTH_KEY, |
| 39 | + }, |
| 40 | +} |
| 41 | +``` |
| 42 | + |
| 43 | +### packer.nvim |
| 44 | + |
| 45 | +```lua |
| 46 | +use({ |
| 47 | + "addsalt1t/translate.nvim", |
| 48 | + config = function() |
| 49 | + require("translate").setup({ |
| 50 | + api_key = vim.env.DEEPL_AUTH_KEY, |
| 51 | + }) |
| 52 | + end, |
| 53 | +}) |
| 54 | +``` |
| 55 | + |
| 56 | +### Local Development |
| 57 | + |
| 58 | +```lua |
| 59 | +{ |
| 60 | + dir = "/path/to/translate.nvim", |
| 61 | + name = "translate.nvim", |
| 62 | + config = function() |
| 63 | + require("translate").setup() |
| 64 | + end, |
| 65 | +} |
| 66 | +``` |
| 67 | + |
| 68 | +## Configuration |
| 69 | + |
| 70 | +The plugin auto-calls `setup()` on load with sensible defaults. Override any option as needed. |
| 71 | + |
| 72 | +### Default configuration |
| 73 | + |
| 74 | +```lua |
| 75 | +require("translate").setup({ |
| 76 | + api_key = vim.env.DEEPL_AUTH_KEY, |
| 77 | + google_api_key = vim.env.GOOGLE_TRANSLATE_API_KEY or vim.env.GOOGLE_API_KEY, |
| 78 | + engine = "deepl", |
| 79 | + engine_labels = { |
| 80 | + deepl = "DeepL", |
| 81 | + google = "Google", |
| 82 | + }, |
| 83 | + free_api = true, |
| 84 | + default_target = "KO", |
| 85 | + persist_target = true, |
| 86 | + state_path = vim.fs.normalize(vim.fn.stdpath("state") .. "/translate.nvim/state.json"), |
| 87 | + keymaps = { |
| 88 | + translate_visual = "<Space>tr", |
| 89 | + select_target = "<Space>tl", |
| 90 | + select_engine = "<Space>te", |
| 91 | + }, |
| 92 | + float = { |
| 93 | + border = "rounded", |
| 94 | + winhighlight = "NormalFloat:Normal,FloatBorder:Normal", |
| 95 | + size_base = "window", |
| 96 | + width_ratio = 1.0, |
| 97 | + height_ratio = 1.0, |
| 98 | + width_offset = 0, |
| 99 | + height_offset = 0, |
| 100 | + min_width = 40, |
| 101 | + min_height = 8, |
| 102 | + inherit_view = true, |
| 103 | + center_vertical = false, |
| 104 | + -- width = 100, -- absolute override (skips ratio calculation) |
| 105 | + -- height = 14, -- absolute override (skips ratio calculation) |
| 106 | + }, |
| 107 | +}) |
| 108 | +``` |
| 109 | + |
| 110 | +### Option reference |
| 111 | + |
| 112 | +| Option | Type | Default | Description | |
| 113 | +|---|---|---|---| |
| 114 | +| `api_key` | `string?` | `$DEEPL_AUTH_KEY` | DeepL API authentication key | |
| 115 | +| `google_api_key` | `string?` | `$GOOGLE_TRANSLATE_API_KEY` | Google Cloud Translation API key | |
| 116 | +| `engine` | `string` | `"deepl"` | Active translation engine (`"deepl"` or `"google"`) | |
| 117 | +| `engine_labels` | `table` | `{ deepl="DeepL", google="Google" }` | Display labels for engine names | |
| 118 | +| `free_api` | `boolean` | `true` | Use DeepL free API endpoint (`api-free.deepl.com`) | |
| 119 | +| `default_target` | `string` | `"KO"` | Fallback target language when current is invalid for engine | |
| 120 | +| `persist_target` | `boolean` | `true` | Save engine and target language to disk between sessions | |
| 121 | +| `state_path` | `string` | `stdpath("state").."/translate.nvim/state.json"` | Absolute path for persisted state file | |
| 122 | +| `keymaps.translate_visual` | `string` | `"<Space>tr"` | Keymap to translate visual selection | |
| 123 | +| `keymaps.select_target` | `string` | `"<Space>tl"` | Keymap to open target language picker | |
| 124 | +| `keymaps.select_engine` | `string` | `"<Space>te"` | Keymap to open engine picker | |
| 125 | + |
| 126 | +### Float window options |
| 127 | + |
| 128 | +| Option | Type | Default | Description | |
| 129 | +|---|---|---|---| |
| 130 | +| `float.border` | `string` | `"rounded"` | Border style (see `:h nvim_open_win`) | |
| 131 | +| `float.winhighlight` | `string` | `"NormalFloat:Normal,FloatBorder:Normal"` | Window highlight groups | |
| 132 | +| `float.size_base` | `string` | `"window"` | Base dimensions from `"window"` or `"editor"` | |
| 133 | +| `float.width_ratio` | `number` | `1.0` | Width as fraction of base (0.0-1.0) | |
| 134 | +| `float.height_ratio` | `number` | `1.0` | Height as fraction of base (0.0-1.0) | |
| 135 | +| `float.width_offset` | `number` | `0` | Additive columns after ratio calculation | |
| 136 | +| `float.height_offset` | `number` | `0` | Additive rows after ratio calculation | |
| 137 | +| `float.min_width` | `integer` | `40` | Minimum window width in columns | |
| 138 | +| `float.min_height` | `integer` | `8` | Minimum window height in rows | |
| 139 | +| `float.width` | `integer?` | `nil` | Absolute width override (skips ratio) | |
| 140 | +| `float.height` | `integer?` | `nil` | Absolute height override (skips ratio) | |
| 141 | +| `float.inherit_view` | `boolean` | `true` | Copy `tabstop`, `shiftwidth`, etc. from source window | |
| 142 | +| `float.center_vertical` | `boolean` | `false` | Vertically center short text in the float | |
| 143 | + |
| 144 | +> **Deprecated:** `max_width_ratio` and `max_height_ratio` are silently migrated to `width_ratio`/`height_ratio`. |
| 145 | +
|
| 146 | +### Engine selection priority |
| 147 | + |
| 148 | +The active engine is resolved in this order: |
| 149 | + |
| 150 | +1. Explicit `engine` option passed to `setup()` |
| 151 | +2. Saved engine from state file (if `persist_target = true`) |
| 152 | +3. First-run auto-prefer: `"google"` when both API keys are present and no saved state |
| 153 | +4. Base default: `"deepl"` |
| 154 | + |
| 155 | +## Translation Engines |
| 156 | + |
| 157 | +### Provider comparison |
| 158 | + |
| 159 | +| | DeepL | Google Cloud Translation | |
| 160 | +|---|---|---| |
| 161 | +| API | DeepL API v2 | Cloud Translation API v2 | |
| 162 | +| Target languages | 35 | 109 | |
| 163 | +| API key env var | `DEEPL_AUTH_KEY` | `GOOGLE_TRANSLATE_API_KEY` / `GOOGLE_API_KEY` | |
| 164 | +| Auth method | `Authorization` header | `X-Goog-Api-Key` header | |
| 165 | +| Free tier | `free_api = true` (default) | N/A (pay-per-use) | |
| 166 | +| Max texts/request | 50 | 50 | |
| 167 | +| Language codes | Region-specific (e.g. `EN-US`, `PT-BR`) | Simple (e.g. `EN`, `PT`) | |
| 168 | + |
| 169 | +### Engine switching |
| 170 | + |
| 171 | +When switching engines via `:TranslateSelectEngine` or `set_engine()`: |
| 172 | + |
| 173 | +- The current target language is normalized and validated for the new engine |
| 174 | +- If the target is unsupported, it falls back to `default_target` |
| 175 | +- Language code aliases are applied automatically (e.g. `EN` → `EN-US` for DeepL) |
| 176 | + |
| 177 | +## Keymaps |
| 178 | + |
| 179 | +| Keymap | Mode | Action | Default | |
| 180 | +|---|---|---|---| |
| 181 | +| `keymaps.translate_visual` | `x` | Translate visual selection | `<Space>tr` | |
| 182 | +| `keymaps.select_target` | `n`, `x` | Open target language picker | `<Space>tl` | |
| 183 | +| `keymaps.select_engine` | `n`, `x` | Open engine picker | `<Space>te` | |
| 184 | + |
| 185 | +Set any keymap to `""` (empty string) to disable it. |
| 186 | + |
| 187 | +## Commands |
| 188 | + |
| 189 | +| Command | Description | |
| 190 | +|---|---| |
| 191 | +| `:TranslateSelectTarget` | Open target language picker for current engine | |
| 192 | +| `:TranslateSelectEngine` | Switch translation engine (`deepl` / `google`) | |
| 193 | + |
| 194 | +## Lua API |
| 195 | + |
| 196 | +```lua |
| 197 | +local translate = require("translate") |
| 198 | + |
| 199 | +-- Setup with options (auto-called on plugin load) |
| 200 | +translate.setup(opts?) |
| 201 | + |
| 202 | +-- Translate current visual selection and show result in float |
| 203 | +translate.translate_visual() |
| 204 | + |
| 205 | +-- Open target language picker (vim.ui.select) |
| 206 | +translate.select_target() |
| 207 | + |
| 208 | +-- Open engine picker (vim.ui.select) |
| 209 | +translate.select_engine() |
| 210 | + |
| 211 | +-- Set target language programmatically |
| 212 | +translate.set_target(code) -- e.g. "EN", "JA", "KO" |
| 213 | + |
| 214 | +-- Set engine programmatically |
| 215 | +translate.set_engine(engine) -- "deepl" or "google" |
| 216 | + |
| 217 | +-- Query current state |
| 218 | +translate.current_target() -- returns e.g. "KO" |
| 219 | +translate.current_engine() -- returns e.g. "deepl" |
| 220 | +``` |
| 221 | + |
| 222 | +## Health Check |
| 223 | + |
| 224 | +```vim |
| 225 | +:checkhealth translate |
| 226 | +``` |
| 227 | + |
| 228 | +Checks: Neovim version (0.10+), `curl` availability, API key environment variables. |
| 229 | + |
| 230 | +## Testing |
| 231 | + |
| 232 | +Run the headless regression test suite: |
| 233 | + |
| 234 | +```bash |
| 235 | +nvim --headless -u NONE -i NONE "+set rtp+=." "+lua require('tests.headless.run_all').run()" "+qa!" |
| 236 | +``` |
| 237 | + |
| 238 | +## License |
| 239 | + |
| 240 | +MIT |
0 commit comments