i18n-lens-language-server 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,10 +7,11 @@ Zed extension plus Language Server for Vue, TypeScript, TSX, JavaScript and JSX.
7
7
  - Inline translation display via LSP inlay hints
8
8
  - Detect `$t`/`t`/`tc`/`$tc`/`i18n.t` calls, `v-t="'key'"`, `v-t="{ path: 'key' }"`, `<i18n-t keypath="key">` (Vue I18n), `<Trans i18nKey="key">` (react-i18next), and `formatMessage({ id: "key" })` (react-intl)
9
9
  - Hover: show all locale values for a key
10
- - Diagnostics: report missing keys, missing locale entries, and mismatched interpolation params
11
- - Quick fixes: add missing interpolation params to supported i18n calls
10
+ - Diagnostics: report missing keys, missing locale entries, mismatched interpolation params, and (on locale files) keys whose params differ across languages
11
+ - Quick fixes: add missing / remove unused interpolation params on supported i18n calls, and create a missing key in every locale file that lacks it
12
12
  - Completion: suggest existing i18n keys with default locale text
13
13
  - Definition: jump from a source key to its locale definition; when several locales define the key, pick which language file to open
14
+ - References: from a key in a locale file (or a source usage), find every place the key is used across the project's source code
14
15
 
15
16
  ## Installation
16
17
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "i18n-lens-language-server",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "type": "module",
5
- "description": "Language server for configurable i18n inlay hints, hover, completion, diagnostics, and definition in Vue/TS/TSX/JS.",
5
+ "description": "Language server for configurable i18n inlay hints, hover, completion, diagnostics, definition, and references in Vue/TS/TSX/JS.",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
package/server/core.js CHANGED
@@ -171,7 +171,8 @@ function collectFunctionCallKeys(text, found) {
171
171
  const end = start + key.length;
172
172
  const args = splitArgsWithOffsets(callText.slice(1, -1));
173
173
  const paramsEdit = getParamsEdit(args.slice(1), parenOffset + 1, parenOffset + callText.length - 1);
174
- found.push({ key, range: makeRange(text, start, end), startOffset: start, endOffset: end, providedParams: collectParamNames(args.slice(1)), paramsEdit });
174
+ const paramsObject = extractParamsObject(args.slice(1), parenOffset + 1);
175
+ found.push({ key, range: makeRange(text, start, end), startOffset: start, endOffset: end, providedParams: collectParamNames(args.slice(1)), paramsEdit, paramsObject });
175
176
  }
176
177
  }
177
178
 
@@ -190,7 +191,8 @@ function collectFormatMessageKeys(text, found) {
190
191
  const start = parenOffset + 1 + idArg.indexOf(key);
191
192
  const end = start + key.length;
192
193
  const paramsEdit = getParamsEdit(args.slice(1), parenOffset + 1, parenOffset + callText.length - 1);
193
- found.push({ key, range: makeRange(text, start, end), startOffset: start, endOffset: end, providedParams: collectParamNames(args.slice(1)), paramsEdit });
194
+ const paramsObject = extractParamsObject(args.slice(1), parenOffset + 1);
195
+ found.push({ key, range: makeRange(text, start, end), startOffset: start, endOffset: end, providedParams: collectParamNames(args.slice(1)), paramsEdit, paramsObject });
194
196
  }
195
197
  }
196
198
 
@@ -285,19 +287,41 @@ function collectParamNames(args) {
285
287
  if (!trimmed.startsWith('{')) continue;
286
288
  const body = trimmed.slice(1, trimmed.lastIndexOf('}'));
287
289
  for (const part of splitArgs(body)) {
288
- const m = part.trim().match(/^(?:['"]([^'"]+)['"]|([A-Za-z_$][\w$]*))\s*(?::|$)/);
289
- const name = m && (m[1] || m[2]);
290
- if (name && !part.trim().startsWith('...')) params.add(name);
290
+ const name = paramNameOf(part);
291
+ if (name) params.add(name);
291
292
  }
292
293
  }
293
294
  return [...params].sort();
294
295
  }
295
296
 
