i18n-lens-language-server 0.12.0 → 0.14.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,11 @@ 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
+ - Recursive locale directories: nested JSON, TS, and JS files map to dot-separated namespaces, with `index` files contributing to their parent namespace
16
17
 
17
18
  ## Installation
18
19
 
@@ -69,6 +70,8 @@ To open a specific language file, use Go to Definition (Ctrl/Cmd+click or F12) o
69
70
 
70
71
  Diagnostics refresh automatically when loaded locale files or locale directories change, so editing translation files should update warnings/errors without restarting the extension.
71
72
 
73
+ Open locale documents take precedence over disk contents. Opening, changing, or closing an existing locale file refreshes translations and diagnostics without requiring a save.
74
+
72
75
  Diagnostics distinguish between these cases:
73
76
 
74
77
  - If a key is missing from every loaded locale, it is reported as an error.
@@ -106,6 +109,17 @@ Comments, `script`/`style`, Vue custom blocks, interpolations (`{{ ... }}`), tra
106
109
  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
110
 
108
111
  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.
112
+ ## Creating missing keys safely
113
+
114
+ 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.
115
+
116
+ - 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.
117
+ - 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.
118
+ - 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.
119
+ - 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.
120
+ - 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.
121
+
122
+ 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
123
 
110
124
  ## Project configuration
111
125
 
@@ -207,6 +221,21 @@ src/locales/ja-JP/common.json
207
221
 
208
222
  A file named `common.json` contributes keys with the `common.` prefix. A file named `index.json` contributes keys without an `index.` prefix.
209
223
 
224
+ Locale directories are scanned recursively. Subdirectories contribute to the namespace:
225
+
226
+ ```text
227
+ src/locales/en-US/system/user.json -> system.user.*
228
+ src/locales/en-US/system/index.json -> system.*
229
+ src/locales/en-US/system/forms/fields.ts -> system.forms.fields.*
230
+ src/locales/en-US/index/index.json -> index.*
231
+ ```
232
+
233
+ Nested JSON, TS, and JS locale files support translation previews, definition, references, and unsaved buffer updates. Adding files in nested directories refreshes translations automatically. Missing-key quick fixes retain the existing namespace directory layout when the target is unambiguous and its parent directory exists.
234
+
235
+ If `localeDirs` overlap, an explicitly configured nested root is excluded from its ancestor's recursive scan, regardless of configuration order or nesting depth. For example, with `["locales", "locales/modules/features"]`, `locales/modules/features/en/user.json` belongs to locale `en` with keys `user.*`, not locale `modules`. Intermediate directories without locale files are watched for new files but do not count as locales. Empty locale files still count as locales and report missing keys.
236
+
237
+ 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.
238
+
210
239
  ## Monorepo workspaces
211
240
 
212
241
  For pnpm/turborepo and other monorepos, put `.zed/i18nlensrc.json` under the workspace root and add `packages` entries:
@@ -230,6 +259,8 @@ For pnpm/turborepo and other monorepos, put `.zed/i18nlensrc.json` under the wor
230
259
 
231
260
  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
261
 
262
+ 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.
263
+
233
264
  ## Local development
234
265
 
235
266
  Install dependencies:
@@ -244,6 +275,8 @@ Run tests:
244
275
  npm test
