@intlayer/lsp 8.11.3 → 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 +53 -0
  9. package/dist/esm/findFieldInFile.mjs.map +1 -0
  10. package/dist/esm/findKeyAtOffset.mjs +20 -10
  11. package/dist/esm/findKeyAtOffset.mjs.map +1 -1
  12. package/dist/esm/findKeyInContentFile.mjs +350 -0
  13. package/dist/esm/findKeyInContentFile.mjs.map +1 -0
  14. package/dist/esm/findTCallAtOffset.mjs +33 -0
  15. package/dist/esm/findTCallAtOffset.mjs.map +1 -0
  16. package/dist/esm/findUsageFieldAtOffset.mjs +33 -0
  17. package/dist/esm/findUsageFieldAtOffset.mjs.map +1 -0
  18. package/dist/esm/index.mjs +533 -38
  19. package/dist/esm/index.mjs.map +1 -1
  20. package/dist/esm/oxcUtils.mjs +130 -0
  21. package/dist/esm/oxcUtils.mjs.map +1 -0
  22. package/dist/esm/usageAnalyzer.mjs +692 -0
  23. package/dist/esm/usageAnalyzer.mjs.map +1 -0
  24. package/dist/esm/utils.mjs +17 -0
  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 +28 -0
  32. package/dist/types/findFieldInFile.d.ts.map +1 -0
  33. package/dist/types/findKeyAtOffset.d.ts +14 -3
  34. package/dist/types/findKeyAtOffset.d.ts.map +1 -1
  35. package/dist/types/findKeyInContentFile.d.ts +33 -0
  36. package/dist/types/findKeyInContentFile.d.ts.map +1 -0
  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 +23 -0
  40. package/dist/types/findUsageFieldAtOffset.d.ts.map +1 -0
  41. package/dist/types/oxcUtils.d.ts +53 -0
  42. package/dist/types/oxcUtils.d.ts.map +1 -0
  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 +19 -0
  46. package/package.json +18 -24
@@ -1,82 +1,577 @@
1
1
  #!/usr/bin/env node
2
+ import { parseText, walkAst } from "./oxcUtils.mjs";
3
+ import { collectNamespaceReferences, findMessageUsageAtOffset, matchCallNamespace } from "./usageAnalyzer.mjs";
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";
2
7
  import { findKeyAtOffset } from "./findKeyAtOffset.mjs";
3
- import { existsSync, readFileSync } from "node:fs";
4
- import { isAbsolute, join } from "node:path";
8
+ import { findContentFieldAtOffset, findKeyInContentFile } from "./findKeyInContentFile.mjs";
9
+ import { CompletionItemKind, DiagnosticSeverity, Location, MarkupKind, Position, ProposedFeatures, Range, TextDocumentSyncKind, TextDocuments, createConnection } from "vscode-languageserver/node";
10
+ import { existsSync } from "node:fs";
11
+ import { readFile } from "node:fs/promises";
12
+ import { dirname, isAbsolute, join } from "node:path";
5
13
  import { pathToFileURL } from "node:url";
6
- import { getConfiguration } from "@intlayer/config";
14
+ import { getConfiguration } from "@intlayer/config/node";
15
+ import { buildComponentFilesList } from "@intlayer/engine/utils";
7
16
  import { getUnmergedDictionaries } from "@intlayer/unmerged-dictionaries-entry";
8
- import { Location, Position, ProposedFeatures, Range, TextDocumentSyncKind, TextDocuments, createConnection } from "vscode-languageserver/node.js";
9
17
  import { TextDocument } from "vscode-languageserver-textdocument";
10
18
 
11
19
  //#region src/index.ts
12
20
  const connection = createConnection(ProposedFeatures.all);
13
21
  const documents = new TextDocuments(TextDocument);
22
+ /**
23
+ * Send a log line to the client's Output panel (View → Output → "Intlayer LSP").
24
+ * Uses connection.console.log so it travels over the JSON-RPC channel and never
25
+ * touches stdout (which is reserved for the LSP protocol).
26
+ */
27
+ const log = (message) => connection.console.log(`[Intlayer LSP] ${message}`);
14
28
  let workspaceRoot;
