i18n-lens-language-server 0.8.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/LICENSE ADDED
@@ -0,0 +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.
package/README.md ADDED
@@ -0,0 +1,219 @@
1
+ # I18n Lens for Zed
2
+
3
+ Zed extension plus Language Server for Vue, TypeScript, TSX, JavaScript and JSX. It shows the real translation text behind i18n keys while editing.
4
+
5
+ ## Features
6
+
7
+ - Inline translation display via LSP inlay hints
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
+ - 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
11
+ - Completion: suggest existing i18n keys with default locale text
12
+ - Definition: jump from a source key to its locale definition; when several locales define the key, pick which language file to open
13
+
14
+ ## Inline translations
15
+
16
+ When inlay hints are enabled in Zed, code like this:
17
+
18
+ ```ts
19
+ t("order.pay_now")
20
+ ```
21
+
22
+ will show the default locale translation inline after the key string, for example:
23
+
24
+ ```ts
25
+ t("order.pay_now") 立即支付
26
+ ```
27
+
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.
29
+
30
+ Long translations are truncated inline to keep the editor readable. Hover still shows the full locale table.
31
+
32
+ ## Hover
33
+
34
+ Hovering over an i18n key shows every loaded locale value in a read-only table.
35
+
36
+ To open a specific language file, use Go to Definition (Ctrl/Cmd+click or F12) on the key. When the key exists in more than one locale, the editor offers each language's file — default locale first — so you can jump straight to the one you want instead of only the default locale.
37
+
38
+ ## Diagnostics
39
+
40
+ Diagnostics refresh automatically when loaded locale files or locale directories change, so editing translation files should update warnings/errors without restarting the extension.
41
+
42
+ Diagnostics distinguish between two missing-key cases:
43
+
44
+ - If a key is missing from every loaded locale, it is reported as an error.
45
+ - 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.
46
+
47
+ For example, if `order.pay_now` exists in `zh-CN` but is missing from `en-US`, the warning message is:
48
+
49
+ ```text
50
+ Missing i18n key "order.pay_now" in locales: en-US
51
+ ```
52
+
53
+ ## Project configuration
54
+
55
+ You can add `.zed/i18nlensrc.json` under your project root to override the defaults:
56
+
57
+ ```json
58
+ {
59
+ "defaultLocale": "zh-CN",
60
+ "localeDirs": ["src/locales", "src/i18n", "locales", "i18n"],
61
+ "inlayHints": {
62
+ "enabled": true,
63
+ "maxLength": 24
64
+ },
65
+ "packages": [
66
+ {
67
+ "root": "apps/web",
68
+ "defaultLocale": "zh-CN",
69
+ "localeDirs": ["src/locales"]
70
+ },
71
+ {
72
+ "root": "packages/admin",
73
+ "defaultLocale": "en-US",
74
+ "localeDirs": ["src/i18n"]
75
+ }
76
+ ]
77
+ }
78
+ ```
79
+
80
+ A template is available in this repository:
81
+
82
+ ```text
83
+ .zed/i18nlensrc.example.json
84
+ ```
85
+
86
+ Supported options:
87
+
88
+ | Option | Type | Default | Description |
89
+ |---|---|---|---|
90
+ | `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. |
94
+ | `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
+
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.
97
+
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. You should not need to rebuild or reload the Zed extension just to apply config changes.
99
+
100
+ ## Locale discovery
101
+
102
+ In a single-project workspace, `localeDirs` are resolved relative to the workspace root. The language server currently reads JSON files from:
103
+
104
+ - `src/locales/*.json`
105
+ - `src/i18n/*.json`
106
+ - `locales/*.json`
107
+ - `i18n/*.json`
108
+
109
+ Locale name comes from filename, for example `zh-CN.json` or `en-US.json`.
110
+
111
+ It also supports locale directories such as:
112
+
113
+ ```text
114
+ src/locales/zh-CN/common.json
115
+ src/locales/en-US/common.json
116
+ ```
117
+
118
+ A file named `common.json` contributes keys with the `common.` prefix. A file named `index.json` contributes keys without an `index.` prefix.
119
+
120
+ ## Monorepo workspaces
121
+
122
+ For pnpm/turborepo and other monorepos, put `.zed/i18nlensrc.json` under the workspace root and add `packages` entries:
123
+
124
+ ```json
125
+ {
126
+ "defaultLocale": "zh-CN",
127
+ "packages": [
128
+ {
129
+ "root": "apps/web",
130
+ "localeDirs": ["src/locales"]
131
+ },
132
+ {
133
+ "root": "packages/admin",
134
+ "defaultLocale": "en-US",
135
+ "localeDirs": ["src/i18n"]
136
+ }
137
+ ]
138
+ }
139
+ ```
140
+
141
+ When a document is opened, I18n Lens selects the package whose `root` is the longest path prefix of that document. `localeDirs` inside a package are resolved relative to that package root, so `apps/web/src/locales` and `packages/admin/src/i18n` are indexed independently. Package entries inherit root-level `defaultLocale`, `localeDirs`, and `inlayHints` unless they override them.
142
+
143
+ ## Local development
144
+
145
+ Install dependencies:
146
+
147
+ ```bash
148
+ npm install
149
+ ```
150
+
151
+ Run tests:
152
+
153
+ ```bash
154
+ npm test
155
+ ```
156
+
157
+ The language server is published to npm as
158
+ [`i18n-lens-language-server`](https://www.npmjs.com/package/i18n-lens-language-server).
159
+ A Zed extension must not ship its language server, so the Rust shell installs
160
+ that npm package into its work directory at runtime via the Zed Extension API
161
+ (`npm_install_package`), the same pattern the official Node-based extensions use.
162
+
163
+ Check the Zed extension adapter:
164
+
165
+ ```bash
166
+ cargo check --target wasm32-wasip1
167
+ ```
168
+
169
+ On this Windows setup, if `cargo` is not visible inside Git Bash / WSL, use:
170
+
171
+ ```bash
172
+ "$HOME/.cargo/bin/cargo.exe" check --target wasm32-wasip1
173
+ ```
174
+
175
+ ## Test in Zed
176
+
177
+ 1. Open Zed.
178
+ 2. Run `Install Dev Extension` from the command palette.
179
+ 3. Select this project root, for example the local `zed-i18n-lens` repository directory.
180
+ 4. Open a Vue/TS/JS project with locale JSON files.
181
+ 5. Test hover, completion, diagnostics, definition, and inlay hints.
182
+
183
+ If inline translations do not appear, check whether inlay hints are enabled in Zed settings.
184
+
185
+ ### Iterating on the language server
186
+
187
+ 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:
188
+
189
+ ```powershell
190
+ ./scripts/deploy-local.ps1
191
+ ```
192
+
193
+ It runs `npm pack`, installs the tarball into `%LOCALAPPDATA%\Zed\extensions\work\i18n-lens\node_modules\i18n-lens-language-server` (the same layout Zed produces), then prompts you to run **restart language server** from the Zed command palette.
194
+
195
+ > 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
+
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
+
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 ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "i18n-lens-language-server",
3
+ "version": "0.8.0",
4
+ "type": "module",
5
+ "description": "Language server for configurable i18n inlay hints, hover, completion, diagnostics, and definition in Vue/TS/TSX/JS.",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/yizixu/zed-i18n-lens.git"
10
+ },
11
+ "main": "server/index.js",
12
+ "bin": {
13
+ "i18n-lens-language-server": "./server/index.js"
14
+ },
15
+ "files": [
16
+ "server/*.js"
17
+ ],
18
+ "scripts": {
19
+ "test": "node --test tests/*.test.js",
20
+ "start": "node server/index.js"
21
+ },
22
+ "dependencies": {
23
+ "vscode-languageserver": "^9.0.1",
24
+ "vscode-languageserver-textdocument": "^1.0.12"
25
+ }
26
+ }
package/server/core.js ADDED
@@ -0,0 +1,473 @@
1
+ export const DEFAULT_CONFIG = Object.freeze({
2
+ defaultLocale: 'zh-CN',
3
+ localeDirs: Object.freeze(['src/locales', 'src/i18n', 'locales', 'i18n']),
4
+ inlayHints: Object.freeze({ enabled: true, maxLength: 24 }),
5
+ packages: Object.freeze([]),
6
+ });
7
+
8
+ export const CONFIG_RELATIVE_PATH = '.zed/i18nlensrc.json';
9
+
10
+ export function resolveConfigPath(workspaceRoot) {
11
+ return joinFsPath(normalizeFsPath(workspaceRoot), CONFIG_RELATIVE_PATH);
12
+ }
13
+
14
+ export function normalizeI18nLensConfig(raw = {}) {
15
+ const base = normalizeProjectConfig(raw, DEFAULT_CONFIG);
16
+ const packages = Array.isArray(raw.packages)
17
+ ? raw.packages
18
+ .map((pkg) => normalizePackageConfig(pkg, base))
19
+ .filter(Boolean)
20
+ : [];
21
+
22
+ return { ...base, packages };
23
+ }
24
+
25
+ function normalizeProjectConfig(raw = {}, defaults = DEFAULT_CONFIG) {
26
+ const defaultLocale = typeof raw.defaultLocale === 'string' && raw.defaultLocale.trim()
27
+ ? raw.defaultLocale.trim()
28
+ : defaults.defaultLocale;
29
+
30
+ const localeDirs = Array.isArray(raw.localeDirs)
31
+ ? raw.localeDirs.filter((dir) => typeof dir === 'string' && dir.trim()).map((dir) => normalizeRelativePath(dir.trim()))
32
+ : [];
33
+
34
+ const defaultInlayHints = defaults.inlayHints || DEFAULT_CONFIG.inlayHints;
35
+ const rawInlayHints = raw.inlayHints && typeof raw.inlayHints === 'object' ? raw.inlayHints : {};
36
+ const enabled = typeof rawInlayHints.enabled === 'boolean' ? rawInlayHints.enabled : defaultInlayHints.enabled;
37
+ const maxLength = Number.isInteger(rawInlayHints.maxLength) && rawInlayHints.maxLength > 0
38
+ ? rawInlayHints.maxLength
39
+ : defaultInlayHints.maxLength;
40
+
41
+ return {
42
+ defaultLocale,
43
+ localeDirs: localeDirs.length > 0 ? localeDirs : [...defaults.localeDirs],
44
+ inlayHints: { enabled, maxLength },
45
+ };
46
+ }
47
+
48
+ function normalizePackageConfig(raw, inherited) {
49
+ if (!raw || typeof raw !== 'object') return undefined;
50
+ const root = typeof raw.root === 'string' && raw.root.trim()
51
+ ? normalizeRelativePath(raw.root.trim())
52
+ : undefined;
53
+ if (!root) return undefined;
54
+ return { root, ...normalizeProjectConfig(raw, inherited) };
55
+ }
56
+
57
+ function normalizeRelativePath(value) {
58
+ return value.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/g, '');
59
+ }
60
+
61
+ export function resolveProjectContext(filePath, workspaceRoot, config) {
62
+ const normalizedWorkspaceRoot = normalizeFsPath(workspaceRoot);
63
+ const normalizedFilePath = normalizeFsPath(filePath);
64
+ const candidates = [
65
+ { root: normalizedWorkspaceRoot, config: withoutPackages(config) },
66
+ ...((config?.packages || []).map((pkg) => ({
67
+ root: normalizeFsPath(joinFsPath(normalizedWorkspaceRoot, pkg.root)),
68
+ config: withoutPackages(pkg),
69
+ }))),
70
+ ];
71
+
72
+ return candidates
73
+ .filter((candidate) => isPathInside(normalizedFilePath, candidate.root))
74
+ .sort((a, b) => b.root.length - a.root.length)[0] || candidates[0];
75
+ }
76
+
77
+ function withoutPackages(config) {
78
+ const { packages: _packages, ...rest } = config || normalizeI18nLensConfig();
79
+ return rest;
80
+ }
81
+
82
+ function normalizeFsPath(value) {
83
+ return String(value || '').replace(/\\/g, '/').replace(/\/+$/g, '');
84
+ }
85
+
86
+ function joinFsPath(root, relative) {
87
+ return root + '/' + normalizeRelativePath(relative || '');
88
+ }
89
+
90
+ function isPathInside(filePath, root) {
91
+ return filePath === root || filePath.startsWith(root + '/');
92
+ }
93
+
94
+
95
+ export function didWatchedFileChange(previous, current) {
96
+ return previous?.mtimeMs !== current?.mtimeMs;
97
+ }
98
+
99
+ export function flattenLocale(value, prefix = '', out = {}) {
100
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return out;
101
+ for (const [key, child] of Object.entries(value)) {
102
+ const fullKey = prefix ? prefix + '.' + key : key;
103
+ if (child && typeof child === 'object' && !Array.isArray(child)) flattenLocale(child, fullKey, out);
104
+ else out[fullKey] = String(child);
105
+ }
106
+ return out;
107
+ }
108
+
109
+ export function getValueByKey(obj, key) {
110
+ return key.split('.').reduce((acc, part) => (acc && Object.prototype.hasOwnProperty.call(acc, part) ? acc[part] : undefined), obj);
111
+ }
112
+
113
+ function offsetToPosition(text, offset) {
114
+ const before = text.slice(0, offset);
115
+ const lines = before.split('\n');
116
+ return { line: lines.length - 1, character: lines[lines.length - 1].length };
117
+ }
118
+
119
+ function makeRange(text, start, end) {
120
+ return { start: offsetToPosition(text, start), end: offsetToPosition(text, end) };
121
+ }
122
+
123
+ export function extractI18nKeys(text) {
124
+ const found = [];
125
+ 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
+ // <i18n-t keypath="key"> (Vue I18n) and <Trans i18nKey="key"> (react-i18next)
131
+ /\b(?:keypath|i18nKey)\s*=\s*(['"])([A-Za-z0-9_.:-]+)\1/g,
132
+ /\bv-t\s*=\s*"'([A-Za-z0-9_.:-]+)'"/g,
133
+ /\bv-t\s*=\s*"\s*\{\s*path\s*:\s*(['"])([A-Za-z0-9_.:-]+)\1/g,
134
+ ];
135
+ for (const re of patterns) {
136
+ let match;
137
+ while ((match = re.exec(text))) {
138
+ const key = match[2] || match[1];
139
+ const keyOffsetInMatch = match[0].indexOf(key);
140
+ const start = match.index + keyOffsetInMatch;
141
+ const end = start + key.length;
142
+ found.push({ key, range: makeRange(text, start, end), startOffset: start, endOffset: end });
143
+ }
144
+ }
145
+ found.sort((a, b) => a.startOffset - b.startOffset);
146
+ return found;
147
+ }
148
+
149
+ export function keyAtPosition(text, position) {
150
+ const offset = positionToOffset(text, position);
151
+ return extractI18nKeys(text).find((item) => item.startOffset <= offset && offset <= item.endOffset);
152
+ }
153
+
154
+ export function positionToOffset(text, position) {
155
+ const lines = text.split('\n');
156
+ let offset = 0;
157
+ for (let i = 0; i < position.line; i++) offset += (lines[i] || '').length + 1;
158
+ return offset + position.character;
159
+ }
160
+
161
+
162
+ export function collectLocaleWatchPaths(localeCache, localeDirs = []) {
163
+ const paths = new Set(localeDirs.filter(Boolean));
164
+ for (const locale of Object.values(localeCache || {})) {
165
+ if (locale.path) paths.add(locale.path);
166
+ for (const file of locale.files || []) paths.add(file);
167
+ }
168
+ return [...paths].sort();
169
+ }
170
+
171
+ export function findLocaleKeyTarget(locale, key, localeTexts = {}) {
172
+ if (!locale || !Object.prototype.hasOwnProperty.call(locale.flat || {}, key)) return undefined;
173
+ const files = Array.isArray(locale.files) && locale.files.length
174
+ ? locale.files
175
+ : (locale.path ? [locale.path] : []);
176
+ const candidates = files
177
+ .map((filePath) => ({ filePath, lookupKey: lookupKeyForLocaleFile(locale, filePath, key) }))
178
+ .sort((a, b) => Number(a.lookupKey === key) - Number(b.lookupKey === key));
179
+
180
+ for (const candidate of candidates) {
181
+ const text = localeTexts[candidate.filePath];
182
+ if (!text) continue;
183
+ const position = /\.json$/i.test(candidate.filePath)
184
+ ? findJsonKeyLocation(text, candidate.lookupKey)
185
+ : findLocaleKeyLocation(text, candidate.lookupKey);
186
+ if (position) return { filePath: candidate.filePath, position };
187
+ }
188
+
189
+ const fallback = candidates[0];
190
+ return fallback ? { filePath: fallback.filePath } : undefined;
191
+ }
192
+
193
+ function lookupKeyForLocaleFile(locale, filePath, key) {
194
+ const isNestedLocaleFile = normalizeFsPath(locale.path) !== normalizeFsPath(filePath);
195
+ const stem = fileStem(filePath);
196
+ if (isNestedLocaleFile && stem && stem !== 'index' && key.startsWith(stem + '.')) return key.slice(stem.length + 1);
197
+ return key;
198
+ }
199
+
200
+ function fileStem(filePath) {
201
+ const fileName = String(filePath || '').replace(/\\/g, '/').split('/').at(-1) || '';
202
+ return fileName.replace(/\.[^.]+$/, '');
203
+ }
204
+
205
+ export function buildHoverMarkdown(key, locales) {
206
+ const names = Object.keys(locales).sort();
207
+ if (names.length === 0) return '**' + key + '**\n\nNo locale JSON files found.';
208
+ const lines = ['**' + key + '**', '', '| Locale | Text |', '|---|---|'];
209
+ for (const name of names) {
210
+ const value = locales[name].flat[key];
211
+ lines.push('| ' + escapeMd(name) + ' | ' + escapeMd(value ?? 'Missing') + ' |');
212
+ }
213
+ return lines.join('\n');
214
+ }
215
+
216
+ function escapeMd(value) {
217
+ return String(value).replace(/\|/g, '\\|').replace(/\n/g, '<br>');
218
+ }
219
+
220
+ export function getDiagnostics(text, locales) {
221
+ const localeEntries = Object.entries(locales);
222
+ if (localeEntries.length === 0) return [];
223
+
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();
230
+
231
+ if (missingLocales.length === 0) return undefined;
232
+ const missingInAllLocales = missingLocales.length === localeEntries.length;
233
+ return {
234
+ severity: missingInAllLocales ? 1 : 2,
235
+ range: item.range,
236
+ message: missingInAllLocales
237
+ ? 'Missing i18n key: ' + item.key
238
+ : 'Missing i18n key "' + item.key + '" in locales: ' + missingLocales.join(', '),
239
+ source: 'i18n-lens',
240
+ data: { key: item.key, missingLocales, missingInAllLocales },
241
+ };
242
+ })
243
+ .filter(Boolean);
244
+ }
245
+
246
+ export function resolvePreferredLocale(locales, defaultLocale) {
247
+ if (!locales || Object.keys(locales).length === 0) return undefined;
248
+ if (locales[defaultLocale]) return locales[defaultLocale];
249
+
250
+ const requested = normalizeLocaleName(defaultLocale);
251
+ const entries = Object.entries(locales);
252
+ const exactNormalized = entries.find(([name]) => normalizeLocaleName(name) === requested);
253
+ if (exactNormalized) return exactNormalized[1];
254
+
255
+ const requestedLanguage = requested.split('-')[0];
256
+ const languageMatch = entries.find(([name]) => {
257
+ const candidate = normalizeLocaleName(name);
258
+ const candidateLanguage = candidate.split('-')[0];
259
+ return candidateLanguage && requestedLanguage && candidateLanguage === requestedLanguage;
260
+ });
261
+ return languageMatch?.[1] || entries[0][1];
262
+ }
263
+
264
+ function normalizeLocaleName(name) {
265
+ return String(name || '').trim().replace(/_/g, '-').toLowerCase();
266
+ }
267
+
268
+ export function getCompletions(prefix, locales, defaultLocale) {
269
+ const keys = new Set();
270
+ for (const locale of Object.values(locales)) for (const key of Object.keys(locale.flat)) keys.add(key);
271
+ const preferred = resolvePreferredLocale(locales, defaultLocale);
272
+ return [...keys]
273
+ .filter((key) => key.startsWith(prefix))
274
+ .sort()
275
+ .map((key) => ({ label: key, kind: 12, detail: preferred?.flat[key] || undefined, insertText: key }));
276
+ }
277
+
278
+
279
+ export function getDefinitionLocaleOrder(locales, defaultLocale) {
280
+ const localeList = Object.values(locales || {});
281
+ const preferred = resolvePreferredLocale(locales, defaultLocale);
282
+ if (!preferred) return localeList;
283
+ return [preferred, ...localeList.filter((locale) => locale !== preferred)];
284
+ }
285
+
286
+ // Every locale that defines the key, with the default locale first, so
287
+ // Go to Definition can return one LSP Location per language file.
288
+ export function collectLocaleKeyTargets(locales, key, localeTexts = {}, defaultLocale) {
289
+ return getDefinitionLocaleOrder(locales, defaultLocale)
290
+ .map((locale) => findLocaleKeyTarget(locale, key, localeTexts))
291
+ .filter((target) => target?.position);
292
+ }
293
+
294
+ export function getCompletionPrefix(text, position) {
295
+ const offset = positionToOffset(text, position);
296
+ const before = text.slice(0, offset);
297
+ const patterns = [
298
+ /(?:\x24tc?|\btc?|\bi18n\.tc?)\(\s*['"]([A-Za-z0-9_.:-]*)$/,
299
+ /\bformatMessage\s*\(\s*\{[^}]*?\bid\s*:\s*['"]([A-Za-z0-9_.:-]*)$/,
300
+ /\b(?:keypath|i18nKey)\s*=\s*['"]([A-Za-z0-9_.:-]*)$/,
301
+ /v-t\s*=\s*"'([A-Za-z0-9_.:-]*)$/,
302
+ ];
303
+ for (const re of patterns) {
304
+ const match = before.match(re);
305
+ if (match) return match[1];
306
+ }
307
+ return '';
308
+ }
309
+
310
+ export function findJsonKeyLocation(jsonText, key) {
311
+ const parts = key.split('.');
312
+ let cursor = 0;
313
+ for (const part of parts) {
314
+ const re = new RegExp('"' + escapeRegExp(part) + '"\\s*:', 'g');
315
+ re.lastIndex = cursor;
316
+ const match = re.exec(jsonText);
317
+ if (!match) return undefined;
318
+ cursor = match.index + match[0].length;
319
+ if (part === parts[parts.length - 1]) return offsetToPosition(jsonText, match.index);
320
+ }
321
+ return undefined;
322
+ }
323
+
324
+
325
+ export function parseTsLocaleModule(text, fileName = 'index.ts') {
326
+ const result = {};
327
+ const stem = fileName.replace(/\.[cm]?[tj]sx?$/i, '');
328
+ const exportObject = extractObjectAfter(text, /export\s+default\s*/g);
329
+ if (exportObject) {
330
+ Object.assign(result, flattenJsObjectLiteral(exportObject, stem === 'index' ? '' : stem));
331
+ }
332
+
333
+ if (stem === 'index') {
334
+ const constRe = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*\{/g;
335
+ let match;
336
+ while ((match = constRe.exec(text))) {
337
+ const objectStart = text.indexOf('{', match.index);
338
+ const objectText = readBalanced(text, objectStart);
339
+ if (objectText) Object.assign(result, flattenJsObjectLiteral(objectText, match[1]));
340
+ }
341
+ }
342
+ return result;
343
+ }
344
+
345
+ function extractObjectAfter(text, re) {
346
+ const match = re.exec(text);
347
+ if (!match) return undefined;
348
+ const start = text.indexOf('{', match.index + match[0].length);
349
+ if (start === -1) return undefined;
350
+ return readBalanced(text, start);
351
+ }
352
+
353
+ function readBalanced(text, start) {
354
+ let depth = 0;
355
+ let quote = '';
356
+ let escaped = false;
357
+ for (let i = start; i < text.length; i++) {
358
+ const ch = text[i];
359
+ if (quote) {
360
+ if (escaped) escaped = false;
361
+ else if (ch === '\\') escaped = true;
362
+ else if (ch === quote) quote = '';
363
+ continue;
364
+ }
365
+ if (ch === '"' || ch === "'" || ch === '`') { quote = ch; continue; }
366
+ if (ch === '{') depth++;
367
+ else if (ch === '}') {
368
+ depth--;
369
+ if (depth === 0) return text.slice(start, i + 1);
370
+ }
371
+ }
372
+ return undefined;
373
+ }
374
+
375
+ function flattenJsObjectLiteral(objectText, prefix = '', out = {}) {
376
+ const body = objectText.replace(/^\s*\{/, '').replace(/\}\s*$/, '');
377
+ let i = 0;
378
+ while (i < body.length) {
379
+ while (i < body.length && /[\s,;]/.test(body[i])) i++;
380
+ if (body.startsWith('//', i)) { const end = body.indexOf('\n', i); i = end === -1 ? body.length : end + 1; continue; }
381
+ if (body.startsWith('/*', i)) { const end = body.indexOf('*/', i + 2); i = end === -1 ? body.length : end + 2; continue; }
382
+ const keyMatch = body.slice(i).match(/^(?:['"]([^'"]+)['"]|([A-Za-z_$][\w$-]*))\s*:/);
383
+ if (!keyMatch) { i++; continue; }
384
+ const key = keyMatch[1] || keyMatch[2];
385
+ i += keyMatch[0].length;
386
+ while (i < body.length && /\s/.test(body[i])) i++;
387
+ const fullKey = prefix ? prefix + '.' + key : key;
388
+ if (body[i] === '{') {
389
+ const nested = readBalanced(body, i);
390
+ if (!nested) break;
391
+ flattenJsObjectLiteral(nested, fullKey, out);
392
+ i += nested.length;
393
+ continue;
394
+ }
395
+ const value = readJsValue(body, i);
396
+ if (value) {
397
+ if (value.kind === 'string') out[fullKey] = value.value;
398
+ i = value.end;
399
+ } else i++;
400
+ }
401
+ return out;
402
+ }
403
+
404
+ function readJsValue(text, start) {
405
+ const quote = text[start];
406
+ if (quote !== '"' && quote !== "'" && quote !== '`') return undefined;
407
+ let value = '';
408
+ let escaped = false;
409
+ for (let i = start + 1; i < text.length; i++) {
410
+ const ch = text[i];
411
+ if (escaped) { value += ch; escaped = false; continue; }
412
+ if (ch === '\\') { escaped = true; continue; }
413
+ if (ch === quote) return { kind: 'string', value, end: i + 1 };
414
+ value += ch;
415
+ }
416
+ return undefined;
417
+ }
418
+
419
+ export function findLocaleKeyLocation(text, key) {
420
+ const parts = key.split('.');
421
+ const last = parts[parts.length - 1];
422
+ const re = new RegExp('(?:["\\\']' + escapeRegExp(last) + '["\\\']|' + escapeRegExp(last) + ')\\s*:', 'g');
423
+ const match = re.exec(text);
424
+ return match ? offsetToPosition(text, match.index) : undefined;
425
+ }
426
+
427
+
428
+ export function getInlayHints(text, range, locales, defaultLocale, options = {}) {
429
+ if (options.enabled === false) return [];
430
+ const maxLength = options.maxLength ?? DEFAULT_CONFIG.inlayHints.maxLength;
431
+ const preferred = resolvePreferredLocale(locales, defaultLocale);
432
+ if (!preferred) return [];
433
+
434
+ return extractI18nKeys(text)
435
+ .filter((item) => isPositionInRange(item.range.start, range))
436
+ .map((item) => {
437
+ const value = preferred.flat[item.key];
438
+ if (value === undefined) return undefined;
439
+ return {
440
+ position: inlayPositionAfterKeyLiteral(text, item),
441
+ label: truncateInlayText(value, maxLength),
442
+ paddingLeft: true,
443
+ tooltip: value,
444
+ };
445
+ })
446
+ .filter(Boolean);
447
+ }
448
+
449
+ function inlayPositionAfterKeyLiteral(text, item) {
450
+ const next = text[item.endOffset];
451
+ const offset = (next === '"' || next === "'") ? item.endOffset + 1 : item.endOffset;
452
+ return offsetToPosition(text, offset);
453
+ }
454
+
455
+ function isPositionInRange(position, range) {
456
+ if (!range) return true;
457
+ return comparePosition(position, range.start) >= 0 && comparePosition(position, range.end) <= 0;
458
+ }
459
+
460
+ function comparePosition(a, b) {
461
+ if (a.line !== b.line) return a.line - b.line;
462
+ return a.character - b.character;
463
+ }
464
+
465
+ function truncateInlayText(value, maxLength) {
466
+ const text = String(value).replace(/\s+/g, ' ').trim();
467
+ if (text.length <= maxLength) return text;
468
+ return text.slice(0, Math.max(0, maxLength - 1)) + '…';
469
+ }
470
+
471
+ function escapeRegExp(value) {
472
+ return value.replace(/[.*+?^()|[\]\{}]/g, '\\$&');
473
+ }
@@ -0,0 +1,272 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { pathToFileURL, fileURLToPath } from 'node:url';
5
+ import { createConnection, TextDocuments, ProposedFeatures, TextDocumentSyncKind, MarkupKind } from 'vscode-languageserver/node.js';
6
+ import { TextDocument } from 'vscode-languageserver-textdocument';
7
+ import {
8
+ flattenLocale,
9
+ buildHoverMarkdown,
10
+ getDiagnostics,
11
+ getCompletions,
12
+ getCompletionPrefix,
13
+ keyAtPosition,
14
+ parseTsLocaleModule,
15
+ collectLocaleKeyTargets,
16
+ getInlayHints,
17
+ normalizeI18nLensConfig,
18
+ resolveConfigPath,
19
+ CONFIG_RELATIVE_PATH,
20
+ didWatchedFileChange,
21
+ collectLocaleWatchPaths,
22
+ resolveProjectContext,
23
+ } from './core.js';
24
+
25
+ const connection = createConnection(ProposedFeatures.all);
26
+ const documents = new TextDocuments(TextDocument);
27
+ let workspaceRoot = process.cwd();
28
+ let config = normalizeI18nLensConfig();
29
+ let watchedConfigPath;
30
+ const watchedLocalePaths = new Set();
31
+ let reloadTimer;
32
+ let configMtimeMs;
33
+ let localeCacheByContext = new Map();
34
+ const configWatchIntervalMs = 1000;
35
+ const localeWatchIntervalMs = 1000;
36
+ const reloadDebounceMs = 100;
37
+
38
+ connection.onInitialize((params) => {
39
+ workspaceRoot = params.workspaceFolders?.[0]?.uri ? fileURLToPath(params.workspaceFolders[0].uri) : (params.rootUri ? fileURLToPath(params.rootUri) : process.cwd());
40
+ loadConfig();
41
+ watchConfigFile();
42
+ return { capabilities: { textDocumentSync: TextDocumentSyncKind.Incremental, hoverProvider: true, completionProvider: { triggerCharacters: ['.', '"', "'"] }, definitionProvider: true, inlayHintProvider: true } };
43
+ });
44
+
45
+ connection.onInitialized(() => {
46
+ // After (re)start the editor does not always re-request inlay hints for
47
+ // already-open documents, so features only appeared after the first edit.
48
+ // Proactively reload config/locales and ask the editor to refresh.
49
+ scheduleProjectReload();
50
+ });
51
+
52
+ documents.onDidOpen((event) => {
53
+ validate(event.document);
54
+ // A freshly started server may receive the inlay-hint request before the
55
+ // locale cache is ready; prompt the editor to re-request once it is.
56
+ refreshInlayHints();
57
+ });
58
+ documents.onDidChangeContent((event) => validate(event.document));
59
+ documents.onDidSave(() => scheduleProjectReload());
60
+
61
+ connection.onHover((params) => {
62
+ const doc = documents.get(params.textDocument.uri);
63
+ if (!doc) return null;
64
+ const item = keyAtPosition(doc.getText(), params.position);
65
+ if (!item) return null;
66
+ const context = getProjectContext(params.textDocument.uri);
67
+ const { locales } = loadLocalesForContext(context);
68
+ return {
69
+ contents: { kind: MarkupKind.Markdown, value: buildHoverMarkdown(item.key, locales) },
70
+ range: item.range,
71
+ };
72
+ });
73
+
74
+ connection.onCompletion((params) => {
75
+ const doc = documents.get(params.textDocument.uri);
76
+ const context = getProjectContext(params.textDocument.uri);
77
+ const { locales } = loadLocalesForContext(context);
78
+ const prefix = doc ? getCompletionPrefix(doc.getText(), params.position) : '';
79
+ return getCompletions(prefix, locales, context.config.defaultLocale);
80
+ });
81
+
82
+ connection.languages.inlayHint.on((params) => {
83
+ const doc = documents.get(params.textDocument.uri);
84
+ if (!doc) return [];
85
+ const context = getProjectContext(params.textDocument.uri);
86
+ const { locales } = loadLocalesForContext(context);
87
+ return getInlayHints(doc.getText(), params.range, locales, context.config.defaultLocale, context.config.inlayHints);
88
+ });
89
+
90
+ connection.onDefinition((params) => {
91
+ const doc = documents.get(params.textDocument.uri);
92
+ if (!doc) return null;
93
+ const item = keyAtPosition(doc.getText(), params.position);
94
+ if (!item) return null;
95
+ const context = getProjectContext(params.textDocument.uri);
96
+ const { locales, localeTexts } = loadLocalesForContext(context);
97
+ const keyTokenLength = item.key.split('.').at(-1).length + 2;
98
+ const locations = collectLocaleKeyTargets(locales, item.key, localeTexts, context.config.defaultLocale)
99
+ .map(({ filePath, position }) => ({
100
+ uri: pathToFileURL(filePath).toString(),
101
+ range: { start: position, end: { line: position.line, character: position.character + keyTokenLength } },
102
+ }));
103
+ return locations.length ? locations : null;
104
+ });
105
+
106
+ function getProjectContext(uri) {
107
+ loadConfigIfChanged();
108
+ const filePath = uri ? fileURLToPath(uri) : workspaceRoot;
109
+ return resolveProjectContext(filePath, workspaceRoot, config);
110
+ }
111
+
112
+ function watchConfigFile() {
113
+ const configPath = resolveConfigPath(workspaceRoot);
114
+ if (watchedConfigPath === configPath) return;
115
+ if (watchedConfigPath) fs.unwatchFile(watchedConfigPath);
116
+ watchedConfigPath = configPath;
117
+ fs.watchFile(configPath, { interval: configWatchIntervalMs }, (current, previous) => {
118
+ if (didWatchedFileChange(previous, current)) scheduleProjectReload();
119
+ });
120
+ }
121
+
122
+ function watchLocalePaths() {
123
+ const nextPaths = new Set();
124
+ const contexts = getAllConfiguredContexts();
125
+ for (const context of contexts) {
126
+ const configuredDirs = context.config.localeDirs
127
+ .map((dir) => path.join(context.root, dir))
128
+ .filter((dir) => fs.existsSync(dir));
129
+ const loaded = loadLocalesForContext(context);
130
+ for (const watchPath of collectLocaleWatchPaths(loaded.locales, configuredDirs)) nextPaths.add(watchPath);
131
+ }
132
+
133
+ for (const watchedPath of watchedLocalePaths) {
134
+ if (nextPaths.has(watchedPath)) continue;
135
+ fs.unwatchFile(watchedPath);
136
+ watchedLocalePaths.delete(watchedPath);
137
+ }
138
+
139
+ for (const watchedPath of nextPaths) {
140
+ if (watchedLocalePaths.has(watchedPath)) continue;
141
+ watchedLocalePaths.add(watchedPath);
142
+ fs.watchFile(watchedPath, { interval: localeWatchIntervalMs }, (current, previous) => {
143
+ if (didWatchedFileChange(previous, current)) scheduleProjectReload();
144
+ });
145
+ }
146
+ }
147
+
148
+ function scheduleProjectReload() {
149
+ clearTimeout(reloadTimer);
150
+ reloadTimer = setTimeout(() => {
151
+ loadConfig();
152
+ localeCacheByContext = new Map();
153
+ watchLocalePaths();
154
+ refreshOpenDocuments();
155
+ refreshInlayHints();
156
+ }, reloadDebounceMs);
157
+ }
158
+
159
+ function refreshOpenDocuments() {
160
+ for (const doc of documents.all()) validate(doc);
161
+ }
162
+
163
+ function refreshInlayHints() {
164
+ try {
165
+ connection.languages.inlayHint.refresh?.();
166
+ } catch (error) {
167
+ connection.console.warn('Failed to refresh inlay hints: ' + error.message);
168
+ }
169
+ }
170
+
171
+ function loadConfigIfChanged() {
172
+ const configPath = resolveConfigPath(workspaceRoot);
173
+ const nextMtimeMs = fs.existsSync(configPath) ? fs.statSync(configPath).mtimeMs : 0;
174
+ if (configMtimeMs === nextMtimeMs) return;
175
+ loadConfig();
176
+ localeCacheByContext = new Map();
177
+ }
178
+
179
+ function loadConfig() {
180
+ const configPath = resolveConfigPath(workspaceRoot);
181
+ configMtimeMs = fs.existsSync(configPath) ? fs.statSync(configPath).mtimeMs : 0;
182
+ if (!configMtimeMs) {
183
+ config = normalizeI18nLensConfig();
184
+ return;
185
+ }
186
+ try {
187
+ config = normalizeI18nLensConfig(JSON.parse(fs.readFileSync(configPath, 'utf8')));
188
+ } catch (error) {
189
+ connection.console.warn('Failed to load ' + CONFIG_RELATIVE_PATH + ': ' + error.message);
190
+ config = normalizeI18nLensConfig();
191
+ }
192
+ }
193
+
194
+ function validate(document) {
195
+ if (!isSourceFile(document.uri)) return;
196
+ const context = getProjectContext(document.uri);
197
+ const { locales } = loadLocalesForContext(context);
198
+ connection.sendDiagnostics({ uri: document.uri, diagnostics: getDiagnostics(document.getText(), locales) });
199
+ }
200
+ function isSourceFile(uri) { return /\.(vue|tsx?|jsx?)$/i.test(uri); }
201
+
202
+ function getAllConfiguredContexts() {
203
+ loadConfigIfChanged();
204
+ return [resolveProjectContext(workspaceRoot, workspaceRoot, config), ...config.packages.map((pkg) => ({
205
+ root: path.resolve(workspaceRoot, pkg.root),
206
+ config: pkg,
207
+ }))];
208
+ }
209
+
210
+ function loadLocalesForContext(context) {
211
+ const cacheKey = context.root + '\0' + JSON.stringify({
212
+ defaultLocale: context.config.defaultLocale,
213
+ localeDirs: context.config.localeDirs,
214
+ inlayHints: context.config.inlayHints,
215
+ });
216
+ const cached = localeCacheByContext.get(cacheKey);
217
+ if (cached) return cached;
218
+
219
+ const locales = {};
220
+ const localeTexts = {};
221
+ for (const dir of context.config.localeDirs) {
222
+ const fullDir = path.join(context.root, dir);
223
+ if (!fs.existsSync(fullDir)) continue;
224
+ loadLocaleFiles(fullDir, locales, localeTexts);
225
+ for (const entry of fs.readdirSync(fullDir, { withFileTypes: true })) {
226
+ if (entry.isDirectory()) loadLocaleDirectory(path.join(fullDir, entry.name), entry.name, locales, localeTexts);
227
+ }
228
+ }
229
+ const loaded = { locales, localeTexts };
230
+ localeCacheByContext.set(cacheKey, loaded);
231
+ return loaded;
232
+ }
233
+
234
+ function loadLocaleDirectory(localeDir, localeName, next, localeTexts) {
235
+ next[localeName] ||= { path: localeDir, flat: {}, files: [] };
236
+ for (const file of fs.readdirSync(localeDir)) {
237
+ const fullPath = path.join(localeDir, file);
238
+ if (fs.statSync(fullPath).isDirectory()) continue;
239
+ try {
240
+ const text = fs.readFileSync(fullPath, 'utf8');
241
+ if (file.endsWith('.json')) Object.assign(next[localeName].flat, prefixByFile(file, flattenLocale(JSON.parse(text))));
242
+ else if (/\.[cm]?[tj]s$/i.test(file)) Object.assign(next[localeName].flat, parseTsLocaleModule(text, file));
243
+ else continue;
244
+ next[localeName].files.push(fullPath);
245
+ localeTexts[fullPath] = text;
246
+ } catch (error) { connection.console.warn('Failed to load locale file ' + fullPath + ': ' + error.message); }
247
+ }
248
+ }
249
+
250
+ function loadLocaleFiles(fullDir, next, localeTexts) {
251
+ for (const file of fs.readdirSync(fullDir)) {
252
+ const fullPath = path.join(fullDir, file);
253
+ if (fs.statSync(fullPath).isDirectory()) continue;
254
+ try {
255
+ const text = fs.readFileSync(fullPath, 'utf8');
256
+ if (file.endsWith('.json')) {
257
+ const name = path.basename(file, '.json');
258
+ next[name] = { path: fullPath, flat: flattenLocale(JSON.parse(text)), files: [fullPath] };
259
+ localeTexts[fullPath] = text;
260
+ }
261
+ } catch (error) { connection.console.warn('Failed to load locale file ' + fullPath + ': ' + error.message); }
262
+ }
263
+ }
264
+
265
+ function prefixByFile(file, flat) {
266
+ const stem = path.basename(file, path.extname(file));
267
+ if (stem === 'index') return flat;
268
+ return Object.fromEntries(Object.entries(flat).map(([key, value]) => [stem + '.' + key, value]));
269
+ }
270
+
271
+ documents.listen(connection);
272
+ connection.listen();