A lightweight Neovim plugin for displaying and managing project i18n (translation) files directly in the editor.
Designed to work across most project types (front-end, backend, mixed monorepos), supporting JSON, YAML, Java .properties, and JS/TS translation modules (Tree-sitter parses JS/TS translation objects).
system.title).{module}, {locales}).Example configuration using lazy.nvim:
{
'yelog/i18n.nvim',
dependencies = {
'ibhagwan/fzf-lua',
'nvim-treesitter/nvim-treesitter'
},
config = function()
require('i18n').setup({
-- Locales to parse; first is the default locale
-- Use I18nNextLocale command to switch the default locale in real time
locales = { 'en', 'zh' },
-- sources can be string or table { pattern = "...", prefix = "..." }
sources = {
'src/locales/{locales}.json',
-- { pattern = "src/locales/lang/{locales}/{module}.ts", prefix = "{module}." },
-- { pattern = "src/views/{bu}/locales/lang/{locales}/{module}.ts", prefix = "{bu}.{module}." },
}
})
end
}
sources and locales to match your project layout.Recommended keymaps (lazy.nvim keys example, using the global I18n helper):
keys = {
{ "<D-S-n>", function() I18n.i18n_keys() end, desc = "Show i18n keys" },
{ "<D-S-B>", function() I18n.next_locale() end, desc = "Switch to next locale" },
{ "<D-S-J>", function() I18n.toggle_origin() end, desc = "Toggle origin overlay" },
}
-- When using the default fzf-lua backend the key picker supports:
-- <CR> : copy key
-- <C-y>: copy current locale translation
-- <C-j>: jump (current locale first, fallback default)
-- <C-l>: choose locale then jump (secondary picker)
-- <C-x>: horizontal split jump
-- <C-v>: vertical split jump
-- <C-t>: tab jump
-- Override these in setup(): i18n_keys.keys = { jump = { "<c-j>" }, choose_locale_jump = { "<c-l>" } }
-- Other popup types: set `i18n_keys = { popup_type = 'telescope' | 'vim_ui' | 'snacks' | 'fzf-lua' }`.
-- `vim_ui` renders a native floating picker; `snacks` delegates to folke/snacks.nvim when available (falling back to the native picker otherwise).
Commands:
locales (wrapping back to the first). Inline overlays refresh automatically.I18n.toggle_origin()
Switches between show_mode = "both" and a translation-only mode (restoring the last non-origin preference, defaulting to "translation_conceal"). Use this when you want to hide or reveal raw keys while leaving translations visible.I18n.toggle_translation()
Toggles the inline translation overlay entirely by jumping between show_mode = "origin" and your previous non-origin mode. Handy for quickly disabling overlays while editing.I18n.toggle_locale_file_eol()
Toggles showing end-of-line translations in locale source files (per i18n key line). When enabled, each key line in a locale translation file shows the current display localeβs translation as EOL virtual text; disabling hides these overlays (useful for focused editing or cleaner diffs).Need a specific layout immediately? Call I18n.set_show_mode('translation') / 'translation_conceal' / 'both' / 'origin' and use I18n.get_show_mode() to inspect the current value.
You can interactively add a missing i18n key (across all configured locales) with a floating window editor.
Command: :I18nAddKey
Usage:
Details:
Example workflow: t("feature.welcome.message") -- key does not exist yet :I18nAddKey Enter default text: "Welcome!" Auto-filled other locales. Edit zh locale line to: "ζ¬’θΏοΌ" to confirm. Now the key exists in all locale files.
The plugin provides a blink.cmp source (i18n.integration.blink_source) that:
Example blink.cmp configuration:
require('blink.cmp').setup({
sources = {
default = { 'i18n', 'snippets', 'lsp', 'path', 'buffer' },
-- cmdline = {}, -- optionally disable / customize cmdline sources
providers = {
lsp = { fallbacks = {} },
i18n = {
name = 'i18n',
module = 'i18n.integration.blink_source',
opts = {
-- future options can be placed here
},
},
},
},
})
Features:
config.options.func_pattern) and ignores matches inside comments(missing)Basic setup (after installing hrsh7th/nvim-cmp):
local cmp = require('cmp')
-- Register the i18n source (do this once, e.g. in your cmp config file)
cmp.register_source('i18n', require('i18n.integration.cmp_source').new())
cmp.setup({
sources = cmp.config.sources({
{ name = 'i18n' },
-- other primary sources...
}, {
-- secondary sources...
}),
})
Lazy.nvim snippet:
{
'yelog/i18n.nvim',
dependencies = {
'hrsh7th/nvim-cmp',
},
config = function()
require('i18n').setup({
locales = { 'en', 'zh' },
sources = { 'src/locales/{locales}.json' },
})
end
}
Tips:
func_pattern (e.g. add more function names or custom matchers), but keeping
precise entries reduces noise.nvim-cmp for quick partial matches even across dotted segments.A Telescope picker is also provided for users who prefer Telescope over fzf-lua.
It offers similar actions: copy key, copy current locale translation, jump to definition (current or default locale), choose locale then jump, and split/vsplit/tab open variants.
Setup (lazy.nvim example):
{
'yelog/i18n.nvim',
dependencies = {
'nvim-telescope/telescope.nvim',
},
config = function()
require('i18n').setup({
locales = { 'en', 'zh' },
sources = { 'src/locales/{locales}.json' },
})
end
}
To switch the picker backend, set i18n_keys = { popup_type = 'telescope' | 'vim_ui' | 'snacks' | 'fzf-lua' } in your setup (or project config).vim_ui renders a native floating picker with preview; snacks delegates to Snacks.picker when installed and falls back to the native picker otherwise.
The same keymap above will now open the chosen UI; Telescope users can press ? inside the picker to view the standard help overlay.
The legacy helpers show_i18n_keys_with_fzf() / show_i18n_keys_with_telescope() are still available but deprecated in favor of i18n_keys().
The plugin exposes require('i18n').setup(opts) where opts is merged with defaults.
Merge precedence (highest last):
require('i18n').setup({...})So a project config will override anything you set in your Neovim config for that particular project.
[!NOTE] The complete, authoritative list of default options (with their current values) lives in
lua/i18n/config.luainside theM.defaultstable. Consult that file to discover every available key, verify current defaults, or track new options introduced in updates.
Requiring the module creates a global I18n alias, so mappings can call helpers
directly (e.g. function() I18n.i18n_keys() end) without requiring the module
inside each callback.
Common options (all optional when a project file is present):
src/locales/{locales}.json{ pattern = "pattern", prefix = "optional.prefix." }{ 't', '$t' }); tables allow advanced control;
raw Lua patterns are still accepted for legacy setups.{ 'vue', 'typescript', 'javascript', 'typescriptreact', 'javascriptreact', 'tsx', 'jsx', 'java' })vim_ui | telescope | fzf-lua | snacks, default vim_ui):I18nKeyUsages finds no key under the cursor (default true)fzf-lua | telescope | vim_ui | snacks, default fzf-lua)both | translation | translation_conceal | origin; defaults to both when unset/unknown). both appends the translation after the raw key on every line. translation hides the key except on the cursor line (where both are shown). translation_conceal hides the key and suppresses the translation on the cursor line so you can edit the raw key comfortably. origin disables the overlay entirely.true)false: disable diagnostics entirely (existing ones are cleared)true: enable diagnostics with default behavior (ERROR severity for missing translations){ ... } (table): enable diagnostics and pass the table as the 4th argument to vim.diagnostic.set (e.g. { underline = false, virtual_text = false })func_pattern quick guide{ 't', '$t' }). Optional
whitespace before the opening parenthesis is allowed.{ call = 'i18n.t', quotes = { "'", '"' }, allow_whitespace = false }.allow_arg_whitespace = false.pattern / patterns
keys when you need something exotic (ensure the key stays in capture group 1).Diagnostics
If diagnostic is enabled (true or a table), the plugin emits diagnostics for missing translations at the position of the i18n key. When a table is provided, it is forwarded verbatim to vim.diagnostic.set(namespace, bufnr, diagnostics, opts) allowing you to tune presentation (underline, virtual_text, signs, severity_sort, etc). Setting diagnostic = false both suppresses generation and clears previously shown diagnostics for the buffer.
Dynamic keys built via string concatenation or Lua .. are ignored to avoid false positives (e.g. t('user.' .. segment) or t('system.user.' + item)).
Patterns support placeholders like {locales} and custom variables such as {module} which will be expanded by scanning the project tree.
Navigation Jump from an i18n key usage to its definition (default locale file + line) using an explicit helper function: Helper: require('i18n').i18n_definition() -> boolean Unified API: all public helpers are available via require('i18n') (e.g. i18n_definition, show_popup, reload_project_config, next_locale). Returns true if it jumped, false if no i18n key / location found (so you can fallback to LSP).
Example keymap that prefers i18n, then falls back to LSP definition:
vim.keymap.set('n', 'gd', function()
-- Jump from an i18n key usage to its definition
if require('i18n').i18n_definition() then
return
end
-- Jump from current i18n definition to the next locale's definition, following the order in locales
if require('i18n').i18n_definition_next_locale() then
return
end
-- Fall back to LSP definition
vim.lsp.buf.definition()
end, { desc = 'i18n or LSP definition' })
Separate key (only i18n):
vim.keymap.set('n', 'gK', function()
require('i18n').i18n_definition()
end, { desc = 'Jump to i18n definition' })
Configuration option: navigation = { open_cmd = "edit", -- or 'vsplit' | 'split' | 'tabedit' }
Line numbers are best-effort for JSON/YAML/.properties (heuristic matching); JS/TS uses Tree-sitter for higher accuracy.
Usage Scanner
Track how often each i18n key appears in your source tree. The plugin scans
files matching func_type (defaults to { 'vue', 'typescript', 'javascript', 'typescriptreact', 'javascriptreact', 'tsx', 'jsx', 'java' }) using
rg --files and falls back to git ls-files --exclude-standard, so
.gitignored paths are skipped automatically.
Initial project scans now run asynchronously after VimEnter, keeping startup
responsive while usage counts backfill in the background.
β [No usages] / β [2 usages] style badges before the translation so coverage and text remain visually distinct.:I18nKeyUsages or require('i18n').i18n_key_usages() inspects the key under the cursor: one usage jumps immediately; multiple usages open your configured picker.func_type are rescanned automatically; trigger a background rescan with require('i18n').refresh_usages() if you tweak configuration on the fly (pass { sync = true } to block until completion).usage = { popup_type = 'telescope' | 'fzf-lua' | 'snacks' | 'vim_ui' } to reuse your preferred picker when resolving multiple usages.usage = { notify_no_key = false } if you prefer quiet fallbacks.:hi I18nUsageLabel, :hi I18nUsageTranslation, and :hi I18nUsageSeparator if you prefer different colors.Example keymap that tries the i18n usage jump first, then falls back to LSP references (mirrors the gd example above):
vim.keymap.set('n', 'gu', function()
if require('i18n').i18n_key_usages() then
return
end
vim.lsp.buf.references()
end, { desc = 'i18n usages or LSP references' })
Extend func_type with additional globs if your project mixes in other languages (e.g. { 'vue', '*.svelte', 'javascriptreact' }).
Popup helper (returns boolean) You can show a transient popup of all translations for the key under cursor: Helper: require('i18n').show_popup() -> boolean Returns true if a popup was shown, false if no key / translations found.
Example combined mapping (try popup first, else fallback to signature help):
vim.keymap.set({ "n", "i" }, "<C-k>", function()
if not require('i18n').show_popup() then
vim.lsp.buf.signature_help()
end
end, { desc = "i18n popup or signature help" })
You can place a project-specific config file at the project root. The plugin will auto-detect (in order) the first existing file:
.i18nrc.jsoni18n.config.json.i18nrc.luaIf found, its values override anything you passed to setup().
Example .i18nrc.json:
{
"locales": ["en_US", "zh_CN"],
"sources": [
"src/locales/{locales}.json",
{ "pattern": "src/locales/lang/{locales}/{module}.ts", "prefix": "{module}." }
]
}
Example .i18nrc.lua:
return {
locales = { "en_US", "zh_CN" },
sources = {
"src/locales/{locales}.json",
{ pattern = "src/locales/lang/{locales}/{module}.ts", prefix = "{module}." },
},
func_pattern = {
't',
'$t',
{ call = 'i18n.t' },
},
func_type = { 'vue', 'typescript' },
usage = { popup_type = 'vim_ui' },
show_mode = 'translation_conceal',
}
Minimal Neovim config (global defaults) β can be empty or partial:
require('i18n').setup({
locales = { 'en', 'zh' }, -- acts as a fallback if project file absent
sources = { 'src/locales/{locales}.json' },
})
If later you add a project config file, just reopen the project (or call:
require('i18n').reload_project_config()
require('i18n').setup(require('i18n').options)
) to apply overrides.
setup()).export default, module.exports, direct object literals, and nested objects). Parsed keys and string values are normalized (quotes removed) and flattened.[!NOTE] If you work on multiple projects, keep the config in the project root to avoid editing your global Neovim config when switching. All examples below use a project-level config; see Project-level Configuration (recommended).
One JSON file per locale
projectA
βββ src
βΒ Β βββ App.vue
βΒ Β βββ locales
βΒ Β βΒ Β βββ en.json
βΒ Β βΒ Β βββ zh.json
βΒ Β βββ main.ts
βββ package.json
βββ tsconfig.json
βββ vite.config.ts
Create a .i18nrc.lua file at the project root:
return {
locales = { "en", "zh" },
sources= {
"src/locales/{locales}.json"
}
}
projectB
βββ src
βΒ Β βββ App.vue
βΒ Β βββ locales
βΒ Β βΒ Β βββ en-US
βΒ Β βΒ Β β βββ common.ts
βΒ Β βΒ Β β βββ system.ts
βΒ Β βΒ Β β βββ ui.ts
βΒ Β βΒ Β βββ zh-CN
βΒ Β βΒ Β βββ common.ts
βΒ Β βΒ Β βββ system.ts
βΒ Β βΒ Β βββ ui.ts
βΒ Β βββ main.ts
βββ package.json
βββ tsconfig.json
βββ vite.config.ts
Create a .i18nrc.lua file at the project root:
return {
locales = { "en-US", "zh-CN" },
sources = {
{ pattern = "src/locales/{locales}/{module}.ts", prefix = "{module}." }
}
}
projectC
βββ src
βΒ Β βββ App.vue
βΒ Β βββ locales
βΒ Β βΒ Β βββ en-US
βΒ Β βΒ Β β βββ common.ts
βΒ Β βΒ Β β βββ system.ts
βΒ Β βΒ Β β βββ ui.ts
βΒ Β βΒ Β βββ zh-CN
βΒ Β βΒ Β βββ common.ts
βΒ Β βΒ Β βββ system.ts
βΒ Β βΒ Β βββ ui.ts
βΒ Β βββ views
βΒ Β β βββ gmail
βΒ Β β β βββ locales
βΒ Β β β Β Β βββ en-US
βΒ Β β β Β Β β βββ inbox.ts
βΒ Β β β Β Β β βββ compose.ts
βΒ Β β β Β Β β βββ settings.ts
βΒ Β β β Β Β βββ zh-CN
βΒ Β β β Β Β βββ inbox.ts
βΒ Β β β Β Β βββ compose.ts
βΒ Β β β Β Β βββ settings.ts
βΒ Β β βββ calendar
βΒ Β β β βββ locales
βΒ Β β β Β Β βββ en-US
βΒ Β β β Β Β β βββ events.ts
βΒ Β β β Β Β β βββ reminders.ts
βΒ Β β β Β Β β βββ settings.ts
βΒ Β β β Β Β βββ zh-CN
βΒ Β β β Β Β βββ events.ts
βΒ Β β β Β Β βββ reminders.ts
βΒ Β β β Β Β βββ settings.ts
βΒ Β β βββ search
βΒ Β β βββ locales
βΒ Β β Β Β βββ en-US
βΒ Β β Β Β β βββ query.ts
βΒ Β β Β Β β βββ results.ts
βΒ Β β Β Β β βββ filters.ts
βΒ Β β Β Β βββ zh-CN
βΒ Β β Β Β βββ query.ts
βΒ Β β Β Β βββ results.ts
βΒ Β β Β Β βββ filters.ts
βΒ Β βββ main.ts
βββ package.json
βββ tsconfig.json
βββ vite.config.ts
With the distributed i18n files below, create a .i18nrc.lua at the project root:
return {
locales = { "en-US", "zh-CN" },
sources = {
{ pattern = "src/locales/{locales}/{module}.ts", prefix = "{module}." },
{ pattern = "src/views/{business}/locales/{locales}/{module}.ts", prefix = "{business}.{module}." }
}
}
Contributions, bug reports and PRs are welcome. Please:
Apache-2.0 License. See LICENSE for details.