15
29
  /**
16
30
  * Resolve (and cache) the Intlayer configuration for the current workspace.
17
31
  * The LSP process cwd is not the project root, so we must resolve the config
18
- * from `workspaceRoot` to get the right `system`/`build` paths.
32
+ * from `workspaceRoot` to get the right `system`/`build`/`content` paths.
19
33
  */
20
- let cachedConfig = null;
21
- const getWorkspaceConfig = () => {
22
- if (cachedConfig) return cachedConfig;
23
- if (!workspaceRoot) return null;
24
- const { system, build } = getConfiguration({ baseDir: workspaceRoot });
25
- cachedConfig = {
26
- system,
27
- build
34
+ const projectConfigs = /* @__PURE__ */ new Map();
35
+ const projectDictionaries = /* @__PURE__ */ new Map();
36
+ const DICTIONARY_CACHE_TTL_MS = 5e3;
37
+ const projectSourceFiles = /* @__PURE__ */ new Map();
38
+ const SOURCE_FILES_CACHE_TTL_MS = 3e4;
39
+ const invalidateConfigCaches = () => {
40
+ projectConfigs.clear();
41
+ projectDictionaries.clear();
42
+ projectSourceFiles.clear();
43
+ };
44
+ const getProjectConfig = (absolutePath) => {
45
+ try {
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);
58
+ } catch (error) {
59
+ log(`getProjectConfig — FAILED: ${error}`);
60
+ return null;
61
+ }
62
+ };
63
+ const getUnmergedDictionariesCached = (config) => {
64
+ const cacheKey = config.system.baseDir;
65
+ const cached = projectDictionaries.get(cacheKey);
66
+ const now = Date.now();
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
72
+ });
73
+ return dictionaries;
74
+ };
75
+ const isContentFile = (uri, config) => {
76
+ return (config.content.fileExtensions ?? []).some((extension) => uri.endsWith(extension));
77
+ };
78
+ /** Read a file as UTF-8, returning null on any error. */
79
+ const tryReadFile = async (filePath) => {
80
+ try {
81
+ return await readFile(filePath, "utf-8");
82
+ } catch {
83
+ return null;
84
+ }
85
+ };
86
+ /**
87
+ * Run `callback` over every item with at most `limit` tasks in-flight at once.
88
+ * JS is single-threaded so `results.push` inside callback is race-free.
89
+ */
90
+ const runConcurrent = async (items, callback, limit = 50) => {
91
+ if (items.length === 0) return;
92
+ let index = 0;
93
+ const worker = async () => {
94
+ while (index < items.length) await callback(items[index++]);
28
95
  };
29
- return cachedConfig;
96
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
97
+ };
98
+ /** Find the `key: "<key>"` line inside a content file so the cursor lands on it. */
99
+ const getKeyRange = async (absolutePath, key) => {
100
+ try {
101
+ const lines = (await readFile(absolutePath, "utf-8")).split(/\r?\n/);
102
+ const keyRegularExpression = new RegExp(`key\\s*:\\s*['"\`]${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}['"\`]`);
103
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
104
+ const column = lines[lineIndex]?.search(keyRegularExpression);
105
+ if (column !== void 0 && column !== -1) return Range.create(Position.create(lineIndex, column), Position.create(lineIndex, lines[lineIndex].length));
106
+ }
107
+ } catch {}
108
+ return Range.create(Position.create(0, 0), Position.create(0, 0));
30
109
  };
31
110
  /**
32
111
  * Return every `.content` file location that declares the given key.
33
112
  * A key can be split across several content files (merged dictionaries), so we
34
113
  * return one `Location` per source file, pointing at the `key:` declaration.
35
114
  */