245
276
  ```
246
277
 
278
+ 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.
279
+
247
280
  The language server is published to npm as
248
281
  [`i18n-lens-language-server`](https://www.npmjs.com/package/i18n-lens-language-server).
249
282
  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.14.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 === ']') {
@@ -513,28 +642,23 @@ export function collectLocaleWatchPaths(localeCache, localeDirs = []) {
513
642
 
514
643
  export function findLocaleKeyTarget(locale, key, localeTexts = {}) {
515
644
  if (!locale || !Object.prototype.hasOwnProperty.call(locale.flat || {}, key)) return undefined;
516
- const files = Array.isArray(locale.files) && locale.files.length
517
- ? locale.files
518
- : (locale.path ? [locale.path] : []);
519
- const candidates = files
520
- .map((filePath) => ({ filePath, lookupKey: lookupKeyForLocaleFile(locale, filePath, key) }))
521
- .sort((a, b) => Number(a.lookupKey === key) - Number(b.lookupKey === key));
522
-
523
- for (const candidate of candidates) {
524
- const text = localeTexts[candidate.filePath];
525
- 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 };
530
- }
531
-
532
- const fallback = candidates[0];
533
- return fallback ? { filePath: fallback.filePath } : undefined;
645
+ // Namespace specificity does not determine which file won during loading.
646
+ const filePath = locale.keyFiles?.[key];
647
+ if (!filePath) return undefined;
648
+ const lookupKey = lookupKeyForLocaleFile(locale, filePath, key);
649
+ if (lookupKey === undefined) return undefined;
650
+ const text = localeTexts[filePath];
651
+ const range = text ? localeKeyRange(text, lookupKey, /\.json$/i.test(filePath)) : undefined;
652
+ return range ? { filePath, position: range.start, range } : { filePath };
534
653
  }
535
654
 
536
655
  function lookupKeyForLocaleFile(locale, filePath, key) {
537
- const isNestedLocaleFile = normalizeFsPath(locale.path) !== normalizeFsPath(filePath);
656
+ const prefix = locale.filePrefixes?.[filePath];
657
+ if (prefix !== undefined) {
658
+ if (!prefix) return key;
659
+ return key.startsWith(prefix + '.') ? key.slice(prefix.length + 1) : undefined;
660
+ }
661
+ const isNestedLocaleFile = !locale.topLevelFiles?.includes(filePath) && normalizeFsPath(locale.path) !== normalizeFsPath(filePath);
538
662
  const stem = fileStem(filePath);
539
663
  if (isNestedLocaleFile && stem && stem !== 'index' && key.startsWith(stem + '.')) return key.slice(stem.length + 1);
540
664
  return key;
@@ -638,16 +762,15 @@ export function getLocaleParamConsistency(key, locales) {
638
762
  export function getLocaleParamDiagnostics(locale, filePath, text, locales) {
639
763
  if (!locale?.flat) return [];
640
764
  const isJson = /\.json$/i.test(filePath);
765
+ const jsonRoot = isJson ? parseJsonStructure(text) || null : undefined;
641
766
  const diagnostics = [];
642
767
  for (const key of Object.keys(locale.flat)) {
643
768
  const consistency = getLocaleParamConsistency(key, locales);
644
769
  if (consistency.consistent) continue;
645
770
  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));
771
+ if (lookupKey === undefined) continue;
772
+ const range = localeKeyRange(text, lookupKey, isJson, jsonRoot);
773
+ if (range) diagnostics.push(paramInconsistencyDiagnostic(key, range, consistency.perLocale));
651
774
  }
652
775
  return diagnostics;
653
776
  }
@@ -791,118 +914,163 @@ export function collectLocaleKeyTargets(locales, key, localeTexts = {}, defaultL
791
914
  export function localeKeyAtPosition(locale, filePath, text, position) {
792
915
  if (!locale || !locale.flat || !text || !position) return undefined;
793
916
  const isJson = /\.json$/i.test(filePath);
917
+ const jsonRoot = isJson ? parseJsonStructure(text) || null : undefined;
794
918
  let lineFallback;
795
919
  let lineFallbackCount = 0;
796
920
  for (const fullKey of Object.keys(locale.flat)) {
797
921
  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;
922
+ if (lookupKey === undefined) continue;
923
+ const range = localeKeyRange(text, lookupKey, isJson, jsonRoot);
924
+ if (!range || range.start.line !== position.line) continue;
925
+ if (comparePosition(position, range.start) >= 0 && comparePosition(position, range.end) < 0) return fullKey;
804
926
  lineFallback = fullKey;
805
927
  lineFallbackCount++;
806
928
  }
807
- return lineFallbackCount === 1 ? lineFallback : undefined;
929
+ return !isJson && lineFallbackCount === 1 ? lineFallback : undefined;
808
930
  }
809
931
 
810
932
  export function getCompletionPrefix(text, position) {
811
- const offset = positionToOffset(text, position);
812
- const before = text.slice(0, offset);
933
+ const before = text.slice(0, positionToOffset(text, position));
934
+ const opaque = computeOpaqueRanges(before);
813
935
  const patterns = [
814
- /(?:\x24tc?|\btc?|\bi18n\.tc?)\(\s*['"]([A-Za-z0-9_.:-]*)$/,
936
+ /(?:\x24tc?|\btc?|\bi18n\.tc?)\s*\(\s*['"]([A-Za-z0-9_.:-]*)$/,
815
937
  /\bformatMessage\s*\(\s*\{[^}]*?\bid\s*:\s*['"]([A-Za-z0-9_.:-]*)$/,
816
938
  /\b(?:keypath|i18nKey)\s*=\s*['"]([A-Za-z0-9_.:-]*)$/,
817
- /v-t\s*=\s*"'([A-Za-z0-9_.:-]*)$/,
939
+ /\bv-t\s*=\s*"'([A-Za-z0-9_.:-]*)$/,
940
+ /\bv-t\s*=\s*'\x22([A-Za-z0-9_.:-]*)$/,
941
+ /\bv-t\s*=\s*"\s*\{\s*path\s*:\s*['"]([A-Za-z0-9_.:-]*)$/,
818
942
  ];
819
943
  for (const re of patterns) {
820
944
  const match = before.match(re);
821
- if (match) return match[1];
945
+ if (!match || isOpaque(opaque, match.index)) continue;
946
+ if (/[\w$:@#-]/.test(before[match.index - 1] || '')) continue;
947
+ return match[1];
822
948
  }
823
- return '';
949
+ return undefined;
824
950
  }
825
951
 
826
952
  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;
953
+ return localeKeyRange(jsonText, key, true)?.start;
838
954
  }
839
955
 
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;
956
+ function localeKeyRange(text, key, isJson, jsonRoot = isJson ? parseJsonStructure(text) : undefined) {
957
+ if (isJson) {
958
+ const property = findJsonProperty(jsonRoot, key.split('.'));
959
+ return property ? makeRange(text, property.start, property.end) : undefined;
856
960
  }
961
+ const start = findLocaleKeyLocation(text, key);
962
+ if (!start) return undefined;
963
+ const offset = positionToOffset(text, start);
964
+ const token = text.slice(offset).match(/^(?:'[^']*'|"[^"]*"|[\w$-]+)/);
965
+ return token ? makeRange(text, offset, offset + token[0].length) : undefined;
966
+ }
857
967
 
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);
968
+ // JSON.parse validates the grammar; this walk retains property-token and value
969
+ // boundaries for edits. Maps avoid prototype keys and duplicate keys are refused.
970
+ function parseJsonStructure(text) {
971
+ try { JSON.parse(text); } catch { return undefined; }
972
+ let offset = 0;
973
+ let duplicate = false;
974
+ const whitespace = () => { while (/\s/.test(text[offset] || '') && offset < text.length) offset++; };
975
+ function stringEnd() {
976
+ offset++;
977
+ while (offset < text.length) {
978
+ if (text[offset] === '\\') offset += 2;
979
+ else if (text[offset++] === '"') break;
980
+ }
981
+ }
982
+ function value() {
983
+ whitespace();
984
+ const start = offset;
985
+ const properties = text[offset] === '{' ? new Map() : undefined;
986
+ if (properties) {
987
+ offset++;
988
+ whitespace();
989
+ while (text[offset] !== '}') {
990
+ const keyStart = offset;
991
+ stringEnd();
992
+ const keyEnd = offset;
993
+ const key = JSON.parse(text.slice(keyStart, keyEnd));
994
+ whitespace();
995
+ offset++; // colon
996
+ const child = value();
997
+ if (properties.has(key)) duplicate = true;
998
+ properties.set(key, { start: keyStart, end: keyEnd, value: child });
999
+ whitespace();
1000
+ if (text[offset] !== ',') break;
1001
+ offset++;
1002
+ whitespace();
1003
+ }
1004
+ offset++;
1005
+ } else if (text[offset] === '[') {
1006
+ offset++;
1007
+ whitespace();
1008
+ while (text[offset] !== ']') {
1009
+ value();
1010
+ whitespace();
1011
+ if (text[offset] !== ',') break;
1012
+ offset++;
1013
+ }
1014
+ offset++;
1015
+ } else if (text[offset] === '"') {
1016
+ stringEnd();
1017
+ } else {
1018
+ while (offset < text.length && !/[\s,\]}]/.test(text[offset])) offset++;
1019
+ }
1020
+ return { start, end: offset, properties };
866
1021
  }
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;
1022
+ const root = value();
1023
+ return duplicate ? undefined : root;
1024
+ }
1025
+
1026
+ function findJsonProperty(node, parts) {
1027
+ if (!node?.properties) return undefined;
1028
+ let found;
1029
+ for (let count = 1; count <= parts.length; count++) {
1030
+ const property = node.properties.get(parts.slice(0, count).join('.'));
1031
+ if (!property) continue;
1032
+ const candidate = count === parts.length ? property : findJsonProperty(property.value, parts.slice(count));
1033
+ if (!candidate) continue;
1034
+ if (found) return undefined; // dotted and nested representations collide
1035
+ found = candidate;
879
1036
  }
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 };
1037
+ return found;
887
1038
  }
888
1039
 
889
- function matchBrace(text, open) {
1040
+ // Insert only into an unambiguous object path. Existing scalar/array prefixes,
1041
+ // duplicate properties and existing values are never overwritten.
1042
+ export function insertNestedJsonKey(jsonText, key, value = '') {
1043
+ let node = parseJsonStructure(jsonText);
1044
+ let parts = key.split('.');
1045
+ if (!node?.properties || parts.some((part) => !part)) return undefined;
890
1046
  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;
1047
+ while (true) {
1048
+ const matches = [];
1049
+ for (let count = 1; count <= parts.length; count++) {
1050
+ const property = node.properties.get(parts.slice(0, count).join('.'));
1051
+ if (property) matches.push({ count, property });
900
1052
  }
901
- if (ch === '"') quote = true;
902
- else if (ch === '{') depth++;
903
- else if (ch === '}' && --depth === 0) return i;
1053
+ if (!matches.length) break;
1054
+ if (matches.length !== 1) return undefined;
1055
+ const { count, property } = matches[0];
1056
+ if (count === parts.length || !property.value.properties) return undefined;
1057
+ parts = parts.slice(count);
1058
+ node = property.value;
1059
+ depth++;
1060
+ }
1061
+ const eol = jsonText.includes('\r\n') ? '\r\n' : '\n';
1062
+ const unit = detectIndentUnit(jsonText);
1063
+ const firstProperty = node.properties.values().next().value;
1064
+ const lineStart = firstProperty ? jsonText.lastIndexOf('\n', firstProperty.start) + 1 : -1;
1065
+ const existingIndent = firstProperty ? jsonText.slice(lineStart, firstProperty.start) : '';
1066
+ const indent = firstProperty && /^[ \t]+$/.test(existingIndent) ? existingIndent : unit.repeat(depth + 1);
1067
+ const snippet = buildNestedProperty(parts, value, indent, unit, eol);
1068
+ let contentEnd = node.end - 1;
1069
+ while (contentEnd > node.start + 1 && /\s/.test(jsonText[contentEnd - 1])) contentEnd--;
1070
+ if (!node.properties.size) {
1071
+ return jsonText.slice(0, node.start + 1) + eol + snippet + eol + indent.slice(0, -unit.length) + jsonText.slice(node.end - 1);
904
1072
  }
905
- return -1;
1073
+ return jsonText.slice(0, contentEnd) + ',' + eol + snippet + jsonText.slice(contentEnd);
906
1074
  }
907
1075
 
908
1076
  function detectIndentUnit(jsonText) {
@@ -910,19 +1078,20 @@ function detectIndentUnit(jsonText) {
910
1078
  return match ? match[1] : ' ';
911
1079
  }
912
1080
 
913
- function buildNestedProperty(parts, value, indent, unit) {
1081
+ function buildNestedProperty(parts, value, indent, unit, eol) {
914
1082
  const keyStr = JSON.stringify(parts[0]);
915
1083
  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 + '}';
1084
+ return indent + keyStr + ': {' + eol + buildNestedProperty(parts.slice(1), value, indent + unit, unit, eol) + eol + indent + '}';
917
1085
  }
918
1086
 
919
1087
 
1088
+ // File and directory namespaces are applied by the loader, not the parser.
920
1089
  export function parseTsLocaleModule(text, fileName = 'index.ts') {
921
1090
  const result = {};
922
1091
  const stem = fileName.replace(/\.[cm]?[tj]sx?$/i, '');
923
1092
  const exportObject = extractObjectAfter(text, /export\s+default\s*/g);
924
1093
  if (exportObject) {
925
- Object.assign(result, flattenJsObjectLiteral(exportObject, stem === 'index' ? '' : stem));
1094
+ Object.assign(result, flattenJsObjectLiteral(exportObject));
926
1095
  }
927
1096
 
928
1097
  if (stem === 'index') {
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,160 @@ 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 prefixes. 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 = filesByPrefix(locale);
235
+ const namespaceFiles = [...targets.keys()].filter((prefix) => prefix && key.startsWith(prefix + '.'));
236
+ if (namespaceFiles.length > 1 || targets.has(key)) return undefined;
237
+ const indexFiles = targets.get('') || [];
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 filesByPrefix(sibling)) {
263
+ if (!stem || !key.startsWith(stem + '.')) continue;
264
+ siblingStems.set(stem, (siblingStems.get(stem) || []).concat(files.map((file) => ({
265
+ file,
266
+ relativePath: path.relative(sibling.directories[0], file),
267
+ }))));
268
+ }
269
+ }
270
+ if (siblingStems.size > 1) return undefined;
271
+ if (siblingStems.size === 1) {
272
+ const [stem, targets] = siblingStems.entries().next().value;
273
+ if (targets.some(({ file }) => !/\.json$/i.test(file))) return undefined;
274
+ const relativePaths = new Set(targets.map(({ relativePath }) => relativePath));
275
+ if (relativePaths.size !== 1) return undefined;
276
+ const targetPath = path.join(directory, [...relativePaths][0]);
277
+ return fs.existsSync(path.dirname(targetPath))
278
+ ? insertOrCreate(targetPath, key.slice(stem.length + 1)) : undefined;
279
+ }
280
+ return indexFile ? insertInto(indexFile, key) : undefined;
281
+ }
282
+
283
+ function filesByPrefix(locale) {
284
+ const prefixes = new Map();
285
+ for (const file of locale.files || []) {
286
+ const prefix = locale.filePrefixes[file];
287
+ if (!prefixes.has(prefix)) prefixes.set(prefix, []);
288
+ prefixes.get(prefix).push(file);
289
+ }
290
+ return prefixes;
291
+ }
292
+
293
+ function insertInto(filePath, lookupKey) {
294
+ if (!lookupKey) return undefined;
295
+ const text = openDocumentText(filePath) ?? readFileSafe(filePath);
296
+ if (text === undefined) return undefined;
297
+ // A whitespace-only file has no JSON structure to insert into; seed it like
298
+ // an empty object (matching its line-ending style) so the fix still lands
299
+ // instead of silently disappearing.
300
+ const source = text.trim() ? text : (text.includes('\r\n') ? '{}\r\n' : '{}\n');
301
+ const newText = insertNestedJsonKey(source, lookupKey, '');
302
+ if (newText === undefined) return undefined;
303
+ return toEditPlan(filePath, text, newText, false);
304
+ }
305
+
306
+ // Prefer an in-place insert when the file exists or is open as a buffer (the
307
+ // file may have been deleted on disk while its document stays alive); fall
308
+ // back to a CreateFile only when there is truly nothing to edit.
309
+ function insertOrCreate(filePath, lookupKey) {
310
+ if (fs.existsSync(filePath) || openDocument(filePath)) return insertInto(filePath, lookupKey);
311
+ return createIn(filePath, lookupKey);
312
+ }
313
+
314
+ // Create a missing namespace JSON file: only ever proposed with a CreateFile
315
+ // operation ahead of the text edit, and only when the file truly does not
316
+ // exist — never as a fallback guess.
317
+ function createIn(filePath, lookupKey) {
318
+ if (!lookupKey || fs.existsSync(filePath) || openDocument(filePath)) return undefined;
319
+ const newText = insertNestedJsonKey('{}\n', lookupKey, '');
320
+ if (newText === undefined) return undefined;
321
+ return toEditPlan(filePath, '', newText, true);
322
+ }
323
+
324
+ function toEditPlan(filePath, text, newText, create) {
325
+ return { uri: pathToFileURL(filePath).toString(), version: documentVersion(filePath), text, newText, create };
326
+ }
327
+
328
+ // Smallest single edit turning `oldText` into `newText`: trim the shared
329
+ // prefix/suffix instead of replacing the whole document.
330
+ function minimalTextEdit(oldText, newText) {
331
+ let start = 0;
332
+ const maxStart = Math.min(oldText.length, newText.length);
333
+ while (start < maxStart && oldText[start] === newText[start]) start++;
334
+ let endOld = oldText.length;
335
+ let endNew = newText.length;
336
+ while (endOld > start && endNew > start && oldText[endOld - 1] === newText[endNew - 1]) { endOld--; endNew--; }
337
+ return { range: { start: offsetToPosition(oldText, start), end: offsetToPosition(oldText, endOld) }, newText: newText.slice(start, endNew) };
338
+ }
339
+
340
+ function offsetToPosition(text, offset) {
341
+ let line = 0;
342
+ let lineStart = 0;
343
+ for (let i = 0; i < offset && i < text.length; i++) {
344
+ if (text[i] === '\n') { line++; lineStart = i + 1; }
345
+ }
346
+ return { line, character: offset - lineStart };
199
347
  }
200
348
 
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 };
349
+ // Document version for an annotated TextDocumentEdit: the exact version for an
350
+ // open document, null when the file is not open in the editor.
351
+ function documentVersion(filePath) {
352
+ return openDocument(filePath)?.version ?? null;
205
353
  }
206
354
 
355
+
207
356
  function getProjectContext(uri) {
208
357
  loadConfigIfChanged();
209
358
  const filePath = uri ? fileURLToPath(uri) : workspaceRoot;
@@ -228,7 +377,7 @@ function watchLocalePaths() {
228
377
  .map((dir) => path.join(context.root, dir))
229
378
  .filter((dir) => fs.existsSync(dir));
230
379
  const loaded = loadLocalesForContext(context);
231
- for (const watchPath of collectLocaleWatchPaths(loaded.locales, configuredDirs)) nextPaths.add(watchPath);
380
+ for (const watchPath of collectLocaleWatchPaths(loaded.locales, [...configuredDirs, ...loaded.watchDirectories])) nextPaths.add(watchPath);
232
381
  }
233
382
 
234
383
  for (const watchedPath of watchedLocalePaths) {
@@ -257,6 +406,10 @@ function scheduleProjectReload() {
257
406
  }, reloadDebounceMs);
258
407
  }
259
408
 
409
+ function invalidateLocaleCaches() {
410
+ localeCacheByContext = new Map();
411
+ }
412
+
260
413
  function refreshOpenDocuments() {
261
414
  for (const doc of documents.all()) validate(doc);
262
415
  }
@@ -274,7 +427,7 @@ function loadConfigIfChanged() {
274
427
  const nextMtimeMs = fs.existsSync(configPath) ? fs.statSync(configPath).mtimeMs : 0;
275
428
  if (configMtimeMs === nextMtimeMs) return;
276
429
  loadConfig();
277
- localeCacheByContext = new Map();
430
+ invalidateLocaleCaches();
278
431
  }
279
432
 
280
433
  function loadConfig() {
@@ -304,7 +457,7 @@ function validate(document) {
304
457
  const { locales } = loadLocalesForContext(context);
305
458
  const templateLanguage = /\.vue$/i.test(document.uri) ? 'vue'
306
459
  : /\.html?$/i.test(document.uri) || document.languageId === 'html' ? 'html' : undefined;
307
- if (isSourceFile(document.uri) || templateLanguage) {
460
+ if ((isSourceFile(document.uri) || templateLanguage) && !isLocaleFileUri(document.uri)) {
308
461
  const text = document.getText();
309
462
  const diagnostics = [
310
463
  ...getDiagnostics(text, locales),
@@ -313,12 +466,44 @@ function validate(document) {
313
466
  connection.sendDiagnostics({ uri: document.uri, diagnostics });
314
467
  return;
315
468
  }
316
- const locale = findLocaleForFile(locales, fileURLToPath(document.uri));
469
+ const filePath = fileURLToPath(document.uri);
470
+ const locale = findLocaleForFile(locales, filePath);
317
471
  if (!locale) return;
318
- connection.sendDiagnostics({ uri: document.uri, diagnostics: getLocaleParamDiagnostics(locale, fileURLToPath(document.uri), document.getText(), locales) });
472
+ connection.sendDiagnostics({ uri: document.uri, diagnostics: getLocaleParamDiagnostics(locale, filePath, document.getText(), locales) });
319
473
  }
474
+
320
475
  function isSourceFile(uri) { return /\.(vue|tsx?|jsx?)$/i.test(uri); }
321
476
 
477
+ const LOCALE_FILE_RE = /\.(json|[cm]?[tj]s)$/i;
478
+
479
+ // Is this file inside a localeDir of ANY configured context? Locale files are
480
+ // identified before source files: a TS locale module must never get source
481
+ // diagnostics, completions or inlay hints.
482
+ function localeDirOfFile(filePath) {
483
+ const normalized = normalizeFsPathLocal(path.resolve(filePath));
484
+ for (const context of getAllConfiguredContexts()) {
485
+ const contextRoot = normalizeFsPathLocal(context.root);
486
+ for (const dir of context.config.localeDirs || []) {
487
+ const absDir = normalizeFsPathLocal(path.resolve(contextRoot, dir));
488
+ if (!isPathInsidePath(normalized, absDir) || normalized === absDir) continue;
489
+ const relative = normalized.slice(absDir.length + 1);
490
+ return { context, dir: absDir, relative };
491
+ }
492
+ }
493
+ return undefined;
494
+ }
495
+
496
+ function isLocaleFileUri(uri) {
497
+ let filePath;
498
+ try { filePath = fileURLToPath(uri); } catch { return false; }
499
+ const info = localeDirOfFile(filePath);
500
+ return !!info && LOCALE_FILE_RE.test(filePath);
501
+ }
502
+
503
+ function isLocaleDocument(document) {
504
+ try { return isLocaleFileUri(document.uri); } catch { return false; }
505
+ }
506
+
322
507
  const IGNORED_SCAN_DIRS = new Set(['node_modules', 'dist', 'build', 'out', 'coverage', 'vendor', '.output', '.next', '.nuxt', '.cache']);
323
508
  const MAX_SCAN_FILES = 5000;
324
509
 
@@ -328,10 +513,14 @@ function resolveKeyAtPosition(uri, position, context) {
328
513
  const filePath = fileURLToPath(uri);
329
514
  const text = documents.get(uri)?.getText() ?? readFileSafe(filePath);
330
515
  if (!text) return undefined;
331
- if (isSourceFile(uri)) return keyAtPosition(text, position)?.key;
332
516
  const { locales } = loadLocalesForContext(context);
333
517
  const locale = findLocaleForFile(locales, filePath);
334
- return locale ? localeKeyAtPosition(locale, filePath, text, position) : undefined;
518
+ // Locale membership wins over the .ts source check: a caret on a key inside
519
+ // en.ts / zh/index.ts must resolve to the flattened key, never to source
520
+ // call sites (which would also spuriously match object literals).
521
+ if (locale) return localeKeyAtPosition(locale, filePath, text, position);
522
+ if (isSourceFile(uri) && !isLocaleFileUri(uri)) return keyAtPosition(text, position)?.key;
523
+ return undefined;
335
524
  }
336
525
 
337
526
  function findLocaleForFile(locales, filePath) {
@@ -343,29 +532,57 @@ function findLocaleForFile(locales, filePath) {
343
532
  return undefined;
344
533
  }
345
534
 
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.
535
+ // Every source-code usage of `key` inside this context only: files under the
536
+ // context root, excluding roots of other configured packages and locale
537
+ // directories, plus open (possibly unsaved) source documents belonging to the
538
+ // context that do not exist on disk yet. Open text wins over disk text.
348
539
  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
- }
540
+ const contextRoot = normalizeFsPathLocal(context.root);
541
+ const nestedRoots = getAllConfiguredContexts()
542
+ .map((candidate) => normalizeFsPathLocal(candidate.root))
543
+ .filter((root) => root !== contextRoot && isPathInsidePath(root, contextRoot));
544
+
353
545
  const locations = [];
354
- for (const filePath of collectSourceFiles(context.root)) {
355
- const text = openTextByPath.get(path.resolve(filePath)) ?? readFileSafe(filePath);
356
- if (!text) continue;
546
+ const scanned = new Set();
547
+ const scan = (filePath, text, uri) => {
548
+ const resolved = normalizeFsPathLocal(path.resolve(filePath));
549
+ if (scanned.has(resolved)) return;
550
+ scanned.add(resolved);
551
+ if (text === undefined) return;
357
552
  for (const range of findCodeKeyRanges(text, key)) {
358
- locations.push({ uri: pathToFileURL(filePath).toString(), range });
553
+ locations.push({ uri: uri ?? pathToFileURL(filePath).toString(), range });
359
554
  }
555
+ };
556
+
557
+ for (const filePath of collectSourceFiles(context.root, nestedRoots)) {
558
+ scan(filePath, openDocumentText(filePath) ?? readFileSafe(filePath));
559
+ }
560
+
561
+ for (const doc of documents.all()) {
562
+ let filePath;
563
+ try { filePath = fileURLToPath(doc.uri); } catch { continue; }
564
+ if (!isSourceFile(filePath)) continue;
565
+ const resolved = normalizeFsPathLocal(path.resolve(filePath));
566
+ if (!isPathInsidePath(resolved, contextRoot)) continue;
567
+ if (nestedRoots.some((root) => isPathInsidePath(resolved, root))) continue;
568
+ if (isLocaleFileUri(doc.uri)) continue;
569
+ scan(filePath, doc.getText(), doc.uri);
360
570
  }
361
571
  return locations;
362
572
  }
363
573
 
364
- function collectSourceFiles(root) {
574
+ function collectSourceFiles(root, excludedRoots = []) {
365
575
  const files = [];
366
576
  const stack = [root];
577
+ const excluded = excludedRoots.map((entry) => normalizeFsPathLocal(entry));
578
+ const localeDirs = getAllConfiguredContexts()
579
+ .flatMap((context) => (context.config.localeDirs || [])
580
+ .map((dir) => normalizeFsPathLocal(path.resolve(context.root, dir))));
367
581
  while (stack.length && files.length < MAX_SCAN_FILES) {
368
582
  const dir = stack.pop();
583
+ const normalizedDir = normalizeFsPathLocal(dir);
584
+ if (excluded.some((entry) => isPathInsidePath(normalizedDir, entry))) continue;
585
+ if (localeDirs.some((entry) => normalizedDir === entry || normalizedDir.startsWith(entry + '/'))) continue;
369
586
  let entries;
370
587
  try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
371
588
  for (const entry of entries) {
@@ -381,10 +598,39 @@ function collectSourceFiles(root) {
381
598
  return files;
382
599
  }
383
600
 
601
+ function openDocument(filePath) {
602
+ let target;
603
+ try { target = path.resolve(filePath); } catch { return undefined; }
604
+ for (const doc of documents.all()) {
605
+ try {
606
+ if (path.resolve(fileURLToPath(doc.uri)) === target) return doc;
607
+ } catch { /* non-file uri */ }
608
+ }
609
+ return undefined;
610
+ }
611
+
612
+ // In-memory text of an open (possibly unsaved) document for filePath.
613
+ function openDocumentText(filePath) {
614
+ return openDocument(filePath)?.getText();
615
+ }
616
+
384
617
  function readFileSafe(filePath) {
385
618
  try { return fs.readFileSync(filePath, 'utf8'); } catch { return undefined; }
386
619
  }
387
620
 
621
+ // Prefer the open document's current text; fall back to disk.
622
+ function localeText(filePath) {
623
+ return openDocumentText(filePath) ?? readFileSafe(filePath);
624
+ }
625
+
626
+ function normalizeFsPathLocal(value) {
627
+ return String(value || '').replace(/\\/g, '/').replace(/\/+$/g, '');
628
+ }
629
+
630
+ function isPathInsidePath(filePath, root) {
631
+ return filePath === root || filePath.startsWith(root + '/');
632
+ }
633
+
388
634
  function getAllConfiguredContexts() {
389
635
  loadConfigIfChanged();
390
636
  return [resolveProjectContext(workspaceRoot, workspaceRoot, config), ...config.packages.map((pkg) => ({
@@ -404,54 +650,104 @@ function loadLocalesForContext(context) {
404
650
 
405
651
  const locales = {};
406
652
  const localeTexts = {};
407
- for (const dir of context.config.localeDirs) {
408
- const fullDir = path.join(context.root, dir);
653
+ const watchDirectories = new Set();
654
+ const localeRoots = context.config.localeDirs.map((dir) => path.resolve(context.root, dir));
655
+ for (const fullDir of localeRoots) {
409
656
  if (!fs.existsSync(fullDir)) continue;
410
657
  loadLocaleFiles(fullDir, locales, localeTexts);
411
658
  for (const entry of fs.readdirSync(fullDir, { withFileTypes: true })) {
412
- if (entry.isDirectory()) loadLocaleDirectory(path.join(fullDir, entry.name), entry.name, locales, localeTexts);
659
+ if (entry.isDirectory()) loadLocaleDirectory(path.join(fullDir, entry.name), entry.name, locales, localeTexts, localeRoots, watchDirectories);
413
660
  }
414
661
  }
415
- const loaded = { locales, localeTexts };
662
+ const loaded = { locales, localeTexts, watchDirectories: [...watchDirectories] };
416
663
  localeCacheByContext.set(cacheKey, loaded);
417
664
  return loaded;
418
665
  }
419
666
 
420
- function loadLocaleDirectory(localeDir, localeName, next, localeTexts) {
421
- next[localeName] ||= { path: localeDir, flat: {}, files: [] };
422
- for (const file of fs.readdirSync(localeDir)) {
667
+ function loadLocaleDirectory(localeDir, localeName, next, localeTexts, localeRoots, watchDirectories, rootDir = localeDir) {
668
+ // An explicit locale root owns its subtree, independently of config order.
669
+ if (localeRoots.includes(localeDir)) return;
670
+ // Fileless directories still need watching, but must not create phantom locales.
671
+ watchDirectories.add(localeDir);
672
+ for (const entry of fs.readdirSync(localeDir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
673
+ const file = entry.name;
423
674
  const fullPath = path.join(localeDir, file);
424
- if (fs.statSync(fullPath).isDirectory()) continue;
675
+ if (entry.isDirectory()) {
676
+ loadLocaleDirectory(fullPath, localeName, next, localeTexts, localeRoots, watchDirectories, rootDir);
677
+ continue;
678
+ }
679
+ if (!LOCALE_FILE_RE.test(file)) continue;
680
+ if (entry.isSymbolicLink() && fs.statSync(fullPath).isDirectory()) continue;
681
+ next[localeName] ||= { path: rootDir, flat: {}, keyFiles: {}, files: [] };
682
+ const locale = next[localeName];
683
+ locale.directories ||= [];
684
+ if (!locale.directories.includes(rootDir)) locale.directories.push(rootDir);
685
+ locale.filePrefixes ||= {};
686
+ const relativeFile = path.relative(rootDir, fullPath);
687
+ if (!locale.files.includes(fullPath)) locale.files.push(fullPath);
688
+ locale.filePrefixes[fullPath] = localeFilePrefix(relativeFile);
425
689
  try {
426
- const text = fs.readFileSync(fullPath, 'utf8');
427
- if (file.endsWith('.json')) Object.assign(next[localeName].flat, prefixByFile(file, flattenLocale(JSON.parse(text))));
428
- else if (/\.[cm]?[tj]s$/i.test(file)) Object.assign(next[localeName].flat, parseTsLocaleModule(text, file));
429
- else continue;
430
- next[localeName].files.push(fullPath);
690
+ const text = localeText(fullPath);
691
+ if (text === undefined) continue;
431
692
  localeTexts[fullPath] = text;
693
+ const flat = prefixByFile(relativeFile, file.endsWith('.json')
694
+ ? flattenLocale(JSON.parse(text)) : parseTsLocaleModule(text, file));
695
+ for (const [key, value] of Object.entries(flat)) {
696
+ locale.flat[key] = value;
697
+ locale.keyFiles[key] = fullPath;
698
+ }
432
699
  } catch (error) { connection.console.warn('Failed to load locale file ' + fullPath + ': ' + error.message); }
433
700
  }
434
701
  }
435
702
 
703
+ // Top-level locale files named after the locale itself (en.json, en.ts, …).
704
+ // Keys are stored verbatim — unlike directory locales no file-stem prefix is
705
+ // applied, so parseTsLocaleModule runs as an 'index' module. When both a .json
706
+ // and a .ts/.js file share the locale name, their keys merge (sorted read
707
+ // order keeps this deterministic); writing stays a JSON-only feature.
436
708
  function loadLocaleFiles(fullDir, next, localeTexts) {
437
- for (const file of fs.readdirSync(fullDir)) {
709
+ const files = fs.readdirSync(fullDir).sort((a, b) => {
710
+ const aJson = a.endsWith('.json') ? 0 : 1;
711
+ const bJson = b.endsWith('.json') ? 0 : 1;
712
+ return aJson - bJson || a.localeCompare(b);
713
+ });
714
+ for (const file of files) {
438
715
  const fullPath = path.join(fullDir, file);
439
716
  if (fs.statSync(fullPath).isDirectory()) continue;
440
717
  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;
718
+ const isJson = file.endsWith('.json');
719
+ const isModule = /\.[cm]?[tj]s$/i.test(file);
720
+ if (!isJson && !isModule) continue;
721
+ const name = file.replace(/\.(json|[cm]?[tj]s)$/i, '');
722
+ next[name] ||= { path: fullPath, flat: {}, keyFiles: {}, files: [], topLevelFiles: [] };
723
+ next[name].filePrefixes ||= {};
724
+ next[name].filePrefixes[fullPath] = '';
725
+ next[name].topLevelFiles ||= [];
726
+ if (!next[name].files.includes(fullPath)) next[name].files.push(fullPath);
727
+ if (!next[name].topLevelFiles.includes(fullPath)) next[name].topLevelFiles.push(fullPath);
728
+ const text = localeText(fullPath);
729
+ if (text === undefined) continue;
730
+ localeTexts[fullPath] = text;
731
+ const flat = isJson ? flattenLocale(JSON.parse(text)) : parseTsLocaleModule(text, 'index.ts');
732
+ for (const [key, value] of Object.entries(flat)) {
733
+ next[name].flat[key] = value;
734
+ next[name].keyFiles[key] = fullPath;
446
735
  }
447
736
  } catch (error) { connection.console.warn('Failed to load locale file ' + fullPath + ': ' + error.message); }
448
737
  }
449
738
  }
450
739
 
451
740
  function prefixByFile(file, flat) {
452
- const stem = path.basename(file, path.extname(file));
453
- if (stem === 'index') return flat;
454
- return Object.fromEntries(Object.entries(flat).map(([key, value]) => [stem + '.' + key, value]));
741
+ const prefix = localeFilePrefix(file);
742
+ if (!prefix) return flat;
743
+ return Object.fromEntries(Object.entries(flat).map(([key, value]) => [prefix + '.' + key, value]));
744
+ }
745
+
746
+ function localeFilePrefix(file) {
747
+ const segments = file.replace(/\\/g, '/').split('/');
748
+ const stem = segments.pop().replace(/\.[^.]+$/, '');
749
+ if (stem !== 'index') segments.push(stem);
750
+ return segments.join('.');
455
751
  }
456
752
 
457
753
  documents.listen(connection);