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.
Files changed (4) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +6 -26
  3. package/package.json +3 -3
  4. 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 from every locale file as errors and keys missing from some locales as warnings
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 two missing-key cases:
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.0",
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": "./server/index.js"
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
- .map((item) => {
226
- const missingLocales = localeEntries
227
- .filter(([, locale]) => !Object.prototype.hasOwnProperty.call(locale.flat, item.key))
228
- .map(([name]) => name)
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
- if (missingLocales.length === 0) return undefined;
324
+ const diagnostics = [];
325
+ if (missingLocales.length > 0) {
232
326
  const missingInAllLocales = missingLocales.length === localeEntries.length;
233
- return {
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
- .filter(Boolean);
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) {