36
- const getContentDeclarationLocations = (key) => {
37
- const config = getWorkspaceConfig();
115
+ const getContentDeclarationLocations = async (key, absolutePath) => {
116
+ const config = getProjectConfig(absolutePath);
38
117
  if (!config) return [];
39
- const unmergedDictionaries = getUnmergedDictionaries(config);
118
+ const unmergedDictionaries = getUnmergedDictionariesCached(config);
40
119
  const dictionaries = Object.values(unmergedDictionaries).flat().filter((dictionary) => dictionary?.key === key && dictionary.filePath);
41
120
  const locations = [];
42
- for (const dictionary of dictionaries) {
121
+ await runConcurrent(dictionaries, async (dictionary) => {
43
122
  const filePath = dictionary.filePath;
44
123
  const absolutePath = isAbsolute(filePath) ? filePath : join(config.system.baseDir, filePath);
45
- if (!existsSync(absolutePath)) continue;
46
- locations.push(Location.create(pathToFileURL(absolutePath).href, getKeyRange(absolutePath, key)));
47
- }
124
+ if (!existsSync(absolutePath)) return;
125
+ locations.push(Location.create(pathToFileURL(absolutePath).href, await getKeyRange(absolutePath, key)));
126
+ });
48
127
  return locations;
49
128
  };
50
- /** Find the `key: "<key>"` line inside a content file so the cursor lands on it. */
51
- const getKeyRange = (absolutePath, key) => {
52
- try {
53
- const lines = readFileSync(absolutePath, "utf-8").split(/\r?\n/);
54
- const keyRegex = new RegExp(`key\\s*:\\s*['"\`]${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}['"\`]`);
55
- for (let line = 0; line < lines.length; line++) {
56
- const column = lines[line].search(keyRegex);
57
- if (column !== -1) return Range.create(Position.create(line, column), Position.create(line, lines[line].length));
129
+ /**
130
+ * Return the cached source-file list, re-scanning only when the cache has
131
+ * expired or been explicitly invalidated (config change, watched-file event).
132
+ */
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;
137
+ const startTime = Date.now();
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;
158
+ };
159
+ /**
160
+ * Return every source-file location that calls useIntlayer / getIntlayer with
161
+ * the given key.
162
+ */
163
+ const getKeyUsageLocations = async (key, absolutePath) => {
164
+ const config = getProjectConfig(absolutePath);
165
+ if (!config) return [];
166
+ const usagePatterns = buildKeyUsagePatterns(key);
167
+ const filePaths = await getSourceFiles(config.system.baseDir, config);
168
+ const locations = [];
169
+ const startTime = Date.now();
170
+ await runConcurrent(filePaths, async (filePath) => {
171
+ const text = await tryReadFile(filePath);
172
+ if (!text?.includes(key)) return;
173
+ const seenStarts = /* @__PURE__ */ new Set();
174
+ for (const usagePattern of usagePatterns) for (const match of text.matchAll(usagePattern)) {
175
+ const matchStart = match.index;
176
+ if (seenStarts.has(matchStart)) continue;
177
+ seenStarts.add(matchStart);
178
+ const matchEnd = matchStart + match[0].length;
179
+ locations.push(Location.create(pathToFileURL(filePath).href, offsetToRange(text, matchStart, matchEnd)));
58
180
  }
59
- } catch {}
181
+ });
182
+ log(`getKeyUsageLocations("${key}") — ${filePaths.length} file(s) searched in ${Date.now() - startTime}ms → ${locations.length} hit(s)`);
183
+ return locations;
184
+ };
185
+ /**
186
+ * Return every source-file location that accesses the field at `fieldPath` on
187
+ * the result of `useIntlayer(dictionaryKey)` / `getIntlayer(dictionaryKey)`.
188
+ *
189
+ * `fieldPath` is an array of property names from the content root to the
190
+ * target field (e.g. `['searchInput', 'text']` for a nested field).
191
+ * Single-element paths (e.g. `['greet']`) behave like the previous
192
+ * single-`fieldName` API.
193
+ */
194
+ const getFieldUsageLocations = async (dictionaryKey, fieldPath, absolutePath) => {
195
+ const config = getProjectConfig(absolutePath);
196
+ if (!config || fieldPath.length === 0) return [];
197
+ const leafFieldName = fieldPath[fieldPath.length - 1];
198
+ const escapedKey = escapeRegularExpression(dictionaryKey);
199
+ const escapedLeaf = escapeRegularExpression(leafFieldName);
200
+ const useCallRegularExpression = buildCallerWithKeyPattern(dictionaryKey);
201
+ const filePaths = await getSourceFiles(config.system.baseDir, config);
202
+ const locations = [];
203
+ const startTime = Date.now();
204
+ await runConcurrent(filePaths, async (filePath) => {
205
+ const text = await tryReadFile(filePath);
206
+ if (!text) return;
207
+ if (!text.includes(dictionaryKey)) return;
208
+ if (fieldPath.some((segment) => !text.includes(segment))) return;
209
+ if (!useCallRegularExpression.test(text)) return;
210
+ for (const range of findFieldRangesInFile(text, escapedKey, leafFieldName, escapedLeaf, fieldPath)) locations.push(Location.create(pathToFileURL(filePath).href, range));
211
+ });
212
+ log(`getFieldUsageLocations("${dictionaryKey}.${fieldPath.join(".")}") — ${filePaths.length} file(s) searched in ${Date.now() - startTime}ms → ${locations.length} hit(s)`);
213
+ return locations;
214
+ };
215
+ /**
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.
220
+ */
221
+ const getFieldRangeInContentFile = async (absolutePath, fieldPath) => {
222
+ const content = await tryReadFile(absolutePath);
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*:`);
226
+ const lines = content.split(/\r?\n/);
227
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
228
+ const line = lines[lineIndex];
229
+ const matchPosition = line.search(fieldRegularExpression);
230
+ if (matchPosition === -1) continue;
231
+ const column = line.indexOf(fieldName, matchPosition);
232
+ if (column === -1) continue;
233
+ return Range.create(Position.create(lineIndex, column), Position.create(lineIndex, column + fieldName.length));
234
+ }
60
235
  return Range.create(Position.create(0, 0), Position.create(0, 0));
61
236
  };
62
- connection.onInitialize((params) => {
63
- workspaceRoot = params.rootPath || (params.workspaceFolders?.[0]?.uri ? decodeURIComponent(new URL(params.workspaceFolders[0].uri).pathname) : void 0);
64
- cachedConfig = null;
237
+ /**
238
+ * Return every content-file location where the field at `fieldPath` is
239
+ * defined as a property of the dictionary with the given `dictionaryKey`.
240
+ */
241
+ const getContentFieldLocations = async (dictionaryKey, fieldPath, absolutePath) => {
242
+ const config = getProjectConfig(absolutePath);
243
+ if (!config) return [];
244
+ const unmergedDictionaries = getUnmergedDictionariesCached(config);
245
+ const dictionaries = Object.values(unmergedDictionaries).flat().filter((dictionary) => dictionary?.key === dictionaryKey && dictionary.filePath);
246
+ const locations = [];
247
+ await runConcurrent(dictionaries, async (dictionary) => {
248
+ const filePath = dictionary.filePath;
249
+ const absolutePath = isAbsolute(filePath) ? filePath : join(config.system.baseDir, filePath);
250
+ if (!existsSync(absolutePath)) return;
251
+ locations.push(Location.create(pathToFileURL(absolutePath).href, await getFieldRangeInContentFile(absolutePath, fieldPath)));
252
+ });
253
+ return locations;
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
+ };
337
+ connection.onInitialize((parameters) => {
338
+ workspaceRoot = parameters.rootPath || (parameters.workspaceFolders?.[0]?.uri ? decodeURIComponent(new URL(parameters.workspaceFolders[0].uri).pathname) : void 0);
339
+ invalidateConfigCaches();
340
+ log(`initialized — workspaceRoot: ${workspaceRoot ?? "(none)"}`);
341
+ if (parameters.workspaceFolders?.length) log(`workspaceFolders: ${parameters.workspaceFolders.map((folder) => folder.name).join(", ")}`);
65
342
  return { capabilities: {
66
343
  textDocumentSync: TextDocumentSyncKind.Incremental,
67
- definitionProvider: true
344
+ definitionProvider: true,
345
+ referencesProvider: true,
346
+ hoverProvider: true,
347
+ completionProvider: {
348
+ triggerCharacters: [
349
+ "\"",
350
+ "'",
351
+ "`",
352
+ "."
353
+ ],
354
+ resolveProvider: false
355
+ }
68
356
  } };
