i18n-lens-language-server 0.8.2 → 0.11.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
@@ -7,9 +7,36 @@ Zed extension plus Language Server for Vue, TypeScript, TSX, JavaScript and JSX.
7
7
  - Inline translation display via LSP inlay hints
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
- - Diagnostics: report missing keys, missing locale entries, and mismatched interpolation params
10
+ - Diagnostics: report missing keys, missing locale entries, mismatched interpolation params, and (on locale files) keys whose params differ across languages
11
+ - Quick fixes: add missing / remove unused interpolation params on supported i18n calls, and create a missing key in every locale file that lacks it
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
14
+ - References: from a key in a locale file (or a source usage), find every place the key is used across the project's source code
15
+
16
+ ## Installation
17
+
18
+ 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.
19
+
20
+ 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.
21
+
22
+ ## Quick start
23
+
24
+ For most projects, add this to your project `.zed/settings.json` or user-level Zed settings:
25
+
26
+ ```json
27
+ {
28
+ "lsp": {
29
+ "i18n-lens": {
30
+ "settings": {
31
+ "defaultLocale": "en-US",
32
+ "localeDirs": ["src/locales", "src/i18n"]
33
+ }
34
+ }
35
+ }
36
+ }
37
+ ```
38
+
39
+ 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.
13
40
 
14
41
  ## Inline translations
15
42
 
@@ -27,7 +54,7 @@ will show the default locale translation inline after the key string, for exampl
27
54
  t("order.pay_now") 立即支付
28
55
  ```
29
56
 
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.
57
+ 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
58
 
32
59
  Long translations are truncated inline to keep the editor readable. Hover still shows the full locale table.
33
60
 
@@ -47,6 +74,7 @@ Diagnostics distinguish between these cases:
47
74
  - 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
75
  - If a translation contains named placeholders such as `{count}` but the source call does not pass them, it reports missing params.
49
76
  - If the source call passes params that no loaded translation uses, it reports unused params.
77
+ - Quick fixes can add missing params to supported i18n calls.
50
78
 
51
79
  For example, if `order.pay_now` exists in `zh-CN` but is missing from `en-US`, the warning message is:
52
80
 
@@ -56,25 +84,57 @@ Missing i18n key "order.pay_now" in locales: en-US
56
84
 
57
85
  ## Project configuration
58
86
 
59
- You can add `.zed/i18nlensrc.json` under your project root to override the defaults:
87
+ I18n Lens supports two configuration sources:
88
+
89
+ 1. Zed settings, under `lsp.i18n-lens.settings`.
90
+ 2. A project file, `.zed/i18nlensrc.json`.
91
+
92
+ The project file is merged on top of Zed settings, so repository-specific configuration can override personal or team-wide editor settings.
93
+
94
+ ### Configure from Zed settings
95
+
96
+ Use this when you want configuration to live in Zed's normal settings system:
60
97
 
61
98
  ```json
