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