297
+ function paramNameOf(part) {
298
+ const trimmed = String(part).trim();
299
+ if (trimmed.startsWith('...')) return undefined;
300
+ const m = trimmed.match(/^(?:['"]([^'"]+)['"]|([A-Za-z_$][\w$]*))\s*(?::|$)/);
301
+ return (m && (m[1] || m[2])) || undefined;
302
+ }
303
+
304
+ // Absolute span of the params object literal plus each top-level property's raw
305
+ // text, so a code action can rebuild the object with unused params removed.
306
+ function extractParamsObject(args, argsStartOffset) {
307
+ const objectArg = args.find((arg) => arg.text.startsWith('{') && arg.text.lastIndexOf('}') > 0);
308
+ if (!objectArg) return undefined;
309
+ const start = argsStartOffset + objectArg.start;
310
+ const body = objectArg.text.slice(1, objectArg.text.lastIndexOf('}'));
311
+ const props = splitArgsWithOffsets(body).map((part) => ({ name: paramNameOf(part.text), text: part.text }));
312
+ return { start, end: start + objectArg.text.length, props };
313
+ }
314
+
296
315
  export function keyAtPosition(text, position) {
297
316
  const offset = positionToOffset(text, position);
298
317
  return extractI18nKeys(text).find((item) => item.startOffset <= offset && offset <= item.endOffset);
299
318
  }
300
319
 
320
+ // Reverse of Go to Definition: every range in `text` (source code) that uses `key`.
321
+ export function findCodeKeyRanges(text, key) {
322
+ return extractI18nKeys(text).filter((item) => item.key === key).map((item) => item.range);
323
+ }
324
+
301
325
  export function positionToOffset(text, position) {
302
326
  const lines = text.split('\n');
303
327
  let offset = 0;
@@ -387,7 +411,11 @@ export function getDiagnostics(text, locales) {
387
411
  data: { key: item.key, missingLocales, missingInAllLocales },
388
412
  });
389
413
  }
390
- if (missingLocales.length !== localeEntries.length) diagnostics.push(...getParamDiagnostics(item, localeEntries));
414
+ if (missingLocales.length !== localeEntries.length) {
415
+ diagnostics.push(...getParamDiagnostics(item, localeEntries));
416
+ const consistency = getLocaleParamConsistency(item.key, locales);
417
+ if (!consistency.consistent) diagnostics.push(paramInconsistencyDiagnostic(item.key, item.range, consistency.perLocale));
418
+ }
391
419
  return diagnostics;
392
420
  });
393
421
  }
@@ -416,19 +444,87 @@ function collectRequiredParams(key, localeEntries) {
416
444
  return [...params].sort();
417
445
  }
418
446
 