62
99
  {
63
- "defaultLocale": "zh-CN",
64
- "localeDirs": ["src/locales", "src/i18n", "locales", "i18n"],
100
+ "lsp": {
101
+ "i18n-lens": {
102
+ "settings": {
103
+ "defaultLocale": "en-US",
104
+ "localeDirs": ["src/locales", "src/i18n"],
105
+ "inlayHints": {
106
+ "enabled": true,
107
+ "maxLength": 32
108
+ }
109
+ }
110
+ }
111
+ }
112
+ }
113
+ ```
114
+
115
+ Zed settings can be user-level or project-level. For project-level Zed settings, put the snippet in `.zed/settings.json`.
116
+
117
+ ### Configure from `.zed/i18nlensrc.json`
118
+
119
+ Use this when the i18n layout should be part of the repository and shared by everyone who opens it:
120
+
121
+ ```json
122
+ {
123
+ "defaultLocale": "en-US",
124
+ "localeDirs": ["src/locales", "src/i18n"],
65
125
  "inlayHints": {
66
126
  "enabled": true,
67
- "maxLength": 24
127
+ "maxLength": 32
68
128
  },
69
129
  "packages": [
70
130
  {
71
131
  "root": "apps/web",
72
- "defaultLocale": "zh-CN",
132
+ "defaultLocale": "en-US",
73
133
  "localeDirs": ["src/locales"]
74
134
  },
75
135
  {
76
136
  "root": "packages/admin",
77
- "defaultLocale": "en-US",
137
+ "defaultLocale": "ja-JP",
78
138
  "localeDirs": ["src/i18n"]
79
139
  }
80
140
  ]
@@ -92,31 +152,31 @@ Supported options:
92
152
  | Option | Type | Default | Description |
93
153
  |---|---|---|---|
94
154
  | `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. |
155
+ | `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. |
156
+ | `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. |
157
+ | `inlayHints.maxLength` | number | `24` | Maximum inline hint label length before truncation. Hover still shows the full text. |
98
158
  | `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
159
 
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.
160
+ 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
161
 
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.
162
+ 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
163
 
104
164
  ## Locale discovery
105
165
 
106
- In a single-project workspace, `localeDirs` are resolved relative to the workspace root. The language server currently reads JSON files from:
166
+ 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
167
 
108
168
  - `src/locales/*.json`
109
169
  - `src/i18n/*.json`
110
170
  - `locales/*.json`
111
171
  - `i18n/*.json`
112
172
 
113
- Locale name comes from filename, for example `zh-CN.json` or `en-US.json`.
173
+ 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
174
 
115
175
  It also supports locale directories such as:
116
176
 
117
177
  ```text
118
- src/locales/zh-CN/common.json
119
178
  src/locales/en-US/common.json
179
+ src/locales/ja-JP/common.json
120
180
  ```
121
181
 
122
182
  A file named `common.json` contributes keys with the `common.` prefix. A file named `index.json` contributes keys without an `index.` prefix.
@@ -127,7 +187,7 @@ For pnpm/turborepo and other monorepos, put `.zed/i18nlensrc.json` under the wor
127
187
 
128
188
  ```json
129
189
  {
130
- "defaultLocale": "zh-CN",
190
+ "defaultLocale": "en-US",
131
191
  "packages": [
132
192
  {
133
193
  "root": "apps/web",
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "i18n-lens-language-server",
3
- "version": "0.8.2",
3
+ "version": "0.11.0",
4
4
  "type": "module",
5
- "description": "Language server for configurable i18n inlay hints, hover, completion, diagnostics, and definition in Vue/TS/TSX/JS.",
5
+ "description": "Language server for configurable i18n inlay hints, hover, completion, diagnostics, definition, and references in Vue/TS/TSX/JS.",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
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,10 @@ 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
+ const paramsObject = extractParamsObject(args.slice(1), parenOffset + 1);
175
+ found.push({ key, range: makeRange(text, start, end), startOffset: start, endOffset: end, providedParams: collectParamNames(args.slice(1)), paramsEdit, paramsObject });
162
176
  }
163
177
  }
164
178
 
@@ -169,14 +183,16 @@ function collectFormatMessageKeys(text, found) {
169
183
  const parenOffset = re.lastIndex - 1;
170
184
  const callText = readParens(text, parenOffset);
171
185
  if (!callText) continue;
172
- const args = splitArgs(callText.slice(1, -1));
173
- const idArg = args[0] || '';
186
+ const args = splitArgsWithOffsets(callText.slice(1, -1));
187
+ const idArg = args[0]?.text || '';
174
188
  const keyMatch = idArg.match(/\bid\s*:\s*(['"])([A-Za-z0-9_.:-]+)\1/);
175
189
  if (!keyMatch) continue;
176
190
  const key = keyMatch[2];
177
191
  const start = parenOffset + 1 + idArg.indexOf(key);
178
192
  const end = start + key.length;
179
- found.push({ key, range: makeRange(text, start, end), startOffset: start, endOffset: end, providedParams: collectParamNames(args.slice(1)) });
193
+ const paramsEdit = getParamsEdit(args.slice(1), parenOffset + 1, parenOffset + callText.length - 1);
194
+ const paramsObject = extractParamsObject(args.slice(1), parenOffset + 1);
195
+ found.push({ key, range: makeRange(text, start, end), startOffset: start, endOffset: end, providedParams: collectParamNames(args.slice(1)), paramsEdit, paramsObject });
180
196
  }
181
197
  }
182
198
 
@@ -225,26 +241,87 @@ function splitArgs(text) {
225
241
  return args;
226
242
  }
227
243
 
244
+ function splitArgsWithOffsets(text) {
245
+ const args = [];
246
+ let start = 0, depth = 0, quote = '', escaped = false;
247
+ for (let i = 0; i < text.length; i++) {
248
+ const ch = text[i];
249
+ if (quote) {
250
+ if (escaped) escaped = false;
251
+ else if (ch === '\\') escaped = true;
252
+ else if (ch === quote) quote = '';
253
+ continue;
254
+ }
255
+ if (ch === '"' || ch === "'" || ch === '`') { quote = ch; continue; }
256
+ if (ch === '{' || ch === '[' || ch === '(') depth++;
257
+ else if (ch === '}' || ch === ']' || ch === ')') depth--;
258
+ else if (ch === ',' && depth === 0) { args.push(trimArg(text, start, i)); start = i + 1; }
259
+ }
260
+ const last = trimArg(text, start, text.length);
261
+ if (last.text) args.push(last);
262
+ return args;
263
+ }
264
+
265
+ function trimArg(text, start, end) {
266
+ while (start < end && /\s/.test(text[start])) start++;
267
+ while (end > start && /\s/.test(text[end - 1])) end--;
268
+ return { text: text.slice(start, end), start, end };
269
+ }
270
+
271
+ function getParamsEdit(args, argsStartOffset, closingParenOffset) {
272
+ const objectArg = args.find((arg) => arg.text.startsWith('{') && arg.text.lastIndexOf('}') > 0);
273
+ if (objectArg) {
274
+ const closeBrace = objectArg.text.lastIndexOf('}');
275
+ let insertAt = closeBrace;
276
+ while (insertAt > 1 && /\s/.test(objectArg.text[insertAt - 1])) insertAt--;
277
+ const body = objectArg.text.slice(1, insertAt);
278
+ return { insertOffset: argsStartOffset + objectArg.start + insertAt, prefix: body.trim() ? ', ' : ' ' };
279
+ }
280
+ return { insertOffset: closingParenOffset, prefix: ', { ', suffix: ' }' };
281
+ }
282
+
228
283
  function collectParamNames(args) {
229
284
  const params = new Set();
230
285
  for (const arg of args) {
231
- const trimmed = arg.trim();
286
+ const trimmed = (typeof arg === 'string' ? arg : arg.text).trim();
232
287
  if (!trimmed.startsWith('{')) continue;
233
288
  const body = trimmed.slice(1, trimmed.lastIndexOf('}'));
234
289
  for (const part of splitArgs(body)) {
235
- const m = part.trim().match(/^(?:['"]([^'"]+)['"]|([A-Za-z_$][\w$]*))\s*(?::|$)/);
236
- const name = m && (m[1] || m[2]);
237
- if (name && !part.trim().startsWith('...')) params.add(name);
290
+ const name = paramNameOf(part);
291
+ if (name) params.add(name);
238
292
  }
239
293
  }
240
294
  return [...params].sort();
241
295
  }
