i18n-lens-language-server 0.12.0 → 0.13.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
@@ -9,10 +9,10 @@ Zed extension plus Language Server for Vue, TypeScript, TSX, JavaScript and JSX.
9
9
  - Hover: show all locale values for a key
10
10
  - Diagnostics: report missing keys, missing locale entries, mismatched interpolation params, and (on locale files) keys whose params differ across languages
11
11
  - Optional raw-text diagnostics: flag likely untranslated Vue/HTML template text and selected user-facing attributes
12
- - Quick fixes: add missing / remove unused interpolation params on supported i18n calls, and create a missing key in every locale file that lacks it
13
- - Completion: suggest existing i18n keys with default locale text
12
+ - Quick fixes: add missing / remove unused interpolation params, and create missing keys in unambiguous JSON locale targets using versioned edits
13
+ - Completion: suggest existing i18n keys with default locale text only inside supported i18n key positions
14
14
  - Definition: jump from a source key to its locale definition; when several locales define the key, pick which language file to open
15
- - 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
+ - References: from a locale definition or source usage, find references within the same project/package context, including open source files not yet saved to disk
16
16
 
17
17
  ## Installation
18
18
 
@@ -69,6 +69,8 @@ To open a specific language file, use Go to Definition (Ctrl/Cmd+click or F12) o
69
69
 
70
70
  Diagnostics refresh automatically when loaded locale files or locale directories change, so editing translation files should update warnings/errors without restarting the extension.
71
71
 
72
+ Open locale documents take precedence over disk contents. Opening, changing, or closing an existing locale file refreshes translations and diagnostics without requiring a save.
73
+
72
74
  Diagnostics distinguish between these cases:
73
75
 
74
76
  - If a key is missing from every loaded locale, it is reported as an error.
@@ -106,6 +108,17 @@ Comments, `script`/`style`, Vue custom blocks, interpolations (`{{ ... }}`), tra
106
108
  There is no Babel/JavaScript/TypeScript/JSX string analysis, workspace-wide scan, automatic extraction or raw-text quick fix. Only open Vue/HTML buffers receive these warnings. The parser is tolerant of incomplete markup, not a full Vue compiler; complex or malformed templates can be missed. Existing key/parameter diagnostics and quick fixes are unchanged.
107
109
 
108
110
  For local development, HTML registration in `extension.toml` requires reinstalling the dev extension in Zed, then running `scripts/deploy-local.ps1` again and restarting the language server. Updating only the server does not register HTML buffers.
111
+ ## Creating missing keys safely
112
+
113
+ The missing-key quick fix supports a single JSON file per locale and directory layouts such as `locales/en/common.json`. For `t('common.submit')`, it inserts `submit` into `common.json`, without duplicating the `common.` prefix. `index.json` has no file-stem prefix.
114
+
115
+ - Edits use the current open document text and version, preserve unrelated content and line endings, and change only the necessary text rather than replacing the entire file.
116
+ - If a target namespace file is missing, the server can create it when another locale establishes the corresponding JSON namespace layout. It does not invent an unknown namespace layout or overwrite a file that appears after the action was requested.
117
+ - Ambiguous layouts (for example, both `common.json` and `common.ts`, overlapping index/namespace ownership including empty namespace objects, or multiple backing directories) do not receive a guessed edit. Invalid JSON, duplicate properties, and scalar/object path conflicts are also refused.
118
+ - A multi-locale diagnostic receives a creation action only when all remaining missing locales can be repaired safely. TS/JS module writes and JSONC locale loading/writes are not supported.
119
+ - Quick fixes require LSP `workspace.workspaceEdit.documentChanges` support. Creating a file additionally requires the `create` resource operation. Existing-file edits use the open document version, or `null` for closed files; the server cannot version-check external changes to closed files.
120
+
121
+ Recognition skips calls inside comments and ordinary strings, and does not treat a concatenated key such as `t('prefix.' + name)` as a static key. Vue directive expressions, JSX expressions, and calls inside template-literal interpolations remain supported. Recognition is lexical, not full framework binding or scope analysis.
109
122
 
110
123
  ## Project configuration
111
124
 
@@ -207,6 +220,8 @@ src/locales/ja-JP/common.json
207
220
 
208
221
  A file named `common.json` contributes keys with the `common.` prefix. A file named `index.json` contributes keys without an `index.` prefix.
209
222
 
223
+ Top-level TS/JS modules such as `locales/en.ts` are also loaded, without adding an `en.` prefix. Directory modules such as `locales/en/common.ts` retain the `common.` prefix; `index.ts` has no file-stem prefix. Modules are parsed statically, never executed. Their definitions support Find References and cross-language parameter diagnostics, not source-code completions or inlay hints. TS/JS key positioning remains best-effort rather than a full syntax-tree analysis.
224
+
210
225
  ## Monorepo workspaces
211
226
 
212
227
  For pnpm/turborepo and other monorepos, put `.zed/i18nlensrc.json` under the workspace root and add `packages` entries:
@@ -230,6 +245,8 @@ For pnpm/turborepo and other monorepos, put `.zed/i18nlensrc.json` under the wor
230
245
 
231
246
  When a document is opened, I18n Lens selects the package whose `root` is the longest path prefix of that document. `localeDirs` inside a package are resolved relative to that package root, so `apps/web/src/locales` and `packages/admin/src/i18n` are indexed independently. Package entries inherit root-level `defaultLocale`, `localeDirs`, `inlayHints`, and `rawTextDiagnostics` unless they override them.
232
247
 
248
+ Find References excludes separately configured nested package roots and locale modules, so identical keys in independent packages are not mixed. The existing source scanner is bounded at approximately 5,000 files; its results are not a project-wide completeness guarantee for larger workspaces.
249
+
233
250
  ## Local development
234
251
 
235
252
  Install dependencies:
@@ -244,6 +261,8 @@ Run tests:
244
261
  npm test
