i18n-lens-language-server 0.8.0 → 0.8.2
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/LICENSE +21 -21
- package/README.md +6 -26
- package/package.json +3 -3
- package/server/core.js +164 -15
package/LICENSE
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 yizixu
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 yizixu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -7,12 +7,14 @@ 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
11
|
- Completion: suggest existing i18n keys with default locale text
|
|
12
12
|
- Definition: jump from a source key to its locale definition; when several locales define the key, pick which language file to open
|
|
13
13
|
|
|
14
14
|
## Inline translations
|
|
15
15
|
|
|
16
|
+
> **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.
|
|
17
|
+
|
|
16
18
|
When inlay hints are enabled in Zed, code like this:
|
|
17
19
|
|
|
18
20
|
```ts
|
|
@@ -39,10 +41,12 @@ To open a specific language file, use Go to Definition (Ctrl/Cmd+click or F12) o
|
|
|
39
41
|
|
|
40
42
|
Diagnostics refresh automatically when loaded locale files or locale directories change, so editing translation files should update warnings/errors without restarting the extension.
|
|
41
43
|
|
|
42
|
-
Diagnostics distinguish between
|
|
44
|
+
Diagnostics distinguish between these cases:
|
|
43
45
|
|
|
44
46
|
- If a key is missing from every loaded locale, it is reported as an error.
|
|
45
47
|
- 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
|
+
- If a translation contains named placeholders such as `{count}` but the source call does not pass them, it reports missing params.
|
|
49
|
+
- If the source call passes params that no loaded translation uses, it reports unused params.
|
|
46
50
|
|
|
47
51
|
For example, if `order.pay_now` exists in `zh-CN` but is missing from `en-US`, the warning message is:
|
|
48
52
|
|
|
@@ -180,8 +184,6 @@ On this Windows setup, if `cargo` is not visible inside Git Bash / WSL, use:
|
|
|
180
184
|
4. Open a Vue/TS/JS project with locale JSON files.
|
|
181
185
|
5. Test hover, completion, diagnostics, definition, and inlay hints.
|
|
182
186
|
|
|
183
|
-
If inline translations do not appear, check whether inlay hints are enabled in Zed settings.
|
|
184
|
-
|
|
185
187
|
### Iterating on the language server
|
|
186
188
|
|
|
187
189
|
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 +196,4 @@ It runs `npm pack`, installs the tarball into `%LOCALAPPDATA%\Zed\extensions\wor
|
|
|
194
196
|
|
|
195
197
|
> 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
198
|
|
|
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
199
|
|
|
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
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "i18n-lens-language-server",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Language server for configurable i18n inlay hints, hover, completion, diagnostics, and definition in Vue/TS/TSX/JS.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
|
-
"url": "https://github.com/yizixu/zed-i18n-lens.git"
|
|
9
|
+
"url": "git+https://github.com/yizixu/zed-i18n-lens.git"
|
|
10
10
|
},
|
|
11
11
|
"main": "server/index.js",
|
|
12
12
|
"bin": {
|
|
13
|
-
"i18n-lens-language-server": "
|
|
13
|
+
"i18n-lens-language-server": "server/index.js"
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
16
16
|
"server/*.js"
|
package/server/core.js
CHANGED
|
@@ -122,11 +122,9 @@ function makeRange(text, start, end) {
|
|
|
122
122
|
|
|
123
123
|
export function extractI18nKeys(text) {
|
|
124
124
|
const found = [];
|
|
125
|
+
collectFunctionCallKeys(text, found);
|
|
126
|
+
collectFormatMessageKeys(text, found);
|
|
125
127
|
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
128
|
// <i18n-t keypath="key"> (Vue I18n) and <Trans i18nKey="key"> (react-i18next)
|
|
131
129
|
/\b(?:keypath|i18nKey)\s*=\s*(['"])([A-Za-z0-9_.:-]+)\1/g,
|
|
132
130
|
/\bv-t\s*=\s*"'([A-Za-z0-9_.:-]+)'"/g,
|
|
@@ -146,6 +144,102 @@ export function extractI18nKeys(text) {
|
|
|
146
144
|
return found;
|
|
147
145
|
}
|
|
148
146
|
|
|
147
|
+
function collectFunctionCallKeys(text, found) {
|
|
148
|
+
// t/$t/tc/$tc/i18n.t/i18n.tc(...) — function calls, incl. Vue I18n plural tc
|
|
149
|
+
const re = /(?:\x24tc?|\btc?|\bi18n\.tc?)\s*\(/g;
|
|
150
|
+
let match;
|
|
151
|
+
while ((match = re.exec(text))) {
|
|
152
|
+
const parenOffset = re.lastIndex - 1;
|
|
153
|
+
const callText = readParens(text, parenOffset);
|
|
154
|
+
if (!callText) continue;
|
|
155
|
+
const keyMatch = callText.match(/^\(\s*(['"])([A-Za-z0-9_.:-]+)\1/);
|
|
156
|
+
if (!keyMatch) continue;
|
|
157
|
+
const key = keyMatch[2];
|
|
158
|
+
const start = parenOffset + keyMatch[0].indexOf(key);
|
|
159
|
+
const end = start + key.length;
|
|
160
|
+
const args = splitArgs(callText.slice(1, -1));
|
|
161
|
+
found.push({ key, range: makeRange(text, start, end), startOffset: start, endOffset: end, providedParams: collectParamNames(args.slice(1)) });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function collectFormatMessageKeys(text, found) {
|
|
166
|
+
const re = /\bformatMessage\s*\(/g;
|
|
167
|
+
let match;
|
|
168
|
+
while ((match = re.exec(text))) {
|
|
169
|
+
const parenOffset = re.lastIndex - 1;
|
|
170
|
+
const callText = readParens(text, parenOffset);
|
|
171
|
+
if (!callText) continue;
|
|
172
|
+
const args = splitArgs(callText.slice(1, -1));
|
|
173
|
+
const idArg = args[0] || '';
|
|
174
|
+
const keyMatch = idArg.match(/\bid\s*:\s*(['"])([A-Za-z0-9_.:-]+)\1/);
|
|
175
|
+
if (!keyMatch) continue;
|
|
176
|
+
const key = keyMatch[2];
|
|
177
|
+
const start = parenOffset + 1 + idArg.indexOf(key);
|
|
178
|
+
const end = start + key.length;
|
|
179
|
+
found.push({ key, range: makeRange(text, start, end), startOffset: start, endOffset: end, providedParams: collectParamNames(args.slice(1)) });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Read a balanced (...) starting at `start`.
|
|
184
|
+
function readParens(text, start) {
|
|
185
|
+
if (text[start] !== '(') return undefined;
|
|
186
|
+
let depth = 0;
|
|
187
|
+
let quote = '';
|
|
188
|
+
let escaped = false;
|
|
189
|
+
for (let i = start; i < text.length; i++) {
|
|
190
|
+
const ch = text[i];
|
|
191
|
+
if (quote) {
|
|
192
|
+
if (escaped) escaped = false;
|
|
193
|
+
else if (ch === '\\') escaped = true;
|
|
194
|
+
else if (ch === quote) quote = '';
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (ch === '"' || ch === "'" || ch === '`') { quote = ch; continue; }
|
|
198
|
+
if (ch === '(' || ch === '{' || ch === '[') depth++;
|
|
199
|
+
else if (ch === ')' || ch === '}' || ch === ']') {
|
|
200
|
+
depth--;
|
|
201
|
+
if (depth === 0) return text.slice(start, i + 1);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function splitArgs(text) {
|
|
208
|
+
const args = [];
|
|
209
|
+
let start = 0, depth = 0, quote = '', escaped = false;
|
|
210
|
+
for (let i = 0; i < text.length; i++) {
|
|
211
|
+
const ch = text[i];
|
|
212
|
+
if (quote) {
|
|
213
|
+
if (escaped) escaped = false;
|
|
214
|
+
else if (ch === '\\') escaped = true;
|
|
215
|
+
else if (ch === quote) quote = '';
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (ch === '"' || ch === "'" || ch === '`') { quote = ch; continue; }
|
|
219
|
+
if (ch === '{' || ch === '[' || ch === '(') depth++;
|
|
220
|
+
else if (ch === '}' || ch === ']' || ch === ')') depth--;
|
|
221
|
+
else if (ch === ',' && depth === 0) { args.push(text.slice(start, i).trim()); start = i + 1; }
|
|
222
|
+
}
|
|
223
|
+
const last = text.slice(start).trim();
|
|
224
|
+
if (last) args.push(last);
|
|
225
|
+
return args;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function collectParamNames(args) {
|
|
229
|
+
const params = new Set();
|
|
230
|
+
for (const arg of args) {
|
|
231
|
+
const trimmed = arg.trim();
|
|
232
|
+
if (!trimmed.startsWith('{')) continue;
|
|
233
|
+
const body = trimmed.slice(1, trimmed.lastIndexOf('}'));
|
|
234
|
+
for (const part of splitArgs(body)) {
|
|
235
|
+
const m = part.trim().match(/^(?:['"]([^'"]+)['"]|([A-Za-z_$][\w$]*))\s*(?::|$)/);
|
|
236
|
+
const name = m && (m[1] || m[2]);
|
|
237
|
+
if (name && !part.trim().startsWith('...')) params.add(name);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return [...params].sort();
|
|
241
|
+
}
|
|
242
|
+
|
|
149
243
|
export function keyAtPosition(text, position) {
|
|
150
244
|
const offset = positionToOffset(text, position);
|
|
151
245
|
return extractI18nKeys(text).find((item) => item.startOffset <= offset && offset <= item.endOffset);
|
|
@@ -221,16 +315,16 @@ export function getDiagnostics(text, locales) {
|
|
|
221
315
|
const localeEntries = Object.entries(locales);
|
|
222
316
|
if (localeEntries.length === 0) return [];
|
|
223
317
|
|
|
224
|
-
return extractI18nKeys(text)
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
.sort();
|
|
318
|
+
return extractI18nKeys(text).flatMap((item) => {
|
|
319
|
+
const missingLocales = localeEntries
|
|
320
|
+
.filter(([, locale]) => !Object.prototype.hasOwnProperty.call(locale.flat, item.key))
|
|
321
|
+
.map(([name]) => name)
|
|
322
|
+
.sort();
|
|
230
323
|
|
|
231
|
-
|
|
324
|
+
const diagnostics = [];
|
|
325
|
+
if (missingLocales.length > 0) {
|
|
232
326
|
const missingInAllLocales = missingLocales.length === localeEntries.length;
|
|
233
|
-
|
|
327
|
+
diagnostics.push({
|
|
234
328
|
severity: missingInAllLocales ? 1 : 2,
|
|
235
329
|
range: item.range,
|
|
236
330
|
message: missingInAllLocales
|
|
@@ -238,9 +332,64 @@ export function getDiagnostics(text, locales) {
|
|
|
238
332
|
: 'Missing i18n key "' + item.key + '" in locales: ' + missingLocales.join(', '),
|
|
239
333
|
source: 'i18n-lens',
|
|
240
334
|
data: { key: item.key, missingLocales, missingInAllLocales },
|
|
241
|
-
};
|
|
242
|
-
}
|
|
243
|
-
.
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
if (missingLocales.length !== localeEntries.length) diagnostics.push(...getParamDiagnostics(item, localeEntries));
|
|
338
|
+
return diagnostics;
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function getParamDiagnostics(item, localeEntries) {
|
|
343
|
+
if (!Array.isArray(item.providedParams)) return [];
|
|
344
|
+
const required = collectRequiredParams(item.key, localeEntries);
|
|
345
|
+
const provided = new Set(item.providedParams);
|
|
346
|
+
const missing = required.filter((n) => !provided.has(n));
|
|
347
|
+
const unused = item.providedParams.filter((n) => !required.includes(n));
|
|
348
|
+
const diagnostics = [];
|
|
349
|
+
if (missing.length > 0)
|
|
350
|
+
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 } });
|
|
351
|
+
if (unused.length > 0)
|
|
352
|
+
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 } });
|
|
353
|
+
return diagnostics;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function collectRequiredParams(key, localeEntries) {
|
|
357
|
+
const params = new Set();
|
|
358
|
+
for (const [, locale] of localeEntries) {
|
|
359
|
+
const value = locale.flat[key];
|
|
360
|
+
if (value === undefined) continue;
|
|
361
|
+
for (const name of extractPlaceholders(value)) params.add(name);
|
|
362
|
+
}
|
|
363
|
+
return [...params].sort();
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function extractPlaceholders(value) {
|
|
367
|
+
const params = new Set();
|
|
368
|
+
// ICU-style: {name, plural, ...} — extract top-level name, skip nested content
|
|
369
|
+
let text = String(value);
|
|
370
|
+
let cursor = 0;
|
|
371
|
+
while (cursor < text.length) {
|
|
372
|
+
const open = text.indexOf('{', cursor);
|
|
373
|
+
if (open === -1) break;
|
|
374
|
+
const head = text.slice(open).match(/^\{\s*([A-Za-z_$][\w$]*)\s*,/);
|
|
375
|
+
if (head) {
|
|
376
|
+
params.add(head[1]);
|
|
377
|
+
let depth = 0, i = open;
|
|
378
|
+
while (i < text.length) {
|
|
379
|
+
if (text[i] === '{') depth++;
|
|
380
|
+
else if (text[i] === '}') { depth--; if (depth === 0) break; }
|
|
381
|
+
i++;
|
|
382
|
+
}
|
|
383
|
+
text = text.slice(0, open) + ' '.repeat(i - open + 1) + text.slice(i + 1);
|
|
384
|
+
cursor = open;
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
cursor = open + 1;
|
|
388
|
+
}
|
|
389
|
+
const re = /\{\s*([A-Za-z_$][\w$]*)\s*\}/g;
|
|
390
|
+
let m;
|
|
391
|
+
while ((m = re.exec(text))) params.add(m[1]);
|
|
392
|
+
return [...params].sort();
|
|
244
393
|
}
|
|
245
394
|
|
|
246
395
|
export function resolvePreferredLocale(locales, defaultLocale) {
|