i18n-lens-language-server 0.8.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,9 +8,35 @@ Zed extension plus Language Server for Vue, TypeScript, TSX, JavaScript and JSX.
8
8
  - Detect `$t`/`t`/`tc`/`$tc`/`i18n.t` calls, `v-t="'key'"`, `v-t="{ path: 'key' }"`, `<i18n-t keypath="key">` (Vue I18n), `<Trans i18nKey="key">` (react-i18next), and `formatMessage({ id: "key" })` (react-intl)
9
9
  - Hover: show all locale values for a key
10
10
  - Diagnostics: report missing keys, missing locale entries, and mismatched interpolation params
11
+ - Quick fixes: add missing interpolation params to supported i18n calls
11
12
  - Completion: suggest existing i18n keys with default locale text
12
13
  - Definition: jump from a source key to its locale definition; when several locales define the key, pick which language file to open
13
14
 
15
+ ## Installation
16
+
17
+ Install **I18n Lens** from Zed's extension catalog. The extension downloads its companion language server package, [`i18n-lens-language-server`](https://www.npmjs.com/package/i18n-lens-language-server), into Zed's extension work directory at runtime.
18
+
19
+ After installation, open a Vue, TypeScript, TSX, JavaScript, or JSX file in a project that contains locale files. If your locale files are not in one of the default directories, configure `localeDirs` as shown below.
20
+
21
+ ## Quick start
22
+
23
+ For most projects, add this to your project `.zed/settings.json` or user-level Zed settings:
24
+
25
+ ```json
26
+ {
27
+ "lsp": {
28
+ "i18n-lens": {
29
+ "settings": {
30
+ "defaultLocale": "en-US",
31
+ "localeDirs": ["src/locales", "src/i18n"]
32
+ }
33
+ }
34
+ }
35
+ }
36
+ ```
37
+
38
+ If you prefer a project-owned config file that can be committed with the repository, create `.zed/i18nlensrc.json` instead. When both are present, `.zed/i18nlensrc.json` takes precedence over Zed settings.
39
+
14
40
  ## Inline translations
15
41
 
16
42
  > **Important:** Inline translations are rendered through Zed's LSP inlay hints. In Zed settings, make sure **Inlay Hints** are enabled and **Inlay Hints: Show Other Hints** is turned on. If **Show Other Hints** is disabled, hover/completion/diagnostics can still work, but inline translation text will not be displayed.
@@ -27,7 +53,7 @@ will show the default locale translation inline after the key string, for exampl
27
53
  t("order.pay_now") 立即支付
28
54
  ```
29
55
 
30
- The inline hint uses `defaultLocale` from `.zed/i18nlensrc.json`, falling back to `zh-CN`. Missing keys are not shown inline because diagnostics already report them.
56
+ The inline hint uses `defaultLocale` from your Zed settings or `.zed/i18nlensrc.json`, falling back to `zh-CN`. Missing keys are not shown inline because diagnostics already report them.
31
57
 
32
58
  Long translations are truncated inline to keep the editor readable. Hover still shows the full locale table.
33
59
 
@@ -47,6 +73,7 @@ Diagnostics distinguish between these cases:
47
73
  - If a key exists in at least one locale but is missing from other locales, it is reported as a warning listing the missing locale names.
48
74
  - If a translation contains named placeholders such as `{count}` but the source call does not pass them, it reports missing params.
49
75
  - If the source call passes params that no loaded translation uses, it reports unused params.
76
+ - Quick fixes can add missing params to supported i18n calls.
50
77
 
51
78
  For example, if `order.pay_now` exists in `zh-CN` but is missing from `en-US`, the warning message is:
52
79
 
@@ -56,25 +83,57 @@ Missing i18n key "order.pay_now" in locales: en-US
56
83
 
57
84
  ## Project configuration
58
85
 
59
- You can add `.zed/i18nlensrc.json` under your project root to override the defaults:
86
+ I18n Lens supports two configuration sources:
87
+
88
+ 1. Zed settings, under `lsp.i18n-lens.settings`.
89
+ 2. A project file, `.zed/i18nlensrc.json`.
90
+
91
+ The project file is merged on top of Zed settings, so repository-specific configuration can override personal or team-wide editor settings.
92
+
93
+ ### Configure from Zed settings
94
+
95
+ Use this when you want configuration to live in Zed's normal settings system:
60
96
 
61
97
  ```json
