i18n-lens-language-server 0.8.1 → 0.9.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 +81 -42
- package/package.json +1 -1
- package/server/core.js +240 -15
- package/server/index.js +33 -5
package/README.md
CHANGED
|
@@ -7,12 +7,40 @@ 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 keys missing
|
|
10
|
+
- Diagnostics: report missing keys, missing locale entries, and mismatched interpolation params
|
|
11
|
+
- Quick fixes: add missing interpolation params to supported i18n calls
|
|
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
|
|
13
14
|
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
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.
|
|
18
|
+
|
|
19
|
+
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.
|
|
20
|
+
|
|
21
|
+
## Quick start
|
|
22
|
+
|
|
23
|
+
For most projects, add this to your project `.zed/settings.json` or user-level Zed settings:
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{
|
|
27
|
+
"lsp": {
|
|
28
|
+
"i18n-lens": {
|
|
29
|
+
"settings": {
|
|
30
|
+
"defaultLocale": "en-US",
|
|
31
|
+
"localeDirs": ["src/locales", "src/i18n"]
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
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.
|
|
39
|
+
|
|
14
40
|
## Inline translations
|
|
15
41
|
|
|
42
|
+
> **Important:** Inline translations are rendered through Zed's LSP inlay hints. In Zed settings, make sure **Inlay Hints** are enabled and **Inlay Hints: Show Other Hints** is turned on. If **Show Other Hints** is disabled, hover/completion/diagnostics can still work, but inline translation text will not be displayed.
|
|
43
|
+
|
|
16
44
|
When inlay hints are enabled in Zed, code like this:
|
|
17
45
|
|
|
18
46
|
```ts
|
|
@@ -25,7 +53,7 @@ will show the default locale translation inline after the key string, for exampl
|
|
|
25
53
|
t("order.pay_now") 立即支付
|
|
26
54
|
```
|
|
27
55
|
|
|
28
|
-
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.
|
|
56
|
+
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.
|
|
29
57
|
|
|
30
58
|
Long translations are truncated inline to keep the editor readable. Hover still shows the full locale table.
|
|
31
59
|
|
|
@@ -39,10 +67,13 @@ To open a specific language file, use Go to Definition (Ctrl/Cmd+click or F12) o
|
|
|
39
67
|
|
|
40
68
|
Diagnostics refresh automatically when loaded locale files or locale directories change, so editing translation files should update warnings/errors without restarting the extension.
|
|
41
69
|
|
|
42
|
-
Diagnostics distinguish between
|
|
70
|
+
Diagnostics distinguish between these cases:
|
|
43
71
|
|
|
44
72
|
- If a key is missing from every loaded locale, it is reported as an error.
|
|
45
73
|
- 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.
|
|
74
|
+
- If a translation contains named placeholders such as `{count}` but the source call does not pass them, it reports missing params.
|
|
75
|
+
- If the source call passes params that no loaded translation uses, it reports unused params.
|
|
76
|
+
- Quick fixes can add missing params to supported i18n calls.
|
|
46
77
|
|
|
47
78
|
For example, if `order.pay_now` exists in `zh-CN` but is missing from `en-US`, the warning message is:
|
|
48
79
|
|
|
@@ -52,25 +83,57 @@ Missing i18n key "order.pay_now" in locales: en-US
|
|
|
52
83
|
|
|
53
84
|
## Project configuration
|
|
54
85
|
|
|
55
|
-
|
|
86
|
+
I18n Lens supports two configuration sources:
|
|
87
|
+
|
|
88
|
+
1. Zed settings, under `lsp.i18n-lens.settings`.
|
|
89
|
+
2. A project file, `.zed/i18nlensrc.json`.
|
|
90
|
+
|
|
91
|
+
The project file is merged on top of Zed settings, so repository-specific configuration can override personal or team-wide editor settings.
|
|
92
|
+
|
|
93
|
+
### Configure from Zed settings
|
|
94
|
+
|
|
95
|
+
Use this when you want configuration to live in Zed's normal settings system:
|
|
56
96
|
|
|
57
97
|
```json
|
|
58
98
|
{
|
|
59
|
-
"
|
|
60
|
-
|
|
99
|
+
"lsp": {
|
|
100
|
+
"i18n-lens": {
|
|
101
|
+
"settings": {
|
|
102
|
+
"defaultLocale": "en-US",
|
|
103
|
+
"localeDirs": ["src/locales", "src/i18n"],
|
|
104
|
+
"inlayHints": {
|
|
105
|
+
"enabled": true,
|
|
106
|
+
"maxLength": 32
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Zed settings can be user-level or project-level. For project-level Zed settings, put the snippet in `.zed/settings.json`.
|
|
115
|
+
|
|
116
|
+
### Configure from `.zed/i18nlensrc.json`
|
|
117
|
+
|
|
118
|
+
Use this when the i18n layout should be part of the repository and shared by everyone who opens it:
|
|
119
|
+
|
|
120
|
+
```json
|
|
121
|
+
{
|
|
122
|
+
"defaultLocale": "en-US",
|
|
123
|
+
"localeDirs": ["src/locales", "src/i18n"],
|
|
61
124
|
"inlayHints": {
|
|
62
125
|
"enabled": true,
|
|
63
|
-
"maxLength":
|
|
126
|
+
"maxLength": 32
|
|
64
127
|
},
|
|
65
128
|
"packages": [
|
|
66
129
|
{
|
|
67
130
|
"root": "apps/web",
|
|
68
|
-
"defaultLocale": "
|
|
131
|
+
"defaultLocale": "en-US",
|
|
69
132
|
"localeDirs": ["src/locales"]
|
|
70
133
|
},
|
|
71
134
|
{
|
|
72
135
|
"root": "packages/admin",
|
|
73
|
-
"defaultLocale": "
|
|
136
|
+
"defaultLocale": "ja-JP",
|
|
74
137
|
"localeDirs": ["src/i18n"]
|
|
75
138
|
}
|
|
76
139
|
]
|
|
@@ -88,31 +151,31 @@ Supported options:
|
|
|
88
151
|
| Option | Type | Default | Description |
|
|
89
152
|
|---|---|---|---|
|
|
90
153
|
| `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`. |
|
|
91
|
-
| `localeDirs` | string[] | `src/locales`, `src/i18n`, `locales`, `i18n` | Workspace-relative directories to scan for locale files. |
|
|
92
|
-
| `inlayHints.enabled` | boolean | `true` | Enables or disables inline translation hints from this language server. |
|
|
93
|
-
| `inlayHints.maxLength` | number | `24` | Maximum inline hint label length before truncation. |
|
|
154
|
+
| `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. |
|
|
155
|
+
| `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. |
|
|
156
|
+
| `inlayHints.maxLength` | number | `24` | Maximum inline hint label length before truncation. Hover still shows the full text. |
|
|
94
157
|
| `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. |
|
|
95
158
|
|
|
96
|
-
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.
|
|
159
|
+
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.
|
|
97
160
|
|
|
98
|
-
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.
|
|
161
|
+
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.
|
|
99
162
|
|
|
100
163
|
## Locale discovery
|
|
101
164
|
|
|
102
|
-
In a single-project workspace, `localeDirs` are resolved relative to the workspace root.
|
|
165
|
+
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:
|
|
103
166
|
|
|
104
167
|
- `src/locales/*.json`
|
|
105
168
|
- `src/i18n/*.json`
|
|
106
169
|
- `locales/*.json`
|
|
107
170
|
- `i18n/*.json`
|
|
108
171
|
|
|
109
|
-
Locale
|
|
172
|
+
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"]`.
|
|
110
173
|
|
|
111
174
|
It also supports locale directories such as:
|
|
112
175
|
|
|
113
176
|
```text
|
|
114
|
-
src/locales/zh-CN/common.json
|
|
115
177
|
src/locales/en-US/common.json
|
|
178
|
+
src/locales/ja-JP/common.json
|
|
116
179
|
```
|
|
117
180
|
|
|
118
181
|
A file named `common.json` contributes keys with the `common.` prefix. A file named `index.json` contributes keys without an `index.` prefix.
|
|
@@ -123,7 +186,7 @@ For pnpm/turborepo and other monorepos, put `.zed/i18nlensrc.json` under the wor
|
|
|
123
186
|
|
|
124
187
|
```json
|
|
125
188
|
{
|
|
126
|
-
"defaultLocale": "
|
|
189
|
+
"defaultLocale": "en-US",
|
|
127
190
|
"packages": [
|
|
128
191
|
{
|
|
129
192
|
"root": "apps/web",
|
|
@@ -180,8 +243,6 @@ On this Windows setup, if `cargo` is not visible inside Git Bash / WSL, use:
|
|
|
180
243
|
4. Open a Vue/TS/JS project with locale JSON files.
|
|
181
244
|
5. Test hover, completion, diagnostics, definition, and inlay hints.
|
|
182
245
|
|
|
183
|
-
If inline translations do not appear, check whether inlay hints are enabled in Zed settings.
|
|
184
|
-
|
|
185
246
|
### Iterating on the language server
|
|
186
247
|
|
|
187
248
|
The published npm version can lag behind your working tree. To test local `server/*.js` changes without publishing, pack the current tree and install it into the Zed work directory, then restart the server:
|
|
@@ -194,26 +255,4 @@ It runs `npm pack`, installs the tarball into `%LOCALAPPDATA%\Zed\extensions\wor
|
|
|
194
255
|
|
|
195
256
|
> To exercise the real install path instead, delete `%LOCALAPPDATA%\Zed\extensions\work\i18n-lens\node_modules` and restart the server — Zed will reinstall the published npm package.
|
|
196
257
|
|
|
197
|
-
## Releasing
|
|
198
|
-
|
|
199
|
-
The version is single-sourced across `Cargo.toml`, `extension.toml`, and
|
|
200
|
-
`package.json` (they must all match — the release workflow enforces this). To
|
|
201
|
-
cut a release:
|
|
202
|
-
|
|
203
|
-
1. Bump `version` in `Cargo.toml`, `extension.toml`, and `package.json`.
|
|
204
|
-
2. Tag and push:
|
|
205
|
-
|
|
206
|
-
```bash
|
|
207
|
-
git tag v<version> # e.g. v0.7.0
|
|
208
|
-
git push origin v<version>
|
|
209
|
-
```
|
|
210
|
-
|
|
211
|
-
The `.github/workflows/release.yml` workflow then verifies the versions
|
|
212
|
-
match, runs the tests, and publishes the language server to npm as
|
|
213
|
-
`i18n-lens-language-server`. This requires an `NPM_TOKEN` repository secret
|
|
214
|
-
with publish rights.
|
|
215
258
|
|
|
216
|
-
To publish manually instead: `npm publish --access public`.
|
|
217
|
-
3. Submit/update the extension in `zed-industries/extensions` (bump the
|
|
218
|
-
`version` in its `extensions.toml` to match) so Zed picks up the new
|
|
219
|
-
extension version, which installs the new npm package.
|
package/package.json
CHANGED
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()
|
|
@@ -122,11 +134,9 @@ function makeRange(text, start, end) {
|
|
|
122
134
|
|
|
123
135
|
export function extractI18nKeys(text) {
|
|
124
136
|
const found = [];
|
|
137
|
+
collectFunctionCallKeys(text, found);
|
|
138
|
+
collectFormatMessageKeys(text, found);
|
|
125
139
|
const patterns = [
|
|
126
|
-
// t/$t/tc/$tc/i18n.t/i18n.tc(...) — function calls, incl. Vue I18n plural tc
|
|
127
|
-
/(?:\x24tc?|\btc?|\bi18n\.tc?)\(\s*(['"])([A-Za-z0-9_.:-]+)\1/g,
|
|
128
|
-
// react-intl: formatMessage({ id: "key" }) (id may not be the first prop)
|
|
129
|
-
/\bformatMessage\s*\(\s*\{[^}]*?\bid\s*:\s*(['"])([A-Za-z0-9_.:-]+)\1/g,
|
|
130
140
|
// <i18n-t keypath="key"> (Vue I18n) and <Trans i18nKey="key"> (react-i18next)
|
|
131
141
|
/\b(?:keypath|i18nKey)\s*=\s*(['"])([A-Za-z0-9_.:-]+)\1/g,
|
|
132
142
|
/\bv-t\s*=\s*"'([A-Za-z0-9_.:-]+)'"/g,
|
|
@@ -146,6 +156,143 @@ export function extractI18nKeys(text) {
|
|
|
146
156
|
return found;
|
|
147
157
|
}
|
|
148
158
|
|
|
159
|
+
function collectFunctionCallKeys(text, found) {
|
|
160
|
+
// t/$t/tc/$tc/i18n.t/i18n.tc(...) — function calls, incl. Vue I18n plural tc
|
|
161
|
+
const re = /(?:\x24tc?|\btc?|\bi18n\.tc?)\s*\(/g;
|
|
162
|
+
let match;
|
|
163
|
+
while ((match = re.exec(text))) {
|
|
164
|
+
const parenOffset = re.lastIndex - 1;
|
|
165
|
+
const callText = readParens(text, parenOffset);
|
|
166
|
+
if (!callText) continue;
|
|
167
|
+
const keyMatch = callText.match(/^\(\s*(['"])([A-Za-z0-9_.:-]+)\1/);
|
|
168
|
+
if (!keyMatch) continue;
|
|
169
|
+
const key = keyMatch[2];
|
|
170
|
+
const start = parenOffset + keyMatch[0].indexOf(key);
|
|
171
|
+
const end = start + key.length;
|
|
172
|
+
const args = splitArgsWithOffsets(callText.slice(1, -1));
|
|
173
|
+
const paramsEdit = getParamsEdit(args.slice(1), parenOffset + 1, parenOffset + callText.length - 1);
|
|
174
|
+
found.push({ key, range: makeRange(text, start, end), startOffset: start, endOffset: end, providedParams: collectParamNames(args.slice(1)), paramsEdit });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function collectFormatMessageKeys(text, found) {
|
|
179
|
+
const re = /\bformatMessage\s*\(/g;
|
|
180
|
+
let match;
|
|
181
|
+
while ((match = re.exec(text))) {
|
|
182
|
+
const parenOffset = re.lastIndex - 1;
|
|
183
|
+
const callText = readParens(text, parenOffset);
|
|
184
|
+
if (!callText) continue;
|
|
185
|
+
const args = splitArgsWithOffsets(callText.slice(1, -1));
|
|
186
|
+
const idArg = args[0]?.text || '';
|
|
187
|
+
const keyMatch = idArg.match(/\bid\s*:\s*(['"])([A-Za-z0-9_.:-]+)\1/);
|
|
188
|
+
if (!keyMatch) continue;
|
|
189
|
+
const key = keyMatch[2];
|
|
190
|
+
const start = parenOffset + 1 + idArg.indexOf(key);
|
|
191
|
+
const end = start + key.length;
|
|
192
|
+
const paramsEdit = getParamsEdit(args.slice(1), parenOffset + 1, parenOffset + callText.length - 1);
|
|
193
|
+
found.push({ key, range: makeRange(text, start, end), startOffset: start, endOffset: end, providedParams: collectParamNames(args.slice(1)), paramsEdit });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Read a balanced (...) starting at `start`.
|
|
198
|
+
function readParens(text, start) {
|
|
199
|
+
if (text[start] !== '(') return undefined;
|
|
200
|
+
let depth = 0;
|
|
201
|
+
let quote = '';
|
|
202
|
+
let escaped = false;
|
|
203
|
+
for (let i = start; i < text.length; i++) {
|
|
204
|
+
const ch = text[i];
|
|
205
|
+
if (quote) {
|
|
206
|
+
if (escaped) escaped = false;
|
|
207
|
+
else if (ch === '\\') escaped = true;
|
|
208
|
+
else if (ch === quote) quote = '';
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (ch === '"' || ch === "'" || ch === '`') { quote = ch; continue; }
|
|
212
|
+
if (ch === '(' || ch === '{' || ch === '[') depth++;
|
|
213
|
+
else if (ch === ')' || ch === '}' || ch === ']') {
|
|
214
|
+
depth--;
|
|
215
|
+
if (depth === 0) return text.slice(start, i + 1);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function splitArgs(text) {
|
|
222
|
+
const args = [];
|
|
223
|
+
let start = 0, depth = 0, quote = '', escaped = false;
|
|
224
|
+
for (let i = 0; i < text.length; i++) {
|
|
225
|
+
const ch = text[i];
|
|
226
|
+
if (quote) {
|
|
227
|
+
if (escaped) escaped = false;
|
|
228
|
+
else if (ch === '\\') escaped = true;
|
|
229
|
+
else if (ch === quote) quote = '';
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (ch === '"' || ch === "'" || ch === '`') { quote = ch; continue; }
|
|
233
|
+
if (ch === '{' || ch === '[' || ch === '(') depth++;
|
|
234
|
+
else if (ch === '}' || ch === ']' || ch === ')') depth--;
|
|
235
|
+
else if (ch === ',' && depth === 0) { args.push(text.slice(start, i).trim()); start = i + 1; }
|
|
236
|
+
}
|
|
237
|
+
const last = text.slice(start).trim();
|
|
238
|
+
if (last) args.push(last);
|
|
239
|
+
return args;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function splitArgsWithOffsets(text) {
|
|
243
|
+
const args = [];
|
|
244
|
+
let start = 0, depth = 0, quote = '', escaped = false;
|
|
245
|
+
for (let i = 0; i < text.length; i++) {
|
|
246
|
+
const ch = text[i];
|
|
247
|
+
if (quote) {
|
|
248
|
+
if (escaped) escaped = false;
|
|
249
|
+
else if (ch === '\\') escaped = true;
|
|
250
|
+
else if (ch === quote) quote = '';
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (ch === '"' || ch === "'" || ch === '`') { quote = ch; continue; }
|
|
254
|
+
if (ch === '{' || ch === '[' || ch === '(') depth++;
|
|
255
|
+
else if (ch === '}' || ch === ']' || ch === ')') depth--;
|
|
256
|
+
else if (ch === ',' && depth === 0) { args.push(trimArg(text, start, i)); start = i + 1; }
|
|
257
|
+
}
|
|
258
|
+
const last = trimArg(text, start, text.length);
|
|
259
|
+
if (last.text) args.push(last);
|
|
260
|
+
return args;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function trimArg(text, start, end) {
|
|
264
|
+
while (start < end && /\s/.test(text[start])) start++;
|
|
265
|
+
while (end > start && /\s/.test(text[end - 1])) end--;
|
|
266
|
+
return { text: text.slice(start, end), start, end };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function getParamsEdit(args, argsStartOffset, closingParenOffset) {
|
|
270
|
+
const objectArg = args.find((arg) => arg.text.startsWith('{') && arg.text.lastIndexOf('}') > 0);
|
|
271
|
+
if (objectArg) {
|
|
272
|
+
const closeBrace = objectArg.text.lastIndexOf('}');
|
|
273
|
+
let insertAt = closeBrace;
|
|
274
|
+
while (insertAt > 1 && /\s/.test(objectArg.text[insertAt - 1])) insertAt--;
|
|
275
|
+
const body = objectArg.text.slice(1, insertAt);
|
|
276
|
+
return { insertOffset: argsStartOffset + objectArg.start + insertAt, prefix: body.trim() ? ', ' : ' ' };
|
|
277
|
+
}
|
|
278
|
+
return { insertOffset: closingParenOffset, prefix: ', { ', suffix: ' }' };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function collectParamNames(args) {
|
|
282
|
+
const params = new Set();
|
|
283
|
+
for (const arg of args) {
|
|
284
|
+
const trimmed = (typeof arg === 'string' ? arg : arg.text).trim();
|
|
285
|
+
if (!trimmed.startsWith('{')) continue;
|
|
286
|
+
const body = trimmed.slice(1, trimmed.lastIndexOf('}'));
|
|
287
|
+
for (const part of splitArgs(body)) {
|
|
288
|
+
const m = part.trim().match(/^(?:['"]([^'"]+)['"]|([A-Za-z_$][\w$]*))\s*(?::|$)/);
|
|
289
|
+
const name = m && (m[1] || m[2]);
|
|
290
|
+
if (name && !part.trim().startsWith('...')) params.add(name);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return [...params].sort();
|
|
294
|
+
}
|
|
295
|
+
|
|
149
296
|
export function keyAtPosition(text, position) {
|
|
150
297
|
const offset = positionToOffset(text, position);
|
|
151
298
|
return extractI18nKeys(text).find((item) => item.startOffset <= offset && offset <= item.endOffset);
|
|
@@ -221,16 +368,16 @@ export function getDiagnostics(text, locales) {
|
|
|
221
368
|
const localeEntries = Object.entries(locales);
|
|
222
369
|
if (localeEntries.length === 0) return [];
|
|
223
370
|
|
|
224
|
-
return extractI18nKeys(text)
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
.sort();
|
|
371
|
+
return extractI18nKeys(text).flatMap((item) => {
|
|
372
|
+
const missingLocales = localeEntries
|
|
373
|
+
.filter(([, locale]) => !Object.prototype.hasOwnProperty.call(locale.flat, item.key))
|
|
374
|
+
.map(([name]) => name)
|
|
375
|
+
.sort();
|
|
230
376
|
|
|
231
|
-
|
|
377
|
+
const diagnostics = [];
|
|
378
|
+
if (missingLocales.length > 0) {
|
|
232
379
|
const missingInAllLocales = missingLocales.length === localeEntries.length;
|
|
233
|
-
|
|
380
|
+
diagnostics.push({
|
|
234
381
|
severity: missingInAllLocales ? 1 : 2,
|
|
235
382
|
range: item.range,
|
|
236
383
|
message: missingInAllLocales
|
|
@@ -238,9 +385,87 @@ export function getDiagnostics(text, locales) {
|
|
|
238
385
|
: 'Missing i18n key "' + item.key + '" in locales: ' + missingLocales.join(', '),
|
|
239
386
|
source: 'i18n-lens',
|
|
240
387
|
data: { key: item.key, missingLocales, missingInAllLocales },
|
|
241
|
-
};
|
|
242
|
-
}
|
|
243
|
-
.
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
if (missingLocales.length !== localeEntries.length) diagnostics.push(...getParamDiagnostics(item, localeEntries));
|
|
391
|
+
return diagnostics;
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function getParamDiagnostics(item, localeEntries) {
|
|
396
|
+
if (!Array.isArray(item.providedParams)) return [];
|
|
397
|
+
const required = collectRequiredParams(item.key, localeEntries);
|
|
398
|
+
const provided = new Set(item.providedParams);
|
|
399
|
+
const missing = required.filter((n) => !provided.has(n));
|
|
400
|
+
const unused = item.providedParams.filter((n) => !required.includes(n));
|
|
401
|
+
const diagnostics = [];
|
|
402
|
+
if (missing.length > 0)
|
|
403
|
+
diagnostics.push({ severity: 2, range: item.range, message: 'Missing i18n params for "' + item.key + '": ' + missing.join(', '), source: 'i18n-lens', data: { key: item.key, missingParams: missing } });
|
|
404
|
+
if (unused.length > 0)
|
|
405
|
+
diagnostics.push({ severity: 2, range: item.range, message: 'Unused i18n params for "' + item.key + '": ' + unused.join(', '), source: 'i18n-lens', data: { key: item.key, unusedParams: unused } });
|
|
406
|
+
return diagnostics;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function collectRequiredParams(key, localeEntries) {
|
|
410
|
+
const params = new Set();
|
|
411
|
+
for (const [, locale] of localeEntries) {
|
|
412
|
+
const value = locale.flat[key];
|
|
413
|
+
if (value === undefined) continue;
|
|
414
|
+
for (const name of extractPlaceholders(value)) params.add(name);
|
|
415
|
+
}
|
|
416
|
+
return [...params].sort();
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export function getParamCodeActions(text, diagnostics = []) {
|
|
420
|
+
const items = extractI18nKeys(text);
|
|
421
|
+
return diagnostics.flatMap((diagnostic) => {
|
|
422
|
+
const missingParams = diagnostic.data?.missingParams;
|
|
423
|
+
if (!Array.isArray(missingParams) || missingParams.length === 0) return [];
|
|
424
|
+
const item = items.find((candidate) => candidate.key === diagnostic.data?.key && sameRange(candidate.range, diagnostic.range));
|
|
425
|
+
if (!item?.paramsEdit) return [];
|
|
426
|
+
const newText = item.paramsEdit.prefix + missingParams.join(', ') + (item.paramsEdit.suffix || '');
|
|
427
|
+
return [{
|
|
428
|
+
title: 'Add missing i18n params: ' + missingParams.join(', '),
|
|
429
|
+
diagnostic,
|
|
430
|
+
edit: { range: makeRange(text, item.paramsEdit.insertOffset, item.paramsEdit.insertOffset), newText },
|
|
431
|
+
}];
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function sameRange(a, b) {
|
|
436
|
+
return a?.start?.line === b?.start?.line
|
|
437
|
+
&& a?.start?.character === b?.start?.character
|
|
438
|
+
&& a?.end?.line === b?.end?.line
|
|
439
|
+
&& a?.end?.character === b?.end?.character;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export function extractPlaceholders(value) {
|
|
443
|
+
const params = new Set();
|
|
444
|
+
// ICU-style: {name, plural, ...} — extract top-level name, skip nested content
|
|
445
|
+
let text = String(value);
|
|
446
|
+
let cursor = 0;
|
|
447
|
+
while (cursor < text.length) {
|
|
448
|
+
const open = text.indexOf('{', cursor);
|
|
449
|
+
if (open === -1) break;
|
|
450
|
+
const head = text.slice(open).match(/^\{\s*([A-Za-z_$][\w$]*)\s*,/);
|
|
451
|
+
if (head) {
|
|
452
|
+
params.add(head[1]);
|
|
453
|
+
let depth = 0, i = open;
|
|
454
|
+
while (i < text.length) {
|
|
455
|
+
if (text[i] === '{') depth++;
|
|
456
|
+
else if (text[i] === '}') { depth--; if (depth === 0) break; }
|
|
457
|
+
i++;
|
|
458
|
+
}
|
|
459
|
+
text = text.slice(0, open) + ' '.repeat(i - open + 1) + text.slice(i + 1);
|
|
460
|
+
cursor = open;
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
cursor = open + 1;
|
|
464
|
+
}
|
|
465
|
+
const re = /\{\s*([A-Za-z_$][\w$]*)\s*\}/g;
|
|
466
|
+
let m;
|
|
467
|
+
while ((m = re.exec(text))) params.add(m[1]);
|
|
468
|
+
return [...params].sort();
|
|
244
469
|
}
|
|
245
470
|
|
|
246
471
|
export function resolvePreferredLocale(locales, defaultLocale) {
|
package/server/index.js
CHANGED
|
@@ -2,12 +2,13 @@
|
|
|
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
|
+
getParamCodeActions,
|
|
11
12
|
getCompletions,
|
|
12
13
|
getCompletionPrefix,
|
|
13
14
|
keyAtPosition,
|
|
@@ -15,6 +16,7 @@ import {
|
|
|
15
16
|
collectLocaleKeyTargets,
|
|
16
17
|
getInlayHints,
|
|
17
18
|
normalizeI18nLensConfig,
|
|
19
|
+
mergeI18nLensConfig,
|
|
18
20
|
resolveConfigPath,
|
|
19
21
|
CONFIG_RELATIVE_PATH,
|
|
20
22
|
didWatchedFileChange,
|
|
@@ -25,6 +27,7 @@ import {
|
|
|
25
27
|
const connection = createConnection(ProposedFeatures.all);
|
|
26
28
|
const documents = new TextDocuments(TextDocument);
|
|
27
29
|
let workspaceRoot = process.cwd();
|
|
30
|
+
let zedSettingsConfig = {};
|
|
28
31
|
let config = normalizeI18nLensConfig();
|
|
29
32
|
let watchedConfigPath;
|
|
30
33
|
const watchedLocalePaths = new Set();
|
|
@@ -37,9 +40,10 @@ const reloadDebounceMs = 100;
|
|
|
37
40
|
|
|
38
41
|
connection.onInitialize((params) => {
|
|
39
42
|
workspaceRoot = params.workspaceFolders?.[0]?.uri ? fileURLToPath(params.workspaceFolders[0].uri) : (params.rootUri ? fileURLToPath(params.rootUri) : process.cwd());
|
|
43
|
+
zedSettingsConfig = normalizeIncomingSettings(params.initializationOptions);
|
|
40
44
|
loadConfig();
|
|
41
45
|
watchConfigFile();
|
|
42
|
-
return { capabilities: { textDocumentSync: TextDocumentSyncKind.Incremental, hoverProvider: true, completionProvider: { triggerCharacters: ['.', '"', "'"] }, definitionProvider: true, inlayHintProvider: true } };
|
|
46
|
+
return { capabilities: { textDocumentSync: TextDocumentSyncKind.Incremental, hoverProvider: true, completionProvider: { triggerCharacters: ['.', '"', "'"] }, definitionProvider: true, inlayHintProvider: true, codeActionProvider: true } };
|
|
43
47
|
});
|
|
44
48
|
|
|
45
49
|
connection.onInitialized(() => {
|
|
@@ -58,6 +62,11 @@ documents.onDidOpen((event) => {
|
|
|
58
62
|
documents.onDidChangeContent((event) => validate(event.document));
|
|
59
63
|
documents.onDidSave(() => scheduleProjectReload());
|
|
60
64
|
|
|
65
|
+
connection.onDidChangeConfiguration((params) => {
|
|
66
|
+
zedSettingsConfig = normalizeIncomingSettings(params.settings);
|
|
67
|
+
scheduleProjectReload();
|
|
68
|
+
});
|
|
69
|
+
|
|
61
70
|
connection.onHover((params) => {
|
|
62
71
|
const doc = documents.get(params.textDocument.uri);
|
|
63
72
|
if (!doc) return null;
|
|
@@ -103,6 +112,18 @@ connection.onDefinition((params) => {
|
|
|
103
112
|
return locations.length ? locations : null;
|
|
104
113
|
});
|
|
105
114
|
|
|
115
|
+
connection.onCodeAction((params) => {
|
|
116
|
+
const doc = documents.get(params.textDocument.uri);
|
|
117
|
+
if (!doc) return [];
|
|
118
|
+
return getParamCodeActions(doc.getText(), params.context.diagnostics)
|
|
119
|
+
.map((action) => ({
|
|
120
|
+
title: action.title,
|
|
121
|
+
kind: CodeActionKind.QuickFix,
|
|
122
|
+
diagnostics: [action.diagnostic],
|
|
123
|
+
edit: { changes: { [params.textDocument.uri]: [action.edit] } },
|
|
124
|
+
}));
|
|
125
|
+
});
|
|
126
|
+
|
|
106
127
|
function getProjectContext(uri) {
|
|
107
128
|
loadConfigIfChanged();
|
|
108
129
|
const filePath = uri ? fileURLToPath(uri) : workspaceRoot;
|
|
@@ -178,19 +199,26 @@ function loadConfigIfChanged() {
|
|
|
178
199
|
|
|
179
200
|
function loadConfig() {
|
|
180
201
|
const configPath = resolveConfigPath(workspaceRoot);
|
|
202
|
+
const settingsConfig = zedSettingsConfig;
|
|
181
203
|
configMtimeMs = fs.existsSync(configPath) ? fs.statSync(configPath).mtimeMs : 0;
|
|
182
204
|
if (!configMtimeMs) {
|
|
183
|
-
config = normalizeI18nLensConfig();
|
|
205
|
+
config = normalizeI18nLensConfig(settingsConfig);
|
|
184
206
|
return;
|
|
185
207
|
}
|
|
186
208
|
try {
|
|
187
|
-
|
|
209
|
+
const fileConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
210
|
+
config = normalizeI18nLensConfig(mergeI18nLensConfig(settingsConfig, fileConfig));
|
|
188
211
|
} catch (error) {
|
|
189
212
|
connection.console.warn('Failed to load ' + CONFIG_RELATIVE_PATH + ': ' + error.message);
|
|
190
|
-
config = normalizeI18nLensConfig();
|
|
213
|
+
config = normalizeI18nLensConfig(settingsConfig);
|
|
191
214
|
}
|
|
192
215
|
}
|
|
193
216
|
|
|
217
|
+
function normalizeIncomingSettings(value) {
|
|
218
|
+
if (!value || typeof value !== 'object') return {};
|
|
219
|
+
return value['i18n-lens'] && typeof value['i18n-lens'] === 'object' ? value['i18n-lens'] : value;
|
|
220
|
+
}
|
|
221
|
+
|
|
194
222
|
function validate(document) {
|
|
195
223
|
if (!isSourceFile(document.uri)) return;
|
|
196
224
|
const context = getProjectContext(document.uri);
|