242
296
 
297
+ function paramNameOf(part) {
298
+ const trimmed = String(part).trim();
299
+ if (trimmed.startsWith('...')) return undefined;
300
+ const m = trimmed.match(/^(?:['"]([^'"]+)['"]|([A-Za-z_$][\w$]*))\s*(?::|$)/);
301
+ return (m && (m[1] || m[2])) || undefined;
302
+ }
303
+
304
+ // Absolute span of the params object literal plus each top-level property's raw
305
+ // text, so a code action can rebuild the object with unused params removed.
306
+ function extractParamsObject(args, argsStartOffset) {
307
+ const objectArg = args.find((arg) => arg.text.startsWith('{') && arg.text.lastIndexOf('}') > 0);
308
+ if (!objectArg) return undefined;
309
+ const start = argsStartOffset + objectArg.start;
310
+ const body = objectArg.text.slice(1, objectArg.text.lastIndexOf('}'));
311
+ const props = splitArgsWithOffsets(body).map((part) => ({ name: paramNameOf(part.text), text: part.text }));
312
+ return { start, end: start + objectArg.text.length, props };
313
+ }
314
+
243
315
  export function keyAtPosition(text, position) {
244
316
  const offset = positionToOffset(text, position);
245
317
  return extractI18nKeys(text).find((item) => item.startOffset <= offset && offset <= item.endOffset);
246
318
  }
247
319
 
320
+ // Reverse of Go to Definition: every range in `text` (source code) that uses `key`.
321
+ export function findCodeKeyRanges(text, key) {
322
+ return extractI18nKeys(text).filter((item) => item.key === key).map((item) => item.range);
323
+ }
324
+
248
325
  export function positionToOffset(text, position) {
249
326
  const lines = text.split('\n');
250
327
  let offset = 0;
@@ -334,7 +411,11 @@ export function getDiagnostics(text, locales) {
334
411
  data: { key: item.key, missingLocales, missingInAllLocales },
335
412
  });
336
413
  }
337
- if (missingLocales.length !== localeEntries.length) diagnostics.push(...getParamDiagnostics(item, localeEntries));
414
+ if (missingLocales.length !== localeEntries.length) {
415
+ diagnostics.push(...getParamDiagnostics(item, localeEntries));
416
+ const consistency = getLocaleParamConsistency(item.key, locales);
417
+ if (!consistency.consistent) diagnostics.push(paramInconsistencyDiagnostic(item.key, item.range, consistency.perLocale));
418
+ }
338
419
  return diagnostics;
339
420
  });
340
421
  }
@@ -363,6 +444,97 @@ function collectRequiredParams(key, localeEntries) {
363
444
  return [...params].sort();
364
445
  }
365
446
 