62
98
  {
63
- "defaultLocale": "zh-CN",
64
- "localeDirs": ["src/locales", "src/i18n", "locales", "i18n"],
99
+ "lsp": {
100
+ "i18n-lens": {
101
+ "settings": {
102
+ "defaultLocale": "en-US",
103
+ "localeDirs": ["src/locales", "src/i18n"],
104
+ "inlayHints": {
105
+ "enabled": true,
106
+ "maxLength": 32
107
+ }
108
+ }
109
+ }
110
+ }
111
+ }
112
+ ```
113
+
114
+ Zed settings can be user-level or project-level. For project-level Zed settings, put the snippet in `.zed/settings.json`.
115
+
116
+ ### Configure from `.zed/i18nlensrc.json`
117
+
118
+ Use this when the i18n layout should be part of the repository and shared by everyone who opens it:
119
+
120
+ ```json
121
+ {
122
+ "defaultLocale": "en-US",
123
+ "localeDirs": ["src/locales", "src/i18n"],
65
124
  "inlayHints": {
66
125
  "enabled": true,
67
- "maxLength": 24
126
+ "maxLength": 32
68
127
  },
69
128
  "packages": [
70
129
  {
71
130
  "root": "apps/web",
72
- "defaultLocale": "zh-CN",
131
+ "defaultLocale": "en-US",
73
132
  "localeDirs": ["src/locales"]
74
133
  },
75
134
  {
76
135
  "root": "packages/admin",
77
- "defaultLocale": "en-US",
136
+ "defaultLocale": "ja-JP",
78
137
  "localeDirs": ["src/i18n"]
79
138
  }
80
139
  ]
@@ -92,31 +151,31 @@ Supported options:
92
151
  | Option | Type | Default | Description |
93
152
  |---|---|---|---|
94
153
  | `defaultLocale` | string | `zh-CN` | Locale used for completion details and inline translation hints. Supports exact names such as `en-US`, case-insensitive matching, `_`/`-` normalization, and language aliases such as `en` matching `en-US`. |
95
- | `localeDirs` | string[] | `src/locales`, `src/i18n`, `locales`, `i18n` | Workspace-relative directories to scan for locale files. |
96
- | `inlayHints.enabled` | boolean | `true` | Enables or disables inline translation hints from this language server. |
97
- | `inlayHints.maxLength` | number | `24` | Maximum inline hint label length before truncation. |
154
+ | `localeDirs` | string[] | `src/locales`, `src/i18n`, `locales`, `i18n` | Workspace-relative directories to scan for locale files. In package configs, paths are relative to that package root. |
155
+ | `inlayHints.enabled` | boolean | `true` | Enables or disables inline translation hints from this language server. Hover, completion, definition, diagnostics, and quick fixes can still work when inline hints are disabled. |
156
+ | `inlayHints.maxLength` | number | `24` | Maximum inline hint label length before truncation. Hover still shows the full text. |
98
157
  | `packages` | array | `[]` | Optional monorepo package contexts. Each item needs a workspace-relative `root` and can override `defaultLocale`, `localeDirs`, and `inlayHints`. The language server picks the longest package root matching the currently edited file. |
99
158
 
100
- Invalid or missing config values fall back to defaults. If `.zed/i18nlensrc.json` contains invalid JSON, the language server logs a warning and continues with default config.
159
+ Invalid or missing config values fall back to defaults. If `.zed/i18nlensrc.json` contains invalid JSON, the language server logs a warning and continues with the Zed settings or default config.
101
160
 
102
- Configuration changes are watched automatically. After saving `.zed/i18nlensrc.json`, the language server reloads config, rebuilds the locale cache, refreshes diagnostics, and asks the editor to refresh inlay hints. You should not need to rebuild or reload the Zed extension just to apply config changes.
161
+ Configuration changes are watched automatically. After saving `.zed/i18nlensrc.json`, the language server reloads config, rebuilds the locale cache, refreshes diagnostics, and asks the editor to refresh inlay hints. Changes made through Zed settings are also handled through the language server configuration update path.
103
162
 
104
163
  ## Locale discovery
105
164
 
106
- In a single-project workspace, `localeDirs` are resolved relative to the workspace root. The language server currently reads JSON files from:
165
+ In a single-project workspace, `localeDirs` are resolved relative to the workspace root. By default, the language server reads JSON files from these directories if they exist:
107
166
 
108
167
  - `src/locales/*.json`
109
168
  - `src/i18n/*.json`
110
169
  - `locales/*.json`
111
170
  - `i18n/*.json`
112
171
 
113
- Locale name comes from filename, for example `zh-CN.json` or `en-US.json`.
172
+ Locale names come from filenames, for example `en-US.json`, `ja-JP.json`, or `zh-CN.json`. If your files are under a nested directory such as `src/i18n/locales/en-US.json`, set `localeDirs` to `["src/i18n/locales"]`.
114
173
 
115
174
  It also supports locale directories such as:
116
175
 
117
176
  ```text
118
- src/locales/zh-CN/common.json
119
177
  src/locales/en-US/common.json
178
+ src/locales/ja-JP/common.json
120
179
  ```
121
180
 
122
181
  A file named `common.json` contributes keys with the `common.` prefix. A file named `index.json` contributes keys without an `index.` prefix.
@@ -127,7 +186,7 @@ For pnpm/turborepo and other monorepos, put `.zed/i18nlensrc.json` under the wor
127
186
 
128
187
  ```json
129
188
  {
130
- "defaultLocale": "zh-CN",
189
+ "defaultLocale": "en-US",
131
190
  "packages": [
132
191
  {
133
192
  "root": "apps/web",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "i18n-lens-language-server",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "description": "Language server for configurable i18n inlay hints, hover, completion, diagnostics, and definition in Vue/TS/TSX/JS.",
6
6
  "license": "MIT",
package/server/core.js CHANGED
@@ -22,6 +22,18 @@ export function normalizeI18nLensConfig(raw = {}) {
22
22
  return { ...base, packages };
23
23
  }
24
24
 
25
+ export function mergeI18nLensConfig(base = {}, override = {}) {
26
+ return {
27
+ ...base,
28
+ ...override,
29
+ inlayHints: {
30
+ ...((base && typeof base.inlayHints === 'object') ? base.inlayHints : {}),
31
+ ...((override && typeof override.inlayHints === 'object') ? override.inlayHints : {}),
32
+ },
33
+ packages: Array.isArray(override?.packages) ? override.packages : base?.packages,
34
+ };
35
+ }
36
+
25
37
  function normalizeProjectConfig(raw = {}, defaults = DEFAULT_CONFIG) {
26
38
  const defaultLocale = typeof raw.defaultLocale === 'string' && raw.defaultLocale.trim()
27
39
  ? raw.defaultLocale.trim()
@@ -157,8 +169,9 @@ function collectFunctionCallKeys(text, found) {
157
169
  const key = keyMatch[2];
158
170
  const start = parenOffset + keyMatch[0].indexOf(key);
159
171
  const end = start + key.length;
160
- const args = splitArgs(callText.slice(1, -1));
161
- found.push({ key, range: makeRange(text, start, end), startOffset: start, endOffset: end, providedParams: collectParamNames(args.slice(1)) });
172
+ const args = splitArgsWithOffsets(callText.slice(1, -1));
173
+ const paramsEdit = getParamsEdit(args.slice(1), parenOffset + 1, parenOffset + callText.length - 1);
174
+ found.push({ key, range: makeRange(text, start, end), startOffset: start, endOffset: end, providedParams: collectParamNames(args.slice(1)), paramsEdit });
162
175
  }
163
176
  }
164
177
 
@@ -169,14 +182,15 @@ function collectFormatMessageKeys(text, found) {
169
182
  const parenOffset = re.lastIndex - 1;
170
183
  const callText = readParens(text, parenOffset);
171
184
  if (!callText) continue;
172
- const args = splitArgs(callText.slice(1, -1));
173
- const idArg = args[0] || '';
185
+ const args = splitArgsWithOffsets(callText.slice(1, -1));
186
+ const idArg = args[0]?.text || '';
174
187
  const keyMatch = idArg.match(/\bid\s*:\s*(['"])([A-Za-z0-9_.:-]+)\1/);
175
188
  if (!keyMatch) continue;
176
189
  const key = keyMatch[2];
177
190
  const start = parenOffset + 1 + idArg.indexOf(key);
178
191
  const end = start + key.length;
179
- found.push({ key, range: makeRange(text, start, end), startOffset: start, endOffset: end, providedParams: collectParamNames(args.slice(1)) });
192
+ const paramsEdit = getParamsEdit(args.slice(1), parenOffset + 1, parenOffset + callText.length - 1);
193
+ found.push({ key, range: makeRange(text, start, end), startOffset: start, endOffset: end, providedParams: collectParamNames(args.slice(1)), paramsEdit });
180
194
  }
181
195
  }
182
196
 
@@ -225,10 +239,49 @@ function splitArgs(text) {
225
239
  return args;
226
240
  }
227
241
 
242
+ function splitArgsWithOffsets(text) {
243
+ const args = [];
244
+ let start = 0, depth = 0, quote = '', escaped = false;
245
+ for (let i = 0; i < text.length; i++) {
246
+ const ch = text[i];
247
+ if (quote) {
248
+ if (escaped) escaped = false;
249
+ else if (ch === '\\') escaped = true;
250
+ else if (ch === quote) quote = '';
251
+ continue;
252
+ }
253
+ if (ch === '"' || ch === "'" || ch === '`') { quote = ch; continue; }
254
+ if (ch === '{' || ch === '[' || ch === '(') depth++;
255
+ else if (ch === '}' || ch === ']' || ch === ')') depth--;
256
+ else if (ch === ',' && depth === 0) { args.push(trimArg(text, start, i)); start = i + 1; }
257
+ }
258
+ const last = trimArg(text, start, text.length);
259
+ if (last.text) args.push(last);
260
+ return args;
261
+ }
262
+
263
+ function trimArg(text, start, end) {
264
+ while (start < end && /\s/.test(text[start])) start++;
265
+ while (end > start && /\s/.test(text[end - 1])) end--;
266
+ return { text: text.slice(start, end), start, end };
267
+ }
268
+
269
+ function getParamsEdit(args, argsStartOffset, closingParenOffset) {
270
+ const objectArg = args.find((arg) => arg.text.startsWith('{') && arg.text.lastIndexOf('}') > 0);
271
+ if (objectArg) {
272
+ const closeBrace = objectArg.text.lastIndexOf('}');
273
+ let insertAt = closeBrace;
274
+ while (insertAt > 1 && /\s/.test(objectArg.text[insertAt - 1])) insertAt--;
275
+ const body = objectArg.text.slice(1, insertAt);
276
+ return { insertOffset: argsStartOffset + objectArg.start + insertAt, prefix: body.trim() ? ', ' : ' ' };
277
+ }
278
+ return { insertOffset: closingParenOffset, prefix: ', { ', suffix: ' }' };
279
+ }
280
+
228
281
  function collectParamNames(args) {
229
282
  const params = new Set();
230
283
  for (const arg of args) {
231
- const trimmed = arg.trim();
284
+ const trimmed = (typeof arg === 'string' ? arg : arg.text).trim();
232
285
  if (!trimmed.startsWith('{')) continue;
233
286
  const body = trimmed.slice(1, trimmed.lastIndexOf('}'));
234
287
  for (const part of splitArgs(body)) {
@@ -363,6 +416,29 @@ function collectRequiredParams(key, localeEntries) {
363
416
  return [...params].sort();
364
417
  }
365
418
 
419
+ export function getParamCodeActions(text, diagnostics = []) {
420
+ const items = extractI18nKeys(text);
421
+ return diagnostics.flatMap((diagnostic) => {
422
+ const missingParams = diagnostic.data?.missingParams;
423
+ if (!Array.isArray(missingParams) || missingParams.length === 0) return [];
424
+ const item = items.find((candidate) => candidate.key === diagnostic.data?.key && sameRange(candidate.range, diagnostic.range));
425
+ if (!item?.paramsEdit) return [];
426
+ const newText = item.paramsEdit.prefix + missingParams.join(', ') + (item.paramsEdit.suffix || '');
427
+ return [{
428
+ title: 'Add missing i18n params: ' + missingParams.join(', '),
429
+ diagnostic,
430
+ edit: { range: makeRange(text, item.paramsEdit.insertOffset, item.paramsEdit.insertOffset), newText },
431
+ }];
432
+ });
433
+ }
434
+
435
+ function sameRange(a, b) {
436
+ return a?.start?.line === b?.start?.line
437
+ && a?.start?.character === b?.start?.character
438
+ && a?.end?.line === b?.end?.line
439
+ && a?.end?.character === b?.end?.character;
440
+ }
441
+
366
442
  export function extractPlaceholders(value) {
367
443
  const params = new Set();
368
444
  // ICU-style: {name, plural, ...} — extract top-level name, skip nested content
package/server/index.js CHANGED
@@ -2,12 +2,13 @@
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { pathToFileURL, fileURLToPath } from 'node:url';
5
- import { createConnection, TextDocuments, ProposedFeatures, TextDocumentSyncKind, MarkupKind } from 'vscode-languageserver/node.js';
5
+ import { createConnection, TextDocuments, ProposedFeatures, TextDocumentSyncKind, MarkupKind, CodeActionKind } from 'vscode-languageserver/node.js';
6
6
  import { TextDocument } from 'vscode-languageserver-textdocument';
7
7
  import {
8
8
  flattenLocale,
9
9
  buildHoverMarkdown,
10
10
  getDiagnostics,
11
+ getParamCodeActions,
11
12
  getCompletions,
12
13
  getCompletionPrefix,
13
14
  keyAtPosition,
@@ -15,6 +16,7 @@ import {
15
16
  collectLocaleKeyTargets,
16
17
  getInlayHints,
17
18
  normalizeI18nLensConfig,
19
+ mergeI18nLensConfig,
18
20
  resolveConfigPath,
19
21
  CONFIG_RELATIVE_PATH,
20
22
  didWatchedFileChange,
@@ -25,6 +27,7 @@ import {
25
27
  const connection = createConnection(ProposedFeatures.all);
26
28
  const documents = new TextDocuments(TextDocument);
27
29
  let workspaceRoot = process.cwd();
30
+ let zedSettingsConfig = {};
28
31
  let config = normalizeI18nLensConfig();
29
32
  let watchedConfigPath;
30
33
  const watchedLocalePaths = new Set();
@@ -37,9 +40,10 @@ const reloadDebounceMs = 100;
37
40
 
38
41
  connection.onInitialize((params) => {
39
42
  workspaceRoot = params.workspaceFolders?.[0]?.uri ? fileURLToPath(params.workspaceFolders[0].uri) : (params.rootUri ? fileURLToPath(params.rootUri) : process.cwd());
43
+ zedSettingsConfig = normalizeIncomingSettings(params.initializationOptions);
40
44
  loadConfig();
41
45
  watchConfigFile();
42
- return { capabilities: { textDocumentSync: TextDocumentSyncKind.Incremental, hoverProvider: true, completionProvider: { triggerCharacters: ['.', '"', "'"] }, definitionProvider: true, inlayHintProvider: true } };
46
+ return { capabilities: { textDocumentSync: TextDocumentSyncKind.Incremental, hoverProvider: true, completionProvider: { triggerCharacters: ['.', '"', "'"] }, definitionProvider: true, inlayHintProvider: true, codeActionProvider: true } };
43
47
  });
44
48
 
45
49
  connection.onInitialized(() => {
@@ -58,6 +62,11 @@ documents.onDidOpen((event) => {
58
62
  documents.onDidChangeContent((event) => validate(event.document));
59
63
  documents.onDidSave(() => scheduleProjectReload());
60
64
 
65
+ connection.onDidChangeConfiguration((params) => {
66
+ zedSettingsConfig = normalizeIncomingSettings(params.settings);
67
+ scheduleProjectReload();
68
+ });
69
+
61
70
  connection.onHover((params) => {
62
71
  const doc = documents.get(params.textDocument.uri);
63
72
  if (!doc) return null;
@@ -103,6 +112,18 @@ connection.onDefinition((params) => {
103
112
  return locations.length ? locations : null;
104
113
  });
105
114
 
115
+ connection.onCodeAction((params) => {
116
+ const doc = documents.get(params.textDocument.uri);
117
+ if (!doc) return [];
118
+ return getParamCodeActions(doc.getText(), params.context.diagnostics)
119
+ .map((action) => ({
120
+ title: action.title,
121
+ kind: CodeActionKind.QuickFix,
122
+ diagnostics: [action.diagnostic],
123
+ edit: { changes: { [params.textDocument.uri]: [action.edit] } },
124
+ }));
125
+ });
126
+
106
127
  function getProjectContext(uri) {
107
128
  loadConfigIfChanged();
108
129
  const filePath = uri ? fileURLToPath(uri) : workspaceRoot;
@@ -178,19 +199,26 @@ function loadConfigIfChanged() {
178
199
 
179
200
  function loadConfig() {
180
201
  const configPath = resolveConfigPath(workspaceRoot);
202
+ const settingsConfig = zedSettingsConfig;
181
203
  configMtimeMs = fs.existsSync(configPath) ? fs.statSync(configPath).mtimeMs : 0;
182
204
  if (!configMtimeMs) {
183
- config = normalizeI18nLensConfig();
205
+ config = normalizeI18nLensConfig(settingsConfig);
184
206
  return;
185
207
  }
186
208
  try {
187
- config = normalizeI18nLensConfig(JSON.parse(fs.readFileSync(configPath, 'utf8')));
209
+ const fileConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
210
+ config = normalizeI18nLensConfig(mergeI18nLensConfig(settingsConfig, fileConfig));
188
211
  } catch (error) {
189
212
  connection.console.warn('Failed to load ' + CONFIG_RELATIVE_PATH + ': ' + error.message);
190
- config = normalizeI18nLensConfig();
213
+ config = normalizeI18nLensConfig(settingsConfig);
191
214
  }
192
215
  }
193
216
 
217
+ function normalizeIncomingSettings(value) {
218
+ if (!value || typeof value !== 'object') return {};
219
+ return value['i18n-lens'] && typeof value['i18n-lens'] === 'object' ? value['i18n-lens'] : value;
220
+ }
221
+
194
222
  function validate(document) {
195
223
  if (!isSourceFile(document.uri)) return;
196
224
  const context = getProjectContext(document.uri);