@intlayer/lsp 8.11.4 → 9.0.0-canary.12

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 (46) hide show
  1. package/dist/esm/_virtual/_rolldown/runtime.mjs +36 -0
  2. package/dist/esm/callers/index.mjs +12 -0
  3. package/dist/esm/callers/index.mjs.map +1 -0
  4. package/dist/esm/callers/patterns.mjs +37 -0
  5. package/dist/esm/callers/patterns.mjs.map +1 -0
  6. package/dist/esm/dictionaryUtils.mjs +84 -0
  7. package/dist/esm/dictionaryUtils.mjs.map +1 -0
  8. package/dist/esm/findFieldInFile.mjs +22 -125
  9. package/dist/esm/findFieldInFile.mjs.map +1 -1
  10. package/dist/esm/findKeyAtOffset.mjs +20 -24
  11. package/dist/esm/findKeyAtOffset.mjs.map +1 -1
  12. package/dist/esm/findKeyInContentFile.mjs +259 -40
  13. package/dist/esm/findKeyInContentFile.mjs.map +1 -1
  14. package/dist/esm/findTCallAtOffset.mjs +33 -0
  15. package/dist/esm/findTCallAtOffset.mjs.map +1 -0
  16. package/dist/esm/findUsageFieldAtOffset.mjs +12 -51
  17. package/dist/esm/findUsageFieldAtOffset.mjs.map +1 -1
  18. package/dist/esm/index.mjs +338 -170
  19. package/dist/esm/index.mjs.map +1 -1
  20. package/dist/esm/oxcUtils.mjs +99 -29
  21. package/dist/esm/oxcUtils.mjs.map +1 -1
  22. package/dist/esm/usageAnalyzer.mjs +692 -0
  23. package/dist/esm/usageAnalyzer.mjs.map +1 -0
  24. package/dist/esm/utils.mjs +12 -1
  25. package/dist/types/callers/index.d.ts +8 -0
  26. package/dist/types/callers/index.d.ts.map +1 -0
  27. package/dist/types/callers/patterns.d.ts +30 -0
  28. package/dist/types/callers/patterns.d.ts.map +1 -0
  29. package/dist/types/dictionaryUtils.d.ts +12 -0
  30. package/dist/types/dictionaryUtils.d.ts.map +1 -0
  31. package/dist/types/findFieldInFile.d.ts +12 -14
  32. package/dist/types/findFieldInFile.d.ts.map +1 -1
  33. package/dist/types/findKeyAtOffset.d.ts +13 -10
  34. package/dist/types/findKeyAtOffset.d.ts.map +1 -1
  35. package/dist/types/findKeyInContentFile.d.ts +15 -13
  36. package/dist/types/findKeyInContentFile.d.ts.map +1 -1
  37. package/dist/types/findTCallAtOffset.d.ts +24 -0
  38. package/dist/types/findTCallAtOffset.d.ts.map +1 -0
  39. package/dist/types/findUsageFieldAtOffset.d.ts +2 -1
  40. package/dist/types/findUsageFieldAtOffset.d.ts.map +1 -1
  41. package/dist/types/oxcUtils.d.ts +42 -13
  42. package/dist/types/oxcUtils.d.ts.map +1 -1
  43. package/dist/types/usageAnalyzer.d.ts +129 -0
  44. package/dist/types/usageAnalyzer.d.ts.map +1 -0
  45. package/dist/types/utils.d.ts +15 -1
  46. package/package.json +9 -22
@@ -1,14 +1,18 @@
1
1
  #!/usr/bin/env node
2
+ import { parseText, walkAst } from "./oxcUtils.mjs";
3
+ import { collectNamespaceReferences, findMessageUsageAtOffset, matchCallNamespace } from "./usageAnalyzer.mjs";
2
4
  import { escapeRegularExpression, findFieldRangesInFile, offsetToRange } from "./findFieldInFile.mjs";
5
+ import { formatDictionaryHover, formatFieldHover, formatFieldValue, getFieldByPath, getFieldsAtPath } from "./dictionaryUtils.mjs";
6
+ import { buildCallerWithKeyPattern, buildKeyUsagePatterns, getCallerNamesAlternation } from "./callers/patterns.mjs";
3
7
  import { findKeyAtOffset } from "./findKeyAtOffset.mjs";
4
8
  import { findContentFieldAtOffset, findKeyInContentFile } from "./findKeyInContentFile.mjs";
5
- import { findUsageFieldAtOffset } from "./findUsageFieldAtOffset.mjs";
6
- import { Location, Position, ProposedFeatures, Range, TextDocumentSyncKind, TextDocuments, createConnection } from "vscode-languageserver/node.js";
9
+ import { CompletionItemKind, DiagnosticSeverity, Location, MarkupKind, Position, ProposedFeatures, Range, TextDocumentSyncKind, TextDocuments, createConnection } from "vscode-languageserver/node";
7
10
  import { existsSync } from "node:fs";
8
- import { readFile, readdir } from "node:fs/promises";
9
- import { extname, isAbsolute, join } from "node:path";
11
+ import { readFile } from "node:fs/promises";
12
+ import { dirname, isAbsolute, join } from "node:path";
10
13
  import { pathToFileURL } from "node:url";
