@intlayer/lsp 8.11.4 → 9.0.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.
Files changed (47) 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 +343 -172
  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 +695 -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 +5 -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 +11 -0
  30. package/dist/types/dictionaryUtils.d.ts.map +1 -0
  31. package/dist/types/findFieldInFile.d.ts +19 -22
  32. package/dist/types/findFieldInFile.d.ts.map +1 -1
  33. package/dist/types/findKeyAtOffset.d.ts +15 -12
  34. package/dist/types/findKeyAtOffset.d.ts.map +1 -1
  35. package/dist/types/findKeyInContentFile.d.ts +23 -21
  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 +14 -13
  40. package/dist/types/findUsageFieldAtOffset.d.ts.map +1 -1
  41. package/dist/types/index.d.ts +1 -1
  42. package/dist/types/oxcUtils.d.ts +44 -15
  43. package/dist/types/oxcUtils.d.ts.map +1 -1
  44. package/dist/types/usageAnalyzer.d.ts +141 -0
  45. package/dist/types/usageAnalyzer.d.ts.map +1 -0
  46. package/dist/types/utils.d.ts +15 -1
  47. package/package.json +11 -24
@@ -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,106 @@ 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 absolutePath = decodeURIComponent(new URL(uri).pathname);
276
+ const config = getProjectConfig(absolutePath);
277
+ const diagnostics = config && !isContentFile(uri, config) ? computeDiagnostics(text, config) : [];
278
+ connection.sendDiagnostics({
279
+ uri,
280
+ version: version ?? void 0,
281
+ diagnostics
282
+ });
283
+ };
284
+ const scheduleDiagnostics = (uri, text, version) => {
285
+ const existing = diagnosticTimers.get(uri);
286
+ if (existing !== void 0) clearTimeout(existing);
287
+ diagnosticTimers.set(uri, setTimeout(() => {
288
+ diagnosticTimers.delete(uri);
289
+ sendDiagnosticsForDocument(uri, text, version);
290
+ }, DIAGNOSTIC_DEBOUNCE_MS));
291
+ };
292
+ /** Find the dictionary key for a simple variable assigned from any registered caller. */
293
+ const resolveVariableToDictionaryKey = (program, varName) => {
294
+ let result = null;
295
+ walkAst(program, (node) => {
296
+ if (result) return true;
297
+ if (node["type"] !== "VariableDeclarator") return;
298
+ const id = node["id"];
299
+ if (id?.["type"] !== "Identifier" || id["name"] !== varName) return;
300
+ const init = node["init"];
301
+ if (!init) return;
302
+ result = matchCallNamespace(init);
303
+ return true;
304
+ });
305
+ return result;
306
+ };
307
+ /**
308
+ * Detect what kind of completion is appropriate at the given offset.
309
+ *
310
+ * Handles:
311
+ * - key completion: cursor inside first string arg of useIntlayer/getIntlayer
312
+ * - field (member): cursor after `variable.` where variable came from useIntlayer
313
+ * - field (destructure): cursor inside `const { | } = useIntlayer("key")`
314
+ */
315
+ const getCompletionContext = (textBefore, textAfter, program) => {
316
+ if (new RegExp(`\\b(?:${getCallerNamesAlternation()})\\b(?:<[^<>]*>)?\\s*\\(\\s*['"\`][^'"\`\\n]*$`).test(textBefore)) return { kind: "key" };
317
+ const memberMatch = /\b(\w+)((?:\.\w+)*)\.$/.exec(textBefore);
318
+ if (memberMatch && program) {
319
+ const rootVar = memberMatch[1];
320
+ const chainStr = memberMatch[2];
321
+ const dictKey = resolveVariableToDictionaryKey(program, rootVar);
322
+ if (dictKey) return {
323
+ kind: "field",
324
+ dictionaryKey: dictKey,
325
+ fieldPath: chainStr ? chainStr.slice(1).split(".") : []
326
+ };
327
+ }
328
+ if (/(?:const|let|var)\s*\{[^}]*$/.test(textBefore)) {
329
+ const destructMatch = new RegExp(`^[^}]*\\}\\s*=\\s*(?:${getCallerNamesAlternation()})(?:<[^<>]*>)?\\s*\\(\\s*['"\`]([^'"\`\\n]+)['"\`]`).exec(textAfter);
330
+ if (destructMatch) return {
331
+ kind: "field",
332
+ dictionaryKey: destructMatch[1],
333
+ fieldPath: []
334
+ };
335
+ }
336
+ return null;
337
+ };
296
338
  connection.onInitialize((parameters) => {
297
339
  workspaceRoot = parameters.rootPath || (parameters.workspaceFolders?.[0]?.uri ? decodeURIComponent(new URL(parameters.workspaceFolders[0].uri).pathname) : void 0);
298
340
  invalidateConfigCaches();
@@ -301,11 +343,22 @@ connection.onInitialize((parameters) => {
301
343
  return { capabilities: {
302
344
  textDocumentSync: TextDocumentSyncKind.Incremental,
303
345
  definitionProvider: true,
304
- referencesProvider: true
346
+ referencesProvider: true,
347
+ hoverProvider: true,
348
+ completionProvider: {
349
+ triggerCharacters: [
350
+ "\"",
351
+ "'",
352
+ "`",
353
+ "."
354
+ ],
355
+ resolveProvider: false
356
+ }
305
357
  } };
306
358
  });
307
359
  connection.onDefinition(async (parameters) => {
308
360
  const uri = parameters.textDocument.uri;
361
+ const absolutePath = decodeURIComponent(new URL(uri).pathname);
309
362
  const position = parameters.position;
310
363
  const document = documents.get(uri);
311
364
  if (!document) {
@@ -314,38 +367,29 @@ connection.onDefinition(async (parameters) => {
314
367
  }
315
368
  const text = document.getText();
316
369
  const offset = document.offsetAt(position);
317
- 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 shortenedUri = uri.replace(/^file:\/\//, "").split("/").slice(-2).join("/");
371
+ log(`onDefinition — ${shortenedUri} ${position.line}:${position.character}`);
372
+ const config = getProjectConfig(absolutePath);
373
+ if (config && isContentFile(uri, config)) {
374
+ log("onDefinition — content file: delegating to extension provider");
335
375
  return null;
336
376
  }
337
377
  const key = findKeyAtOffset(text, offset);
338
378
  if (key) {
339
379
  log(`onDefinition — useIntlayer key: "${key}" → searching content declarations`);
340
- const locations = await getContentDeclarationLocations(key);
380
+ const locations = await getContentDeclarationLocations(key, absolutePath);
341
381
  log(`onDefinition — found ${locations.length} content declaration(s) for key "${key}"`);
342
382
  return locations.length ? locations : null;
343
383
  }
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}"`);
384
+ const usage = findMessageUsageAtOffset(text, offset);
385
+ if (usage && usage.fieldPath.length > 0) {
386
+ log(`onDefinition — usage field: "${usage.dictionaryKey}.${usage.fieldPath.join(".")}" (${usage.callerName}) → searching content field`);
387
+ const locations = await getContentFieldLocations(usage.dictionaryKey, usage.fieldPath, absolutePath);
388
+ log(`onDefinition — found ${locations.length} content file(s) with field "${usage.fieldPath.join(".")}"`);
389
+ return locations.length ? locations : null;
390
+ }
391
+ if (usage) {
392
+ const locations = await getContentDeclarationLocations(usage.dictionaryKey, absolutePath);
349
393
  return locations.length ? locations : null;
350
394
  }
351
395
  log("onDefinition — no Intlayer key at cursor, no result");
@@ -362,22 +406,26 @@ connection.onDefinition(async (parameters) => {
362
406
  */
363
407
  connection.onReferences(async (parameters) => {
364
408
  const uri = parameters.textDocument.uri;
409
+ const absolutePath = decodeURIComponent(new URL(uri).pathname);
365
410
  const position = parameters.position;
366
411
  const document = documents.get(uri);
367
412
  if (!document) return null;
368
413
  const text = document.getText();
369
414
  const offset = document.offsetAt(position);
370
- log(`onReferences — ${uri.replace(/^file:\/\//, "").split("/").slice(-2).join("/")} ${position.line}:${position.character}`);
371
- if (isContentFile(uri)) {
415
+ const shortenedUri = uri.replace(/^file:\/\//, "").split("/").slice(-2).join("/");
416
+ log(`onReferences — ${shortenedUri} ${position.line}:${position.character}`);
417
+ const config = getProjectConfig(absolutePath);
418
+ if (config && isContentFile(uri, config)) {
372
419
  const key = findKeyInContentFile(text, offset);
373
420
  if (key) {
374
- const locations = await getKeyUsageLocations(key);
421
+ const locations = await getKeyUsageLocations(key, absolutePath);
375
422
  log(`onReferences — key "${key}" → ${locations.length} call site(s)`);
376
423
  return locations.length ? locations : null;
377
424
  }
378
- const fieldInfo = findContentFieldAtOffset(text, offset);
425
+ const extMatch = uri.match(/\.[^.]+$/);
426
+ const fieldInfo = findContentFieldAtOffset(text, offset, extMatch ? extMatch[0] : "");
379
427
  if (fieldInfo) {
380
- const locations = await getFieldUsageLocations(fieldInfo.dictionaryKey, fieldInfo.fieldPath);
428
+ const locations = await getFieldUsageLocations(fieldInfo.dictionaryKey, fieldInfo.fieldPath, absolutePath);
381
429
  log(`onReferences — field "${fieldInfo.fieldPath.join(".")}" → ${locations.length} usage(s)`);
382
430
  return locations.length ? locations : null;
383
431
  }
@@ -385,23 +433,146 @@ connection.onReferences(async (parameters) => {
385
433
  }
386
434
  const key = findKeyAtOffset(text, offset);
387
435
  if (key) {
388
- const locations = await getContentDeclarationLocations(key);
436
+ const locations = await getContentDeclarationLocations(key, absolutePath);
389
437
  log(`onReferences — key "${key}" → ${locations.length} declaration(s)`);
390
438
  return locations.length ? locations : null;
391
439
  }
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)`);
440
+ const usage = findMessageUsageAtOffset(text, offset);
441
+ if (usage && usage.fieldPath.length > 0) {
442
+ const locations = await getContentFieldLocations(usage.dictionaryKey, usage.fieldPath, absolutePath);
443
+ log(`onReferences — field "${usage.fieldPath.join(".")}" → ${locations.length} content definition(s)`);
396
444
  return locations.length ? locations : null;
397
445
  }
398
446
  return null;
399
447
  });
448
+ connection.onHover(async (parameters) => {
449
+ const uri = parameters.textDocument.uri;
450
+ const absolutePath = decodeURIComponent(new URL(uri).pathname);
451
+ const document = documents.get(uri);
452
+ if (!document) return null;
453
+ const text = document.getText();
454
+ const offset = document.offsetAt(parameters.position);
455
+ const config = getProjectConfig(absolutePath);
456
+ if (!config) return null;
457
+ const unmergedDictionaries = getUnmergedDictionariesCached(config);
458
+ const getDicts = (key) => Object.values(unmergedDictionaries).flat().filter((d) => d?.key === key);
459
+ if (isContentFile(uri, config)) {
460
+ const key = findKeyInContentFile(text, offset);
461
+ if (key) {
462
+ const dicts = getDicts(key);
463
+ if (dicts.length > 0) return { contents: {
464
+ kind: MarkupKind.Markdown,
465
+ value: formatDictionaryHover(dicts, key)
466
+ } };
467
+ }
468
+ const fieldInfo = findContentFieldAtOffset(text, offset, uri.match(/\.[^.]+$/)?.[0] ?? "");
469
+ if (fieldInfo) {
470
+ const dicts = getDicts(fieldInfo.dictionaryKey);
471
+ if (dicts.length > 0) {
472
+ const hoverText = formatFieldHover(dicts, fieldInfo.dictionaryKey, fieldInfo.fieldPath);
473
+ if (hoverText) return { contents: {
474
+ kind: MarkupKind.Markdown,
475
+ value: hoverText
476
+ } };
477
+ }
478
+ }
479
+ return null;
480
+ }
481
+ const key = findKeyAtOffset(text, offset);
482
+ if (key) {
483
+ const dicts = getDicts(key);
484
+ if (dicts.length > 0) return { contents: {
485
+ kind: MarkupKind.Markdown,
486
+ value: formatDictionaryHover(dicts, key)
487
+ } };
488
+ return null;
489
+ }
490
+ const usage = findMessageUsageAtOffset(text, offset);
491
+ if (usage) {
492
+ const dicts = getDicts(usage.dictionaryKey);
493
+ if (dicts.length > 0) {
494
+ if (usage.fieldPath.length === 0) return { contents: {
495
+ kind: MarkupKind.Markdown,
496
+ value: formatDictionaryHover(dicts, usage.dictionaryKey)
497
+ } };
498
+ const hoverText = formatFieldHover(dicts, usage.dictionaryKey, usage.fieldPath);
499
+ if (hoverText) return { contents: {
500
+ kind: MarkupKind.Markdown,
501
+ value: hoverText
502
+ } };
503
+ }
504
+ }
505
+ return null;
506
+ });
507
+ connection.onCompletion(async (parameters) => {
508
+ const uri = parameters.textDocument.uri;
509
+ const absolutePath = decodeURIComponent(new URL(uri).pathname);
510
+ const document = documents.get(uri);
511
+ if (!document) return null;
512
+ const text = document.getText();
513
+ const offset = document.offsetAt(parameters.position);
514
+ const config = getProjectConfig(absolutePath);
515
+ if (!config) return null;
516
+ const program = parseText(text);
517
+ const context = getCompletionContext(text.slice(0, offset), text.slice(offset), program);
518
+ if (!context) return null;
519
+ const unmergedDictionaries = getUnmergedDictionariesCached(config);
520
+ if (context.kind === "key") {
521
+ const seen = /* @__PURE__ */ new Set();
522
+ const items = [];
523
+ for (const dictionary of Object.values(unmergedDictionaries).flat()) {
524
+ if (!dictionary?.key || seen.has(dictionary.key)) continue;
525
+ seen.add(dictionary.key);
526
+ items.push({
527
+ label: dictionary.key,
528
+ kind: CompletionItemKind.Value,
529
+ detail: dictionary.title ?? void 0,
530
+ documentation: dictionary.description ? {
531
+ kind: MarkupKind.Markdown,
532
+ value: dictionary.description
533
+ } : void 0
534
+ });
535
+ }
536
+ log(`onCompletion — key: ${items.length} dictionary keys`);
537
+ return items.length > 0 ? items : null;
538
+ }
539
+ if (context.kind === "field") {
540
+ const dictionaries = Object.values(unmergedDictionaries).flat().filter((dictionary) => dictionary?.key === context.dictionaryKey);
541
+ if (dictionaries.length === 0) return null;
542
+ const primary = dictionaries[0];
543
+ const items = getFieldsAtPath(primary.content, context.fieldPath).map((fieldName) => {
544
+ const value = getFieldByPath(primary.content, [...context.fieldPath, fieldName]);
545
+ return {
546
+ label: fieldName,
547
+ kind: CompletionItemKind.Field,
548
+ detail: formatFieldValue(value)
549
+ };
550
+ });
551
+ log(`onCompletion — field "${context.dictionaryKey}.${context.fieldPath.join(".")}": ${items.length} field(s)`);
552
+ return items.length > 0 ? items : null;
553
+ }
554
+ return null;
555
+ });
556
+ documents.onDidOpen((event) => {
557
+ sendDiagnosticsForDocument(event.document.uri, event.document.getText(), event.document.version);
558
+ });
559
+ documents.onDidChangeContent((event) => {
560
+ scheduleDiagnostics(event.document.uri, event.document.getText(), event.document.version);
561
+ });
562
+ documents.onDidClose((event) => {
563
+ const uri = event.document.uri;
564
+ const timer = diagnosticTimers.get(uri);
565
+ if (timer !== void 0) {
566
+ clearTimeout(timer);
567
+ diagnosticTimers.delete(uri);
568
+ }
569
+ connection.sendDiagnostics({
570
+ uri,
571
+ diagnostics: []
572
+ });
573
+ });
400
574
  connection.onDidChangeWatchedFiles(() => {
401
- cachedSourceFiles = null;
402
- sourceFilesCachedAt = 0;
403
- cachedDictionaries = null;
404
- dictionariesCachedAt = 0;
575
+ invalidateConfigCaches();
405
576
  log("watched files changed — source file list and dictionary cache invalidated");
406
577
  });
407
578
  documents.listen(connection);