447
+ // Compare the interpolation placeholders a key uses across every locale that
448
+ // defines it. `consistent` is false when two locales that both define the key
449
+ // carry different param sets (a translation forgot or renamed a placeholder).
450
+ // Locales that do not define the key are ignored — that is a missing-key concern.
451
+ export function getLocaleParamConsistency(key, locales) {
452
+ const perLocale = [];
453
+ for (const [name, locale] of Object.entries(locales || {})) {
454
+ const value = locale.flat?.[key];
455
+ if (value === undefined) continue;
456
+ perLocale.push({ name, params: extractPlaceholders(value) });
457
+ }
458
+ const union = [...new Set(perLocale.flatMap((entry) => entry.params))].sort();
459
+ const consistent = perLocale.length < 2
460
+ || perLocale.every((entry) => entry.params.length === union.length && union.every((param) => entry.params.includes(param)));
461
+ return { consistent, perLocale, union };
462
+ }
463
+
464
+ // Warnings for keys defined in `locale`'s file whose params disagree with the
465
+ // same key in other locales, anchored at each key's position in the file text.
466
+ export function getLocaleParamDiagnostics(locale, filePath, text, locales) {
467
+ if (!locale?.flat) return [];
468
+ const isJson = /\.json$/i.test(filePath);
469
+ const diagnostics = [];
470
+ for (const key of Object.keys(locale.flat)) {
471
+ const consistency = getLocaleParamConsistency(key, locales);
472
+ if (consistency.consistent) continue;
473
+ const lookupKey = lookupKeyForLocaleFile(locale, filePath, key);
474
+ const position = isJson ? findJsonKeyLocation(text, lookupKey) : findLocaleKeyLocation(text, lookupKey);
475
+ if (!position) continue;
476
+ const leaf = lookupKey.split('.').at(-1);
477
+ const end = { line: position.line, character: position.character + leaf.length + (isJson ? 2 : 0) };
478
+ diagnostics.push(paramInconsistencyDiagnostic(key, { start: position, end }, consistency.perLocale));
479
+ }
480
+ return diagnostics;
481
+ }
482
+
483
+ function paramInconsistencyDiagnostic(key, range, perLocale) {
484
+ return {
485
+ severity: 2,
486
+ range,
487
+ message: 'i18n params for "' + key + '" differ across locales — ' + formatPerLocaleParams(perLocale),
488
+ source: 'i18n-lens',
489
+ data: { key, paramInconsistency: perLocale },
490
+ };
491
+ }
492
+
493
+ function formatPerLocaleParams(perLocale) {
494
+ return perLocale
495
+ .map((entry) => entry.name + ': ' + (entry.params.length ? entry.params.map((p) => '{' + p + '}').join(', ') : '(none)'))
496
+ .join('; ');
497
+ }
498
+
419
499
  export function getParamCodeActions(text, diagnostics = []) {
420
500
  const items = extractI18nKeys(text);
421
501
  return diagnostics.flatMap((diagnostic) => {
422
- const missingParams = diagnostic.data?.missingParams;
423
- if (!Array.isArray(missingParams) || missingParams.length === 0) return [];
424
502
  const item = items.find((candidate) => candidate.key === diagnostic.data?.key && sameRange(candidate.range, diagnostic.range));
425
- if (!item?.paramsEdit) return [];
426
- const newText = item.paramsEdit.prefix + missingParams.join(', ') + (item.paramsEdit.suffix || '');
427
- return [{
428
- title: 'Add missing i18n params: ' + missingParams.join(', '),
429
- diagnostic,
430
- edit: { range: makeRange(text, item.paramsEdit.insertOffset, item.paramsEdit.insertOffset), newText },
431
- }];
503
+
504
+ const missingParams = diagnostic.data?.missingParams;
505
+ if (Array.isArray(missingParams) && missingParams.length > 0) {
506
+ if (!item?.paramsEdit) return [];
507
+ const newText = item.paramsEdit.prefix + missingParams.join(', ') + (item.paramsEdit.suffix || '');
508
+ return [{
509
+ title: 'Add missing i18n params: ' + missingParams.join(', '),
510
+ diagnostic,
511
+ edit: { range: makeRange(text, item.paramsEdit.insertOffset, item.paramsEdit.insertOffset), newText },
512
+ }];
513
+ }
514
+
515
+ const unusedParams = diagnostic.data?.unusedParams;
516
+ if (Array.isArray(unusedParams) && unusedParams.length > 0) {
517
+ if (!item?.paramsObject) return [];
518
+ const kept = item.paramsObject.props.filter((prop) => !unusedParams.includes(prop.name));
519
+ const rebuilt = kept.length ? '{ ' + kept.map((prop) => prop.text).join(', ') + ' }' : '{}';
520
+ return [{
521
+ title: 'Remove unused i18n params: ' + unusedParams.join(', '),
522
+ diagnostic,
523
+ edit: { range: makeRange(text, item.paramsObject.start, item.paramsObject.end), newText: rebuilt },
524
+ }];
525
+ }
526
+
527
+ return [];
432
528
  });
433
529
  }
434
530
 
@@ -516,6 +612,29 @@ export function collectLocaleKeyTargets(locales, key, localeTexts = {}, defaultL
516
612
  .filter((target) => target?.position);
517
613
  }
518
614
 