69
357
  });
70
- connection.onDefinition((params) => {
71
- const document = documents.get(params.textDocument.uri);
358
+ connection.onDefinition(async (parameters) => {
359
+ const uri = parameters.textDocument.uri;
360
+ const absolutePath = decodeURIComponent(new URL(uri).pathname);
361
+ const position = parameters.position;
362
+ const document = documents.get(uri);
363
+ if (!document) {
364
+ log(`onDefinition — document not found in cache: ${uri}`);
365
+ return null;
366
+ }
367
+ const text = document.getText();
368
+ const offset = document.offsetAt(position);
369
+ log(`onDefinition — ${uri.replace(/^file:\/\//, "").split("/").slice(-2).join("/")} ${position.line}:${position.character}`);
370
+ const config = getProjectConfig(absolutePath);
371
+ if (config && isContentFile(uri, config)) {
372
+ log("onDefinition — content file: delegating to extension provider");
373
+ return null;
374
+ }
375
+ const key = findKeyAtOffset(text, offset);
376
+ if (key) {
377
+ log(`onDefinition — useIntlayer key: "${key}" → searching content declarations`);
378
+ const locations = await getContentDeclarationLocations(key, absolutePath);
379
+ log(`onDefinition — found ${locations.length} content declaration(s) for key "${key}"`);
380
+ return locations.length ? locations : null;
381
+ }
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);
391
+ return locations.length ? locations : null;
392
+ }
393
+ log("onDefinition — no Intlayer key at cursor, no result");
394
+ return null;
395
+ });
396
+ /**
397
+ * "Find All References" handler (Shift+F12).
398
+ *
399
+ * From a content file, returns every source-file location that uses the key or
400
+ * field at the cursor — the reverse direction of Go-to-Definition.
401
+ *
402
+ * From a source file, returns all content-file declarations for the key or
403
+ * field at the cursor — complementing what Go-to-Definition already handles.
404
+ */
405
+ connection.onReferences(async (parameters) => {
406
+ const uri = parameters.textDocument.uri;
407
+ const absolutePath = decodeURIComponent(new URL(uri).pathname);
408
+ const position = parameters.position;
409
+ const document = documents.get(uri);
72
410
  if (!document) return null;
73
- const key = findKeyAtOffset(document.getText(), document.offsetAt(params.position));
411
+ const text = document.getText();
412
+ const offset = document.offsetAt(position);
413
+ log(`onReferences — ${uri.replace(/^file:\/\//, "").split("/").slice(-2).join("/")} ${position.line}:${position.character}`);
414
+ const config = getProjectConfig(absolutePath);
415
+ if (config && isContentFile(uri, config)) {
416
+ const key = findKeyInContentFile(text, offset);
417
+ if (key) {
418
+ const locations = await getKeyUsageLocations(key, absolutePath);
419
+ log(`onReferences — key "${key}" → ${locations.length} call site(s)`);
420
+ return locations.length ? locations : null;
421
+ }
422
+ const extMatch = uri.match(/\.[^.]+$/);
423
+ const fieldInfo = findContentFieldAtOffset(text, offset, extMatch ? extMatch[0] : "");
424
+ if (fieldInfo) {
425
+ const locations = await getFieldUsageLocations(fieldInfo.dictionaryKey, fieldInfo.fieldPath, absolutePath);
426
+ log(`onReferences — field "${fieldInfo.fieldPath.join(".")}" → ${locations.length} usage(s)`);
427
+ return locations.length ? locations : null;
428
+ }
429
+ return null;
430
+ }
431
+ const key = findKeyAtOffset(text, offset);
74
432
  if (key) {
75
- const locations = getContentDeclarationLocations(key);
433
+ const locations = await getContentDeclarationLocations(key, absolutePath);
434
+ log(`onReferences — key "${key}" → ${locations.length} declaration(s)`);
435
+ return locations.length ? locations : null;
436
+ }
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)`);
76
441
  return locations.length ? locations : null;
77
442
  }
78
443
  return null;
79
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
+ });
571
+ connection.onDidChangeWatchedFiles(() => {
572
+ invalidateConfigCaches();
573
+ log("watched files changed — source file list and dictionary cache invalidated");
574
+ });
80
575
  documents.listen(connection);
81
576
  connection.listen();
82
577