447
+ // Compare the interpolation placeholders a key uses across every locale that
448
+ // defines it. `consistent` is false when two locales that both define the key
449
+ // carry different param sets (a translation forgot or renamed a placeholder).
450
+ // Locales that do not define the key are ignored — that is a missing-key concern.
451
+ export function getLocaleParamConsistency(key, locales) {
452
+ const perLocale = [];
453
+ for (const [name, locale] of Object.entries(locales || {})) {
454
+ const value = locale.flat?.[key];
455
+ if (value === undefined) continue;
456
+ perLocale.push({ name, params: extractPlaceholders(value) });
457
+ }
458
+ const union = [...new Set(perLocale.flatMap((entry) => entry.params))].sort();
459
+ const consistent = perLocale.length < 2
460
+ || perLocale.every((entry) => entry.params.length === union.length && union.every((param) => entry.params.includes(param)));
461
+ return { consistent, perLocale, union };
462
+ }
463
+
464
+ // Warnings for keys defined in `locale`'s file whose params disagree with the
465
+ // same key in other locales, anchored at each key's position in the file text.
466
+ export function getLocaleParamDiagnostics(locale, filePath, text, locales) {
467
+ if (!locale?.flat) return [];
468
+ const isJson = /\.json$/i.test(filePath);
469
+ const diagnostics = [];
470
+ for (const key of Object.keys(locale.flat)) {
471
+ const consistency = getLocaleParamConsistency(key, locales);
472
+ if (consistency.consistent) continue;
473
+ const lookupKey = lookupKeyForLocaleFile(locale, filePath, key);
474
+ const position = isJson ? findJsonKeyLocation(text, lookupKey) : findLocaleKeyLocation(text, lookupKey);
475
+ if (!position) continue;
476
+ const leaf = lookupKey.split('.').at(-1);
477
+ const end = { line: position.line, character: position.character + leaf.length + (isJson ? 2 : 0) };
478
+ diagnostics.push(paramInconsistencyDiagnostic(key, { start: position, end }, consistency.perLocale));
479
+ }
480
+ return diagnostics;
481
+ }
482
+
483
+ function paramInconsistencyDiagnostic(key, range, perLocale) {
484
+ return {
485
+ severity: 2,
486
+ range,
487
+ message: 'i18n params for "' + key + '" differ across locales — ' + formatPerLocaleParams(perLocale),
488
+ source: 'i18n-lens',
489
+ data: { key, paramInconsistency: perLocale },
490
+ };
491
+ }
492
+
493
+ function formatPerLocaleParams(perLocale) {
494
+ return perLocale
495
+ .map((entry) => entry.name + ': ' + (entry.params.length ? entry.params.map((p) => '{' + p + '}').join(', ') : '(none)'))
496
+ .join('; ');
497
+ }
498
+
499
+ export function getParamCodeActions(text, diagnostics = []) {
500
+ const items = extractI18nKeys(text);
501
+ return diagnostics.flatMap((diagnostic) => {
502
+ const item = items.find((candidate) => candidate.key === diagnostic.data?.key && sameRange(candidate.range, diagnostic.range));
503
+
504
+ const missingParams = diagnostic.data?.missingParams;
505
+ if (Array.isArray(missingParams) && missingParams.length > 0) {
506
+ if (!item?.paramsEdit) return [];
507
+ const newText = item.paramsEdit.prefix + missingParams.join(', ') + (item.paramsEdit.suffix || '');
508
+ return [{
509
+ title: 'Add missing i18n params: ' + missingParams.join(', '),
510
+ diagnostic,
511
+ edit: { range: makeRange(text, item.paramsEdit.insertOffset, item.paramsEdit.insertOffset), newText },
512
+ }];
513
+ }
514
+
515
+ const unusedParams = diagnostic.data?.unusedParams;
516
+ if (Array.isArray(unusedParams) && unusedParams.length > 0) {
517
+ if (!item?.paramsObject) return [];
518
+ const kept = item.paramsObject.props.filter((prop) => !unusedParams.includes(prop.name));
519
+ const rebuilt = kept.length ? '{ ' + kept.map((prop) => prop.text).join(', ') + ' }' : '{}';
520
+ return [{
521
+ title: 'Remove unused i18n params: ' + unusedParams.join(', '),
522
+ diagnostic,
523
+ edit: { range: makeRange(text, item.paramsObject.start, item.paramsObject.end), newText: rebuilt },
524
+ }];
525
+ }
526
+
527
+ return [];
528
+ });
529
+ }
530
+
531
+ function sameRange(a, b) {
532
+ return a?.start?.line === b?.start?.line
533
+ && a?.start?.character === b?.start?.character
534
+ && a?.end?.line === b?.end?.line
535
+ && a?.end?.character === b?.end?.character;
536
+ }
537
+
366
538
  export function extractPlaceholders(value) {
367
539
  const params = new Set();
368
540
  // ICU-style: {name, plural, ...} — extract top-level name, skip nested content
@@ -440,6 +612,29 @@ export function collectLocaleKeyTargets(locales, key, localeTexts = {}, defaultL
440
612
  .filter((target) => target?.position);
441
613
  }
442
614
 
615
+ // Inverse of findLocaleKeyTarget: given a caret position inside a locale file,
616
+ // return the full flattened key it points at (so Find References can jump back
617
+ // to the code). Reuses the same key->location logic as Go to Definition to stay
618
+ // consistent; precise for JSON, best-effort (line-based) for TS/JS locale modules.
619
+ export function localeKeyAtPosition(locale, filePath, text, position) {
620
+ if (!locale || !locale.flat || !text || !position) return undefined;
621
+ const isJson = /\.json$/i.test(filePath);
622
+ let lineFallback;
623
+ let lineFallbackCount = 0;
624
+ for (const fullKey of Object.keys(locale.flat)) {
625
+ const lookupKey = lookupKeyForLocaleFile(locale, filePath, fullKey);
626
+ const loc = isJson ? findJsonKeyLocation(text, lookupKey) : findLocaleKeyLocation(text, lookupKey);
627
+ if (!loc || loc.line !== position.line) continue;
628
+ const leaf = lookupKey.split('.').at(-1);
629
+ const tokenStart = loc.character;
630
+ const tokenEnd = tokenStart + leaf.length + (isJson ? 2 : 0);
631
+ if (position.character >= tokenStart && position.character <= tokenEnd) return fullKey;
632
+ lineFallback = fullKey;
633
+ lineFallbackCount++;
634
+ }
635
+ return lineFallbackCount === 1 ? lineFallback : undefined;
636
+ }
637
+
443
638
  export function getCompletionPrefix(text, position) {
444
639
  const offset = positionToOffset(text, position);
445
640
  const before = text.slice(0, offset);
@@ -470,6 +665,85 @@ export function findJsonKeyLocation(jsonText, key) {
470
665
  return undefined;
471
666
  }
472
667
 
668
+ // Surgically insert a dotted `key` into a JSON document, creating intermediate
669
+ // objects as needed and preserving the rest of the file's formatting. Returns
670
+ // the new text, or undefined if the JSON is unparseable or the key already exists.
671
+ export function insertNestedJsonKey(jsonText, key, value = '') {
672
+ let root;
673
+ try { root = JSON.parse(jsonText); } catch { return undefined; }
674
+ if (!root || typeof root !== 'object' || Array.isArray(root)) return undefined;
675
+ if (getValueByKey(root, key) !== undefined) return undefined;
676
+
677
+ const parts = key.split('.');
678
+ let node = root;
679
+ let existingDepth = 0;
680
+ for (; existingDepth < parts.length - 1; existingDepth++) {
681
+ const next = node[parts[existingDepth]];
682
+ if (next && typeof next === 'object' && !Array.isArray(next)) node = next;
683
+ else break;
684
+ }
685
+
686
+ const span = findJsonObjectSpan(jsonText, parts.slice(0, existingDepth));
687
+ if (!span) return undefined;
688
+
689
+ const unit = detectIndentUnit(jsonText);
690
+ const snippet = buildNestedProperty(parts.slice(existingDepth), value, unit.repeat(existingDepth + 1), unit);
691
+ const inner = jsonText.slice(span.openBrace + 1, span.contentEnd).trim();
692
+ if (inner.length === 0) {
693
+ return jsonText.slice(0, span.openBrace + 1) + '\n' + snippet + '\n' + unit.repeat(existingDepth) + jsonText.slice(span.closeBrace);
694
+ }
695
+ const needsComma = jsonText[span.contentEnd - 1] !== ',';
696
+ return jsonText.slice(0, span.contentEnd) + (needsComma ? ',' : '') + '\n' + snippet + jsonText.slice(span.contentEnd);
697
+ }
698
+
699
+ function findJsonObjectSpan(jsonText, pathParts) {
700
+ let searchFrom = 0;
701
+ if (pathParts.length) {
702
+ const pos = findJsonKeyLocation(jsonText, pathParts.join('.'));
703
+ if (!pos) return undefined;
704
+ const colon = jsonText.indexOf(':', positionToOffset(jsonText, pos));
705
+ if (colon === -1) return undefined;
706
+ searchFrom = colon + 1;
707
+ }
708
+ const openBrace = jsonText.indexOf('{', searchFrom);
709
+ if (openBrace === -1) return undefined;
710
+ const closeBrace = matchBrace(jsonText, openBrace);
711
+ if (closeBrace === -1) return undefined;
712
+ let contentEnd = closeBrace;
713
+ while (contentEnd > openBrace + 1 && /\s/.test(jsonText[contentEnd - 1])) contentEnd--;
714
+ return { openBrace, closeBrace, contentEnd };
715
+ }
716
+
717
+ function matchBrace(text, open) {
718
+ let depth = 0;
719
+ let quote = false;
720
+ let escaped = false;
721
+ for (let i = open; i < text.length; i++) {
722
+ const ch = text[i];
723
+ if (quote) {
724
+ if (escaped) escaped = false;
725
+ else if (ch === '\\') escaped = true;
726
+ else if (ch === '"') quote = false;
727
+ continue;
728
+ }
729
+ if (ch === '"') quote = true;
730
+ else if (ch === '{') depth++;
731
+ else if (ch === '}' && --depth === 0) return i;
732
+ }
733
+ return -1;
734
+ }
735
+
736
+ function detectIndentUnit(jsonText) {
737
+ const match = jsonText.match(/\n([ \t]+)\S/);
738
+ return match ? match[1] : ' ';
739
+ }
740
+
741
+ function buildNestedProperty(parts, value, indent, unit) {
742
+ const keyStr = JSON.stringify(parts[0]);
743
+ if (parts.length === 1) return indent + keyStr + ': ' + JSON.stringify(value);
744
+ return indent + keyStr + ': {\n' + buildNestedProperty(parts.slice(1), value, indent + unit, unit) + '\n' + indent + '}';
745
+ }
746
+
473
747
 
474
748
  export function parseTsLocaleModule(text, fileName = 'index.ts') {
475
749
  const result = {};
package/server/index.js CHANGED
@@ -2,19 +2,25 @@
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
+ getLocaleParamDiagnostics,
12
+ getParamCodeActions,
13
+ insertNestedJsonKey,
11
14
  getCompletions,
12
15
  getCompletionPrefix,
13
16
  keyAtPosition,
14
17
  parseTsLocaleModule,
15
18
  collectLocaleKeyTargets,
19
+ localeKeyAtPosition,
20
+ findCodeKeyRanges,
16
21
  getInlayHints,
17
22
  normalizeI18nLensConfig,
23
+ mergeI18nLensConfig,
18
24
  resolveConfigPath,
19
25
  CONFIG_RELATIVE_PATH,
20
26
  didWatchedFileChange,
@@ -25,6 +31,7 @@ import {
25
31
  const connection = createConnection(ProposedFeatures.all);
26
32
  const documents = new TextDocuments(TextDocument);
27
33
  let workspaceRoot = process.cwd();
34
+ let zedSettingsConfig = {};
28
35
  let config = normalizeI18nLensConfig();
29
36
  let watchedConfigPath;
30
37
  const watchedLocalePaths = new Set();
@@ -37,9 +44,10 @@ const reloadDebounceMs = 100;
37
44
 
38
45
  connection.onInitialize((params) => {
39
46
  workspaceRoot = params.workspaceFolders?.[0]?.uri ? fileURLToPath(params.workspaceFolders[0].uri) : (params.rootUri ? fileURLToPath(params.rootUri) : process.cwd());
47
+ zedSettingsConfig = normalizeIncomingSettings(params.initializationOptions);
40
48
  loadConfig();
41
49
  watchConfigFile();
42
- return { capabilities: { textDocumentSync: TextDocumentSyncKind.Incremental, hoverProvider: true, completionProvider: { triggerCharacters: ['.', '"', "'"] }, definitionProvider: true, inlayHintProvider: true } };
50
+ return { capabilities: { textDocumentSync: TextDocumentSyncKind.Incremental, hoverProvider: true, completionProvider: { triggerCharacters: ['.', '"', "'"] }, definitionProvider: true, referencesProvider: true, inlayHintProvider: true, codeActionProvider: true } };
43
51
  });
44
52
 
45
53
  connection.onInitialized(() => {
@@ -58,6 +66,11 @@ documents.onDidOpen((event) => {
58
66
  documents.onDidChangeContent((event) => validate(event.document));
59
67
  documents.onDidSave(() => scheduleProjectReload());
60
68
 
69
+ connection.onDidChangeConfiguration((params) => {
70
+ zedSettingsConfig = normalizeIncomingSettings(params.settings);
71
+ scheduleProjectReload();
72
+ });
73
+
61
74
  connection.onHover((params) => {
62
75
  const doc = documents.get(params.textDocument.uri);
63
76
  if (!doc) return null;
@@ -72,6 +85,7 @@ connection.onHover((params) => {
72
85
  });
73
86
 
74
87
  connection.onCompletion((params) => {
88
+ if (!isSourceFile(params.textDocument.uri)) return [];
75
89
  const doc = documents.get(params.textDocument.uri);
76
90
  const context = getProjectContext(params.textDocument.uri);
77
91
  const { locales } = loadLocalesForContext(context);
@@ -80,6 +94,7 @@ connection.onCompletion((params) => {
80
94
  });
81
95
 
82
96
  connection.languages.inlayHint.on((params) => {
97
+ if (!isSourceFile(params.textDocument.uri)) return [];
83
98
  const doc = documents.get(params.textDocument.uri);
84
99
  if (!doc) return [];
85
100
  const context = getProjectContext(params.textDocument.uri);
@@ -103,6 +118,91 @@ connection.onDefinition((params) => {
103
118
  return locations.length ? locations : null;
104
119
  });
105
120
 
121
+ connection.onReferences((params) => {
122
+ const uri = params.textDocument.uri;
123
+ const context = getProjectContext(uri);
124
+ const key = resolveKeyAtPosition(uri, params.position, context);
125
+ if (!key) return null;
126
+
127
+ const locations = collectCodeReferences(context, key);
128
+ if (params.context?.includeDeclaration) {
129
+ const { locales, localeTexts } = loadLocalesForContext(context);
130
+ const keyTokenLength = key.split('.').at(-1).length + 2;
131
+ for (const { filePath, position } of collectLocaleKeyTargets(locales, key, localeTexts, context.config.defaultLocale)) {
132
+ locations.push({
133
+ uri: pathToFileURL(filePath).toString(),
134
+ range: { start: position, end: { line: position.line, character: position.character + keyTokenLength } },
135
+ });
136
+ }
137
+ }
138
+ return locations.length ? locations : null;
139
+ });
140
+
141
+ connection.onCodeAction((params) => {
142
+ const doc = documents.get(params.textDocument.uri);
143
+ const diagnostics = params.context.diagnostics || [];
144
+ const actions = [];
145
+ if (doc) {
146
+ for (const action of getParamCodeActions(doc.getText(), diagnostics)) {
147
+ actions.push({
148
+ title: action.title,
149
+ kind: CodeActionKind.QuickFix,
150
+ diagnostics: [action.diagnostic],
151
+ edit: { changes: { [params.textDocument.uri]: [action.edit] } },
152
+ });
153
+ }
154
+ }
155
+ actions.push(...getCreateKeyActions(diagnostics, getProjectContext(params.textDocument.uri)));
156
+ return actions;
157
+ });
158
+
159
+ // Quick fix for a "missing i18n key" diagnostic: create the key (empty value) in
160
+ // every locale file that lacks it, as a cross-file workspace edit. Only single
161
+ // flat JSON locale files are supported; nested/TS locales are skipped.
162
+ function getCreateKeyActions(diagnostics, context) {
163
+ const { locales, localeTexts } = loadLocalesForContext(context);
164
+ const actions = [];
165
+ for (const diagnostic of diagnostics) {
166
+ const key = diagnostic.data?.key;
167
+ const missingLocales = diagnostic.data?.missingLocales;
168
+ if (!key || !Array.isArray(missingLocales) || missingLocales.length === 0) continue;
169
+
170
+ const changes = {};
171
+ for (const name of missingLocales) {
172
+ const filePath = flatLocaleFile(locales[name]);
173
+ if (!filePath) continue;
174
+ const text = localeTexts[filePath] ?? readFileSafe(filePath);
175
+ if (text === undefined) continue;
176
+ const newText = insertNestedJsonKey(text, key, '');
177
+ if (newText === undefined) continue;
178
+ changes[pathToFileURL(filePath).toString()] = [fullDocumentEdit(text, newText)];
179
+ }
180
+
181
+ const fileCount = Object.keys(changes).length;
182
+ if (fileCount === 0) continue;
183
+ actions.push({
184
+ title: `Create i18n key "${key}" in ${fileCount} locale file(s)`,
185
+ kind: CodeActionKind.QuickFix,
186
+ diagnostics: [diagnostic],
187
+ edit: { changes },
188
+ });
189
+ }
190
+ return actions;
191
+ }
192
+
193
+ // A locale backed by a single flat .json file (not a nested directory locale).
194
+ function flatLocaleFile(locale) {
195
+ if (!locale || !/\.json$/i.test(locale.path || '')) return undefined;
196
+ const files = locale.files?.length ? locale.files : [locale.path];
197
+ return files.length === 1 && files[0] === locale.path ? locale.path : undefined;
198
+ }
199
+
200
+ function fullDocumentEdit(oldText, newText) {
201
+ const lines = oldText.split('\n');
202
+ const endLine = lines.length - 1;
203
+ return { range: { start: { line: 0, character: 0 }, end: { line: endLine, character: lines[endLine].length } }, newText };
204
+ }
205
+
106
206
  function getProjectContext(uri) {
107
207
  loadConfigIfChanged();
108
208
  const filePath = uri ? fileURLToPath(uri) : workspaceRoot;
@@ -178,27 +278,105 @@ function loadConfigIfChanged() {
178
278
 
179
279
  function loadConfig() {
180
280
  const configPath = resolveConfigPath(workspaceRoot);
281
+ const settingsConfig = zedSettingsConfig;
181
282
  configMtimeMs = fs.existsSync(configPath) ? fs.statSync(configPath).mtimeMs : 0;
182
283
  if (!configMtimeMs) {
183
- config = normalizeI18nLensConfig();
284
+ config = normalizeI18nLensConfig(settingsConfig);
184
285
  return;
185
286
  }
186
287
  try {
187
- config = normalizeI18nLensConfig(JSON.parse(fs.readFileSync(configPath, 'utf8')));
288
+ const fileConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
289
+ config = normalizeI18nLensConfig(mergeI18nLensConfig(settingsConfig, fileConfig));
188
290
  } catch (error) {
189
291
  connection.console.warn('Failed to load ' + CONFIG_RELATIVE_PATH + ': ' + error.message);
190
- config = normalizeI18nLensConfig();
292
+ config = normalizeI18nLensConfig(settingsConfig);
191
293
  }
192
294
  }
193
295
 
296
+ function normalizeIncomingSettings(value) {
297
+ if (!value || typeof value !== 'object') return {};
298
+ return value['i18n-lens'] && typeof value['i18n-lens'] === 'object' ? value['i18n-lens'] : value;
299
+ }
300
+
194
301
  function validate(document) {
195
- if (!isSourceFile(document.uri)) return;
196
302
  const context = getProjectContext(document.uri);
197
303
  const { locales } = loadLocalesForContext(context);
198
- connection.sendDiagnostics({ uri: document.uri, diagnostics: getDiagnostics(document.getText(), locales) });
304
+ if (isSourceFile(document.uri)) {
305
+ connection.sendDiagnostics({ uri: document.uri, diagnostics: getDiagnostics(document.getText(), locales) });
306
+ return;
307
+ }
308
+ const locale = findLocaleForFile(locales, fileURLToPath(document.uri));
309
+ if (!locale) return;
310
+ connection.sendDiagnostics({ uri: document.uri, diagnostics: getLocaleParamDiagnostics(locale, fileURLToPath(document.uri), document.getText(), locales) });
199
311
  }
200
312
  function isSourceFile(uri) { return /\.(vue|tsx?|jsx?)$/i.test(uri); }
201
313
 
314
+ const IGNORED_SCAN_DIRS = new Set(['node_modules', 'dist', 'build', 'out', 'coverage', 'vendor', '.output', '.next', '.nuxt', '.cache']);
315
+ const MAX_SCAN_FILES = 5000;
316
+
317
+ // Resolve the i18n key under `position`, whether the caret sits on a t()/keypath
318
+ // usage in source code or on a key inside a locale JSON/TS file.
319
+ function resolveKeyAtPosition(uri, position, context) {
320
+ const filePath = fileURLToPath(uri);
321
+ const text = documents.get(uri)?.getText() ?? readFileSafe(filePath);
322
+ if (!text) return undefined;
323
+ if (isSourceFile(uri)) return keyAtPosition(text, position)?.key;
324
+ const { locales } = loadLocalesForContext(context);
325
+ const locale = findLocaleForFile(locales, filePath);
326
+ return locale ? localeKeyAtPosition(locale, filePath, text, position) : undefined;
327
+ }
328
+
329
+ function findLocaleForFile(locales, filePath) {
330
+ const target = path.resolve(filePath);
331
+ for (const locale of Object.values(locales)) {
332
+ const files = locale.files?.length ? locale.files : (locale.path ? [locale.path] : []);
333
+ if (files.some((file) => path.resolve(file) === target)) return locale;
334
+ }
335
+ return undefined;
336
+ }
337
+
338
+ // Every source-code usage of `key` within the project/package root. Prefers the
339
+ // in-memory text of open (possibly unsaved) documents over the on-disk copy.
340
+ function collectCodeReferences(context, key) {
341
+ const openTextByPath = new Map();
342
+ for (const doc of documents.all()) {
343
+ try { openTextByPath.set(path.resolve(fileURLToPath(doc.uri)), doc.getText()); } catch { /* non-file uri */ }
344
+ }
345
+ const locations = [];
346
+ for (const filePath of collectSourceFiles(context.root)) {
347
+ const text = openTextByPath.get(path.resolve(filePath)) ?? readFileSafe(filePath);
348
+ if (!text) continue;
349
+ for (const range of findCodeKeyRanges(text, key)) {
350
+ locations.push({ uri: pathToFileURL(filePath).toString(), range });
351
+ }
352
+ }
353
+ return locations;
354
+ }
355
+
356
+ function collectSourceFiles(root) {
357
+ const files = [];
358
+ const stack = [root];
359
+ while (stack.length && files.length < MAX_SCAN_FILES) {
360
+ const dir = stack.pop();
361
+ let entries;
362
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
363
+ for (const entry of entries) {
364
+ const full = path.join(dir, entry.name);
365
+ if (entry.isDirectory()) {
366
+ if (entry.name.startsWith('.') || IGNORED_SCAN_DIRS.has(entry.name)) continue;
367
+ stack.push(full);
368
+ } else if (isSourceFile(entry.name)) {
369
+ files.push(full);
370
+ }
371
+ }
372
+ }
373
+ return files;
374
+ }
375
+
376
+ function readFileSafe(filePath) {
377
+ try { return fs.readFileSync(filePath, 'utf8'); } catch { return undefined; }
378
+ }
379
+
202
380
  function getAllConfiguredContexts() {
203
381
  loadConfigIfChanged();
204
382
  return [resolveProjectContext(workspaceRoot, workspaceRoot, config), ...config.packages.map((pkg) => ({