245
262
  ```
246
263
 
264
+ The suite covers pure-function behavior plus real stdio LSP sessions in temporary workspaces, including dirty locale buffers, namespace file creation, client capabilities, and package-isolated references.
265
+
247
266
  The language server is published to npm as
248
267
  [`i18n-lens-language-server`](https://www.npmjs.com/package/i18n-lens-language-server).
249
268
  A Zed extension must not ship its language server, so the Rust shell installs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "i18n-lens-language-server",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "type": "module",
5
5
  "description": "Language server for configurable i18n inlay hints, hover, completion, diagnostics, definition, and references in Vue/TS/TSX/JS.",
6
6
  "license": "MIT",
package/server/core.js CHANGED
@@ -303,22 +303,133 @@ function offsetToPosition(text, offset) {
303
303
  function makeRange(text, start, end) {
304
304
  return { start: offsetToPosition(text, start), end: offsetToPosition(text, end) };
305
305
  }
306
+ // Lexical mask over mixed Vue/TS/JSX text: returns the sorted ranges that are
307
+ // NOT real code — comments, plain strings, template-literal raw text, HTML
308
+ // comments, and ordinary (non-directive) attribute values. Vue directive
309
+ // attribute values (`:x="…"`, `v-x="…"`, `@x="…"`, `#x="…"`) and `${…}`
310
+ // interpolations inside template literals stay transparent so real
311
+ // expressions inside them are still scanned. Best-effort lexing only — no AST.
312
+ function computeOpaqueRanges(text) {
313
+ const ranges = [];
314
+ const stack = [{ type: 'code' }];
315
+ const top = () => stack[stack.length - 1];
316
+ let i = 0;
317
+ while (i < text.length) {
318
+ const f = top();
319
+ const ch = text[i];
320
+ if (f.type === 'lineComment') {
321
+ if (ch === '\n') { ranges.push({ start: f.start, end: i }); stack.pop(); }
322
+ i++;
323
+ continue;
324
+ }
325
+ if (f.type === 'blockComment') {
326
+ if (ch === '*' && text[i + 1] === '/') { ranges.push({ start: f.start, end: i + 2 }); stack.pop(); i += 2; continue; }
327
+ i++;
328
+ continue;
329
+ }
330
+ if (f.type === 'htmlComment') {
331
+ if (ch === '-' && text[i + 1] === '-' && text[i + 2] === '>') { ranges.push({ start: f.start, end: i + 3 }); stack.pop(); i += 3; continue; }
332
+ i++;
333
+ continue;
334
+ }
335
+ if (f.type === 'string') {
336
+ if (ch === '\\') { i += 2; continue; }
337
+ if (ch === f.quote) { ranges.push({ start: f.start, end: i + 1 }); stack.pop(); }
338
+ i++;
339
+ continue;
340
+ }
341
+ if (f.type === 'template') {
342
+ if (ch === '\\') { i += 2; continue; }
343
+ if (ch === '`') { ranges.push({ start: f.start, end: i + 1 }); stack.pop(); i++; continue; }
344
+ if (ch === '$' && text[i + 1] === '{') {
345
+ ranges.push({ start: f.start, end: i });
346
+ stack.pop();
347
+ stack.push({ type: 'templateExpr', depth: 0 });
348
+ i += 2;
349
+ continue;
350
+ }
351
+ i++;
352
+ continue;
353
+ }
354
+
355
+ // code-like frames: code, templateExpr, attrExpr
356
+ if (f.type === 'attrExpr' && f.depth === 0 && ch === f.quote) { stack.pop(); i++; continue; }
357
+ if (ch === '{') {
358
+ if (f.type === 'templateExpr' || f.type === 'attrExpr') f.depth++;
359
+ i++;
360
+ continue;
361
+ }
362
+ if (ch === '}') {
363
+ if (f.type === 'templateExpr') {
364
+ if (f.depth === 0) { stack.pop(); stack.push({ type: 'template', start: i + 1 }); }
365
+ else f.depth--;
366
+ } else if (f.type === 'attrExpr' && f.depth > 0) f.depth--;
367
+ i++;
368
+ continue;
369
+ }
370
+ if (ch === '/' && text[i + 1] === '/') { stack.push({ type: 'lineComment', start: i }); i += 2; continue; }
371
+ if (ch === '/' && text[i + 1] === '*') { stack.push({ type: 'blockComment', start: i }); i += 2; continue; }
372
+ if (ch === '<' && text[i + 1] === '!' && text[i + 2] === '-' && text[i + 3] === '-') { stack.push({ type: 'htmlComment', start: i }); i += 4; continue; }
373
+ if (ch === '"' || ch === "'") {
374
+ if (f.type === 'code' && isAttrExprQuote(text, i)) stack.push({ type: 'attrExpr', quote: ch, depth: 0 });
375
+ else stack.push({ type: 'string', quote: ch, start: i });
376
+ i++;
377
+ continue;
378
+ }
379
+ if (ch === '`') { stack.push({ type: 'template', start: i }); i++; continue; }
380
+ i++;
381
+ }
382
+ while (stack.length > 1) {
383
+ const f = stack.pop();
384
+ if (f.start !== undefined) ranges.push({ start: f.start, end: text.length });
385
+ }
386
+ return ranges;
387
+ }
388
+
389
+ // A quote is a Vue directive attribute opener when preceded by `name=` where
390
+ // name is `:foo`, `@foo`, `#foo`, `v-foo`, or `ns:foo` (v-on:, v-bind:, …).
391
+ // Plain attributes (`title="…"`, JSX `attr="…"`) keep string semantics.
392
+ function isAttrExprQuote(text, quoteOffset) {
393
+ const before = text.slice(Math.max(0, quoteOffset - 200), quoteOffset);
394
+ return /(?:^|[\s<"'`])(?:[:@#][\w.:[\]-]*|[\w-]+:[\w.:[\]-]*|v-[\w.:[\]-]*)\s*=\s*$/.test(before);
395
+ }
396
+
397
+ function isOpaque(ranges, offset) {
398
+ let lo = 0;
399
+ let hi = ranges.length - 1;
400
+ while (lo <= hi) {
401
+ const mid = (lo + hi) >> 1;
402
+ const r = ranges[mid];
403
+ if (offset < r.start) hi = mid - 1;
404
+ else if (offset >= r.end) lo = mid + 1;
405
+ else return true;
406
+ }
407
+ return false;
408
+ }
409
+
306
410
 
307
411
  export function extractI18nKeys(text) {
308
412
  const found = [];
309
- collectFunctionCallKeys(text, found);
310
- collectFormatMessageKeys(text, found);
413
+ const opaque = computeOpaqueRanges(text);
414
+ collectFunctionCallKeys(text, found, opaque);
415
+ collectFormatMessageKeys(text, found, opaque);
311
416
  const patterns = [
312
417
  // <i18n-t keypath="key"> (Vue I18n) and <Trans i18nKey="key"> (react-i18next)
313
418
  /\b(?:keypath|i18nKey)\s*=\s*(['"])([A-Za-z0-9_.:-]+)\1/g,
314
419
  /\bv-t\s*=\s*"'([A-Za-z0-9_.:-]+)'"/g,
420
+ /\bv-t\s*=\s*'"([A-Za-z0-9_.:-]+)"'/g,
315
421
  /\bv-t\s*=\s*"\s*\{\s*path\s*:\s*(['"])([A-Za-z0-9_.:-]+)\1/g,
316
422
  ];
317
423
  for (const re of patterns) {
318
424
  let match;
319
425
  while ((match = re.exec(text))) {
426
+ const before = text[match.index - 1];
427
+ // Literal attribute only — bound directives (`:keypath`, `@`, `#`, `.prop`)
428
+ // hold expressions handled by the call scanner, not a literal key.
429
+ if (before !== undefined && /[\w:@#.-]/.test(before)) continue;
430
+ if (isOpaque(opaque, match.index)) continue;
320
431
  const key = match[2] || match[1];
321
- const keyOffsetInMatch = match[0].indexOf(key);
432
+ const keyOffsetInMatch = match[0].lastIndexOf(key);
322
433
  const start = match.index + keyOffsetInMatch;
323
434
  const end = start + key.length;
324
435
  found.push({ key, range: makeRange(text, start, end), startOffset: start, endOffset: end });
@@ -328,15 +439,19 @@ export function extractI18nKeys(text) {
328
439
  return found;
329
440
  }
330
441
 
331
- function collectFunctionCallKeys(text, found) {
442
+ function collectFunctionCallKeys(text, found, opaque) {
332
443
  // t/$t/tc/$tc/i18n.t/i18n.tc(...) — function calls, incl. Vue I18n plural tc
333
444
  const re = /(?:\x24tc?|\btc?|\bi18n\.tc?)\s*\(/g;
334
445
  let match;
335
446
  while ((match = re.exec(text))) {
336
447
  const parenOffset = re.lastIndex - 1;
448
+ if (/[\w$]/.test(text[match.index - 1] || '')) continue;
449
+ if (isOpaque(opaque, match.index)) continue;
337
450
  const callText = readParens(text, parenOffset);
338
451
  if (!callText) continue;
339
- const keyMatch = callText.match(/^\(\s*(['"])([A-Za-z0-9_.:-]+)\1/);
452
+ // First arg must be exactly one string literal — dynamic keys like
453
+ // `t('prefix.' + name)` are skipped, never half-matched.
454
+ const keyMatch = callText.match(/^\(\s*(['"])([A-Za-z0-9_.:-]+)\1(?=\s*[,)])/);
340
455
  if (!keyMatch) continue;
341
456
  const key = keyMatch[2];
342
457
  const start = parenOffset + keyMatch[0].indexOf(key);
@@ -348,19 +463,21 @@ function collectFunctionCallKeys(text, found) {
348
463
  }
349
464
  }
350
465
 
351
- function collectFormatMessageKeys(text, found) {
466
+ function collectFormatMessageKeys(text, found, opaque) {
352
467
  const re = /\bformatMessage\s*\(/g;
353
468
  let match;
354
469
  while ((match = re.exec(text))) {
470
+ if (/[\w$]/.test(text[match.index - 1] || '')) continue;
471
+ if (isOpaque(opaque, match.index)) continue;
355
472
  const parenOffset = re.lastIndex - 1;
356
473
  const callText = readParens(text, parenOffset);
357
474
  if (!callText) continue;
358
475
  const args = splitArgsWithOffsets(callText.slice(1, -1));
359
476
  const idArg = args[0]?.text || '';
360
- const keyMatch = idArg.match(/\bid\s*:\s*(['"])([A-Za-z0-9_.:-]+)\1/);
477
+ const keyMatch = idArg.match(/\bid\s*:\s*(['"])([A-Za-z0-9_.:-]+)\1(?=\s*[,}])/);
361
478
  if (!keyMatch) continue;
362
479
  const key = keyMatch[2];
363
- const start = parenOffset + 1 + idArg.indexOf(key);
480
+ const start = parenOffset + 1 + args[0].start + keyMatch.index + keyMatch[0].lastIndexOf(key);
364
481
  const end = start + key.length;
365
482
  const paramsEdit = getParamsEdit(args.slice(1), parenOffset + 1, parenOffset + callText.length - 1);
366
483
  const paramsObject = extractParamsObject(args.slice(1), parenOffset + 1);
@@ -382,6 +499,18 @@ function readParens(text, start) {
382
499
  else if (ch === quote) quote = '';
383
500
  continue;
384
501
  }
502
+ if (ch === '/' && text[i + 1] === '/') {
503
+ const end = text.indexOf('\n', i + 2);
504
+ if (end < 0) return undefined;
505
+ i = end;
506
+ continue;
507
+ }
508
+ if (ch === '/' && text[i + 1] === '*') {
509
+ const end = text.indexOf('*/', i + 2);
510
+ if (end < 0) return undefined;
511
+ i = end + 1;
512
+ continue;
513
+ }
385
514
  if (ch === '"' || ch === "'" || ch === '`') { quote = ch; continue; }
386
515
  if (ch === '(' || ch === '{' || ch === '[') depth++;
387
516
  else if (ch === ')' || ch === '}' || ch === ']') {
@@ -523,10 +652,8 @@ export function findLocaleKeyTarget(locale, key, localeTexts = {}) {
523
652
  for (const candidate of candidates) {
524
653
  const text = localeTexts[candidate.filePath];
525
654
  if (!text) continue;
526
- const position = /\.json$/i.test(candidate.filePath)
527
- ? findJsonKeyLocation(text, candidate.lookupKey)
528
- : findLocaleKeyLocation(text, candidate.lookupKey);
529
- if (position) return { filePath: candidate.filePath, position };
655
+ const range = localeKeyRange(text, candidate.lookupKey, /\.json$/i.test(candidate.filePath));
656
+ if (range) return { filePath: candidate.filePath, position: range.start, range };
530
657
  }
531
658
 
532
659
  const fallback = candidates[0];
@@ -534,7 +661,7 @@ export function findLocaleKeyTarget(locale, key, localeTexts = {}) {
534
661
  }
535
662
 
536
663
  function lookupKeyForLocaleFile(locale, filePath, key) {
537
- const isNestedLocaleFile = normalizeFsPath(locale.path) !== normalizeFsPath(filePath);
664
+ const isNestedLocaleFile = !locale.topLevelFiles?.includes(filePath) && normalizeFsPath(locale.path) !== normalizeFsPath(filePath);
538
665
  const stem = fileStem(filePath);
539
666
  if (isNestedLocaleFile && stem && stem !== 'index' && key.startsWith(stem + '.')) return key.slice(stem.length + 1);
540
667
  return key;
@@ -638,16 +765,14 @@ export function getLocaleParamConsistency(key, locales) {
638
765
  export function getLocaleParamDiagnostics(locale, filePath, text, locales) {
639
766
  if (!locale?.flat) return [];
640
767
  const isJson = /\.json$/i.test(filePath);
768
+ const jsonRoot = isJson ? parseJsonStructure(text) || null : undefined;
641
769
  const diagnostics = [];
642
770
  for (const key of Object.keys(locale.flat)) {
643
771
  const consistency = getLocaleParamConsistency(key, locales);
644
772
  if (consistency.consistent) continue;
645
773
  const lookupKey = lookupKeyForLocaleFile(locale, filePath, key);
646
- const position = isJson ? findJsonKeyLocation(text, lookupKey) : findLocaleKeyLocation(text, lookupKey);
647
- if (!position) continue;
648
- const leaf = lookupKey.split('.').at(-1);
649
- const end = { line: position.line, character: position.character + leaf.length + (isJson ? 2 : 0) };
650
- diagnostics.push(paramInconsistencyDiagnostic(key, { start: position, end }, consistency.perLocale));
774
+ const range = localeKeyRange(text, lookupKey, isJson, jsonRoot);
775
+ if (range) diagnostics.push(paramInconsistencyDiagnostic(key, range, consistency.perLocale));
651
776
  }
652
777
  return diagnostics;
653
778
  }
@@ -791,118 +916,162 @@ export function collectLocaleKeyTargets(locales, key, localeTexts = {}, defaultL
791
916
  export function localeKeyAtPosition(locale, filePath, text, position) {
792
917
  if (!locale || !locale.flat || !text || !position) return undefined;
793
918
  const isJson = /\.json$/i.test(filePath);
919
+ const jsonRoot = isJson ? parseJsonStructure(text) || null : undefined;
794
920
  let lineFallback;
795
921
  let lineFallbackCount = 0;
796
922
  for (const fullKey of Object.keys(locale.flat)) {
797
923
  const lookupKey = lookupKeyForLocaleFile(locale, filePath, fullKey);
798
- const loc = isJson ? findJsonKeyLocation(text, lookupKey) : findLocaleKeyLocation(text, lookupKey);
799
- if (!loc || loc.line !== position.line) continue;
800
- const leaf = lookupKey.split('.').at(-1);
801
- const tokenStart = loc.character;
802
- const tokenEnd = tokenStart + leaf.length + (isJson ? 2 : 0);
803
- if (position.character >= tokenStart && position.character <= tokenEnd) return fullKey;
924
+ const range = localeKeyRange(text, lookupKey, isJson, jsonRoot);
925
+ if (!range || range.start.line !== position.line) continue;
926
+ if (comparePosition(position, range.start) >= 0 && comparePosition(position, range.end) < 0) return fullKey;
804
927
  lineFallback = fullKey;
805
928
  lineFallbackCount++;
806
929
  }
807
- return lineFallbackCount === 1 ? lineFallback : undefined;
930
+ return !isJson && lineFallbackCount === 1 ? lineFallback : undefined;
808
931
  }
809
932
 
810
933
  export function getCompletionPrefix(text, position) {
811
- const offset = positionToOffset(text, position);
812
- const before = text.slice(0, offset);
934
+ const before = text.slice(0, positionToOffset(text, position));
935
+ const opaque = computeOpaqueRanges(before);
813
936
  const patterns = [
814
- /(?:\x24tc?|\btc?|\bi18n\.tc?)\(\s*['"]([A-Za-z0-9_.:-]*)$/,
937
+ /(?:\x24tc?|\btc?|\bi18n\.tc?)\s*\(\s*['"]([A-Za-z0-9_.:-]*)$/,
815
938
  /\bformatMessage\s*\(\s*\{[^}]*?\bid\s*:\s*['"]([A-Za-z0-9_.:-]*)$/,
816
939
  /\b(?:keypath|i18nKey)\s*=\s*['"]([A-Za-z0-9_.:-]*)$/,
817
- /v-t\s*=\s*"'([A-Za-z0-9_.:-]*)$/,
940
+ /\bv-t\s*=\s*"'([A-Za-z0-9_.:-]*)$/,
941
+ /\bv-t\s*=\s*'\x22([A-Za-z0-9_.:-]*)$/,
942
+ /\bv-t\s*=\s*"\s*\{\s*path\s*:\s*['"]([A-Za-z0-9_.:-]*)$/,
818
943
  ];
819
944
  for (const re of patterns) {
820
945
  const match = before.match(re);
821
- if (match) return match[1];
946
+ if (!match || isOpaque(opaque, match.index)) continue;
947
+ if (/[\w$:@#-]/.test(before[match.index - 1] || '')) continue;
948
+ return match[1];
822
949
  }
823
- return '';
950
+ return undefined;
824
951
  }
825
952
 
826
953
  export function findJsonKeyLocation(jsonText, key) {
827
- const parts = key.split('.');
828
- let cursor = 0;
829
- for (const part of parts) {
830
- const re = new RegExp('"' + escapeRegExp(part) + '"\\s*:', 'g');
831
- re.lastIndex = cursor;
832
- const match = re.exec(jsonText);
833
- if (!match) return undefined;
834
- cursor = match.index + match[0].length;
835
- if (part === parts[parts.length - 1]) return offsetToPosition(jsonText, match.index);
836
- }
837
- return undefined;
954
+ return localeKeyRange(jsonText, key, true)?.start;
838
955
  }
839
956
 
840
- // Surgically insert a dotted `key` into a JSON document, creating intermediate
841
- // objects as needed and preserving the rest of the file's formatting. Returns
842
- // the new text, or undefined if the JSON is unparseable or the key already exists.
843
- export function insertNestedJsonKey(jsonText, key, value = '') {
844
- let root;
845
- try { root = JSON.parse(jsonText); } catch { return undefined; }
846
- if (!root || typeof root !== 'object' || Array.isArray(root)) return undefined;
847
- if (getValueByKey(root, key) !== undefined) return undefined;
848
-
849
- const parts = key.split('.');
850
- let node = root;
851
- let existingDepth = 0;
852
- for (; existingDepth < parts.length - 1; existingDepth++) {
853
- const next = node[parts[existingDepth]];
854
- if (next && typeof next === 'object' && !Array.isArray(next)) node = next;
855
- else break;
957
+ function localeKeyRange(text, key, isJson, jsonRoot = isJson ? parseJsonStructure(text) : undefined) {
958
+ if (isJson) {
959
+ const property = findJsonProperty(jsonRoot, key.split('.'));
960
+ return property ? makeRange(text, property.start, property.end) : undefined;
856
961
  }
962
+ const start = findLocaleKeyLocation(text, key);
963
+ if (!start) return undefined;
964
+ const offset = positionToOffset(text, start);
965
+ const token = text.slice(offset).match(/^(?:'[^']*'|"[^"]*"|[\w$-]+)/);
966
+ return token ? makeRange(text, offset, offset + token[0].length) : undefined;
967
+ }
857
968
 
858
- const span = findJsonObjectSpan(jsonText, parts.slice(0, existingDepth));
859
- if (!span) return undefined;
860
-
861
- const unit = detectIndentUnit(jsonText);
862
- const snippet = buildNestedProperty(parts.slice(existingDepth), value, unit.repeat(existingDepth + 1), unit);
863
- const inner = jsonText.slice(span.openBrace + 1, span.contentEnd).trim();
864
- if (inner.length === 0) {
865
- return jsonText.slice(0, span.openBrace + 1) + '\n' + snippet + '\n' + unit.repeat(existingDepth) + jsonText.slice(span.closeBrace);
969
+ // JSON.parse validates the grammar; this walk retains property-token and value
970
+ // boundaries for edits. Maps avoid prototype keys and duplicate keys are refused.
971
+ function parseJsonStructure(text) {
972
+ try { JSON.parse(text); } catch { return undefined; }
973
+ let offset = 0;
974
+ let duplicate = false;
975
+ const whitespace = () => { while (/\s/.test(text[offset] || '') && offset < text.length) offset++; };
976
+ function stringEnd() {
977
+ offset++;
978
+ while (offset < text.length) {
979
+ if (text[offset] === '\\') offset += 2;
980
+ else if (text[offset++] === '"') break;
981
+ }
982
+ }
983
+ function value() {
984
+ whitespace();
985
+ const start = offset;
986
+ const properties = text[offset] === '{' ? new Map() : undefined;
987
+ if (properties) {
988
+ offset++;
989
+ whitespace();
990
+ while (text[offset] !== '}') {
991
+ const keyStart = offset;
992
+ stringEnd();
993
+ const keyEnd = offset;
994
+ const key = JSON.parse(text.slice(keyStart, keyEnd));
995
+ whitespace();
996
+ offset++; // colon
997
+ const child = value();
998
+ if (properties.has(key)) duplicate = true;
999
+ properties.set(key, { start: keyStart, end: keyEnd, value: child });
1000
+ whitespace();
1001
+ if (text[offset] !== ',') break;
1002
+ offset++;
1003
+ whitespace();
1004
+ }
1005
+ offset++;
1006
+ } else if (text[offset] === '[') {
1007
+ offset++;
1008
+ whitespace();
1009
+ while (text[offset] !== ']') {
1010
+ value();
1011
+ whitespace();
1012
+ if (text[offset] !== ',') break;
1013
+ offset++;
1014
+ }
1015
+ offset++;
1016
+ } else if (text[offset] === '"') {
1017
+ stringEnd();
1018
+ } else {
1019
+ while (offset < text.length && !/[\s,\]}]/.test(text[offset])) offset++;
1020
+ }
1021
+ return { start, end: offset, properties };
866
1022
  }
867
- const needsComma = jsonText[span.contentEnd - 1] !== ',';
868
- return jsonText.slice(0, span.contentEnd) + (needsComma ? ',' : '') + '\n' + snippet + jsonText.slice(span.contentEnd);
869
- }
870
-
871
- function findJsonObjectSpan(jsonText, pathParts) {
872
- let searchFrom = 0;
873
- if (pathParts.length) {
874
- const pos = findJsonKeyLocation(jsonText, pathParts.join('.'));
875
- if (!pos) return undefined;
876
- const colon = jsonText.indexOf(':', positionToOffset(jsonText, pos));
877
- if (colon === -1) return undefined;
878
- searchFrom = colon + 1;
1023
+ const root = value();
1024
+ return duplicate ? undefined : root;
1025
+ }
1026
+
1027
+ function findJsonProperty(node, parts) {
1028
+ if (!node?.properties) return undefined;
1029
+ let found;
1030
+ for (let count = 1; count <= parts.length; count++) {
1031
+ const property = node.properties.get(parts.slice(0, count).join('.'));
1032
+ if (!property) continue;
1033
+ const candidate = count === parts.length ? property : findJsonProperty(property.value, parts.slice(count));
1034
+ if (!candidate) continue;
1035
+ if (found) return undefined; // dotted and nested representations collide
1036
+ found = candidate;
879
1037
  }
880
- const openBrace = jsonText.indexOf('{', searchFrom);
881
- if (openBrace === -1) return undefined;
882
- const closeBrace = matchBrace(jsonText, openBrace);
883
- if (closeBrace === -1) return undefined;
884
- let contentEnd = closeBrace;
885
- while (contentEnd > openBrace + 1 && /\s/.test(jsonText[contentEnd - 1])) contentEnd--;
886
- return { openBrace, closeBrace, contentEnd };
1038
+ return found;
887
1039
  }
888
1040
 
889
- function matchBrace(text, open) {
1041
+ // Insert only into an unambiguous object path. Existing scalar/array prefixes,
1042
+ // duplicate properties and existing values are never overwritten.
1043
+ export function insertNestedJsonKey(jsonText, key, value = '') {
1044
+ let node = parseJsonStructure(jsonText);
1045
+ let parts = key.split('.');
1046
+ if (!node?.properties || parts.some((part) => !part)) return undefined;
890
1047
  let depth = 0;
891
- let quote = false;
892
- let escaped = false;
893
- for (let i = open; i < text.length; i++) {
894
- const ch = text[i];
895
- if (quote) {
896
- if (escaped) escaped = false;
897
- else if (ch === '\\') escaped = true;
898
- else if (ch === '"') quote = false;
899
- continue;
1048
+ while (true) {
1049
+ const matches = [];
1050
+ for (let count = 1; count <= parts.length; count++) {
1051
+ const property = node.properties.get(parts.slice(0, count).join('.'));
1052
+ if (property) matches.push({ count, property });
900
1053
  }
901
- if (ch === '"') quote = true;
902
- else if (ch === '{') depth++;
903
- else if (ch === '}' && --depth === 0) return i;
1054
+ if (!matches.length) break;
1055
+ if (matches.length !== 1) return undefined;
1056
+ const { count, property } = matches[0];
1057
+ if (count === parts.length || !property.value.properties) return undefined;
1058
+ parts = parts.slice(count);
1059
+ node = property.value;
1060
+ depth++;
1061
+ }
1062
+ const eol = jsonText.includes('\r\n') ? '\r\n' : '\n';
1063
+ const unit = detectIndentUnit(jsonText);
1064
+ const firstProperty = node.properties.values().next().value;
1065
+ const lineStart = firstProperty ? jsonText.lastIndexOf('\n', firstProperty.start) + 1 : -1;
1066
+ const existingIndent = firstProperty ? jsonText.slice(lineStart, firstProperty.start) : '';
1067
+ const indent = firstProperty && /^[ \t]+$/.test(existingIndent) ? existingIndent : unit.repeat(depth + 1);
1068
+ const snippet = buildNestedProperty(parts, value, indent, unit, eol);
1069
+ let contentEnd = node.end - 1;
1070
+ while (contentEnd > node.start + 1 && /\s/.test(jsonText[contentEnd - 1])) contentEnd--;
1071
+ if (!node.properties.size) {
1072
+ return jsonText.slice(0, node.start + 1) + eol + snippet + eol + indent.slice(0, -unit.length) + jsonText.slice(node.end - 1);
904
1073
  }
905
- return -1;
1074
+ return jsonText.slice(0, contentEnd) + ',' + eol + snippet + jsonText.slice(contentEnd);
906
1075
  }
907
1076
 
908
1077
  function detectIndentUnit(jsonText) {
@@ -910,10 +1079,10 @@ function detectIndentUnit(jsonText) {
910
1079
  return match ? match[1] : ' ';
911
1080
  }
912
1081
 
913
- function buildNestedProperty(parts, value, indent, unit) {
1082
+ function buildNestedProperty(parts, value, indent, unit, eol) {
914
1083
  const keyStr = JSON.stringify(parts[0]);
915
1084
  if (parts.length === 1) return indent + keyStr + ': ' + JSON.stringify(value);
916
- return indent + keyStr + ': {\n' + buildNestedProperty(parts.slice(1), value, indent + unit, unit) + '\n' + indent + '}';
1085
+ return indent + keyStr + ': {' + eol + buildNestedProperty(parts.slice(1), value, indent + unit, unit, eol) + eol + indent + '}';
917
1086
  }
918
1087
 
919
1088
 
package/server/index.js CHANGED
@@ -2,7 +2,7 @@
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, CodeActionKind } from 'vscode-languageserver/node.js';
5
+ import { createConnection, TextDocuments, ProposedFeatures, TextDocumentSyncKind, MarkupKind, CodeActionKind, TextDocumentEdit, CreateFile, ResourceOperationKind } from 'vscode-languageserver/node.js';
6
6
  import { TextDocument } from 'vscode-languageserver-textdocument';
7
7
  import {
8
8
  flattenLocale,
@@ -39,6 +39,11 @@ const watchedLocalePaths = new Set();
39
39
  let reloadTimer;
40
40
  let configMtimeMs;
41
41
  let localeCacheByContext = new Map();
42
+ // Whether the client can apply WorkspaceEdit.documentChanges / CreateFile.
43
+ // Without documentChanges there is no versioned-edit support, so multi-file
44
+ // quick fixes are not offered at all rather than applied unsafely.
45
+ let clientSupportsDocumentChanges = false;
46
+ let clientSupportsCreateFile = false;
42
47
  const configWatchIntervalMs = 1000;
43
48
  const localeWatchIntervalMs = 1000;
44
49
  const reloadDebounceMs = 100;
@@ -46,6 +51,11 @@ const reloadDebounceMs = 100;
46
51
  connection.onInitialize((params) => {
47
52
  workspaceRoot = params.workspaceFolders?.[0]?.uri ? fileURLToPath(params.workspaceFolders[0].uri) : (params.rootUri ? fileURLToPath(params.rootUri) : process.cwd());
48
53
  zedSettingsConfig = normalizeIncomingSettings(params.initializationOptions);
54
+ const editCaps = params.capabilities?.workspace?.workspaceEdit;
55
+ clientSupportsDocumentChanges = editCaps?.documentChanges === true;
56
+ clientSupportsCreateFile = clientSupportsDocumentChanges
57
+ && Array.isArray(editCaps?.resourceOperations)
58
+ && editCaps.resourceOperations.includes(ResourceOperationKind.Create);
49
59
  loadConfig();
50
60
  watchConfigFile();
51
61
  return { capabilities: { textDocumentSync: TextDocumentSyncKind.Incremental, hoverProvider: true, completionProvider: { triggerCharacters: ['.', '"', "'"] }, definitionProvider: true, referencesProvider: true, inlayHintProvider: true, codeActionProvider: true } };
@@ -59,12 +69,38 @@ connection.onInitialized(() => {
59
69
  });
60
70
 
61
71
  documents.onDidOpen((event) => {
72
+ if (isLocaleDocument(event.document)) {
73
+ // An open (possibly dirty) locale document overrides the disk copy, so a
74
+ // previously cached load is stale the moment the file opens.
75
+ invalidateLocaleCaches();
76
+ }
62
77
  validate(event.document);
63
78
  // A freshly started server may receive the inlay-hint request before the
64
79
  // locale cache is ready; prompt the editor to re-request once it is.
65
80
  refreshInlayHints();
66
81
  });
67
- documents.onDidChangeContent((event) => validate(event.document));
82
+
83
+ documents.onDidChangeContent((event) => {
84
+ if (isLocaleDocument(event.document)) {
85
+ // Open locale text feeds the cache, so it must be dropped now — the very
86
+ // next request must already see this snapshot, not a debounced reload.
87
+ invalidateLocaleCaches();
88
+ refreshOpenDocuments();
89
+ refreshInlayHints();
90
+ return;
91
+ }
92
+ validate(event.document);
93
+ });
94
+
95
+ // An open locale document overrides the disk copy. Closing it reverts to
96
+ // disk, which is also a content change for every feature.
97
+ documents.onDidClose((event) => {
98
+ if (!isLocaleDocument(event.document)) return;
99
+ invalidateLocaleCaches();
100
+ refreshOpenDocuments();
101
+ refreshInlayHints();
102
+ });
103
+
68
104
  documents.onDidSave(() => scheduleProjectReload());
69
105
 
70
106
  connection.onDidChangeConfiguration((params) => {
@@ -73,6 +109,7 @@ connection.onDidChangeConfiguration((params) => {
73
109
  });
74
110
 
75
111
  connection.onHover((params) => {
112
+ if (isLocaleFileUri(params.textDocument.uri)) return null;
76
113
  const doc = documents.get(params.textDocument.uri);
77
114
  if (!doc) return null;
78
115
  const item = keyAtPosition(doc.getText(), params.position);
@@ -86,16 +123,19 @@ connection.onHover((params) => {
86
123
  });
87
124
 
88
125
  connection.onCompletion((params) => {
89
- if (!isSourceFile(params.textDocument.uri)) return [];
126
+ if (!isSourceFile(params.textDocument.uri) || isLocaleFileUri(params.textDocument.uri)) return [];
90
127
  const doc = documents.get(params.textDocument.uri);
128
+ if (!doc) return [];
129
+ const prefix = getCompletionPrefix(doc.getText(), params.position);
130
+ // undefined = caret is not inside an i18n call; '' = empty key prefix.
131
+ if (prefix === undefined) return [];
91
132
  const context = getProjectContext(params.textDocument.uri);
92
133
  const { locales } = loadLocalesForContext(context);
93
- const prefix = doc ? getCompletionPrefix(doc.getText(), params.position) : '';
94
134
  return getCompletions(prefix, locales, context.config.defaultLocale);
95
135
  });
96
136
 
97
137
  connection.languages.inlayHint.on((params) => {
98
- if (!isSourceFile(params.textDocument.uri)) return [];
138
+ if (!isSourceFile(params.textDocument.uri) || isLocaleFileUri(params.textDocument.uri)) return [];
99
139
  const doc = documents.get(params.textDocument.uri);
100
140
  if (!doc) return [];
101
141
  const context = getProjectContext(params.textDocument.uri);
@@ -104,18 +144,15 @@ connection.languages.inlayHint.on((params) => {
104
144
  });
105
145
 
106
146
  connection.onDefinition((params) => {
147
+ if (isLocaleFileUri(params.textDocument.uri)) return null;
107
148
  const doc = documents.get(params.textDocument.uri);
108
149
  if (!doc) return null;
109
150
  const item = keyAtPosition(doc.getText(), params.position);
110
151
  if (!item) return null;
111
152
  const context = getProjectContext(params.textDocument.uri);
112
153
  const { locales, localeTexts } = loadLocalesForContext(context);
113
- const keyTokenLength = item.key.split('.').at(-1).length + 2;
114
154
  const locations = collectLocaleKeyTargets(locales, item.key, localeTexts, context.config.defaultLocale)
115
- .map(({ filePath, position }) => ({
116
- uri: pathToFileURL(filePath).toString(),
117
- range: { start: position, end: { line: position.line, character: position.character + keyTokenLength } },
118
- }));
155
+ .map(({ filePath, range }) => ({ uri: pathToFileURL(filePath).toString(), range }));
119
156
  return locations.length ? locations : null;
120
157
  });
121
158
 
@@ -128,12 +165,8 @@ connection.onReferences((params) => {
128
165
  const locations = collectCodeReferences(context, key);
129
166
  if (params.context?.includeDeclaration) {
130
167
  const { locales, localeTexts } = loadLocalesForContext(context);
131
- const keyTokenLength = key.split('.').at(-1).length + 2;
132
- for (const { filePath, position } of collectLocaleKeyTargets(locales, key, localeTexts, context.config.defaultLocale)) {
133
- locations.push({
134
- uri: pathToFileURL(filePath).toString(),
135
- range: { start: position, end: { line: position.line, character: position.character + keyTokenLength } },
136
- });
168
+ for (const { filePath, range } of collectLocaleKeyTargets(locales, key, localeTexts, context.config.defaultLocale)) {
169
+ locations.push({ uri: pathToFileURL(filePath).toString(), range });
137
170
  }
138
171
  }
139
172
  return locations.length ? locations : null;
@@ -143,13 +176,13 @@ connection.onCodeAction((params) => {
143
176
  const doc = documents.get(params.textDocument.uri);
144
177
  const diagnostics = params.context.diagnostics || [];
145
178
  const actions = [];
146
- if (doc) {
179
+ if (clientSupportsDocumentChanges && doc && isSourceFile(params.textDocument.uri) && !isLocaleFileUri(params.textDocument.uri)) {
147
180
  for (const action of getParamCodeActions(doc.getText(), diagnostics)) {
148
181
  actions.push({
149
182
  title: action.title,
150
183
  kind: CodeActionKind.QuickFix,
151
184
  diagnostics: [action.diagnostic],
152
- edit: { changes: { [params.textDocument.uri]: [action.edit] } },
185
+ edit: { documentChanges: [TextDocumentEdit.create({ uri: doc.uri, version: doc.version }, [action.edit])] },
153
186
  });
154
187
  }
155
188
  }
@@ -157,10 +190,8 @@ connection.onCodeAction((params) => {
157
190
  return actions;
158
191
  });
159
192
 
160
- // Quick fix for a "missing i18n key" diagnostic: create the key (empty value) in
161
- // every locale file that lacks it, as a cross-file workspace edit. Only single
162
- // flat JSON locale files are supported; nested/TS locales are skipped.
163
193
  function getCreateKeyActions(diagnostics, context) {
194
+ if (!clientSupportsDocumentChanges) return [];
164
195
  const { locales, localeTexts } = loadLocalesForContext(context);
165
196
  const actions = [];
166
197
  for (const diagnostic of diagnostics) {
@@ -168,42 +199,153 @@ function getCreateKeyActions(diagnostics, context) {
168
199
  const missingLocales = diagnostic.data?.missingLocales;
169
200
  if (!key || !Array.isArray(missingLocales) || missingLocales.length === 0) continue;
170
201
 
171
- const changes = {};
172
- for (const name of missingLocales) {
173
- const filePath = flatLocaleFile(locales[name]);
174
- if (!filePath) continue;
175
- const text = localeTexts[filePath] ?? readFileSafe(filePath);
176
- if (text === undefined) continue;
177
- const newText = insertNestedJsonKey(text, key, '');
178
- if (newText === undefined) continue;
179
- changes[pathToFileURL(filePath).toString()] = [fullDocumentEdit(text, newText)];
202
+ const missing = missingLocales.filter((name) => locales[name] && !Object.hasOwn(locales[name].flat, key));
203
+ const plans = missing.map((name) => planKeyInsertion(key, locales[name], locales, localeTexts));
204
+ // Do not offer a partial repair of a multi-locale diagnostic.
205
+ if (!plans.length || plans.some((plan) => !plan || (plan.create && !clientSupportsCreateFile))) continue;
206
+ const documentChanges = [];
207
+ for (const plan of plans) {
208
+ // If a file appeared after this action was computed, fail rather than
209
+ // inserting into unrelated content at a stale offset.
210
+ if (plan.create) documentChanges.push(CreateFile.create(plan.uri));
211
+ documentChanges.push(TextDocumentEdit.create({ uri: plan.uri, version: plan.version }, [minimalTextEdit(plan.text, plan.newText)]));
180
212
  }
181
-
182
- const fileCount = Object.keys(changes).length;
183
- if (fileCount === 0) continue;
184
213
  actions.push({
185
- title: `Create i18n key "${key}" in ${fileCount} locale file(s)`,
214
+ title: `Create i18n key "${key}" in ${plans.length} locale file(s)`,
186
215
  kind: CodeActionKind.QuickFix,
187
216
  diagnostics: [diagnostic],
188
- edit: { changes },
217
+ edit: { documentChanges },
189
218
  });
190
219
  }
191
220
  return actions;
192
221
  }
193
222
 
194
- // A locale backed by a single flat .json file (not a nested directory locale).
195
- function flatLocaleFile(locale) {
196
- if (!locale || !/\.json$/i.test(locale.path || '')) return undefined;
197
- const files = locale.files?.length ? locale.files : [locale.path];
198
- return files.length === 1 && files[0] === locale.path ? locale.path : undefined;
223
+ // Namespace routing follows loaded file stems. Index-owned prefixes and
224
+ // overlapping layouts are not guessed; ambiguous targets disable the action.
225
+ function planKeyInsertion(key, locale, locales, localeTexts) {
226
+ if (!locale) return undefined;
227
+ if (!locale.directories?.length) {
228
+ const files = locale.files || [];
229
+ return files.length === 1 && /\.json$/i.test(files[0])
230
+ ? insertInto(files[0], key) : undefined;
231
+ }
232
+ if (locale.directories.length !== 1 || locale.topLevelFiles?.length) return undefined;
233
+ const directory = locale.directories[0];
234
+ const targets = filesByStem(locale);
235
+ const namespaceFiles = [...targets.keys()].filter((stem) => stem !== 'index' && key.startsWith(stem + '.'));
236
+ if (namespaceFiles.length > 1 || targets.has(key)) return undefined;
237
+ const indexFiles = targets.get('index') || [];
238
+ const indexFile = indexFiles.length === 1 && /\.json$/i.test(indexFiles[0]) ? indexFiles[0] : undefined;
239
+ let indexOwnsPrefix = false;
240
+ if (indexFiles.length) {
241
+ if (!indexFile) return undefined;
242
+ try {
243
+ const text = localeTexts[indexFile];
244
+ const prefix = key.split('.')[0];
245
+ // A whitespace-only index owns nothing; treat it like an empty object.
246
+ indexOwnsPrefix = Object.keys(JSON.parse(text.trim() ? text : '{}'))
247
+ .some((existing) => existing === prefix || existing.startsWith(prefix + '.'));
248
+ } catch { return undefined; }
249
+ }
250
+ if (namespaceFiles.length) {
251
+ if (indexOwnsPrefix) return undefined;
252
+ const stem = namespaceFiles[0];
253
+ const files = targets.get(stem);
254
+ return files.length === 1 && /\.json$/i.test(files[0])
255
+ ? insertInto(files[0], key.slice(stem.length + 1)) : undefined;
256
+ }
257
+ if (indexOwnsPrefix) return insertInto(indexFile, key);
258
+
259
+ const siblingStems = new Map();
260
+ for (const sibling of Object.values(locales)) {
261
+ if (sibling === locale || sibling.directories?.length !== 1 || sibling.topLevelFiles?.length) continue;
262
+ for (const [stem, files] of filesByStem(sibling)) {
263
+ if (stem === 'index' || !key.startsWith(stem + '.')) continue;
264
+ siblingStems.set(stem, (siblingStems.get(stem) || []).concat(files));
265
+ }
266
+ }
267
+ if (siblingStems.size > 1) return undefined;
268
+ if (siblingStems.size === 1) {
269
+ const [stem, files] = siblingStems.entries().next().value;
270
+ if (files.some((file) => !/\.json$/i.test(file))) return undefined;
271
+ return insertOrCreate(path.join(directory, stem + '.json'), key.slice(stem.length + 1));
272
+ }
273
+ return indexFile ? insertInto(indexFile, key) : undefined;
274
+ }
275
+
276
+ function filesByStem(locale) {
277
+ const stems = new Map();
278
+ for (const file of locale.files || []) {
279
+ const stem = path.basename(file, path.extname(file));
280
+ if (!stems.has(stem)) stems.set(stem, []);
281
+ stems.get(stem).push(file);
282
+ }
283
+ return stems;
284
+ }
285
+
286
+ function insertInto(filePath, lookupKey) {
287
+ if (!lookupKey) return undefined;
288
+ const text = openDocumentText(filePath) ?? readFileSafe(filePath);
289
+ if (text === undefined) return undefined;
290
+ // A whitespace-only file has no JSON structure to insert into; seed it like
291
+ // an empty object (matching its line-ending style) so the fix still lands
292
+ // instead of silently disappearing.
293
+ const source = text.trim() ? text : (text.includes('\r\n') ? '{}\r\n' : '{}\n');
294
+ const newText = insertNestedJsonKey(source, lookupKey, '');
295
+ if (newText === undefined) return undefined;
296
+ return toEditPlan(filePath, text, newText, false);
297
+ }
298
+
299
+ // Prefer an in-place insert when the file exists or is open as a buffer (the
300
+ // file may have been deleted on disk while its document stays alive); fall
301
+ // back to a CreateFile only when there is truly nothing to edit.
302
+ function insertOrCreate(filePath, lookupKey) {
303
+ if (fs.existsSync(filePath) || openDocument(filePath)) return insertInto(filePath, lookupKey);
304
+ return createIn(filePath, lookupKey);
305
+ }
306
+
307
+ // Create a missing namespace JSON file: only ever proposed with a CreateFile
308
+ // operation ahead of the text edit, and only when the file truly does not
309
+ // exist — never as a fallback guess.
310
+ function createIn(filePath, lookupKey) {
311
+ if (!lookupKey || fs.existsSync(filePath) || openDocument(filePath)) return undefined;
312
+ const newText = insertNestedJsonKey('{}\n', lookupKey, '');
313
+ if (newText === undefined) return undefined;
314
+ return toEditPlan(filePath, '', newText, true);
199
315
  }
200
316
 
201
- function fullDocumentEdit(oldText, newText) {
202
- const lines = oldText.split('\n');
203
- const endLine = lines.length - 1;
204
- return { range: { start: { line: 0, character: 0 }, end: { line: endLine, character: lines[endLine].length } }, newText };
317
+ function toEditPlan(filePath, text, newText, create) {
318
+ return { uri: pathToFileURL(filePath).toString(), version: documentVersion(filePath), text, newText, create };
205
319
  }
206
320
 
321
+ // Smallest single edit turning `oldText` into `newText`: trim the shared
322
+ // prefix/suffix instead of replacing the whole document.
323
+ function minimalTextEdit(oldText, newText) {
324
+ let start = 0;
325
+ const maxStart = Math.min(oldText.length, newText.length);
326
+ while (start < maxStart && oldText[start] === newText[start]) start++;
327
+ let endOld = oldText.length;
328
+ let endNew = newText.length;
329
+ while (endOld > start && endNew > start && oldText[endOld - 1] === newText[endNew - 1]) { endOld--; endNew--; }
330
+ return { range: { start: offsetToPosition(oldText, start), end: offsetToPosition(oldText, endOld) }, newText: newText.slice(start, endNew) };
331
+ }
332
+
333
+ function offsetToPosition(text, offset) {
334
+ let line = 0;
335
+ let lineStart = 0;
336
+ for (let i = 0; i < offset && i < text.length; i++) {
337
+ if (text[i] === '\n') { line++; lineStart = i + 1; }
338
+ }
339
+ return { line, character: offset - lineStart };
340
+ }
341
+
342
+ // Document version for an annotated TextDocumentEdit: the exact version for an
343
+ // open document, null when the file is not open in the editor.
344
+ function documentVersion(filePath) {
345
+ return openDocument(filePath)?.version ?? null;
346
+ }
347
+
348
+
207
349
  function getProjectContext(uri) {
208
350
  loadConfigIfChanged();
209
351
  const filePath = uri ? fileURLToPath(uri) : workspaceRoot;
@@ -257,6 +399,10 @@ function scheduleProjectReload() {
257
399
  }, reloadDebounceMs);
258
400
  }
259
401
 
402
+ function invalidateLocaleCaches() {
403
+ localeCacheByContext = new Map();
404
+ }
405
+
260
406
  function refreshOpenDocuments() {
261
407
  for (const doc of documents.all()) validate(doc);
262
408
  }
@@ -274,7 +420,7 @@ function loadConfigIfChanged() {
274
420
  const nextMtimeMs = fs.existsSync(configPath) ? fs.statSync(configPath).mtimeMs : 0;
275
421
  if (configMtimeMs === nextMtimeMs) return;
276
422
  loadConfig();
277
- localeCacheByContext = new Map();
423
+ invalidateLocaleCaches();
278
424
  }
279
425
 
280
426
  function loadConfig() {
@@ -304,7 +450,7 @@ function validate(document) {
304
450
  const { locales } = loadLocalesForContext(context);
305
451
  const templateLanguage = /\.vue$/i.test(document.uri) ? 'vue'
306
452
  : /\.html?$/i.test(document.uri) || document.languageId === 'html' ? 'html' : undefined;
307
- if (isSourceFile(document.uri) || templateLanguage) {
453
+ if ((isSourceFile(document.uri) || templateLanguage) && !isLocaleFileUri(document.uri)) {
308
454
  const text = document.getText();
309
455
  const diagnostics = [
310
456
  ...getDiagnostics(text, locales),
@@ -313,12 +459,47 @@ function validate(document) {
313
459
  connection.sendDiagnostics({ uri: document.uri, diagnostics });
314
460
  return;
315
461
  }
316
- const locale = findLocaleForFile(locales, fileURLToPath(document.uri));
462
+ const filePath = fileURLToPath(document.uri);
463
+ const locale = findLocaleForFile(locales, filePath);
317
464
  if (!locale) return;
318
- connection.sendDiagnostics({ uri: document.uri, diagnostics: getLocaleParamDiagnostics(locale, fileURLToPath(document.uri), document.getText(), locales) });
465
+ connection.sendDiagnostics({ uri: document.uri, diagnostics: getLocaleParamDiagnostics(locale, filePath, document.getText(), locales) });
319
466
  }
467
+
320
468
  function isSourceFile(uri) { return /\.(vue|tsx?|jsx?)$/i.test(uri); }
321
469
 
470
+ const LOCALE_FILE_RE = /\.(json|[cm]?[tj]s)$/i;
471
+
472
+ // Is this file inside a localeDir of ANY configured context? Locale files are
473
+ // identified before source files: a TS locale module must never get source
474
+ // diagnostics, completions or inlay hints.
475
+ function localeDirOfFile(filePath) {
476
+ const normalized = normalizeFsPathLocal(path.resolve(filePath));
477
+ for (const context of getAllConfiguredContexts()) {
478
+ const contextRoot = normalizeFsPathLocal(context.root);
479
+ for (const dir of context.config.localeDirs || []) {
480
+ const absDir = normalizeFsPathLocal(path.resolve(contextRoot, dir));
481
+ if (!isPathInsidePath(normalized, absDir) || normalized === absDir) continue;
482
+ const relative = normalized.slice(absDir.length + 1);
483
+ const depth = relative.split('/').length - 1;
484
+ // Only files directly under the localeDir or one level deep (the locale
485
+ // directory itself) are locale files, matching the loaders.
486
+ if (depth <= 1) return { context, dir: absDir, relative };
487
+ }
488
+ }
489
+ return undefined;
490
+ }
491
+
492
+ function isLocaleFileUri(uri) {
493
+ let filePath;
494
+ try { filePath = fileURLToPath(uri); } catch { return false; }
495
+ const info = localeDirOfFile(filePath);
496
+ return !!info && LOCALE_FILE_RE.test(filePath);
497
+ }
498
+
499
+ function isLocaleDocument(document) {
500
+ try { return isLocaleFileUri(document.uri); } catch { return false; }
501
+ }
502
+
322
503
  const IGNORED_SCAN_DIRS = new Set(['node_modules', 'dist', 'build', 'out', 'coverage', 'vendor', '.output', '.next', '.nuxt', '.cache']);
323
504
  const MAX_SCAN_FILES = 5000;
324
505
 
@@ -328,10 +509,14 @@ function resolveKeyAtPosition(uri, position, context) {
328
509
  const filePath = fileURLToPath(uri);
329
510
  const text = documents.get(uri)?.getText() ?? readFileSafe(filePath);
330
511
  if (!text) return undefined;
331
- if (isSourceFile(uri)) return keyAtPosition(text, position)?.key;
332
512
  const { locales } = loadLocalesForContext(context);
333
513
  const locale = findLocaleForFile(locales, filePath);
334
- return locale ? localeKeyAtPosition(locale, filePath, text, position) : undefined;
514
+ // Locale membership wins over the .ts source check: a caret on a key inside
515
+ // en.ts / zh/index.ts must resolve to the flattened key, never to source
516
+ // call sites (which would also spuriously match object literals).
517
+ if (locale) return localeKeyAtPosition(locale, filePath, text, position);
518
+ if (isSourceFile(uri) && !isLocaleFileUri(uri)) return keyAtPosition(text, position)?.key;
519
+ return undefined;
335
520
  }
336
521
 
337
522
  function findLocaleForFile(locales, filePath) {
@@ -343,29 +528,57 @@ function findLocaleForFile(locales, filePath) {
343
528
  return undefined;
344
529
  }
345
530
 
346
- // Every source-code usage of `key` within the project/package root. Prefers the
347
- // in-memory text of open (possibly unsaved) documents over the on-disk copy.
531
+ // Every source-code usage of `key` inside this context only: files under the
532
+ // context root, excluding roots of other configured packages and locale
533
+ // directories, plus open (possibly unsaved) source documents belonging to the
534
+ // context that do not exist on disk yet. Open text wins over disk text.
348
535
  function collectCodeReferences(context, key) {
349
- const openTextByPath = new Map();
350
- for (const doc of documents.all()) {
351
- try { openTextByPath.set(path.resolve(fileURLToPath(doc.uri)), doc.getText()); } catch { /* non-file uri */ }
352
- }
536
+ const contextRoot = normalizeFsPathLocal(context.root);
537
+ const nestedRoots = getAllConfiguredContexts()
538
+ .map((candidate) => normalizeFsPathLocal(candidate.root))
539
+ .filter((root) => root !== contextRoot && isPathInsidePath(root, contextRoot));
540
+
353
541
  const locations = [];
354
- for (const filePath of collectSourceFiles(context.root)) {
355
- const text = openTextByPath.get(path.resolve(filePath)) ?? readFileSafe(filePath);
356
- if (!text) continue;
542
+ const scanned = new Set();
543
+ const scan = (filePath, text, uri) => {
544
+ const resolved = normalizeFsPathLocal(path.resolve(filePath));
545
+ if (scanned.has(resolved)) return;
546
+ scanned.add(resolved);
547
+ if (text === undefined) return;
357
548
  for (const range of findCodeKeyRanges(text, key)) {
358
- locations.push({ uri: pathToFileURL(filePath).toString(), range });
549
+ locations.push({ uri: uri ?? pathToFileURL(filePath).toString(), range });
359
550
  }
551
+ };
552
+
553
+ for (const filePath of collectSourceFiles(context.root, nestedRoots)) {
554
+ scan(filePath, openDocumentText(filePath) ?? readFileSafe(filePath));
555
+ }
556
+
557
+ for (const doc of documents.all()) {
558
+ let filePath;
559
+ try { filePath = fileURLToPath(doc.uri); } catch { continue; }
560
+ if (!isSourceFile(filePath)) continue;
561
+ const resolved = normalizeFsPathLocal(path.resolve(filePath));
562
+ if (!isPathInsidePath(resolved, contextRoot)) continue;
563
+ if (nestedRoots.some((root) => isPathInsidePath(resolved, root))) continue;
564
+ if (isLocaleFileUri(doc.uri)) continue;
565
+ scan(filePath, doc.getText(), doc.uri);
360
566
  }
361
567
  return locations;
362
568
  }
363
569
 
364
- function collectSourceFiles(root) {
570
+ function collectSourceFiles(root, excludedRoots = []) {
365
571
  const files = [];
366
572
  const stack = [root];
573
+ const excluded = excludedRoots.map((entry) => normalizeFsPathLocal(entry));
574
+ const localeDirs = getAllConfiguredContexts()
575
+ .flatMap((context) => (context.config.localeDirs || [])
576
+ .map((dir) => normalizeFsPathLocal(path.resolve(context.root, dir))));
367
577
  while (stack.length && files.length < MAX_SCAN_FILES) {
368
578
  const dir = stack.pop();
579
+ const normalizedDir = normalizeFsPathLocal(dir);
580
+ if (excluded.some((entry) => isPathInsidePath(normalizedDir, entry))) continue;
581
+ if (localeDirs.some((entry) => normalizedDir === entry || normalizedDir.startsWith(entry + '/'))) continue;
369
582
  let entries;
370
583
  try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
371
584
  for (const entry of entries) {
@@ -381,10 +594,39 @@ function collectSourceFiles(root) {
381
594
  return files;
382
595
  }
383
596
 
597
+ function openDocument(filePath) {
598
+ let target;
599
+ try { target = path.resolve(filePath); } catch { return undefined; }
600
+ for (const doc of documents.all()) {
601
+ try {
602
+ if (path.resolve(fileURLToPath(doc.uri)) === target) return doc;
603
+ } catch { /* non-file uri */ }
604
+ }
605
+ return undefined;
606
+ }
607
+
608
+ // In-memory text of an open (possibly unsaved) document for filePath.
609
+ function openDocumentText(filePath) {
610
+ return openDocument(filePath)?.getText();
611
+ }
612
+
384
613
  function readFileSafe(filePath) {
385
614
  try { return fs.readFileSync(filePath, 'utf8'); } catch { return undefined; }
386
615
  }
387
616
 
617
+ // Prefer the open document's current text; fall back to disk.
618
+ function localeText(filePath) {
619
+ return openDocumentText(filePath) ?? readFileSafe(filePath);
620
+ }
621
+
622
+ function normalizeFsPathLocal(value) {
623
+ return String(value || '').replace(/\\/g, '/').replace(/\/+$/g, '');
624
+ }
625
+
626
+ function isPathInsidePath(filePath, root) {
627
+ return filePath === root || filePath.startsWith(root + '/');
628
+ }
629
+
388
630
  function getAllConfiguredContexts() {
389
631
  loadConfigIfChanged();
390
632
  return [resolveProjectContext(workspaceRoot, workspaceRoot, config), ...config.packages.map((pkg) => ({
@@ -419,31 +661,52 @@ function loadLocalesForContext(context) {
419
661
 
420
662
  function loadLocaleDirectory(localeDir, localeName, next, localeTexts) {
421
663
  next[localeName] ||= { path: localeDir, flat: {}, files: [] };
422
- for (const file of fs.readdirSync(localeDir)) {
664
+ next[localeName].directories ||= [];
665
+ if (!next[localeName].directories.includes(localeDir)) next[localeName].directories.push(localeDir);
666
+ for (const file of fs.readdirSync(localeDir).sort()) {
423
667
  const fullPath = path.join(localeDir, file);
424
668
  if (fs.statSync(fullPath).isDirectory()) continue;
669
+ if (!LOCALE_FILE_RE.test(file)) continue;
670
+ if (!next[localeName].files.includes(fullPath)) next[localeName].files.push(fullPath);
425
671
  try {
426
- const text = fs.readFileSync(fullPath, 'utf8');
672
+ const text = localeText(fullPath);
673
+ if (text === undefined) continue;
674
+ localeTexts[fullPath] = text;
427
675
  if (file.endsWith('.json')) Object.assign(next[localeName].flat, prefixByFile(file, flattenLocale(JSON.parse(text))));
428
676
  else if (/\.[cm]?[tj]s$/i.test(file)) Object.assign(next[localeName].flat, parseTsLocaleModule(text, file));
429
677
  else continue;
430
- next[localeName].files.push(fullPath);
431
- localeTexts[fullPath] = text;
432
678
  } catch (error) { connection.console.warn('Failed to load locale file ' + fullPath + ': ' + error.message); }
433
679
  }
434
680
  }
435
681
 
682
+ // Top-level locale files named after the locale itself (en.json, en.ts, …).
683
+ // Keys are stored verbatim — unlike directory locales no file-stem prefix is
684
+ // applied, so parseTsLocaleModule runs as an 'index' module. When both a .json
685
+ // and a .ts/.js file share the locale name, their keys merge (sorted read
686
+ // order keeps this deterministic); writing stays a JSON-only feature.
436
687
  function loadLocaleFiles(fullDir, next, localeTexts) {
437
- for (const file of fs.readdirSync(fullDir)) {
688
+ const files = fs.readdirSync(fullDir).sort((a, b) => {
689
+ const aJson = a.endsWith('.json') ? 0 : 1;
690
+ const bJson = b.endsWith('.json') ? 0 : 1;
691
+ return aJson - bJson || a.localeCompare(b);
692
+ });
693
+ for (const file of files) {
438
694
  const fullPath = path.join(fullDir, file);
439
695
  if (fs.statSync(fullPath).isDirectory()) continue;
440
696
  try {
441
- const text = fs.readFileSync(fullPath, 'utf8');
442
- if (file.endsWith('.json')) {
443
- const name = path.basename(file, '.json');
444
- next[name] = { path: fullPath, flat: flattenLocale(JSON.parse(text)), files: [fullPath] };
445
- localeTexts[fullPath] = text;
446
- }
697
+ const isJson = file.endsWith('.json');
698
+ const isModule = /\.[cm]?[tj]s$/i.test(file);
699
+ if (!isJson && !isModule) continue;
700
+ const name = file.replace(/\.(json|[cm]?[tj]s)$/i, '');
701
+ next[name] ||= { path: fullPath, flat: {}, files: [], topLevelFiles: [] };
702
+ next[name].topLevelFiles ||= [];
703
+ if (!next[name].files.includes(fullPath)) next[name].files.push(fullPath);
704
+ if (!next[name].topLevelFiles.includes(fullPath)) next[name].topLevelFiles.push(fullPath);
705
+ const text = localeText(fullPath);
706
+ if (text === undefined) continue;
707
+ localeTexts[fullPath] = text;
708
+ const flat = isJson ? flattenLocale(JSON.parse(text)) : parseTsLocaleModule(text, 'index.ts');
709
+ Object.assign(next[name].flat, flat);
447
710
  } catch (error) { connection.console.warn('Failed to load locale file ' + fullPath + ': ' + error.message); }
448
711
  }
449
712
  }