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 +3 -2
- package/package.json +2 -2
- package/server/core.js +213 -15
- package/server/index.js +161 -11
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,
|
|
11
|
-
- Quick fixes: add missing interpolation params
|
|
10
|
+
- Diagnostics: report missing keys, missing locale entries, mismatched interpolation params, and (on locale files) keys whose params differ across languages
|
|
11
|
+
- Quick fixes: add missing / remove unused interpolation params on supported i18n calls, and create a missing key in every locale file that lacks it
|
|
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.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Language server for configurable i18n inlay hints, hover, completion, diagnostics, and
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
289
|
-
|
|
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)
|
|
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
|
-
|
|
426
|
-
const
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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) => ({
|