11
14
  import { getConfiguration } from "@intlayer/config/node";
15
+ import { buildComponentFilesList } from "@intlayer/engine/utils";
12
16
  import { getUnmergedDictionaries } from "@intlayer/unmerged-dictionaries-entry";
13
17
  import { TextDocument } from "vscode-languageserver-textdocument";
14
18
 
@@ -27,91 +31,49 @@ let workspaceRoot;
27
31
  * The LSP process cwd is not the project root, so we must resolve the config
28
32
  * from `workspaceRoot` to get the right `system`/`build`/`content` paths.
29
33
  */
30
- let cachedConfig = null;
31
- /**
32
- * Cached unmerged dictionaries with a short TTL so that running
33
- * `intlayer build` during a session picks up without a full re-init.
34
- */
35
- let cachedDictionaries = null;
36
- let dictionariesCachedAt = 0;
34
+ const projectConfigs = /* @__PURE__ */ new Map();
35
+ const projectDictionaries = /* @__PURE__ */ new Map();
37
36
  const DICTIONARY_CACHE_TTL_MS = 5e3;
38
- let cachedSourceExtensions = null;
39
- let cachedExcludedDirs = null;
40
- let cachedFileExclusionRegexes = null;
41
- /**
42
- * Cached list of source file paths.
43
- * Walking the directory tree is the dominant cost on every Go-to-Definition
44
- * from a content file. We cache the result for 30 s and re-scan only when
45
- * the client reports a watched-file change or the config is invalidated.
46
- */
47
- let cachedSourceFiles = null;
48
- let sourceFilesCachedAt = 0;
37
+ const projectSourceFiles = /* @__PURE__ */ new Map();
49
38
  const SOURCE_FILES_CACHE_TTL_MS = 3e4;
50
39
  const invalidateConfigCaches = () => {
51
- cachedConfig = null;
52
- cachedDictionaries = null;
53
- dictionariesCachedAt = 0;
54
- cachedSourceExtensions = null;
55
- cachedExcludedDirs = null;
56
- cachedFileExclusionRegexes = null;
57
- cachedSourceFiles = null;
58
- sourceFilesCachedAt = 0;
40
+ projectConfigs.clear();
41
+ projectDictionaries.clear();
42
+ projectSourceFiles.clear();
59
43
  };