615
+ // Inverse of findLocaleKeyTarget: given a caret position inside a locale file,
616
+ // return the full flattened key it points at (so Find References can jump back
617
+ // to the code). Reuses the same key->location logic as Go to Definition to stay
618
+ // consistent; precise for JSON, best-effort (line-based) for TS/JS locale modules.
619
+ export function localeKeyAtPosition(locale, filePath, text, position) {
620
+ if (!locale || !locale.flat || !text || !position) return undefined;
621
+ const isJson = /\.json$/i.test(filePath);
622
+ let lineFallback;
623
+ let lineFallbackCount = 0;
624
+ for (const fullKey of Object.keys(locale.flat)) {
625
+ const lookupKey = lookupKeyForLocaleFile(locale, filePath, fullKey);
626
+ const loc = isJson ? findJsonKeyLocation(text, lookupKey) : findLocaleKeyLocation(text, lookupKey);
627
+ if (!loc || loc.line !== position.line) continue;
628
+ const leaf = lookupKey.split('.').at(-1);
629
+ const tokenStart = loc.character;
630
+ const tokenEnd = tokenStart + leaf.length + (isJson ? 2 : 0);
631
+ if (position.character >= tokenStart && position.character <= tokenEnd) return fullKey;
632
+ lineFallback = fullKey;
633
+ lineFallbackCount++;
634
+ }
635
+ return lineFallbackCount === 1 ? lineFallback : undefined;
636
+ }
637
+
519
638
  export function getCompletionPrefix(text, position) {
520
639
  const offset = positionToOffset(text, position);
521
640
  const before = text.slice(0, offset);
@@ -546,6 +665,85 @@ export function findJsonKeyLocation(jsonText, key) {
546
665
  return undefined;
547
666
  }
548
667
 
668
+ // Surgically insert a dotted `key` into a JSON document, creating intermediate
669
+ // objects as needed and preserving the rest of the file's formatting. Returns
670
+ // the new text, or undefined if the JSON is unparseable or the key already exists.
671
+ export function insertNestedJsonKey(jsonText, key, value = '') {
672
+ let root;
673
+ try { root = JSON.parse(jsonText); } catch { return undefined; }
674
+ if (!root || typeof root !== 'object' || Array.isArray(root)) return undefined;
675
+ if (getValueByKey(root, key) !== undefined) return undefined;
676
+
677
+ const parts = key.split('.');
678
+ let node = root;
679
+ let existingDepth = 0;
680
+ for (; existingDepth < parts.length - 1; existingDepth++) {
681
+ const next = node[parts[existingDepth]];
682
+ if (next && typeof next === 'object' && !Array.isArray(next)) node = next;
683
+ else break;
684
+ }
685
+
686
+ const span = findJsonObjectSpan(jsonText, parts.slice(0, existingDepth));
687
+ if (!span) return undefined;
688
+
689
+ const unit = detectIndentUnit(jsonText);
690
+ const snippet = buildNestedProperty(parts.slice(existingDepth), value, unit.repeat(existingDepth + 1), unit);
691
+ const inner = jsonText.slice(span.openBrace + 1, span.contentEnd).trim();
692
+ if (inner.length === 0) {
693
+ return jsonText.slice(0, span.openBrace + 1) + '\n' + snippet + '\n' + unit.repeat(existingDepth) + jsonText.slice(span.closeBrace);
694
+ }
695
+ const needsComma = jsonText[span.contentEnd - 1] !== ',';
696
+ return jsonText.slice(0, span.contentEnd) + (needsComma ? ',' : '') + '\n' + snippet + jsonText.slice(span.contentEnd);
697
+ }
698
+
699
+ function findJsonObjectSpan(jsonText, pathParts) {
700
+ let searchFrom = 0;
701
+ if (pathParts.length) {
702
+ const pos = findJsonKeyLocation(jsonText, pathParts.join('.'));
703
+ if (!pos) return undefined;
704
+ const colon = jsonText.indexOf(':', positionToOffset(jsonText, pos));
705
+ if (colon === -1) return undefined;
706
+ searchFrom = colon + 1;
707
+ }
708
+ const openBrace = jsonText.indexOf('{', searchFrom);
709
+ if (openBrace === -1) return undefined;
710
+ const closeBrace = matchBrace(jsonText, openBrace);
711
+ if (closeBrace === -1) return undefined;
712
+ let contentEnd = closeBrace;
713
+ while (contentEnd > openBrace + 1 && /\s/.test(jsonText[contentEnd - 1])) contentEnd--;
714
+ return { openBrace, closeBrace, contentEnd };
715
+ }
716
+
717
+ function matchBrace(text, open) {
718
+ let depth = 0;
719
+ let quote = false;
720
+ let escaped = false;
721
+ for (let i = open; i < text.length; i++) {
722
+ const ch = text[i];
723
+ if (quote) {
724
+ if (escaped) escaped = false;
725
+ else if (ch === '\\') escaped = true;
726
+ else if (ch === '"') quote = false;
727
+ continue;
728
+ }
729
+ if (ch === '"') quote = true;
730
+ else if (ch === '{') depth++;
731
+ else if (ch === '}' && --depth === 0) return i;
732
+ }
733
+ return -1;
734
+ }
735
+
736
+ function detectIndentUnit(jsonText) {
737
+ const match = jsonText.match(/\n([ \t]+)\S/);
738
+ return match ? match[1] : ' ';
739
+ }
740
+
741
+ function buildNestedProperty(parts, value, indent, unit) {
742
+ const keyStr = JSON.stringify(parts[0]);
743
+ if (parts.length === 1) return indent + keyStr + ': ' + JSON.stringify(value);
744
+ return indent + keyStr + ': {\n' + buildNestedProperty(parts.slice(1), value, indent + unit, unit) + '\n' + indent + '}';
745
+ }
746
+
549
747
 
550
748
  export function parseTsLocaleModule(text, fileName = 'index.ts') {
551
749
  const result = {};
package/server/index.js CHANGED
@@ -8,12 +8,16 @@ import {
8
8
  flattenLocale,
9
9
  buildHoverMarkdown,
10
10
  getDiagnostics,
11
+ getLocaleParamDiagnostics,
11
12
  getParamCodeActions,
13
+ insertNestedJsonKey,
12
14
  getCompletions,
13
15
  getCompletionPrefix,
14
16
  keyAtPosition,
15
17
  parseTsLocaleModule,
16
18
  collectLocaleKeyTargets,
19
+ localeKeyAtPosition,
20
+ findCodeKeyRanges,
17
21
  getInlayHints,
18
22
  normalizeI18nLensConfig,
19
23
  mergeI18nLensConfig,
@@ -43,7 +47,7 @@ connection.onInitialize((params) => {
43
47
  zedSettingsConfig = normalizeIncomingSettings(params.initializationOptions);
44
48
  loadConfig();
45
49
  watchConfigFile();
46
- return { capabilities: { textDocumentSync: TextDocumentSyncKind.Incremental, hoverProvider: true, completionProvider: { triggerCharacters: ['.', '"', "'"] }, definitionProvider: true, inlayHintProvider: true, codeActionProvider: true } };
50
+ return { capabilities: { textDocumentSync: TextDocumentSyncKind.Incremental, hoverProvider: true, completionProvider: { triggerCharacters: ['.', '"', "'"] }, definitionProvider: true, referencesProvider: true, inlayHintProvider: true, codeActionProvider: true } };
47
51
  });
48
52
 
49
53
  connection.onInitialized(() => {
@@ -81,6 +85,7 @@ connection.onHover((params) => {
81
85
  });
82
86
 
83
87
  connection.onCompletion((params) => {
88
+ if (!isSourceFile(params.textDocument.uri)) return [];
84
89
  const doc = documents.get(params.textDocument.uri);
85
90
  const context = getProjectContext(params.textDocument.uri);
86
91
  const { locales } = loadLocalesForContext(context);
@@ -89,6 +94,7 @@ connection.onCompletion((params) => {
89
94
  });
90
95
 
91
96
  connection.languages.inlayHint.on((params) => {
97
+ if (!isSourceFile(params.textDocument.uri)) return [];
92
98
  const doc = documents.get(params.textDocument.uri);
93
99
  if (!doc) return [];
94
100
  const context = getProjectContext(params.textDocument.uri);
@@ -112,18 +118,91 @@ connection.onDefinition((params) => {
112
118
  return locations.length ? locations : null;
113
119
  });
114
120
 
121
+ connection.onReferences((params) => {
122
+ const uri = params.textDocument.uri;
123
+ const context = getProjectContext(uri);
124
+ const key = resolveKeyAtPosition(uri, params.position, context);
125
+ if (!key) return null;
126
+
127
+ const locations = collectCodeReferences(context, key);
128
+ if (params.context?.includeDeclaration) {
129
+ const { locales, localeTexts } = loadLocalesForContext(context);
130
+ const keyTokenLength = key.split('.').at(-1).length + 2;
131
+ for (const { filePath, position } of collectLocaleKeyTargets(locales, key, localeTexts, context.config.defaultLocale)) {
132
+ locations.push({
133
+ uri: pathToFileURL(filePath).toString(),
134
+ range: { start: position, end: { line: position.line, character: position.character + keyTokenLength } },
135
+ });
136
+ }
137
+ }
138
+ return locations.length ? locations : null;
139
+ });
140
+
115
141
  connection.onCodeAction((params) => {
116
142
  const doc = documents.get(params.textDocument.uri);
117
- if (!doc) return [];
118
- return getParamCodeActions(doc.getText(), params.context.diagnostics)
119
- .map((action) => ({
120
- title: action.title,
121
- kind: CodeActionKind.QuickFix,
122
- diagnostics: [action.diagnostic],
123
- edit: { changes: { [params.textDocument.uri]: [action.edit] } },
124
- }));
143
+ const diagnostics = params.context.diagnostics || [];
144
+ const actions = [];
145
+ if (doc) {
146
+ for (const action of getParamCodeActions(doc.getText(), diagnostics)) {
147
+ actions.push({
148
+ title: action.title,
149
+ kind: CodeActionKind.QuickFix,
150
+ diagnostics: [action.diagnostic],
151
+ edit: { changes: { [params.textDocument.uri]: [action.edit] } },
152
+ });
153
+ }
154
+ }
155
+ actions.push(...getCreateKeyActions(diagnostics, getProjectContext(params.textDocument.uri)));
156
+ return actions;
125
157
  });
126
158
 
159
+ // Quick fix for a "missing i18n key" diagnostic: create the key (empty value) in
160
+ // every locale file that lacks it, as a cross-file workspace edit. Only single
161
+ // flat JSON locale files are supported; nested/TS locales are skipped.
162
+ function getCreateKeyActions(diagnostics, context) {
163
+ const { locales, localeTexts } = loadLocalesForContext(context);
164
+ const actions = [];
165
+ for (const diagnostic of diagnostics) {
166
+ const key = diagnostic.data?.key;
167
+ const missingLocales = diagnostic.data?.missingLocales;
168
+ if (!key || !Array.isArray(missingLocales) || missingLocales.length === 0) continue;
169
+
170
+ const changes = {};
171
+ for (const name of missingLocales) {
172
+ const filePath = flatLocaleFile(locales[name]);
173
+ if (!filePath) continue;
174
+ const text = localeTexts[filePath] ?? readFileSafe(filePath);
175
+ if (text === undefined) continue;
176
+ const newText = insertNestedJsonKey(text, key, '');
177
+ if (newText === undefined) continue;
178
+ changes[pathToFileURL(filePath).toString()] = [fullDocumentEdit(text, newText)];
179
+ }
180
+
181
+ const fileCount = Object.keys(changes).length;
182
+ if (fileCount === 0) continue;
183
+ actions.push({
184
+ title: `Create i18n key "${key}" in ${fileCount} locale file(s)`,
185
+ kind: CodeActionKind.QuickFix,
186
+ diagnostics: [diagnostic],
187
+ edit: { changes },
188
+ });
189
+ }
190
+ return actions;
191
+ }
192
+
193
+ // A locale backed by a single flat .json file (not a nested directory locale).
194
+ function flatLocaleFile(locale) {
195
+ if (!locale || !/\.json$/i.test(locale.path || '')) return undefined;
196
+ const files = locale.files?.length ? locale.files : [locale.path];
197
+ return files.length === 1 && files[0] === locale.path ? locale.path : undefined;
198
+ }
199
+
200
+ function fullDocumentEdit(oldText, newText) {
201
+ const lines = oldText.split('\n');
202
+ const endLine = lines.length - 1;
203
+ return { range: { start: { line: 0, character: 0 }, end: { line: endLine, character: lines[endLine].length } }, newText };
204
+ }
205
+
127
206
  function getProjectContext(uri) {
128
207
  loadConfigIfChanged();
129
208
  const filePath = uri ? fileURLToPath(uri) : workspaceRoot;
@@ -220,13 +299,84 @@ function normalizeIncomingSettings(value) {
220
299
  }
221
300
 
222
301
  function validate(document) {
223
- if (!isSourceFile(document.uri)) return;
224
302
  const context = getProjectContext(document.uri);
225
303
  const { locales } = loadLocalesForContext(context);
226
- connection.sendDiagnostics({ uri: document.uri, diagnostics: getDiagnostics(document.getText(), locales) });
304
+ if (isSourceFile(document.uri)) {
305
+ connection.sendDiagnostics({ uri: document.uri, diagnostics: getDiagnostics(document.getText(), locales) });
306
+ return;
307
+ }
308
+ const locale = findLocaleForFile(locales, fileURLToPath(document.uri));
309
+ if (!locale) return;
310
+ connection.sendDiagnostics({ uri: document.uri, diagnostics: getLocaleParamDiagnostics(locale, fileURLToPath(document.uri), document.getText(), locales) });
227
311
  }
228
312
  function isSourceFile(uri) { return /\.(vue|tsx?|jsx?)$/i.test(uri); }
229
313
 
314
+ const IGNORED_SCAN_DIRS = new Set(['node_modules', 'dist', 'build', 'out', 'coverage', 'vendor', '.output', '.next', '.nuxt', '.cache']);
315
+ const MAX_SCAN_FILES = 5000;
316
+
317
+ // Resolve the i18n key under `position`, whether the caret sits on a t()/keypath
318
+ // usage in source code or on a key inside a locale JSON/TS file.
319
+ function resolveKeyAtPosition(uri, position, context) {
320
+ const filePath = fileURLToPath(uri);
321
+ const text = documents.get(uri)?.getText() ?? readFileSafe(filePath);
322
+ if (!text) return undefined;
323
+ if (isSourceFile(uri)) return keyAtPosition(text, position)?.key;
324
+ const { locales } = loadLocalesForContext(context);
325
+ const locale = findLocaleForFile(locales, filePath);
326
+ return locale ? localeKeyAtPosition(locale, filePath, text, position) : undefined;
327
+ }
328
+
329
+ function findLocaleForFile(locales, filePath) {
330
+ const target = path.resolve(filePath);
331
+ for (const locale of Object.values(locales)) {
332
+ const files = locale.files?.length ? locale.files : (locale.path ? [locale.path] : []);
333
+ if (files.some((file) => path.resolve(file) === target)) return locale;
334
+ }
335
+ return undefined;
336
+ }
337
+
338
+ // Every source-code usage of `key` within the project/package root. Prefers the
339
+ // in-memory text of open (possibly unsaved) documents over the on-disk copy.
340
+ function collectCodeReferences(context, key) {
341
+ const openTextByPath = new Map();
342
+ for (const doc of documents.all()) {
343
+ try { openTextByPath.set(path.resolve(fileURLToPath(doc.uri)), doc.getText()); } catch { /* non-file uri */ }
344
+ }
345
+ const locations = [];
346
+ for (const filePath of collectSourceFiles(context.root)) {
347
+ const text = openTextByPath.get(path.resolve(filePath)) ?? readFileSafe(filePath);
348
+ if (!text) continue;
349
+ for (const range of findCodeKeyRanges(text, key)) {
350
+ locations.push({ uri: pathToFileURL(filePath).toString(), range });
351
+ }
352
+ }
353
+ return locations;
354
+ }
355
+
356
+ function collectSourceFiles(root) {
357
+ const files = [];
358
+ const stack = [root];
359
+ while (stack.length && files.length < MAX_SCAN_FILES) {
360
+ const dir = stack.pop();
361
+ let entries;
362
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
363
+ for (const entry of entries) {
364
+ const full = path.join(dir, entry.name);
365
+ if (entry.isDirectory()) {
366
+ if (entry.name.startsWith('.') || IGNORED_SCAN_DIRS.has(entry.name)) continue;
367
+ stack.push(full);
368
+ } else if (isSourceFile(entry.name)) {
369
+ files.push(full);
370
+ }
371
+ }
372
+ }
373
+ return files;
374
+ }
375
+
376
+ function readFileSafe(filePath) {
377
+ try { return fs.readFileSync(filePath, 'utf8'); } catch { return undefined; }
378
+ }
379
+
230
380
  function getAllConfiguredContexts() {
231
381
  loadConfigIfChanged();
232
382
  return [resolveProjectContext(workspaceRoot, workspaceRoot, config), ...config.packages.map((pkg) => ({