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