60
- const getWorkspaceConfig = () => {
61
- if (cachedConfig) return cachedConfig;
62
- if (!workspaceRoot) {
63
- log("getWorkspaceConfig — no workspace root, skipping");
64
- return null;
65
- }
44
+ const getProjectConfig = (absolutePath) => {
66
45
  try {
67
- log(`getWorkspaceConfig — loading config from: ${workspaceRoot}`);
68
- const { system, build, content } = getConfiguration({ baseDir: workspaceRoot });
69
- cachedConfig = {
70
- system,
71
- build,
72
- content
73
- };
74
- log(`getWorkspaceConfig — OK baseDir=${system.baseDir} unmergedDictionariesDir=${system.unmergedDictionariesDir}`);
75
- return cachedConfig;
46
+ const { system, build, content, compiler } = getConfiguration({ baseDir: dirname(absolutePath) });
47
+ const cacheKey = system.baseDir;
48
+ if (!projectConfigs.has(cacheKey)) {
49
+ projectConfigs.set(cacheKey, {
50
+ system,
51
+ build,
52
+ content,
53
+ compiler
54
+ });
55
+ log(`getProjectConfig — OK baseDir=${system.baseDir} unmergedDictionariesDir=${system.unmergedDictionariesDir}`);
56
+ }
57
+ return projectConfigs.get(cacheKey);
76
58
  } catch (error) {
77
- log(`getWorkspaceConfig — FAILED: ${error}`);
59
+ log(`getProjectConfig — FAILED: ${error}`);
78
60
  return null;
79
61
  }
80
62
  };
81
- const getUnmergedDictionariesCached = () => {
63
+ const getUnmergedDictionariesCached = (config) => {
64
+ const cacheKey = config.system.baseDir;
65
+ const cached = projectDictionaries.get(cacheKey);
82
66
  const now = Date.now();
83
- if (cachedDictionaries && now - dictionariesCachedAt < DICTIONARY_CACHE_TTL_MS) return cachedDictionaries;
84
- const config = getWorkspaceConfig();
85
- if (!config) return {};
86
- cachedDictionaries = getUnmergedDictionaries(config);
87
- dictionariesCachedAt = now;
88
- return cachedDictionaries;
89
- };
90
- const getSourceExtensions = () => {
91
- if (cachedSourceExtensions) return cachedSourceExtensions;
92
- const traversePattern = getWorkspaceConfig()?.build.traversePattern ?? [];
93
- cachedSourceExtensions = new Set(traversePattern.filter((pattern) => !pattern.startsWith("!")).flatMap((pattern) => {
94
- const match = pattern.match(/\.\{([^}]+)\}/);
95
- return match?.[1] ? match[1].split(",").map((extension) => `.${extension.trim()}`) : [];
96
- }));
97
- return cachedSourceExtensions;
98
- };
99
- const getExcludedDirs = () => {
100
- if (cachedExcludedDirs) return cachedExcludedDirs;
101
- const traversePattern = getWorkspaceConfig()?.build.traversePattern ?? [];
102
- cachedExcludedDirs = new Set(traversePattern.filter((pattern) => /^!\*\*\/[^*/]+\/\*\*$/.test(pattern)).map((pattern) => pattern.replace(/^!\*\*\//, "").replace(/\/\*\*$/, "")));
103
- return cachedExcludedDirs;
104
- };
105
- const getFileExclusionRegexes = () => {
106
- if (cachedFileExclusionRegexes) return cachedFileExclusionRegexes;
107
- cachedFileExclusionRegexes = (getWorkspaceConfig()?.build.traversePattern ?? []).filter((pattern) => pattern.startsWith("!") && !/^!\*\*\/[^*/]+\/\*\*$/.test(pattern)).map((pattern) => {
108
- const inner = pattern.replace(/^!(\*\*\/)*/, "");
109
- return new RegExp(`${inner.replace(/\./g, "\\.").replace(/\*/g, "[^\\/]*")}$`);
67
+ if (cached && now - cached.cachedAt < DICTIONARY_CACHE_TTL_MS) return cached.dictionaries;
68
+ const dictionaries = getUnmergedDictionaries(config);
69
+ projectDictionaries.set(cacheKey, {
70
+ dictionaries,
71
+ cachedAt: now
110
72
  });
111
- return cachedFileExclusionRegexes;
73
+ return dictionaries;
112
74
  };
113
- const isContentFile = (uri) => {
114
- return (getWorkspaceConfig()?.content.fileExtensions ?? []).some((extension) => uri.endsWith(extension));
75
+ const isContentFile = (uri, config) => {
76
+ return (config.content.fileExtensions ?? []).some((extension) => uri.endsWith(extension));
115
77
  };
116
78
  /** Read a file as UTF-8, returning null on any error. */
117
79
  const tryReadFile = async (filePath) => {
@@ -133,32 +95,6 @@ const runConcurrent = async (items, callback, limit = 50) => {
133
95
  };
134
96
  await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
135
97
  };
136
- /**
137
- * Recursively collect all source-file paths under `directory`.
138
- * Uses async readdir so it does not block the Node.js event loop.
139
- */
140
- const collectSourceFiles = async (directory) => {
141
- const results = [];
142
- const sourceExtensions = getSourceExtensions();
143
- const excludedDirs = getExcludedDirs();
144
- const fileExclusionRegexes = getFileExclusionRegexes();
145
- const contentExtensions = getWorkspaceConfig()?.content.fileExtensions ?? [];
146
- const visit = async (dir) => {
147
- let entries;
148
- try {
149
- entries = await readdir(dir, { withFileTypes: true });
150
- } catch {
151
- return;
152
- }
153
- await Promise.all(entries.map(async (entry) => {
154
- if (entry.isDirectory()) {
155
- if (!excludedDirs.has(entry.name)) await visit(join(dir, entry.name));
156
- } else if (sourceExtensions.has(extname(entry.name)) && !contentExtensions.some((extension) => entry.name.endsWith(extension)) && !fileExclusionRegexes.some((regularExpression) => regularExpression.test(entry.name))) results.push(join(dir, entry.name));
157
- }));
158
- };
159
- await visit(directory);
160
- return results;
161
- };
162
98
  /** Find the `key: "<key>"` line inside a content file so the cursor lands on it. */
163
99
  const getKeyRange = async (absolutePath, key) => {
164
100
  try {
@@ -176,10 +112,10 @@ const getKeyRange = async (absolutePath, key) => {
176
112
  * A key can be split across several content files (merged dictionaries), so we
177
113
  * return one `Location` per source file, pointing at the `key:` declaration.
178
114
  */
179
- const getContentDeclarationLocations = async (key) => {
180
- const config = getWorkspaceConfig();
115
+ const getContentDeclarationLocations = async (key, absolutePath) => {
116
+ const config = getProjectConfig(absolutePath);
181
117
  if (!config) return [];
182
- const unmergedDictionaries = getUnmergedDictionariesCached();
118
+ const unmergedDictionaries = getUnmergedDictionariesCached(config);
183
119
  const dictionaries = Object.values(unmergedDictionaries).flat().filter((dictionary) => dictionary?.key === key && dictionary.filePath);
184
120
  const locations = [];
185
121
  await runConcurrent(dictionaries, async (dictionary) => {
@@ -194,30 +130,51 @@ const getContentDeclarationLocations = async (key) => {
194
130
  * Return the cached source-file list, re-scanning only when the cache has
195
131
  * expired or been explicitly invalidated (config change, watched-file event).
196
132
  */
197
- const getSourceFiles = async (directory) => {
198
- if (cachedSourceFiles && Date.now() - sourceFilesCachedAt < SOURCE_FILES_CACHE_TTL_MS) return cachedSourceFiles;
133
+ const getSourceFiles = async (directory, config) => {
134
+ const now = Date.now();
135
+ const cached = projectSourceFiles.get(directory);
136
+ if (cached && now - cached.cachedAt < SOURCE_FILES_CACHE_TTL_MS) return cached.files;
199
137
  const startTime = Date.now();
200
- cachedSourceFiles = await collectSourceFiles(directory);
201
- sourceFilesCachedAt = Date.now();
202
- log(`source files scanned: ${cachedSourceFiles.length} file(s) in ${Date.now() - startTime}ms`);
203
- return cachedSourceFiles;
138
+ const files = buildComponentFilesList(config, [
139
+ "**/.output/**",
140
+ "**/.svelte-kit/**",
141
+ "**/src-tauri/**",
142
+ "**/.next/**",
143
+ "**/.nuxt/**",
144
+ "**/.expo/**",
145
+ "**/.vercel/**",
146
+ "**/.turbo/**",
147
+ "**/.tanstack/**",
148
+ "**/dist/**",
149
+ "**/build/**",
150
+ "**/node_modules/**"
151
+ ]);
152
+ projectSourceFiles.set(directory, {
153
+ files,
154
+ cachedAt: Date.now()
155
+ });
156
+ log(`source files scanned: ${files.length} file(s) in ${Date.now() - startTime}ms`);
157
+ return files;
204
158
  };
205
159
  /**
206
160
  * Return every source-file location that calls useIntlayer / getIntlayer with
207
161
  * the given key.
208
162
  */
209
- const getKeyUsageLocations = async (key) => {
210
- const config = getWorkspaceConfig();
163
+ const getKeyUsageLocations = async (key, absolutePath) => {
164
+ const config = getProjectConfig(absolutePath);
211
165
  if (!config) return [];
212
- const usageRegularExpression = new RegExp(`\\b(?:useIntlayer|getIntlayer)\\b\\s*(?:<[^<>()]*>)?\\s*\\(\\s*(['"\`])${escapeRegularExpression(key)}\\1`, "g");
213
- const filePaths = await getSourceFiles(config.system.baseDir);
166
+ const usagePatterns = buildKeyUsagePatterns(key);
167
+ const filePaths = await getSourceFiles(config.system.baseDir, config);
214
168
  const locations = [];
215
169
  const startTime = Date.now();
216
170
  await runConcurrent(filePaths, async (filePath) => {
217
171
  const text = await tryReadFile(filePath);
218
172
  if (!text?.includes(key)) return;
219
- for (const match of text.matchAll(usageRegularExpression)) {
173
+ const seenStarts = /* @__PURE__ */ new Set();
174
+ for (const usagePattern of usagePatterns) for (const match of text.matchAll(usagePattern)) {
220
175
  const matchStart = match.index;
176
+ if (seenStarts.has(matchStart)) continue;
177
+ seenStarts.add(matchStart);
221
178
  const matchEnd = matchStart + match[0].length;
222
179
  locations.push(Location.create(pathToFileURL(filePath).href, offsetToRange(text, matchStart, matchEnd)));
223
180
  }
@@ -234,14 +191,14 @@ const getKeyUsageLocations = async (key) => {
234
191
  * Single-element paths (e.g. `['greet']`) behave like the previous
235
192
  * single-`fieldName` API.
236
193
  */
237
- const getFieldUsageLocations = async (dictionaryKey, fieldPath) => {
238
- const config = getWorkspaceConfig();
194
+ const getFieldUsageLocations = async (dictionaryKey, fieldPath, absolutePath) => {
195
+ const config = getProjectConfig(absolutePath);
239
196
  if (!config || fieldPath.length === 0) return [];
240
197
  const leafFieldName = fieldPath[fieldPath.length - 1];
241
198
  const escapedKey = escapeRegularExpression(dictionaryKey);
242
199
  const escapedLeaf = escapeRegularExpression(leafFieldName);
243
- const useCallRegularExpression = new RegExp(`\\b(?:useIntlayer|getIntlayer)\\b(?:<[^<>()]*>)?\\s*\\(\\s*['"\`]${escapedKey}['"\`]`);
244
- const filePaths = await getSourceFiles(config.system.baseDir);
200
+ const useCallRegularExpression = buildCallerWithKeyPattern(dictionaryKey);
201
+ const filePaths = await getSourceFiles(config.system.baseDir, config);
245
202
  const locations = [];
246
203
  const startTime = Date.now();
247
204
  await runConcurrent(filePaths, async (filePath) => {
@@ -256,15 +213,17 @@ const getFieldUsageLocations = async (dictionaryKey, fieldPath) => {
256
213
  return locations;
257
214
  };
258
215
  /**
259
- * Find the line/column of `fieldName` as a property key inside a content file.
260
- * Looks for `fieldName:` (the field definition), not just any occurrence.
261
- * Falls back to the top of the file if the field cannot be located.
216
+ * Find the line/column of the field's leaf name as a property key inside a
217
+ * content file. Looks for `fieldName:` or `'field.name':` (flat keys with
218
+ * dots, as used by lingui catalogs), not just any occurrence. Falls back to
219
+ * the top of the file if the field cannot be located.
262
220
  */
263
- const getFieldRangeInContentFile = async (absolutePath, fieldName) => {
221
+ const getFieldRangeInContentFile = async (absolutePath, fieldPath) => {
264
222
  const content = await tryReadFile(absolutePath);
265
- if (!content) return Range.create(Position.create(0, 0), Position.create(0, 0));
223
+ const fieldName = fieldPath[fieldPath.length - 1];
224
+ if (!content || !fieldName) return Range.create(Position.create(0, 0), Position.create(0, 0));
225
+ const fieldRegularExpression = new RegExp(`(?<![.\\w])(['"\`]?)${escapeRegularExpression(fieldName)}\\1\\s*:`);
266
226
  const lines = content.split(/\r?\n/);
267
- const fieldRegularExpression = new RegExp(`(?<![.\\w])${escapeRegularExpression(fieldName)}(?![\\w])\\s*:`);
268
227
  for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
269
228
  const line = lines[lineIndex];
270
229
  const matchPosition = line.search(fieldRegularExpression);
@@ -276,23 +235,105 @@ const getFieldRangeInContentFile = async (absolutePath, fieldName) => {
276
235
  return Range.create(Position.create(0, 0), Position.create(0, 0));
277
236
  };
278
237
  /**
279
- * Return every content-file location where `fieldName` is defined as a
280
- * property of the dictionary with the given `dictionaryKey`.
238
+ * Return every content-file location where the field at `fieldPath` is
239
+ * defined as a property of the dictionary with the given `dictionaryKey`.
281
240
  */
282
- const getContentFieldLocations = async (dictionaryKey, fieldName) => {
283
- const config = getWorkspaceConfig();
241
+ const getContentFieldLocations = async (dictionaryKey, fieldPath, absolutePath) => {
242
+ const config = getProjectConfig(absolutePath);
284
243
  if (!config) return [];
285
- const unmergedDictionaries = getUnmergedDictionariesCached();
244
+ const unmergedDictionaries = getUnmergedDictionariesCached(config);
286
245
  const dictionaries = Object.values(unmergedDictionaries).flat().filter((dictionary) => dictionary?.key === dictionaryKey && dictionary.filePath);
287
246
  const locations = [];
288
247
  await runConcurrent(dictionaries, async (dictionary) => {
289
248
  const filePath = dictionary.filePath;
290
249
  const absolutePath = isAbsolute(filePath) ? filePath : join(config.system.baseDir, filePath);
291
250
  if (!existsSync(absolutePath)) return;
292
- locations.push(Location.create(pathToFileURL(absolutePath).href, await getFieldRangeInContentFile(absolutePath, fieldName)));
251
+ locations.push(Location.create(pathToFileURL(absolutePath).href, await getFieldRangeInContentFile(absolutePath, fieldPath)));
293
252
  });
294
253
  return locations;
295
254
  };
255
+ const DIAGNOSTIC_DEBOUNCE_MS = 300;
256
+ const diagnosticTimers = /* @__PURE__ */ new Map();
257
+ const computeDiagnostics = (text, config) => {
258
+ const namespaceReferences = collectNamespaceReferences(text);
259
+ if (namespaceReferences.length === 0) return [];
260
+ const unmergedDictionaries = getUnmergedDictionariesCached(config);
261
+ const knownKeys = new Set(Object.values(unmergedDictionaries).flat().map((dictionary) => dictionary?.key).filter((key) => typeof key === "string"));
262
+ const diagnostics = [];
263
+ for (const reference of namespaceReferences) {
264
+ if (knownKeys.has(reference.dictionaryKey)) continue;
265
+ diagnostics.push({
266
+ range: offsetToRange(text, reference.namespaceStart, reference.namespaceEnd),
267
+ severity: DiagnosticSeverity.Warning,
268
+ source: "intlayer",
269
+ message: `Dictionary key "${reference.dictionaryKey}" is not declared in any content file`
270
+ });
271
+ }
272
+ return diagnostics;
273
+ };
274
+ const sendDiagnosticsForDocument = (uri, text, version) => {
275
+ const config = getProjectConfig(decodeURIComponent(new URL(uri).pathname));
276
+ const diagnostics = config && !isContentFile(uri, config) ? computeDiagnostics(text, config) : [];
277
+ connection.sendDiagnostics({
278
+ uri,
279
+ version: version ?? void 0,
280
+ diagnostics
281
+ });
282
+ };
283
+ const scheduleDiagnostics = (uri, text, version) => {
284
+ const existing = diagnosticTimers.get(uri);
285
+ if (existing !== void 0) clearTimeout(existing);
286
+ diagnosticTimers.set(uri, setTimeout(() => {
287
+ diagnosticTimers.delete(uri);
288
+ sendDiagnosticsForDocument(uri, text, version);
289
+ }, DIAGNOSTIC_DEBOUNCE_MS));
290
+ };
291
+ /** Find the dictionary key for a simple variable assigned from any registered caller. */
292
+ const resolveVariableToDictionaryKey = (program, varName) => {
293
+ let result = null;
294
+ walkAst(program, (node) => {
295
+ if (result) return true;
296
+ if (node["type"] !== "VariableDeclarator") return;
297
+ const id = node["id"];
298
+ if (id?.["type"] !== "Identifier" || id["name"] !== varName) return;
299
+ const init = node["init"];
300
+ if (!init) return;
301
+ result = matchCallNamespace(init);
302
+ return true;
303
+ });
304
+ return result;
305
+ };
306
+ /**
307
+ * Detect what kind of completion is appropriate at the given offset.
308
+ *
309
+ * Handles:
310
+ * - key completion: cursor inside first string arg of useIntlayer/getIntlayer
311
+ * - field (member): cursor after `variable.` where variable came from useIntlayer
312
+ * - field (destructure): cursor inside `const { | } = useIntlayer("key")`
313
+ */
314
+ const getCompletionContext = (textBefore, textAfter, program) => {
315
+ if (new RegExp(`\\b(?:${getCallerNamesAlternation()})\\b(?:<[^<>]*>)?\\s*\\(\\s*['"\`][^'"\`\\n]*$`).test(textBefore)) return { kind: "key" };
316
+ const memberMatch = /\b(\w+)((?:\.\w+)*)\.$/.exec(textBefore);
317
+ if (memberMatch && program) {
318
+ const rootVar = memberMatch[1];
319
+ const chainStr = memberMatch[2];
320
+ const dictKey = resolveVariableToDictionaryKey(program, rootVar);
321
+ if (dictKey) return {
322
+ kind: "field",
323
+ dictionaryKey: dictKey,
324
+ fieldPath: chainStr ? chainStr.slice(1).split(".") : []
325
+ };
326
+ }
327
+ if (/(?:const|let|var)\s*\{[^}]*$/.test(textBefore)) {
328
+ const destructMatch = new RegExp(`^[^}]*\\}\\s*=\\s*(?:${getCallerNamesAlternation()})(?:<[^<>]*>)?\\s*\\(\\s*['"\`]([^'"\`\\n]+)['"\`]`).exec(textAfter);
329
+ if (destructMatch) return {
330
+ kind: "field",
331
+ dictionaryKey: destructMatch[1],
332
+ fieldPath: []
333
+ };
334
+ }
335
+ return null;
336
+ };
296
337
  connection.onInitialize((parameters) => {
297
338
  workspaceRoot = parameters.rootPath || (parameters.workspaceFolders?.[0]?.uri ? decodeURIComponent(new URL(parameters.workspaceFolders[0].uri).pathname) : void 0);
298
339
  invalidateConfigCaches();
@@ -301,11 +342,22 @@ connection.onInitialize((parameters) => {
301
342
  return { capabilities: {
302
343
  textDocumentSync: TextDocumentSyncKind.Incremental,
303
344
  definitionProvider: true,
304
- referencesProvider: true
345
+ referencesProvider: true,
346
+ hoverProvider: true,
347
+ completionProvider: {
348
+ triggerCharacters: [
349
+ "\"",
350
+ "'",
351
+ "`",
352
+ "."
353
+ ],
354
+ resolveProvider: false
355
+ }
305
356
  } };
306
357
  });
307
358
  connection.onDefinition(async (parameters) => {
308
359
  const uri = parameters.textDocument.uri;
360
+ const absolutePath = decodeURIComponent(new URL(uri).pathname);
309
361
  const position = parameters.position;
310
362
  const document = documents.get(uri);
311
363
  if (!document) {
@@ -315,37 +367,27 @@ connection.onDefinition(async (parameters) => {
315
367
  const text = document.getText();
316
368
  const offset = document.offsetAt(position);
317
369
  log(`onDefinition — ${uri.replace(/^file:\/\//, "").split("/").slice(-2).join("/")} ${position.line}:${position.character}`);
318
- if (isContentFile(uri)) {
319
- log("onDefinition — detected content file");
320
- const key = findKeyInContentFile(text, offset);
321
- if (key) {
322
- log(`onDefinition — key declaration: "${key}" → searching call sites`);
323
- const locations = await getKeyUsageLocations(key);
324
- log(`onDefinition — found ${locations.length} call site(s) for key "${key}"`);
325
- return locations.length ? locations : null;
326
- }
327
- const fieldInfo = findContentFieldAtOffset(text, offset);
328
- if (fieldInfo) {
329
- log(`onDefinition — field: "${fieldInfo.dictionaryKey}.${fieldInfo.fieldPath.join(".")}" → searching usages`);
330
- const locations = await getFieldUsageLocations(fieldInfo.dictionaryKey, fieldInfo.fieldPath);
331
- log(`onDefinition — found ${locations.length} usage(s) for field path "${fieldInfo.fieldPath.join(".")}"`);
332
- return locations.length ? locations : null;
333
- }
334
- log("onDefinition — cursor not on a key or field declaration, no result");
370
+ const config = getProjectConfig(absolutePath);
371
+ if (config && isContentFile(uri, config)) {
372
+ log("onDefinition — content file: delegating to extension provider");
335
373
  return null;
336
374
  }
337
375
  const key = findKeyAtOffset(text, offset);
338
376
  if (key) {
339
377
  log(`onDefinition — useIntlayer key: "${key}" → searching content declarations`);
340
- const locations = await getContentDeclarationLocations(key);
378
+ const locations = await getContentDeclarationLocations(key, absolutePath);
341
379
  log(`onDefinition — found ${locations.length} content declaration(s) for key "${key}"`);
342
380
  return locations.length ? locations : null;
343
381
  }
344
- const usageField = findUsageFieldAtOffset(text, offset);
345
- if (usageField) {
346
- log(`onDefinition — usage field: "${usageField.dictionaryKey}.${usageField.fieldName}" → searching content field`);
347
- const locations = await getContentFieldLocations(usageField.dictionaryKey, usageField.fieldName);
348
- log(`onDefinition — found ${locations.length} content file(s) with field "${usageField.fieldName}"`);
382
+ const usage = findMessageUsageAtOffset(text, offset);
383
+ if (usage && usage.fieldPath.length > 0) {
384
+ log(`onDefinition — usage field: "${usage.dictionaryKey}.${usage.fieldPath.join(".")}" (${usage.callerName}) → searching content field`);
385
+ const locations = await getContentFieldLocations(usage.dictionaryKey, usage.fieldPath, absolutePath);
386
+ log(`onDefinition — found ${locations.length} content file(s) with field "${usage.fieldPath.join(".")}"`);
387
+ return locations.length ? locations : null;
388
+ }
389
+ if (usage) {
390
+ const locations = await getContentDeclarationLocations(usage.dictionaryKey, absolutePath);
349
391
  return locations.length ? locations : null;
350
392
  }
351
393
  log("onDefinition — no Intlayer key at cursor, no result");
@@ -362,22 +404,25 @@ connection.onDefinition(async (parameters) => {
362
404
  */
363
405
  connection.onReferences(async (parameters) => {
364
406
  const uri = parameters.textDocument.uri;
407
+ const absolutePath = decodeURIComponent(new URL(uri).pathname);
365
408
  const position = parameters.position;
366
409
  const document = documents.get(uri);
367
410
  if (!document) return null;
368
411
  const text = document.getText();
369
412
  const offset = document.offsetAt(position);
370
413
  log(`onReferences — ${uri.replace(/^file:\/\//, "").split("/").slice(-2).join("/")} ${position.line}:${position.character}`);
371
- if (isContentFile(uri)) {
414
+ const config = getProjectConfig(absolutePath);
415
+ if (config && isContentFile(uri, config)) {
372
416
  const key = findKeyInContentFile(text, offset);
373
417
  if (key) {
374
- const locations = await getKeyUsageLocations(key);
418
+ const locations = await getKeyUsageLocations(key, absolutePath);
375
419
  log(`onReferences — key "${key}" → ${locations.length} call site(s)`);
376
420
  return locations.length ? locations : null;
377
421
  }
378
- const fieldInfo = findContentFieldAtOffset(text, offset);
422
+ const extMatch = uri.match(/\.[^.]+$/);
423
+ const fieldInfo = findContentFieldAtOffset(text, offset, extMatch ? extMatch[0] : "");
379
424
  if (fieldInfo) {
380
- const locations = await getFieldUsageLocations(fieldInfo.dictionaryKey, fieldInfo.fieldPath);
425
+ const locations = await getFieldUsageLocations(fieldInfo.dictionaryKey, fieldInfo.fieldPath, absolutePath);
381
426
  log(`onReferences — field "${fieldInfo.fieldPath.join(".")}" → ${locations.length} usage(s)`);
382
427
  return locations.length ? locations : null;
383
428
  }
@@ -385,23 +430,146 @@ connection.onReferences(async (parameters) => {
385
430
  }
386
431
  const key = findKeyAtOffset(text, offset);
387
432
  if (key) {
388
- const locations = await getContentDeclarationLocations(key);
433
+ const locations = await getContentDeclarationLocations(key, absolutePath);
389
434
  log(`onReferences — key "${key}" → ${locations.length} declaration(s)`);
390
435
  return locations.length ? locations : null;
391
436
  }
392
- const usageField = findUsageFieldAtOffset(text, offset);
393
- if (usageField) {
394
- const locations = await getContentFieldLocations(usageField.dictionaryKey, usageField.fieldName);
395
- log(`onReferences — field "${usageField.fieldName}" → ${locations.length} content definition(s)`);
437
+ const usage = findMessageUsageAtOffset(text, offset);
438
+ if (usage && usage.fieldPath.length > 0) {
439
+ const locations = await getContentFieldLocations(usage.dictionaryKey, usage.fieldPath, absolutePath);
440
+ log(`onReferences — field "${usage.fieldPath.join(".")}" → ${locations.length} content definition(s)`);
396
441
  return locations.length ? locations : null;
397
442
  }
398
443
  return null;
399
444
  });
445
+ connection.onHover(async (parameters) => {
446
+ const uri = parameters.textDocument.uri;
447
+ const absolutePath = decodeURIComponent(new URL(uri).pathname);
448
+ const document = documents.get(uri);
449
+ if (!document) return null;
450
+ const text = document.getText();
451
+ const offset = document.offsetAt(parameters.position);
452
+ const config = getProjectConfig(absolutePath);
453
+ if (!config) return null;
454
+ const unmergedDictionaries = getUnmergedDictionariesCached(config);
455
+ const getDicts = (key) => Object.values(unmergedDictionaries).flat().filter((d) => d?.key === key);
456
+ if (isContentFile(uri, config)) {
457
+ const key = findKeyInContentFile(text, offset);
458
+ if (key) {
459
+ const dicts = getDicts(key);
460
+ if (dicts.length > 0) return { contents: {
461
+ kind: MarkupKind.Markdown,
462
+ value: formatDictionaryHover(dicts, key)
463
+ } };
464
+ }
465
+ const fieldInfo = findContentFieldAtOffset(text, offset, uri.match(/\.[^.]+$/)?.[0] ?? "");
466
+ if (fieldInfo) {
467
+ const dicts = getDicts(fieldInfo.dictionaryKey);
468
+ if (dicts.length > 0) {
469
+ const hoverText = formatFieldHover(dicts, fieldInfo.dictionaryKey, fieldInfo.fieldPath);
470
+ if (hoverText) return { contents: {
471
+ kind: MarkupKind.Markdown,
472
+ value: hoverText
473
+ } };
474
+ }
475
+ }
476
+ return null;
477
+ }
478
+ const key = findKeyAtOffset(text, offset);
479
+ if (key) {
480
+ const dicts = getDicts(key);
481
+ if (dicts.length > 0) return { contents: {
482
+ kind: MarkupKind.Markdown,
483
+ value: formatDictionaryHover(dicts, key)
484
+ } };
485
+ return null;
486
+ }
487
+ const usage = findMessageUsageAtOffset(text, offset);
488
+ if (usage) {
489
+ const dicts = getDicts(usage.dictionaryKey);
490
+ if (dicts.length > 0) {
491
+ if (usage.fieldPath.length === 0) return { contents: {
492
+ kind: MarkupKind.Markdown,
493
+ value: formatDictionaryHover(dicts, usage.dictionaryKey)
494
+ } };
495
+ const hoverText = formatFieldHover(dicts, usage.dictionaryKey, usage.fieldPath);
496
+ if (hoverText) return { contents: {
497
+ kind: MarkupKind.Markdown,
498
+ value: hoverText
499
+ } };
500
+ }
501
+ }
502
+ return null;
503
+ });
504
+ connection.onCompletion(async (parameters) => {
505
+ const uri = parameters.textDocument.uri;
506
+ const absolutePath = decodeURIComponent(new URL(uri).pathname);
507
+ const document = documents.get(uri);
508
+ if (!document) return null;
509
+ const text = document.getText();
510
+ const offset = document.offsetAt(parameters.position);
511
+ const config = getProjectConfig(absolutePath);
512
+ if (!config) return null;
513
+ const program = parseText(text);
514
+ const context = getCompletionContext(text.slice(0, offset), text.slice(offset), program);
515
+ if (!context) return null;
516
+ const unmergedDictionaries = getUnmergedDictionariesCached(config);
517
+ if (context.kind === "key") {
518
+ const seen = /* @__PURE__ */ new Set();
519
+ const items = [];
520
+ for (const dictionary of Object.values(unmergedDictionaries).flat()) {
521
+ if (!dictionary?.key || seen.has(dictionary.key)) continue;
522
+ seen.add(dictionary.key);
523
+ items.push({
524
+ label: dictionary.key,
525
+ kind: CompletionItemKind.Value,
526
+ detail: dictionary.title ?? void 0,
527
+ documentation: dictionary.description ? {
528
+ kind: MarkupKind.Markdown,
529
+ value: dictionary.description
530
+ } : void 0
531
+ });
532
+ }
533
+ log(`onCompletion — key: ${items.length} dictionary keys`);
534
+ return items.length > 0 ? items : null;
535
+ }
536
+ if (context.kind === "field") {
537
+ const dictionaries = Object.values(unmergedDictionaries).flat().filter((dictionary) => dictionary?.key === context.dictionaryKey);
538
+ if (dictionaries.length === 0) return null;
539
+ const primary = dictionaries[0];
540
+ const items = getFieldsAtPath(primary.content, context.fieldPath).map((fieldName) => {
541
+ const value = getFieldByPath(primary.content, [...context.fieldPath, fieldName]);
542
+ return {
543
+ label: fieldName,
544
+ kind: CompletionItemKind.Field,
545
+ detail: formatFieldValue(value)
546
+ };
547
+ });
548
+ log(`onCompletion — field "${context.dictionaryKey}.${context.fieldPath.join(".")}": ${items.length} field(s)`);
549
+ return items.length > 0 ? items : null;
550
+ }
551
+ return null;
552
+ });
553
+ documents.onDidOpen((event) => {
554
+ sendDiagnosticsForDocument(event.document.uri, event.document.getText(), event.document.version);
555
+ });
556
+ documents.onDidChangeContent((event) => {
557
+ scheduleDiagnostics(event.document.uri, event.document.getText(), event.document.version);
558
+ });
559
+ documents.onDidClose((event) => {
560
+ const uri = event.document.uri;
561
+ const timer = diagnosticTimers.get(uri);
562
+ if (timer !== void 0) {
563
+ clearTimeout(timer);
564
+ diagnosticTimers.delete(uri);
565
+ }
566
+ connection.sendDiagnostics({
567
+ uri,
568
+ diagnostics: []
569
+ });
570
+ });
400
571
  connection.onDidChangeWatchedFiles(() => {
401
- cachedSourceFiles = null;
402
- sourceFilesCachedAt = 0;
403
- cachedDictionaries = null;
404
- dictionariesCachedAt = 0;
572
+ invalidateConfigCaches();
405
573
  log("watched files changed — source file list and dictionary cache invalidated");
406
574
  });
407
575
  documents.listen(connection);