i18n-lens-language-server 0.8.2 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -17
- package/package.json +2 -2
- package/server/core.js +284 -10
- package/server/index.js +185 -7
package/README.md
CHANGED
|
@@ -7,9 +7,36 @@ Zed extension plus Language Server for Vue, TypeScript, TSX, JavaScript and JSX.
|
|
|
7
7
|
- Inline translation display via LSP inlay hints
|
|
8
8
|
- Detect `$t`/`t`/`tc`/`$tc`/`i18n.t` calls, `v-t="'key'"`, `v-t="{ path: 'key' }"`, `<i18n-t keypath="key">` (Vue I18n), `<Trans i18nKey="key">` (react-i18next), and `formatMessage({ id: "key" })` (react-intl)
|
|
9
9
|
- Hover: show all locale values for a key
|
|
10
|
-
- Diagnostics: report missing keys, missing locale entries,
|
|
10
|
+
- Diagnostics: report missing keys, missing locale entries, mismatched interpolation params, and (on locale files) keys whose params differ across languages
|
|
11
|
+
- Quick fixes: add missing / remove unused interpolation params on supported i18n calls, and create a missing key in every locale file that lacks it
|
|
11
12
|
- Completion: suggest existing i18n keys with default locale text
|
|
12
13
|
- Definition: jump from a source key to its locale definition; when several locales define the key, pick which language file to open
|
|
14
|
+
- References: from a key in a locale file (or a source usage), find every place the key is used across the project's source code
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
Install **I18n Lens** from Zed's extension catalog. The extension downloads its companion language server package, [`i18n-lens-language-server`](https://www.npmjs.com/package/i18n-lens-language-server), into Zed's extension work directory at runtime.
|
|
19
|
+
|
|
20
|
+
After installation, open a Vue, TypeScript, TSX, JavaScript, or JSX file in a project that contains locale files. If your locale files are not in one of the default directories, configure `localeDirs` as shown below.
|
|
21
|
+
|
|
22
|
+
## Quick start
|
|
23
|
+
|
|
24
|
+
For most projects, add this to your project `.zed/settings.json` or user-level Zed settings:
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
{
|
|
28
|
+
"lsp": {
|
|
29
|
+
"i18n-lens": {
|
|
30
|
+
"settings": {
|
|
31
|
+
"defaultLocale": "en-US",
|
|
32
|
+
"localeDirs": ["src/locales", "src/i18n"]
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
If you prefer a project-owned config file that can be committed with the repository, create `.zed/i18nlensrc.json` instead. When both are present, `.zed/i18nlensrc.json` takes precedence over Zed settings.
|
|
13
40
|
|
|
14
41
|
## Inline translations
|
|
15
42
|
|
|
@@ -27,7 +54,7 @@ will show the default locale translation inline after the key string, for exampl
|
|
|
27
54
|
t("order.pay_now") 立即支付
|
|
28
55
|
```
|
|
29
56
|
|
|
30
|
-
The inline hint uses `defaultLocale` from `.zed/i18nlensrc.json`, falling back to `zh-CN`. Missing keys are not shown inline because diagnostics already report them.
|
|
57
|
+
The inline hint uses `defaultLocale` from your Zed settings or `.zed/i18nlensrc.json`, falling back to `zh-CN`. Missing keys are not shown inline because diagnostics already report them.
|
|
31
58
|
|
|
32
59
|
Long translations are truncated inline to keep the editor readable. Hover still shows the full locale table.
|
|
33
60
|
|
|
@@ -47,6 +74,7 @@ Diagnostics distinguish between these cases:
|
|
|
47
74
|
- If a key exists in at least one locale but is missing from other locales, it is reported as a warning listing the missing locale names.
|
|
48
75
|
- If a translation contains named placeholders such as `{count}` but the source call does not pass them, it reports missing params.
|
|
49
76
|
- If the source call passes params that no loaded translation uses, it reports unused params.
|
|
77
|
+
- Quick fixes can add missing params to supported i18n calls.
|
|
50
78
|
|
|
51
79
|
For example, if `order.pay_now` exists in `zh-CN` but is missing from `en-US`, the warning message is:
|
|
52
80
|
|
|
@@ -56,25 +84,57 @@ Missing i18n key "order.pay_now" in locales: en-US
|
|
|
56
84
|
|
|
57
85
|
## Project configuration
|
|
58
86
|
|
|
59
|
-
|
|
87
|
+
I18n Lens supports two configuration sources:
|
|
88
|
+
|
|
89
|
+
1. Zed settings, under `lsp.i18n-lens.settings`.
|
|
90
|
+
2. A project file, `.zed/i18nlensrc.json`.
|
|
91
|
+
|
|
92
|
+
The project file is merged on top of Zed settings, so repository-specific configuration can override personal or team-wide editor settings.
|
|
93
|
+
|
|
94
|
+
### Configure from Zed settings
|
|
95
|
+
|
|
96
|
+
Use this when you want configuration to live in Zed's normal settings system:
|
|
60
97
|
|
|
61
98
|
```json
|
|
62
99
|
{
|
|
63
|
-
"
|
|
64
|
-
|
|
100
|
+
"lsp": {
|
|
101
|
+
"i18n-lens": {
|
|
102
|
+
"settings": {
|
|
103
|
+
"defaultLocale": "en-US",
|
|
104
|
+
"localeDirs": ["src/locales", "src/i18n"],
|
|
105
|
+
"inlayHints": {
|
|
106
|
+
"enabled": true,
|
|
107
|
+
"maxLength": 32
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Zed settings can be user-level or project-level. For project-level Zed settings, put the snippet in `.zed/settings.json`.
|
|
116
|
+
|
|
117
|
+
### Configure from `.zed/i18nlensrc.json`
|
|
118
|
+
|
|
119
|
+
Use this when the i18n layout should be part of the repository and shared by everyone who opens it:
|
|
120
|
+
|
|
121
|
+
```json
|
|
122
|
+
{
|
|
123
|
+
"defaultLocale": "en-US",
|
|
124
|
+
"localeDirs": ["src/locales", "src/i18n"],
|
|
65
125
|
"inlayHints": {
|
|
66
126
|
"enabled": true,
|
|
67
|
-
"maxLength":
|
|
127
|
+
"maxLength": 32
|
|
68
128
|
},
|
|
69
129
|
"packages": [
|
|
70
130
|
{
|
|
71
131
|
"root": "apps/web",
|
|
72
|
-
"defaultLocale": "
|
|
132
|
+
"defaultLocale": "en-US",
|
|
73
133
|
"localeDirs": ["src/locales"]
|
|
74
134
|
},
|
|
75
135
|
{
|
|
76
136
|
"root": "packages/admin",
|
|
77
|
-
"defaultLocale": "
|
|
137
|
+
"defaultLocale": "ja-JP",
|
|
78
138
|
"localeDirs": ["src/i18n"]
|
|
79
139
|
}
|
|
80
140
|
]
|
|
@@ -92,31 +152,31 @@ Supported options:
|
|
|
92
152
|
| Option | Type | Default | Description |
|
|
93
153
|
|---|---|---|---|
|
|
94
154
|
| `defaultLocale` | string | `zh-CN` | Locale used for completion details and inline translation hints. Supports exact names such as `en-US`, case-insensitive matching, `_`/`-` normalization, and language aliases such as `en` matching `en-US`. |
|
|
95
|
-
| `localeDirs` | string[] | `src/locales`, `src/i18n`, `locales`, `i18n` | Workspace-relative directories to scan for locale files. |
|
|
96
|
-
| `inlayHints.enabled` | boolean | `true` | Enables or disables inline translation hints from this language server. |
|
|
97
|
-
| `inlayHints.maxLength` | number | `24` | Maximum inline hint label length before truncation. |
|
|
155
|
+
| `localeDirs` | string[] | `src/locales`, `src/i18n`, `locales`, `i18n` | Workspace-relative directories to scan for locale files. In package configs, paths are relative to that package root. |
|
|
156
|
+
| `inlayHints.enabled` | boolean | `true` | Enables or disables inline translation hints from this language server. Hover, completion, definition, diagnostics, and quick fixes can still work when inline hints are disabled. |
|
|
157
|
+
| `inlayHints.maxLength` | number | `24` | Maximum inline hint label length before truncation. Hover still shows the full text. |
|
|
98
158
|
| `packages` | array | `[]` | Optional monorepo package contexts. Each item needs a workspace-relative `root` and can override `defaultLocale`, `localeDirs`, and `inlayHints`. The language server picks the longest package root matching the currently edited file. |
|
|
99
159
|
|
|
100
|
-
Invalid or missing config values fall back to defaults. If `.zed/i18nlensrc.json` contains invalid JSON, the language server logs a warning and continues with default config.
|
|
160
|
+
Invalid or missing config values fall back to defaults. If `.zed/i18nlensrc.json` contains invalid JSON, the language server logs a warning and continues with the Zed settings or default config.
|
|
101
161
|
|
|
102
|
-
Configuration changes are watched automatically. After saving `.zed/i18nlensrc.json`, the language server reloads config, rebuilds the locale cache, refreshes diagnostics, and asks the editor to refresh inlay hints.
|
|
162
|
+
Configuration changes are watched automatically. After saving `.zed/i18nlensrc.json`, the language server reloads config, rebuilds the locale cache, refreshes diagnostics, and asks the editor to refresh inlay hints. Changes made through Zed settings are also handled through the language server configuration update path.
|
|
103
163
|
|
|
104
164
|
## Locale discovery
|
|
105
165
|
|
|
106
|
-
In a single-project workspace, `localeDirs` are resolved relative to the workspace root.
|
|
166
|
+
In a single-project workspace, `localeDirs` are resolved relative to the workspace root. By default, the language server reads JSON files from these directories if they exist:
|
|
107
167
|
|
|
108
168
|
- `src/locales/*.json`
|
|
109
169
|
- `src/i18n/*.json`
|
|
110
170
|
- `locales/*.json`
|
|
111
171
|
- `i18n/*.json`
|
|
112
172
|
|
|
113
|
-
Locale
|
|
173
|
+
Locale names come from filenames, for example `en-US.json`, `ja-JP.json`, or `zh-CN.json`. If your files are under a nested directory such as `src/i18n/locales/en-US.json`, set `localeDirs` to `["src/i18n/locales"]`.
|
|
114
174
|
|
|
115
175
|
It also supports locale directories such as:
|
|
116
176
|
|
|
117
177
|
```text
|
|
118
|
-
src/locales/zh-CN/common.json
|
|
119
178
|
src/locales/en-US/common.json
|
|
179
|
+
src/locales/ja-JP/common.json
|
|
120
180
|
```
|
|
121
181
|
|
|
122
182
|
A file named `common.json` contributes keys with the `common.` prefix. A file named `index.json` contributes keys without an `index.` prefix.
|
|
@@ -127,7 +187,7 @@ For pnpm/turborepo and other monorepos, put `.zed/i18nlensrc.json` under the wor
|
|
|
127
187
|
|
|
128
188
|
```json
|
|
129
189
|
{
|
|
130
|
-
"defaultLocale": "
|
|
190
|
+
"defaultLocale": "en-US",
|
|
131
191
|
"packages": [
|
|
132
192
|
{
|
|
133
193
|
"root": "apps/web",
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "i18n-lens-language-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Language server for configurable i18n inlay hints, hover, completion, diagnostics, and
|
|
5
|
+
"description": "Language server for configurable i18n inlay hints, hover, completion, diagnostics, definition, and references in Vue/TS/TSX/JS.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
package/server/core.js
CHANGED
|
@@ -22,6 +22,18 @@ export function normalizeI18nLensConfig(raw = {}) {
|
|
|
22
22
|
return { ...base, packages };
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
export function mergeI18nLensConfig(base = {}, override = {}) {
|
|
26
|
+
return {
|
|
27
|
+
...base,
|
|
28
|
+
...override,
|
|
29
|
+
inlayHints: {
|
|
30
|
+
...((base && typeof base.inlayHints === 'object') ? base.inlayHints : {}),
|
|
31
|
+
...((override && typeof override.inlayHints === 'object') ? override.inlayHints : {}),
|
|
32
|
+
},
|
|
33
|
+
packages: Array.isArray(override?.packages) ? override.packages : base?.packages,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
25
37
|
function normalizeProjectConfig(raw = {}, defaults = DEFAULT_CONFIG) {
|
|
26
38
|
const defaultLocale = typeof raw.defaultLocale === 'string' && raw.defaultLocale.trim()
|
|
27
39
|
? raw.defaultLocale.trim()
|
|
@@ -157,8 +169,10 @@ function collectFunctionCallKeys(text, found) {
|
|
|
157
169
|
const key = keyMatch[2];
|
|
158
170
|
const start = parenOffset + keyMatch[0].indexOf(key);
|
|
159
171
|
const end = start + key.length;
|
|
160
|
-
const args =
|
|
161
|
-
|
|
172
|
+
const args = splitArgsWithOffsets(callText.slice(1, -1));
|
|
173
|
+
const paramsEdit = getParamsEdit(args.slice(1), parenOffset + 1, parenOffset + callText.length - 1);
|
|
174
|
+
const paramsObject = extractParamsObject(args.slice(1), parenOffset + 1);
|
|
175
|
+
found.push({ key, range: makeRange(text, start, end), startOffset: start, endOffset: end, providedParams: collectParamNames(args.slice(1)), paramsEdit, paramsObject });
|
|
162
176
|
}
|
|
163
177
|
}
|
|
164
178
|
|
|
@@ -169,14 +183,16 @@ function collectFormatMessageKeys(text, found) {
|
|
|
169
183
|
const parenOffset = re.lastIndex - 1;
|
|
170
184
|
const callText = readParens(text, parenOffset);
|
|
171
185
|
if (!callText) continue;
|
|
172
|
-
const args =
|
|
173
|
-
const idArg = args[0] || '';
|
|
186
|
+
const args = splitArgsWithOffsets(callText.slice(1, -1));
|
|
187
|
+
const idArg = args[0]?.text || '';
|
|
174
188
|
const keyMatch = idArg.match(/\bid\s*:\s*(['"])([A-Za-z0-9_.:-]+)\1/);
|
|
175
189
|
if (!keyMatch) continue;
|
|
176
190
|
const key = keyMatch[2];
|
|
177
191
|
const start = parenOffset + 1 + idArg.indexOf(key);
|
|
178
192
|
const end = start + key.length;
|
|
179
|
-
|
|
193
|
+
const paramsEdit = getParamsEdit(args.slice(1), parenOffset + 1, parenOffset + callText.length - 1);
|
|
194
|
+
const paramsObject = extractParamsObject(args.slice(1), parenOffset + 1);
|
|
195
|
+
found.push({ key, range: makeRange(text, start, end), startOffset: start, endOffset: end, providedParams: collectParamNames(args.slice(1)), paramsEdit, paramsObject });
|
|
180
196
|
}
|
|
181
197
|
}
|
|
182
198
|
|
|
@@ -225,26 +241,87 @@ function splitArgs(text) {
|
|
|
225
241
|
return args;
|
|
226
242
|
}
|
|
227
243
|
|
|
244
|
+
function splitArgsWithOffsets(text) {
|
|
245
|
+
const args = [];
|
|
246
|
+
let start = 0, depth = 0, quote = '', escaped = false;
|
|
247
|
+
for (let i = 0; i < text.length; i++) {
|
|
248
|
+
const ch = text[i];
|
|
249
|
+
if (quote) {
|
|
250
|
+
if (escaped) escaped = false;
|
|
251
|
+
else if (ch === '\\') escaped = true;
|
|
252
|
+
else if (ch === quote) quote = '';
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (ch === '"' || ch === "'" || ch === '`') { quote = ch; continue; }
|
|
256
|
+
if (ch === '{' || ch === '[' || ch === '(') depth++;
|
|
257
|
+
else if (ch === '}' || ch === ']' || ch === ')') depth--;
|
|
258
|
+
else if (ch === ',' && depth === 0) { args.push(trimArg(text, start, i)); start = i + 1; }
|
|
259
|
+
}
|
|
260
|
+
const last = trimArg(text, start, text.length);
|
|
261
|
+
if (last.text) args.push(last);
|
|
262
|
+
return args;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function trimArg(text, start, end) {
|
|
266
|
+
while (start < end && /\s/.test(text[start])) start++;
|
|
267
|
+
while (end > start && /\s/.test(text[end - 1])) end--;
|
|
268
|
+
return { text: text.slice(start, end), start, end };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function getParamsEdit(args, argsStartOffset, closingParenOffset) {
|
|
272
|
+
const objectArg = args.find((arg) => arg.text.startsWith('{') && arg.text.lastIndexOf('}') > 0);
|
|
273
|
+
if (objectArg) {
|
|
274
|
+
const closeBrace = objectArg.text.lastIndexOf('}');
|
|
275
|
+
let insertAt = closeBrace;
|
|
276
|
+
while (insertAt > 1 && /\s/.test(objectArg.text[insertAt - 1])) insertAt--;
|
|
277
|
+
const body = objectArg.text.slice(1, insertAt);
|
|
278
|
+
return { insertOffset: argsStartOffset + objectArg.start + insertAt, prefix: body.trim() ? ', ' : ' ' };
|
|
279
|
+
}
|
|
280
|
+
return { insertOffset: closingParenOffset, prefix: ', { ', suffix: ' }' };
|
|
281
|
+
}
|
|
282
|
+
|
|
228
283
|
function collectParamNames(args) {
|
|
229
284
|
const params = new Set();
|
|
230
285
|
for (const arg of args) {
|
|
231
|
-
const trimmed = arg.trim();
|
|
286
|
+
const trimmed = (typeof arg === 'string' ? arg : arg.text).trim();
|
|
232
287
|
if (!trimmed.startsWith('{')) continue;
|
|
233
288
|
const body = trimmed.slice(1, trimmed.lastIndexOf('}'));
|
|
234
289
|
for (const part of splitArgs(body)) {
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
if (name && !part.trim().startsWith('...')) params.add(name);
|
|
290
|
+
const name = paramNameOf(part);
|
|
291
|
+
if (name) params.add(name);
|
|
238
292
|
}
|
|
239
293
|
}
|
|
240
294
|
return [...params].sort();
|
|
241
295
|
}
|
|
242
296
|
|
|
297
|
+
function paramNameOf(part) {
|
|
298
|
+
const trimmed = String(part).trim();
|
|
299
|
+
if (trimmed.startsWith('...')) return undefined;
|
|
300
|
+
const m = trimmed.match(/^(?:['"]([^'"]+)['"]|([A-Za-z_$][\w$]*))\s*(?::|$)/);
|
|
301
|
+
return (m && (m[1] || m[2])) || undefined;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Absolute span of the params object literal plus each top-level property's raw
|
|
305
|
+
// text, so a code action can rebuild the object with unused params removed.
|
|
306
|
+
function extractParamsObject(args, argsStartOffset) {
|
|
307
|
+
const objectArg = args.find((arg) => arg.text.startsWith('{') && arg.text.lastIndexOf('}') > 0);
|
|
308
|
+
if (!objectArg) return undefined;
|
|
309
|
+
const start = argsStartOffset + objectArg.start;
|
|
310
|
+
const body = objectArg.text.slice(1, objectArg.text.lastIndexOf('}'));
|
|
311
|
+
const props = splitArgsWithOffsets(body).map((part) => ({ name: paramNameOf(part.text), text: part.text }));
|
|
312
|
+
return { start, end: start + objectArg.text.length, props };
|
|
313
|
+
}
|
|
314
|
+
|
|
243
315
|
export function keyAtPosition(text, position) {
|
|
244
316
|
const offset = positionToOffset(text, position);
|
|
245
317
|
return extractI18nKeys(text).find((item) => item.startOffset <= offset && offset <= item.endOffset);
|
|
246
318
|
}
|
|
247
319
|
|
|
320
|
+
// Reverse of Go to Definition: every range in `text` (source code) that uses `key`.
|
|
321
|
+
export function findCodeKeyRanges(text, key) {
|
|
322
|
+
return extractI18nKeys(text).filter((item) => item.key === key).map((item) => item.range);
|
|
323
|
+
}
|
|
324
|
+
|
|
248
325
|
export function positionToOffset(text, position) {
|
|
249
326
|
const lines = text.split('\n');
|
|
250
327
|
let offset = 0;
|
|
@@ -334,7 +411,11 @@ export function getDiagnostics(text, locales) {
|
|
|
334
411
|
data: { key: item.key, missingLocales, missingInAllLocales },
|
|
335
412
|
});
|
|
336
413
|
}
|
|
337
|
-
if (missingLocales.length !== localeEntries.length)
|
|
414
|
+
if (missingLocales.length !== localeEntries.length) {
|
|
415
|
+
diagnostics.push(...getParamDiagnostics(item, localeEntries));
|
|
416
|
+
const consistency = getLocaleParamConsistency(item.key, locales);
|
|
417
|
+
if (!consistency.consistent) diagnostics.push(paramInconsistencyDiagnostic(item.key, item.range, consistency.perLocale));
|
|
418
|
+
}
|
|
338
419
|
return diagnostics;
|
|
339
420
|
});
|
|
340
421
|
}
|
|
@@ -363,6 +444,97 @@ function collectRequiredParams(key, localeEntries) {
|
|
|
363
444
|
return [...params].sort();
|
|
364
445
|
}
|
|
365
446
|
|
|
447
|
+
// Compare the interpolation placeholders a key uses across every locale that
|
|
448
|
+
// defines it. `consistent` is false when two locales that both define the key
|
|
449
|
+
// carry different param sets (a translation forgot or renamed a placeholder).
|
|
450
|
+
// Locales that do not define the key are ignored — that is a missing-key concern.
|
|
451
|
+
export function getLocaleParamConsistency(key, locales) {
|
|
452
|
+
const perLocale = [];
|
|
453
|
+
for (const [name, locale] of Object.entries(locales || {})) {
|
|
454
|
+
const value = locale.flat?.[key];
|
|
455
|
+
if (value === undefined) continue;
|
|
456
|
+
perLocale.push({ name, params: extractPlaceholders(value) });
|
|
457
|
+
}
|
|
458
|
+
const union = [...new Set(perLocale.flatMap((entry) => entry.params))].sort();
|
|
459
|
+
const consistent = perLocale.length < 2
|
|
460
|
+
|| perLocale.every((entry) => entry.params.length === union.length && union.every((param) => entry.params.includes(param)));
|
|
461
|
+
return { consistent, perLocale, union };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Warnings for keys defined in `locale`'s file whose params disagree with the
|
|
465
|
+
// same key in other locales, anchored at each key's position in the file text.
|
|
466
|
+
export function getLocaleParamDiagnostics(locale, filePath, text, locales) {
|
|
467
|
+
if (!locale?.flat) return [];
|
|
468
|
+
const isJson = /\.json$/i.test(filePath);
|
|
469
|
+
const diagnostics = [];
|
|
470
|
+
for (const key of Object.keys(locale.flat)) {
|
|
471
|
+
const consistency = getLocaleParamConsistency(key, locales);
|
|
472
|
+
if (consistency.consistent) continue;
|
|
473
|
+
const lookupKey = lookupKeyForLocaleFile(locale, filePath, key);
|
|
474
|
+
const position = isJson ? findJsonKeyLocation(text, lookupKey) : findLocaleKeyLocation(text, lookupKey);
|
|
475
|
+
if (!position) continue;
|
|
476
|
+
const leaf = lookupKey.split('.').at(-1);
|
|
477
|
+
const end = { line: position.line, character: position.character + leaf.length + (isJson ? 2 : 0) };
|
|
478
|
+
diagnostics.push(paramInconsistencyDiagnostic(key, { start: position, end }, consistency.perLocale));
|
|
479
|
+
}
|
|
480
|
+
return diagnostics;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function paramInconsistencyDiagnostic(key, range, perLocale) {
|
|
484
|
+
return {
|
|
485
|
+
severity: 2,
|
|
486
|
+
range,
|
|
487
|
+
message: 'i18n params for "' + key + '" differ across locales — ' + formatPerLocaleParams(perLocale),
|
|
488
|
+
source: 'i18n-lens',
|
|
489
|
+
data: { key, paramInconsistency: perLocale },
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function formatPerLocaleParams(perLocale) {
|
|
494
|
+
return perLocale
|
|
495
|
+
.map((entry) => entry.name + ': ' + (entry.params.length ? entry.params.map((p) => '{' + p + '}').join(', ') : '(none)'))
|
|
496
|
+
.join('; ');
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
export function getParamCodeActions(text, diagnostics = []) {
|
|
500
|
+
const items = extractI18nKeys(text);
|
|
501
|
+
return diagnostics.flatMap((diagnostic) => {
|
|
502
|
+
const item = items.find((candidate) => candidate.key === diagnostic.data?.key && sameRange(candidate.range, diagnostic.range));
|
|
503
|
+
|
|
504
|
+
const missingParams = diagnostic.data?.missingParams;
|
|
505
|
+
if (Array.isArray(missingParams) && missingParams.length > 0) {
|
|
506
|
+
if (!item?.paramsEdit) return [];
|
|
507
|
+
const newText = item.paramsEdit.prefix + missingParams.join(', ') + (item.paramsEdit.suffix || '');
|
|
508
|
+
return [{
|
|
509
|
+
title: 'Add missing i18n params: ' + missingParams.join(', '),
|
|
510
|
+
diagnostic,
|
|
511
|
+
edit: { range: makeRange(text, item.paramsEdit.insertOffset, item.paramsEdit.insertOffset), newText },
|
|
512
|
+
}];
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const unusedParams = diagnostic.data?.unusedParams;
|
|
516
|
+
if (Array.isArray(unusedParams) && unusedParams.length > 0) {
|
|
517
|
+
if (!item?.paramsObject) return [];
|
|
518
|
+
const kept = item.paramsObject.props.filter((prop) => !unusedParams.includes(prop.name));
|
|
519
|
+
const rebuilt = kept.length ? '{ ' + kept.map((prop) => prop.text).join(', ') + ' }' : '{}';
|
|
520
|
+
return [{
|
|
521
|
+
title: 'Remove unused i18n params: ' + unusedParams.join(', '),
|
|
522
|
+
diagnostic,
|
|
523
|
+
edit: { range: makeRange(text, item.paramsObject.start, item.paramsObject.end), newText: rebuilt },
|
|
524
|
+
}];
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
return [];
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function sameRange(a, b) {
|
|
532
|
+
return a?.start?.line === b?.start?.line
|
|
533
|
+
&& a?.start?.character === b?.start?.character
|
|
534
|
+
&& a?.end?.line === b?.end?.line
|
|
535
|
+
&& a?.end?.character === b?.end?.character;
|
|
536
|
+
}
|
|
537
|
+
|
|
366
538
|
export function extractPlaceholders(value) {
|
|
367
539
|
const params = new Set();
|
|
368
540
|
// ICU-style: {name, plural, ...} — extract top-level name, skip nested content
|
|
@@ -440,6 +612,29 @@ export function collectLocaleKeyTargets(locales, key, localeTexts = {}, defaultL
|
|
|
440
612
|
.filter((target) => target?.position);
|
|
441
613
|
}
|
|
442
614
|
|
|
615
|
+
// Inverse of findLocaleKeyTarget: given a caret position inside a locale file,
|
|
616
|
+
// return the full flattened key it points at (so Find References can jump back
|
|
617
|
+
// to the code). Reuses the same key->location logic as Go to Definition to stay
|
|
618
|
+
// consistent; precise for JSON, best-effort (line-based) for TS/JS locale modules.
|
|
619
|
+
export function localeKeyAtPosition(locale, filePath, text, position) {
|
|
620
|
+
if (!locale || !locale.flat || !text || !position) return undefined;
|
|
621
|
+
const isJson = /\.json$/i.test(filePath);
|
|
622
|
+
let lineFallback;
|
|
623
|
+
let lineFallbackCount = 0;
|
|
624
|
+
for (const fullKey of Object.keys(locale.flat)) {
|
|
625
|
+
const lookupKey = lookupKeyForLocaleFile(locale, filePath, fullKey);
|
|
626
|
+
const loc = isJson ? findJsonKeyLocation(text, lookupKey) : findLocaleKeyLocation(text, lookupKey);
|
|
627
|
+
if (!loc || loc.line !== position.line) continue;
|
|
628
|
+
const leaf = lookupKey.split('.').at(-1);
|
|
629
|
+
const tokenStart = loc.character;
|
|
630
|
+
const tokenEnd = tokenStart + leaf.length + (isJson ? 2 : 0);
|
|
631
|
+
if (position.character >= tokenStart && position.character <= tokenEnd) return fullKey;
|
|
632
|
+
lineFallback = fullKey;
|
|
633
|
+
lineFallbackCount++;
|
|
634
|
+
}
|
|
635
|
+
return lineFallbackCount === 1 ? lineFallback : undefined;
|
|
636
|
+
}
|
|
637
|
+
|
|
443
638
|
export function getCompletionPrefix(text, position) {
|
|
444
639
|
const offset = positionToOffset(text, position);
|
|
445
640
|
const before = text.slice(0, offset);
|
|
@@ -470,6 +665,85 @@ export function findJsonKeyLocation(jsonText, key) {
|
|
|
470
665
|
return undefined;
|
|
471
666
|
}
|
|
472
667
|
|
|
668
|
+
// Surgically insert a dotted `key` into a JSON document, creating intermediate
|
|
669
|
+
// objects as needed and preserving the rest of the file's formatting. Returns
|
|
670
|
+
// the new text, or undefined if the JSON is unparseable or the key already exists.
|
|
671
|
+
export function insertNestedJsonKey(jsonText, key, value = '') {
|
|
672
|
+
let root;
|
|
673
|
+
try { root = JSON.parse(jsonText); } catch { return undefined; }
|
|
674
|
+
if (!root || typeof root !== 'object' || Array.isArray(root)) return undefined;
|
|
675
|
+
if (getValueByKey(root, key) !== undefined) return undefined;
|
|
676
|
+
|
|
677
|
+
const parts = key.split('.');
|
|
678
|
+
let node = root;
|
|
679
|
+
let existingDepth = 0;
|
|
680
|
+
for (; existingDepth < parts.length - 1; existingDepth++) {
|
|
681
|
+
const next = node[parts[existingDepth]];
|
|
682
|
+
if (next && typeof next === 'object' && !Array.isArray(next)) node = next;
|
|
683
|
+
else break;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const span = findJsonObjectSpan(jsonText, parts.slice(0, existingDepth));
|
|
687
|
+
if (!span) return undefined;
|
|
688
|
+
|
|
689
|
+
const unit = detectIndentUnit(jsonText);
|
|
690
|
+
const snippet = buildNestedProperty(parts.slice(existingDepth), value, unit.repeat(existingDepth + 1), unit);
|
|
691
|
+
const inner = jsonText.slice(span.openBrace + 1, span.contentEnd).trim();
|
|
692
|
+
if (inner.length === 0) {
|
|
693
|
+
return jsonText.slice(0, span.openBrace + 1) + '\n' + snippet + '\n' + unit.repeat(existingDepth) + jsonText.slice(span.closeBrace);
|
|
694
|
+
}
|
|
695
|
+
const needsComma = jsonText[span.contentEnd - 1] !== ',';
|
|
696
|
+
return jsonText.slice(0, span.contentEnd) + (needsComma ? ',' : '') + '\n' + snippet + jsonText.slice(span.contentEnd);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function findJsonObjectSpan(jsonText, pathParts) {
|
|
700
|
+
let searchFrom = 0;
|
|
701
|
+
if (pathParts.length) {
|
|
702
|
+
const pos = findJsonKeyLocation(jsonText, pathParts.join('.'));
|
|
703
|
+
if (!pos) return undefined;
|
|
704
|
+
const colon = jsonText.indexOf(':', positionToOffset(jsonText, pos));
|
|
705
|
+
if (colon === -1) return undefined;
|
|
706
|
+
searchFrom = colon + 1;
|
|
707
|
+
}
|
|
708
|
+
const openBrace = jsonText.indexOf('{', searchFrom);
|
|
709
|
+
if (openBrace === -1) return undefined;
|
|
710
|
+
const closeBrace = matchBrace(jsonText, openBrace);
|
|
711
|
+
if (closeBrace === -1) return undefined;
|
|
712
|
+
let contentEnd = closeBrace;
|
|
713
|
+
while (contentEnd > openBrace + 1 && /\s/.test(jsonText[contentEnd - 1])) contentEnd--;
|
|
714
|
+
return { openBrace, closeBrace, contentEnd };
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function matchBrace(text, open) {
|
|
718
|
+
let depth = 0;
|
|
719
|
+
let quote = false;
|
|
720
|
+
let escaped = false;
|
|
721
|
+
for (let i = open; i < text.length; i++) {
|
|
722
|
+
const ch = text[i];
|
|
723
|
+
if (quote) {
|
|
724
|
+
if (escaped) escaped = false;
|
|
725
|
+
else if (ch === '\\') escaped = true;
|
|
726
|
+
else if (ch === '"') quote = false;
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
729
|
+
if (ch === '"') quote = true;
|
|
730
|
+
else if (ch === '{') depth++;
|
|
731
|
+
else if (ch === '}' && --depth === 0) return i;
|
|
732
|
+
}
|
|
733
|
+
return -1;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function detectIndentUnit(jsonText) {
|
|
737
|
+
const match = jsonText.match(/\n([ \t]+)\S/);
|
|
738
|
+
return match ? match[1] : ' ';
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function buildNestedProperty(parts, value, indent, unit) {
|
|
742
|
+
const keyStr = JSON.stringify(parts[0]);
|
|
743
|
+
if (parts.length === 1) return indent + keyStr + ': ' + JSON.stringify(value);
|
|
744
|
+
return indent + keyStr + ': {\n' + buildNestedProperty(parts.slice(1), value, indent + unit, unit) + '\n' + indent + '}';
|
|
745
|
+
}
|
|
746
|
+
|
|
473
747
|
|
|
474
748
|
export function parseTsLocaleModule(text, fileName = 'index.ts') {
|
|
475
749
|
const result = {};
|
package/server/index.js
CHANGED
|
@@ -2,19 +2,25 @@
|
|
|
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 } from 'vscode-languageserver/node.js';
|
|
5
|
+
import { createConnection, TextDocuments, ProposedFeatures, TextDocumentSyncKind, MarkupKind, CodeActionKind } from 'vscode-languageserver/node.js';
|
|
6
6
|
import { TextDocument } from 'vscode-languageserver-textdocument';
|
|
7
7
|
import {
|
|
8
8
|
flattenLocale,
|
|
9
9
|
buildHoverMarkdown,
|
|
10
10
|
getDiagnostics,
|
|
11
|
+
getLocaleParamDiagnostics,
|
|
12
|
+
getParamCodeActions,
|
|
13
|
+
insertNestedJsonKey,
|
|
11
14
|
getCompletions,
|
|
12
15
|
getCompletionPrefix,
|
|
13
16
|
keyAtPosition,
|
|
14
17
|
parseTsLocaleModule,
|
|
15
18
|
collectLocaleKeyTargets,
|
|
19
|
+
localeKeyAtPosition,
|
|
20
|
+
findCodeKeyRanges,
|
|
16
21
|
getInlayHints,
|
|
17
22
|
normalizeI18nLensConfig,
|
|
23
|
+
mergeI18nLensConfig,
|
|
18
24
|
resolveConfigPath,
|
|
19
25
|
CONFIG_RELATIVE_PATH,
|
|
20
26
|
didWatchedFileChange,
|
|
@@ -25,6 +31,7 @@ import {
|
|
|
25
31
|
const connection = createConnection(ProposedFeatures.all);
|
|
26
32
|
const documents = new TextDocuments(TextDocument);
|
|
27
33
|
let workspaceRoot = process.cwd();
|
|
34
|
+
let zedSettingsConfig = {};
|
|
28
35
|
let config = normalizeI18nLensConfig();
|
|
29
36
|
let watchedConfigPath;
|
|
30
37
|
const watchedLocalePaths = new Set();
|
|
@@ -37,9 +44,10 @@ const reloadDebounceMs = 100;
|
|
|
37
44
|
|
|
38
45
|
connection.onInitialize((params) => {
|
|
39
46
|
workspaceRoot = params.workspaceFolders?.[0]?.uri ? fileURLToPath(params.workspaceFolders[0].uri) : (params.rootUri ? fileURLToPath(params.rootUri) : process.cwd());
|
|
47
|
+
zedSettingsConfig = normalizeIncomingSettings(params.initializationOptions);
|
|
40
48
|
loadConfig();
|
|
41
49
|
watchConfigFile();
|
|
42
|
-
return { capabilities: { textDocumentSync: TextDocumentSyncKind.Incremental, hoverProvider: true, completionProvider: { triggerCharacters: ['.', '"', "'"] }, definitionProvider: true, inlayHintProvider: true } };
|
|
50
|
+
return { capabilities: { textDocumentSync: TextDocumentSyncKind.Incremental, hoverProvider: true, completionProvider: { triggerCharacters: ['.', '"', "'"] }, definitionProvider: true, referencesProvider: true, inlayHintProvider: true, codeActionProvider: true } };
|
|
43
51
|
});
|
|
44
52
|
|
|
45
53
|
connection.onInitialized(() => {
|
|
@@ -58,6 +66,11 @@ documents.onDidOpen((event) => {
|
|
|
58
66
|
documents.onDidChangeContent((event) => validate(event.document));
|
|
59
67
|
documents.onDidSave(() => scheduleProjectReload());
|
|
60
68
|
|
|
69
|
+
connection.onDidChangeConfiguration((params) => {
|
|
70
|
+
zedSettingsConfig = normalizeIncomingSettings(params.settings);
|
|
71
|
+
scheduleProjectReload();
|
|
72
|
+
});
|
|
73
|
+
|
|
61
74
|
connection.onHover((params) => {
|
|
62
75
|
const doc = documents.get(params.textDocument.uri);
|
|
63
76
|
if (!doc) return null;
|
|
@@ -72,6 +85,7 @@ connection.onHover((params) => {
|
|
|
72
85
|
});
|
|
73
86
|
|
|
74
87
|
connection.onCompletion((params) => {
|
|
88
|
+
if (!isSourceFile(params.textDocument.uri)) return [];
|
|
75
89
|
const doc = documents.get(params.textDocument.uri);
|
|
76
90
|
const context = getProjectContext(params.textDocument.uri);
|
|
77
91
|
const { locales } = loadLocalesForContext(context);
|
|
@@ -80,6 +94,7 @@ connection.onCompletion((params) => {
|
|
|
80
94
|
});
|
|
81
95
|
|
|
82
96
|
connection.languages.inlayHint.on((params) => {
|
|
97
|
+
if (!isSourceFile(params.textDocument.uri)) return [];
|
|
83
98
|
const doc = documents.get(params.textDocument.uri);
|
|
84
99
|
if (!doc) return [];
|
|
85
100
|
const context = getProjectContext(params.textDocument.uri);
|
|
@@ -103,6 +118,91 @@ connection.onDefinition((params) => {
|
|
|
103
118
|
return locations.length ? locations : null;
|
|
104
119
|
});
|
|
105
120
|
|
|
121
|
+
connection.onReferences((params) => {
|
|
122
|
+
const uri = params.textDocument.uri;
|
|
123
|
+
const context = getProjectContext(uri);
|
|
124
|
+
const key = resolveKeyAtPosition(uri, params.position, context);
|
|
125
|
+
if (!key) return null;
|
|
126
|
+
|
|
127
|
+
const locations = collectCodeReferences(context, key);
|
|
128
|
+
if (params.context?.includeDeclaration) {
|
|
129
|
+
const { locales, localeTexts } = loadLocalesForContext(context);
|
|
130
|
+
const keyTokenLength = key.split('.').at(-1).length + 2;
|
|
131
|
+
for (const { filePath, position } of collectLocaleKeyTargets(locales, key, localeTexts, context.config.defaultLocale)) {
|
|
132
|
+
locations.push({
|
|
133
|
+
uri: pathToFileURL(filePath).toString(),
|
|
134
|
+
range: { start: position, end: { line: position.line, character: position.character + keyTokenLength } },
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return locations.length ? locations : null;
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
connection.onCodeAction((params) => {
|
|
142
|
+
const doc = documents.get(params.textDocument.uri);
|
|
143
|
+
const diagnostics = params.context.diagnostics || [];
|
|
144
|
+
const actions = [];
|
|
145
|
+
if (doc) {
|
|
146
|
+
for (const action of getParamCodeActions(doc.getText(), diagnostics)) {
|
|
147
|
+
actions.push({
|
|
148
|
+
title: action.title,
|
|
149
|
+
kind: CodeActionKind.QuickFix,
|
|
150
|
+
diagnostics: [action.diagnostic],
|
|
151
|
+
edit: { changes: { [params.textDocument.uri]: [action.edit] } },
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
actions.push(...getCreateKeyActions(diagnostics, getProjectContext(params.textDocument.uri)));
|
|
156
|
+
return actions;
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// Quick fix for a "missing i18n key" diagnostic: create the key (empty value) in
|
|
160
|
+
// every locale file that lacks it, as a cross-file workspace edit. Only single
|
|
161
|
+
// flat JSON locale files are supported; nested/TS locales are skipped.
|
|
162
|
+
function getCreateKeyActions(diagnostics, context) {
|
|
163
|
+
const { locales, localeTexts } = loadLocalesForContext(context);
|
|
164
|
+
const actions = [];
|
|
165
|
+
for (const diagnostic of diagnostics) {
|
|
166
|
+
const key = diagnostic.data?.key;
|
|
167
|
+
const missingLocales = diagnostic.data?.missingLocales;
|
|
168
|
+
if (!key || !Array.isArray(missingLocales) || missingLocales.length === 0) continue;
|
|
169
|
+
|
|
170
|
+
const changes = {};
|
|
171
|
+
for (const name of missingLocales) {
|
|
172
|
+
const filePath = flatLocaleFile(locales[name]);
|
|
173
|
+
if (!filePath) continue;
|
|
174
|
+
const text = localeTexts[filePath] ?? readFileSafe(filePath);
|
|
175
|
+
if (text === undefined) continue;
|
|
176
|
+
const newText = insertNestedJsonKey(text, key, '');
|
|
177
|
+
if (newText === undefined) continue;
|
|
178
|
+
changes[pathToFileURL(filePath).toString()] = [fullDocumentEdit(text, newText)];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const fileCount = Object.keys(changes).length;
|
|
182
|
+
if (fileCount === 0) continue;
|
|
183
|
+
actions.push({
|
|
184
|
+
title: `Create i18n key "${key}" in ${fileCount} locale file(s)`,
|
|
185
|
+
kind: CodeActionKind.QuickFix,
|
|
186
|
+
diagnostics: [diagnostic],
|
|
187
|
+
edit: { changes },
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
return actions;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// A locale backed by a single flat .json file (not a nested directory locale).
|
|
194
|
+
function flatLocaleFile(locale) {
|
|
195
|
+
if (!locale || !/\.json$/i.test(locale.path || '')) return undefined;
|
|
196
|
+
const files = locale.files?.length ? locale.files : [locale.path];
|
|
197
|
+
return files.length === 1 && files[0] === locale.path ? locale.path : undefined;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function fullDocumentEdit(oldText, newText) {
|
|
201
|
+
const lines = oldText.split('\n');
|
|
202
|
+
const endLine = lines.length - 1;
|
|
203
|
+
return { range: { start: { line: 0, character: 0 }, end: { line: endLine, character: lines[endLine].length } }, newText };
|
|
204
|
+
}
|
|
205
|
+
|
|
106
206
|
function getProjectContext(uri) {
|
|
107
207
|
loadConfigIfChanged();
|
|
108
208
|
const filePath = uri ? fileURLToPath(uri) : workspaceRoot;
|
|
@@ -178,27 +278,105 @@ function loadConfigIfChanged() {
|
|
|
178
278
|
|
|
179
279
|
function loadConfig() {
|
|
180
280
|
const configPath = resolveConfigPath(workspaceRoot);
|
|
281
|
+
const settingsConfig = zedSettingsConfig;
|
|
181
282
|
configMtimeMs = fs.existsSync(configPath) ? fs.statSync(configPath).mtimeMs : 0;
|
|
182
283
|
if (!configMtimeMs) {
|
|
183
|
-
config = normalizeI18nLensConfig();
|
|
284
|
+
config = normalizeI18nLensConfig(settingsConfig);
|
|
184
285
|
return;
|
|
185
286
|
}
|
|
186
287
|
try {
|
|
187
|
-
|
|
288
|
+
const fileConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
289
|
+
config = normalizeI18nLensConfig(mergeI18nLensConfig(settingsConfig, fileConfig));
|
|
188
290
|
} catch (error) {
|
|
189
291
|
connection.console.warn('Failed to load ' + CONFIG_RELATIVE_PATH + ': ' + error.message);
|
|
190
|
-
config = normalizeI18nLensConfig();
|
|
292
|
+
config = normalizeI18nLensConfig(settingsConfig);
|
|
191
293
|
}
|
|
192
294
|
}
|
|
193
295
|
|
|
296
|
+
function normalizeIncomingSettings(value) {
|
|
297
|
+
if (!value || typeof value !== 'object') return {};
|
|
298
|
+
return value['i18n-lens'] && typeof value['i18n-lens'] === 'object' ? value['i18n-lens'] : value;
|
|
299
|
+
}
|
|
300
|
+
|
|
194
301
|
function validate(document) {
|
|
195
|
-
if (!isSourceFile(document.uri)) return;
|
|
196
302
|
const context = getProjectContext(document.uri);
|
|
197
303
|
const { locales } = loadLocalesForContext(context);
|
|
198
|
-
|
|
304
|
+
if (isSourceFile(document.uri)) {
|
|
305
|
+
connection.sendDiagnostics({ uri: document.uri, diagnostics: getDiagnostics(document.getText(), locales) });
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const locale = findLocaleForFile(locales, fileURLToPath(document.uri));
|
|
309
|
+
if (!locale) return;
|
|
310
|
+
connection.sendDiagnostics({ uri: document.uri, diagnostics: getLocaleParamDiagnostics(locale, fileURLToPath(document.uri), document.getText(), locales) });
|
|
199
311
|
}
|
|
200
312
|
function isSourceFile(uri) { return /\.(vue|tsx?|jsx?)$/i.test(uri); }
|
|
201
313
|
|
|
314
|
+
const IGNORED_SCAN_DIRS = new Set(['node_modules', 'dist', 'build', 'out', 'coverage', 'vendor', '.output', '.next', '.nuxt', '.cache']);
|
|
315
|
+
const MAX_SCAN_FILES = 5000;
|
|
316
|
+
|
|
317
|
+
// Resolve the i18n key under `position`, whether the caret sits on a t()/keypath
|
|
318
|
+
// usage in source code or on a key inside a locale JSON/TS file.
|
|
319
|
+
function resolveKeyAtPosition(uri, position, context) {
|
|
320
|
+
const filePath = fileURLToPath(uri);
|
|
321
|
+
const text = documents.get(uri)?.getText() ?? readFileSafe(filePath);
|
|
322
|
+
if (!text) return undefined;
|
|
323
|
+
if (isSourceFile(uri)) return keyAtPosition(text, position)?.key;
|
|
324
|
+
const { locales } = loadLocalesForContext(context);
|
|
325
|
+
const locale = findLocaleForFile(locales, filePath);
|
|
326
|
+
return locale ? localeKeyAtPosition(locale, filePath, text, position) : undefined;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function findLocaleForFile(locales, filePath) {
|
|
330
|
+
const target = path.resolve(filePath);
|
|
331
|
+
for (const locale of Object.values(locales)) {
|
|
332
|
+
const files = locale.files?.length ? locale.files : (locale.path ? [locale.path] : []);
|
|
333
|
+
if (files.some((file) => path.resolve(file) === target)) return locale;
|
|
334
|
+
}
|
|
335
|
+
return undefined;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Every source-code usage of `key` within the project/package root. Prefers the
|
|
339
|
+
// in-memory text of open (possibly unsaved) documents over the on-disk copy.
|
|
340
|
+
function collectCodeReferences(context, key) {
|
|
341
|
+
const openTextByPath = new Map();
|
|
342
|
+
for (const doc of documents.all()) {
|
|
343
|
+
try { openTextByPath.set(path.resolve(fileURLToPath(doc.uri)), doc.getText()); } catch { /* non-file uri */ }
|
|
344
|
+
}
|
|
345
|
+
const locations = [];
|
|
346
|
+
for (const filePath of collectSourceFiles(context.root)) {
|
|
347
|
+
const text = openTextByPath.get(path.resolve(filePath)) ?? readFileSafe(filePath);
|
|
348
|
+
if (!text) continue;
|
|
349
|
+
for (const range of findCodeKeyRanges(text, key)) {
|
|
350
|
+
locations.push({ uri: pathToFileURL(filePath).toString(), range });
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return locations;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function collectSourceFiles(root) {
|
|
357
|
+
const files = [];
|
|
358
|
+
const stack = [root];
|
|
359
|
+
while (stack.length && files.length < MAX_SCAN_FILES) {
|
|
360
|
+
const dir = stack.pop();
|
|
361
|
+
let entries;
|
|
362
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
|
363
|
+
for (const entry of entries) {
|
|
364
|
+
const full = path.join(dir, entry.name);
|
|
365
|
+
if (entry.isDirectory()) {
|
|
366
|
+
if (entry.name.startsWith('.') || IGNORED_SCAN_DIRS.has(entry.name)) continue;
|
|
367
|
+
stack.push(full);
|
|
368
|
+
} else if (isSourceFile(entry.name)) {
|
|
369
|
+
files.push(full);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return files;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function readFileSafe(filePath) {
|
|
377
|
+
try { return fs.readFileSync(filePath, 'utf8'); } catch { return undefined; }
|
|
378
|
+
}
|
|
379
|
+
|
|
202
380
|
function getAllConfiguredContexts() {
|
|
203
381
|
loadConfigIfChanged();
|
|
204
382
|
return [resolveProjectContext(workspaceRoot, workspaceRoot, config), ...config.packages.map((pkg) => ({
|