@intlayer/lsp 8.11.2 → 8.11.4

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.
@@ -1,103 +1,409 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync, readFileSync } from "node:fs";
3
- import { isAbsolute, join } from "node:path";
2
+ import { escapeRegularExpression, findFieldRangesInFile, offsetToRange } from "./findFieldInFile.mjs";
3
+ import { findKeyAtOffset } from "./findKeyAtOffset.mjs";
4
+ 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";
7
+ import { existsSync } from "node:fs";
8
+ import { readFile, readdir } from "node:fs/promises";
9
+ import { extname, isAbsolute, join } from "node:path";
4
10
  import { pathToFileURL } from "node:url";
5
- import { getConfiguration } from "@intlayer/config";
11
+ import { getConfiguration } from "@intlayer/config/node";
6
12
  import { getUnmergedDictionaries } from "@intlayer/unmerged-dictionaries-entry";
7
- import { Location, Position, ProposedFeatures, Range, TextDocumentSyncKind, TextDocuments, createConnection } from "vscode-languageserver/node.js";
8
13
  import { TextDocument } from "vscode-languageserver-textdocument";
9
14
 
10
15
  //#region src/index.ts
11
- /**
12
- * Intlayer content getters that take a dictionary key as their first argument.
13
- * They are exported (under the same names) by every `*-intlayer` package, so we
14
- * only need to match the call name + the string-literal key to support all of:
15
- * next-intlayer, react-intlayer, vue-intlayer, svelte-intlayer,
16
- * preact-intlayer, solid-intlayer, angular-intlayer, lit-intlayer,
17
- * express-intlayer, hono-intlayer, fastify-intlayer, adonis-intlayer, intlayer
18
- */
19
- const INTLAYER_GETTERS = [
20
- "useIntlayer",
21
- "getIntlayer",
22
- "useDictionary",
23
- "getDictionary"
24
- ];
25
- const buildGetterRegex = () => new RegExp(`\\b(${INTLAYER_GETTERS.join("|")})\\b\\s*(?:<[^<>()]*>)?\\s*\\(\\s*(['"\`])([^'"\`]+)\\2`, "g");
26
16
  const connection = createConnection(ProposedFeatures.all);
27
17
  const documents = new TextDocuments(TextDocument);
18
+ /**
19
+ * Send a log line to the client's Output panel (View → Output → "Intlayer LSP").
20
+ * Uses connection.console.log so it travels over the JSON-RPC channel and never
21
+ * touches stdout (which is reserved for the LSP protocol).
22
+ */
23
+ const log = (message) => connection.console.log(`[Intlayer LSP] ${message}`);
28
24
  let workspaceRoot;
29
25
  /**
30
26
  * Resolve (and cache) the Intlayer configuration for the current workspace.
31
27
  * The LSP process cwd is not the project root, so we must resolve the config
32
- * from `workspaceRoot` to get the right `system`/`build` paths.
28
+ * from `workspaceRoot` to get the right `system`/`build`/`content` paths.
33
29
  */
34
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;
37
+ 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;
49
+ const SOURCE_FILES_CACHE_TTL_MS = 3e4;
50
+ 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;
59
+ };
35
60
  const getWorkspaceConfig = () => {
36
61
  if (cachedConfig) return cachedConfig;
37
- if (!workspaceRoot) return null;
38
- const { system, build } = getConfiguration({ baseDir: workspaceRoot });
39
- cachedConfig = {
40
- system,
41
- build
62
+ if (!workspaceRoot) {
63
+ log("getWorkspaceConfig — no workspace root, skipping");
64
+ return null;
65
+ }
66
+ 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;
76
+ } catch (error) {
77
+ log(`getWorkspaceConfig — FAILED: ${error}`);
78
+ return null;
79
+ }
80
+ };
81
+ const getUnmergedDictionariesCached = () => {
82
+ 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, "[^\\/]*")}$`);
110
+ });
111
+ return cachedFileExclusionRegexes;
112
+ };
113
+ const isContentFile = (uri) => {
114
+ return (getWorkspaceConfig()?.content.fileExtensions ?? []).some((extension) => uri.endsWith(extension));
115
+ };
116
+ /** Read a file as UTF-8, returning null on any error. */
117
+ const tryReadFile = async (filePath) => {
118
+ try {
119
+ return await readFile(filePath, "utf-8");
120
+ } catch {
121
+ return null;
122
+ }
123
+ };
124
+ /**
125
+ * Run `callback` over every item with at most `limit` tasks in-flight at once.
126
+ * JS is single-threaded so `results.push` inside callback is race-free.
127
+ */
128
+ const runConcurrent = async (items, callback, limit = 50) => {
129
+ if (items.length === 0) return;
130
+ let index = 0;
131
+ const worker = async () => {
132
+ while (index < items.length) await callback(items[index++]);
42
133
  };
43
- return cachedConfig;
134
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
135
+ };
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
+ /** Find the `key: "<key>"` line inside a content file so the cursor lands on it. */
163
+ const getKeyRange = async (absolutePath, key) => {
164
+ try {
165
+ const lines = (await readFile(absolutePath, "utf-8")).split(/\r?\n/);
166
+ const keyRegularExpression = new RegExp(`key\\s*:\\s*['"\`]${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}['"\`]`);
167
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
168
+ const column = lines[lineIndex]?.search(keyRegularExpression);
169
+ if (column !== void 0 && column !== -1) return Range.create(Position.create(lineIndex, column), Position.create(lineIndex, lines[lineIndex].length));
170
+ }
171
+ } catch {}
172
+ return Range.create(Position.create(0, 0), Position.create(0, 0));
44
173
  };
45
174
  /**
46
175
  * Return every `.content` file location that declares the given key.
47
176
  * A key can be split across several content files (merged dictionaries), so we
48
177
  * return one `Location` per source file, pointing at the `key:` declaration.
49
178
  */
50
- const getContentDeclarationLocations = (key) => {
179
+ const getContentDeclarationLocations = async (key) => {
51
180
  const config = getWorkspaceConfig();
52
181
  if (!config) return [];
53
- const unmergedDictionaries = getUnmergedDictionaries(config);
182
+ const unmergedDictionaries = getUnmergedDictionariesCached();
54
183
  const dictionaries = Object.values(unmergedDictionaries).flat().filter((dictionary) => dictionary?.key === key && dictionary.filePath);
55
184
  const locations = [];
56
- for (const dictionary of dictionaries) {
185
+ await runConcurrent(dictionaries, async (dictionary) => {
57
186
  const filePath = dictionary.filePath;
58
187
  const absolutePath = isAbsolute(filePath) ? filePath : join(config.system.baseDir, filePath);
59
- if (!existsSync(absolutePath)) continue;
60
- locations.push(Location.create(pathToFileURL(absolutePath).href, getKeyRange(absolutePath, key)));
61
- }
188
+ if (!existsSync(absolutePath)) return;
189
+ locations.push(Location.create(pathToFileURL(absolutePath).href, await getKeyRange(absolutePath, key)));
190
+ });
62
191
  return locations;
63
192
  };
64
- /** Find the `key: "<key>"` line inside a content file so the cursor lands on it. */
65
- const getKeyRange = (absolutePath, key) => {
66
- try {
67
- const lines = readFileSync(absolutePath, "utf-8").split(/\r?\n/);
68
- const keyRegex = new RegExp(`key\\s*:\\s*['"\`]${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}['"\`]`);
69
- for (let line = 0; line < lines.length; line++) {
70
- const column = lines[line].search(keyRegex);
71
- if (column !== -1) return Range.create(Position.create(line, column), Position.create(line, lines[line].length));
193
+ /**
194
+ * Return the cached source-file list, re-scanning only when the cache has
195
+ * expired or been explicitly invalidated (config change, watched-file event).
196
+ */
197
+ const getSourceFiles = async (directory) => {
198
+ if (cachedSourceFiles && Date.now() - sourceFilesCachedAt < SOURCE_FILES_CACHE_TTL_MS) return cachedSourceFiles;
199
+ 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;
204
+ };
205
+ /**
206
+ * Return every source-file location that calls useIntlayer / getIntlayer with
207
+ * the given key.
208
+ */
209
+ const getKeyUsageLocations = async (key) => {
210
+ const config = getWorkspaceConfig();
211
+ 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);
214
+ const locations = [];
215
+ const startTime = Date.now();
216
+ await runConcurrent(filePaths, async (filePath) => {
217
+ const text = await tryReadFile(filePath);
218
+ if (!text?.includes(key)) return;
219
+ for (const match of text.matchAll(usageRegularExpression)) {
220
+ const matchStart = match.index;
221
+ const matchEnd = matchStart + match[0].length;
222
+ locations.push(Location.create(pathToFileURL(filePath).href, offsetToRange(text, matchStart, matchEnd)));
72
223
  }
73
- } catch {}
224
+ });
225
+ log(`getKeyUsageLocations("${key}") — ${filePaths.length} file(s) searched in ${Date.now() - startTime}ms → ${locations.length} hit(s)`);
226
+ return locations;
227
+ };
228
+ /**
229
+ * Return every source-file location that accesses the field at `fieldPath` on
230
+ * the result of `useIntlayer(dictionaryKey)` / `getIntlayer(dictionaryKey)`.
231
+ *
232
+ * `fieldPath` is an array of property names from the content root to the
233
+ * target field (e.g. `['searchInput', 'text']` for a nested field).
234
+ * Single-element paths (e.g. `['greet']`) behave like the previous
235
+ * single-`fieldName` API.
236
+ */
237
+ const getFieldUsageLocations = async (dictionaryKey, fieldPath) => {
238
+ const config = getWorkspaceConfig();
239
+ if (!config || fieldPath.length === 0) return [];
240
+ const leafFieldName = fieldPath[fieldPath.length - 1];
241
+ const escapedKey = escapeRegularExpression(dictionaryKey);
242
+ const escapedLeaf = escapeRegularExpression(leafFieldName);
243
+ const useCallRegularExpression = new RegExp(`\\b(?:useIntlayer|getIntlayer)\\b(?:<[^<>()]*>)?\\s*\\(\\s*['"\`]${escapedKey}['"\`]`);
244
+ const filePaths = await getSourceFiles(config.system.baseDir);
245
+ const locations = [];
246
+ const startTime = Date.now();
247
+ await runConcurrent(filePaths, async (filePath) => {
248
+ const text = await tryReadFile(filePath);
249
+ if (!text) return;
250
+ if (!text.includes(dictionaryKey)) return;
251
+ if (fieldPath.some((segment) => !text.includes(segment))) return;
252
+ if (!useCallRegularExpression.test(text)) return;
253
+ for (const range of findFieldRangesInFile(text, escapedKey, leafFieldName, escapedLeaf, fieldPath)) locations.push(Location.create(pathToFileURL(filePath).href, range));
254
+ });
255
+ log(`getFieldUsageLocations("${dictionaryKey}.${fieldPath.join(".")}") — ${filePaths.length} file(s) searched in ${Date.now() - startTime}ms → ${locations.length} hit(s)`);
256
+ return locations;
257
+ };
258
+ /**
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.
262
+ */
263
+ const getFieldRangeInContentFile = async (absolutePath, fieldName) => {
264
+ const content = await tryReadFile(absolutePath);
265
+ if (!content) return Range.create(Position.create(0, 0), Position.create(0, 0));
266
+ const lines = content.split(/\r?\n/);
267
+ const fieldRegularExpression = new RegExp(`(?<![.\\w])${escapeRegularExpression(fieldName)}(?![\\w])\\s*:`);
268
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
269
+ const line = lines[lineIndex];
270
+ const matchPosition = line.search(fieldRegularExpression);
271
+ if (matchPosition === -1) continue;
272
+ const column = line.indexOf(fieldName, matchPosition);
273
+ if (column === -1) continue;
274
+ return Range.create(Position.create(lineIndex, column), Position.create(lineIndex, column + fieldName.length));
275
+ }
74
276
  return Range.create(Position.create(0, 0), Position.create(0, 0));
75
277
  };
76
- connection.onInitialize((params) => {
77
- workspaceRoot = params.rootPath || (params.workspaceFolders?.[0]?.uri ? decodeURIComponent(new URL(params.workspaceFolders[0].uri).pathname) : void 0);
78
- cachedConfig = null;
278
+ /**
279
+ * Return every content-file location where `fieldName` is defined as a
280
+ * property of the dictionary with the given `dictionaryKey`.
281
+ */
282
+ const getContentFieldLocations = async (dictionaryKey, fieldName) => {
283
+ const config = getWorkspaceConfig();
284
+ if (!config) return [];
285
+ const unmergedDictionaries = getUnmergedDictionariesCached();
286
+ const dictionaries = Object.values(unmergedDictionaries).flat().filter((dictionary) => dictionary?.key === dictionaryKey && dictionary.filePath);
287
+ const locations = [];
288
+ await runConcurrent(dictionaries, async (dictionary) => {
289
+ const filePath = dictionary.filePath;
290
+ const absolutePath = isAbsolute(filePath) ? filePath : join(config.system.baseDir, filePath);
291
+ if (!existsSync(absolutePath)) return;
292
+ locations.push(Location.create(pathToFileURL(absolutePath).href, await getFieldRangeInContentFile(absolutePath, fieldName)));
293
+ });
294
+ return locations;
295
+ };
296
+ connection.onInitialize((parameters) => {
297
+ workspaceRoot = parameters.rootPath || (parameters.workspaceFolders?.[0]?.uri ? decodeURIComponent(new URL(parameters.workspaceFolders[0].uri).pathname) : void 0);
298
+ invalidateConfigCaches();
299
+ log(`initialized — workspaceRoot: ${workspaceRoot ?? "(none)"}`);
300
+ if (parameters.workspaceFolders?.length) log(`workspaceFolders: ${parameters.workspaceFolders.map((folder) => folder.name).join(", ")}`);
79
301
  return { capabilities: {
80
302
  textDocumentSync: TextDocumentSyncKind.Incremental,
81
- definitionProvider: true
303
+ definitionProvider: true,
304
+ referencesProvider: true
82
305
  } };
83
306
  });
84
- connection.onDefinition((params) => {
85
- const document = documents.get(params.textDocument.uri);
307
+ connection.onDefinition(async (parameters) => {
308
+ const uri = parameters.textDocument.uri;
309
+ const position = parameters.position;
310
+ const document = documents.get(uri);
311
+ if (!document) {
312
+ log(`onDefinition — document not found in cache: ${uri}`);
313
+ return null;
314
+ }
315
+ const text = document.getText();
316
+ 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");
335
+ return null;
336
+ }
337
+ const key = findKeyAtOffset(text, offset);
338
+ if (key) {
339
+ log(`onDefinition — useIntlayer key: "${key}" → searching content declarations`);
340
+ const locations = await getContentDeclarationLocations(key);
341
+ log(`onDefinition — found ${locations.length} content declaration(s) for key "${key}"`);
342
+ return locations.length ? locations : null;
343
+ }
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}"`);
349
+ return locations.length ? locations : null;
350
+ }
351
+ log("onDefinition — no Intlayer key at cursor, no result");
352
+ return null;
353
+ });
354
+ /**
355
+ * "Find All References" handler (Shift+F12).
356
+ *
357
+ * From a content file, returns every source-file location that uses the key or
358
+ * field at the cursor — the reverse direction of Go-to-Definition.
359
+ *
360
+ * From a source file, returns all content-file declarations for the key or
361
+ * field at the cursor — complementing what Go-to-Definition already handles.
362
+ */
363
+ connection.onReferences(async (parameters) => {
364
+ const uri = parameters.textDocument.uri;
365
+ const position = parameters.position;
366
+ const document = documents.get(uri);
86
367
  if (!document) return null;
87
368
  const text = document.getText();
88
- const offset = document.offsetAt(params.position);
89
- const regex = buildGetterRegex();
90
- for (const match of text.matchAll(regex)) {
91
- const start = match.index;
92
- const end = start + match[0].length;
93
- if (offset >= start && offset <= end) {
94
- const key = match[3];
95
- const locations = getContentDeclarationLocations(key);
369
+ const offset = document.offsetAt(position);
370
+ log(`onReferences — ${uri.replace(/^file:\/\//, "").split("/").slice(-2).join("/")} ${position.line}:${position.character}`);
371
+ if (isContentFile(uri)) {
372
+ const key = findKeyInContentFile(text, offset);
373
+ if (key) {
374
+ const locations = await getKeyUsageLocations(key);
375
+ log(`onReferences — key "${key}" → ${locations.length} call site(s)`);
96
376
  return locations.length ? locations : null;
97
377
  }
378
+ const fieldInfo = findContentFieldAtOffset(text, offset);
379
+ if (fieldInfo) {
380
+ const locations = await getFieldUsageLocations(fieldInfo.dictionaryKey, fieldInfo.fieldPath);
381
+ log(`onReferences — field "${fieldInfo.fieldPath.join(".")}" → ${locations.length} usage(s)`);
382
+ return locations.length ? locations : null;
383
+ }
384
+ return null;
385
+ }
386
+ const key = findKeyAtOffset(text, offset);
387
+ if (key) {
388
+ const locations = await getContentDeclarationLocations(key);
389
+ log(`onReferences — key "${key}" → ${locations.length} declaration(s)`);
390
+ return locations.length ? locations : null;
391
+ }
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)`);
396
+ return locations.length ? locations : null;
98
397
  }
99
398
  return null;
100
399
  });
400
+ connection.onDidChangeWatchedFiles(() => {
401
+ cachedSourceFiles = null;
402
+ sourceFilesCachedAt = 0;
403
+ cachedDictionaries = null;
404
+ dictionariesCachedAt = 0;
405
+ log("watched files changed — source file list and dictionary cache invalidated");
406
+ });
101
407
  documents.listen(connection);
102
408
  connection.listen();
103
409
 
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":[],"sources":["../../src/index.ts"],"sourcesContent":["#!/usr/bin/env node\nimport { existsSync, readFileSync } from 'node:fs';\nimport { isAbsolute, join } from 'node:path';\nimport { pathToFileURL } from 'node:url';\nimport { getConfiguration } from '@intlayer/config';\nimport type { IntlayerConfig } from '@intlayer/types/config';\nimport { getUnmergedDictionaries } from '@intlayer/unmerged-dictionaries-entry';\nimport {\n createConnection,\n type InitializeParams,\n type InitializeResult,\n Location,\n Position,\n ProposedFeatures,\n Range,\n TextDocumentSyncKind,\n TextDocuments,\n} from 'vscode-languageserver/node.js';\nimport { TextDocument } from 'vscode-languageserver-textdocument';\n\n/**\n * Intlayer content getters that take a dictionary key as their first argument.\n * They are exported (under the same names) by every `*-intlayer` package, so we\n * only need to match the call name + the string-literal key to support all of:\n * next-intlayer, react-intlayer, vue-intlayer, svelte-intlayer,\n * preact-intlayer, solid-intlayer, angular-intlayer, lit-intlayer,\n * express-intlayer, hono-intlayer, fastify-intlayer, adonis-intlayer, intlayer\n */\nconst INTLAYER_GETTERS = [\n 'useIntlayer',\n 'getIntlayer',\n 'useDictionary',\n 'getDictionary',\n] as const;\n\n// Matches e.g. `useIntlayer(\"homepage\")`, `getIntlayer<T>('homepage', locale)`\n// Capture group 3 is the dictionary key.\nconst buildGetterRegex = () =>\n new RegExp(\n `\\\\b(${INTLAYER_GETTERS.join('|')})\\\\b\\\\s*(?:<[^<>()]*>)?\\\\s*\\\\(\\\\s*(['\"\\`])([^'\"\\`]+)\\\\2`,\n 'g'\n );\n\n// Create a connection for the server, using Node's IPC as a transport.\n// Also include all preview / proposed LSP features.\nconst connection = createConnection(ProposedFeatures.all);\n\n// Create a simple text document manager.\nconst documents: TextDocuments<TextDocument> = new TextDocuments(TextDocument);\n\nlet workspaceRoot: string | null | undefined;\n\n/**\n * Resolve (and cache) the Intlayer configuration for the current workspace.\n * The LSP process cwd is not the project root, so we must resolve the config\n * from `workspaceRoot` to get the right `system`/`build` paths.\n */\nlet cachedConfig: Pick<IntlayerConfig, 'system' | 'build'> | null = null;\nconst getWorkspaceConfig = () => {\n if (cachedConfig) return cachedConfig;\n if (!workspaceRoot) return null;\n\n const { system, build } = getConfiguration({ baseDir: workspaceRoot });\n cachedConfig = { system, build };\n return cachedConfig;\n};\n\n/**\n * Return every `.content` file location that declares the given key.\n * A key can be split across several content files (merged dictionaries), so we\n * return one `Location` per source file, pointing at the `key:` declaration.\n */\nconst getContentDeclarationLocations = (key: string): Location[] => {\n const config = getWorkspaceConfig();\n if (!config) return [];\n\n const unmergedDictionaries = getUnmergedDictionaries(config);\n\n const dictionaries = Object.values(unmergedDictionaries)\n .flat()\n .filter((dictionary) => dictionary?.key === key && dictionary.filePath);\n\n const locations: Location[] = [];\n\n for (const dictionary of dictionaries) {\n const filePath = dictionary.filePath!;\n const absolutePath = isAbsolute(filePath)\n ? filePath\n : join(config.system.baseDir, filePath);\n\n if (!existsSync(absolutePath)) continue;\n\n locations.push(\n Location.create(\n pathToFileURL(absolutePath).href,\n getKeyRange(absolutePath, key)\n )\n );\n }\n\n return locations;\n};\n\n/** Find the `key: \"<key>\"` line inside a content file so the cursor lands on it. */\nconst getKeyRange = (absolutePath: string, key: string): Range => {\n try {\n const content = readFileSync(absolutePath, 'utf-8');\n const lines = content.split(/\\r?\\n/);\n const keyRegex = new RegExp(\n `key\\\\s*:\\\\s*['\"\\`]${key.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}['\"\\`]`\n );\n\n for (let line = 0; line < lines.length; line++) {\n const column = lines[line].search(keyRegex);\n if (column !== -1) {\n return Range.create(\n Position.create(line, column),\n Position.create(line, lines[line].length)\n );\n }\n }\n } catch {\n // fall through to the top of the file\n }\n\n return Range.create(Position.create(0, 0), Position.create(0, 0));\n};\n\nconnection.onInitialize((params: InitializeParams): InitializeResult => {\n workspaceRoot =\n params.rootPath ||\n (params.workspaceFolders?.[0]?.uri\n ? decodeURIComponent(new URL(params.workspaceFolders[0].uri).pathname)\n : undefined);\n cachedConfig = null;\n\n return {\n capabilities: {\n textDocumentSync: TextDocumentSyncKind.Incremental,\n // Declare that this server provides \"Go to Definition\" support\n definitionProvider: true,\n },\n };\n});\n\nconnection.onDefinition((params) => {\n const document = documents.get(params.textDocument.uri);\n if (!document) return null;\n\n const text = document.getText();\n const offset = document.offsetAt(params.position);\n\n const regex = buildGetterRegex();\n\n for (const match of text.matchAll(regex)) {\n const start = match.index;\n const end = start + match[0].length;\n\n // Only resolve the call the cursor is actually inside of.\n if (offset >= start && offset <= end) {\n const key = match[3];\n const locations = getContentDeclarationLocations(key);\n return locations.length ? locations : null;\n }\n }\n\n return null;\n});\n\n// Make the text document manager listen on the connection\n// for open, change and close text document events\ndocuments.listen(connection);\n\n// Listen on the connection\nconnection.listen();\n"],"mappings":";;;;;;;;;;;;;;;;;;AA4BA,MAAM,mBAAmB;CACvB;CACA;CACA;CACA;AACF;AAIA,MAAM,yBACJ,IAAI,OACF,OAAO,iBAAiB,KAAK,GAAG,EAAE,0DAClC,GACF;AAIF,MAAM,aAAa,iBAAiB,iBAAiB,GAAG;AAGxD,MAAM,YAAyC,IAAI,cAAc,YAAY;AAE7E,IAAI;;;;;;AAOJ,IAAI,eAAgE;AACpE,MAAM,2BAA2B;CAC/B,IAAI,cAAc,OAAO;CACzB,IAAI,CAAC,eAAe,OAAO;CAE3B,MAAM,EAAE,QAAQ,UAAU,iBAAiB,EAAE,SAAS,cAAc,CAAC;CACrE,eAAe;EAAE;EAAQ;CAAM;CAC/B,OAAO;AACT;;;;;;AAOA,MAAM,kCAAkC,QAA4B;CAClE,MAAM,SAAS,mBAAmB;CAClC,IAAI,CAAC,QAAQ,OAAO,CAAC;CAErB,MAAM,uBAAuB,wBAAwB,MAAM;CAE3D,MAAM,eAAe,OAAO,OAAO,oBAAoB,EACpD,KAAK,EACL,QAAQ,eAAe,YAAY,QAAQ,OAAO,WAAW,QAAQ;CAExE,MAAM,YAAwB,CAAC;CAE/B,KAAK,MAAM,cAAc,cAAc;EACrC,MAAM,WAAW,WAAW;EAC5B,MAAM,eAAe,WAAW,QAAQ,IACpC,WACA,KAAK,OAAO,OAAO,SAAS,QAAQ;EAExC,IAAI,CAAC,WAAW,YAAY,GAAG;EAE/B,UAAU,KACR,SAAS,OACP,cAAc,YAAY,EAAE,MAC5B,YAAY,cAAc,GAAG,CAC/B,CACF;CACF;CAEA,OAAO;AACT;;AAGA,MAAM,eAAe,cAAsB,QAAuB;CAChE,IAAI;EAEF,MAAM,QADU,aAAa,cAAc,OACvB,EAAE,MAAM,OAAO;EACnC,MAAM,WAAW,IAAI,OACnB,qBAAqB,IAAI,QAAQ,uBAAuB,MAAM,EAAE,OAClE;EAEA,KAAK,IAAI,OAAO,GAAG,OAAO,MAAM,QAAQ,QAAQ;GAC9C,MAAM,SAAS,MAAM,MAAM,OAAO,QAAQ;GAC1C,IAAI,WAAW,IACb,OAAO,MAAM,OACX,SAAS,OAAO,MAAM,MAAM,GAC5B,SAAS,OAAO,MAAM,MAAM,MAAM,MAAM,CAC1C;EAEJ;CACF,QAAQ,CAER;CAEA,OAAO,MAAM,OAAO,SAAS,OAAO,GAAG,CAAC,GAAG,SAAS,OAAO,GAAG,CAAC,CAAC;AAClE;AAEA,WAAW,cAAc,WAA+C;CACtE,gBACE,OAAO,aACN,OAAO,mBAAmB,IAAI,MAC3B,mBAAmB,IAAI,IAAI,OAAO,iBAAiB,GAAG,GAAG,EAAE,QAAQ,IACnE;CACN,eAAe;CAEf,OAAO,EACL,cAAc;EACZ,kBAAkB,qBAAqB;EAEvC,oBAAoB;CACtB,EACF;AACF,CAAC;AAED,WAAW,cAAc,WAAW;CAClC,MAAM,WAAW,UAAU,IAAI,OAAO,aAAa,GAAG;CACtD,IAAI,CAAC,UAAU,OAAO;CAEtB,MAAM,OAAO,SAAS,QAAQ;CAC9B,MAAM,SAAS,SAAS,SAAS,OAAO,QAAQ;CAEhD,MAAM,QAAQ,iBAAiB;CAE/B,KAAK,MAAM,SAAS,KAAK,SAAS,KAAK,GAAG;EACxC,MAAM,QAAQ,MAAM;EACpB,MAAM,MAAM,QAAQ,MAAM,GAAG;EAG7B,IAAI,UAAU,SAAS,UAAU,KAAK;GACpC,MAAM,MAAM,MAAM;GAClB,MAAM,YAAY,+BAA+B,GAAG;GACpD,OAAO,UAAU,SAAS,YAAY;EACxC;CACF;CAEA,OAAO;AACT,CAAC;AAID,UAAU,OAAO,UAAU;AAG3B,WAAW,OAAO"}
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../../src/index.ts"],"sourcesContent":["#!/usr/bin/env node\nimport { type Dirent, existsSync } from 'node:fs';\nimport { readdir, readFile } from 'node:fs/promises';\nimport { extname, isAbsolute, join } from 'node:path';\nimport { pathToFileURL } from 'node:url';\nimport { getConfiguration } from '@intlayer/config/node';\nimport type { IntlayerConfig } from '@intlayer/types/config';\nimport { getUnmergedDictionaries } from '@intlayer/unmerged-dictionaries-entry';\nimport {\n createConnection,\n type InitializeParams,\n type InitializeResult,\n Location,\n Position,\n ProposedFeatures,\n Range,\n type ReferenceParams,\n TextDocumentSyncKind,\n TextDocuments,\n} from 'vscode-languageserver/node.js';\nimport { TextDocument } from 'vscode-languageserver-textdocument';\nimport {\n escapeRegularExpression,\n findFieldRangesInFile,\n offsetToRange,\n} from './findFieldInFile';\nimport { findKeyAtOffset } from './findKeyAtOffset';\nimport {\n findContentFieldAtOffset,\n findKeyInContentFile,\n} from './findKeyInContentFile';\nimport { findUsageFieldAtOffset } from './findUsageFieldAtOffset';\n\n// Create a connection for the server, using Node's IPC as a transport.\n// Also include all preview / proposed LSP features.\nconst connection = createConnection(ProposedFeatures.all);\n\n// Create a simple text document manager.\nconst documents: TextDocuments<TextDocument> = new TextDocuments(TextDocument);\n\n/**\n * Send a log line to the client's Output panel (View → Output → \"Intlayer LSP\").\n * Uses connection.console.log so it travels over the JSON-RPC channel and never\n * touches stdout (which is reserved for the LSP protocol).\n */\nconst log = (message: string): void =>\n connection.console.log(`[Intlayer LSP] ${message}`);\n\nlet workspaceRoot: string | null | undefined;\n\ntype WorkspaceConfig = Pick<IntlayerConfig, 'system' | 'build' | 'content'>;\n\n/**\n * Resolve (and cache) the Intlayer configuration for the current workspace.\n * The LSP process cwd is not the project root, so we must resolve the config\n * from `workspaceRoot` to get the right `system`/`build`/`content` paths.\n */\nlet cachedConfig: WorkspaceConfig | null = null;\n\n/**\n * Cached unmerged dictionaries with a short TTL so that running\n * `intlayer build` during a session picks up without a full re-init.\n */\nlet cachedDictionaries: ReturnType<typeof getUnmergedDictionaries> | null =\n null;\nlet dictionariesCachedAt = 0;\nconst DICTIONARY_CACHE_TTL_MS = 5_000;\n\n// Pattern-derived lookup structures — computed lazily from the live workspace\n// config so they respect any user customisation of traversePattern /\n// fileExtensions.\nlet cachedSourceExtensions: Set<string> | null = null;\nlet cachedExcludedDirs: Set<string> | null = null;\nlet cachedFileExclusionRegexes: RegExp[] | null = null;\n\n/**\n * Cached list of source file paths.\n * Walking the directory tree is the dominant cost on every Go-to-Definition\n * from a content file. We cache the result for 30 s and re-scan only when\n * the client reports a watched-file change or the config is invalidated.\n */\nlet cachedSourceFiles: string[] | null = null;\nlet sourceFilesCachedAt = 0;\nconst SOURCE_FILES_CACHE_TTL_MS = 30_000;\n\nconst invalidateConfigCaches = () => {\n cachedConfig = null;\n cachedDictionaries = null;\n dictionariesCachedAt = 0;\n cachedSourceExtensions = null;\n cachedExcludedDirs = null;\n cachedFileExclusionRegexes = null;\n cachedSourceFiles = null;\n sourceFilesCachedAt = 0;\n};\n\nconst getWorkspaceConfig = (): WorkspaceConfig | null => {\n if (cachedConfig) return cachedConfig;\n if (!workspaceRoot) {\n log('getWorkspaceConfig — no workspace root, skipping');\n return null;\n }\n try {\n log(`getWorkspaceConfig — loading config from: ${workspaceRoot}`);\n const { system, build, content } = getConfiguration({\n baseDir: workspaceRoot,\n });\n cachedConfig = { system, build, content };\n log(\n `getWorkspaceConfig — OK baseDir=${system.baseDir} unmergedDictionariesDir=${system.unmergedDictionariesDir}`\n );\n return cachedConfig;\n } catch (error) {\n log(`getWorkspaceConfig — FAILED: ${error}`);\n return null;\n }\n};\n\nconst getUnmergedDictionariesCached = () => {\n const now = Date.now();\n if (\n cachedDictionaries &&\n now - dictionariesCachedAt < DICTIONARY_CACHE_TTL_MS\n ) {\n return cachedDictionaries;\n }\n const config = getWorkspaceConfig();\n if (!config) return {};\n cachedDictionaries = getUnmergedDictionaries(config);\n dictionariesCachedAt = now;\n return cachedDictionaries;\n};\n\n// ---------------------------------------------------------------------------\n// Pattern helpers — derived lazily from the live config.\n// ---------------------------------------------------------------------------\n\n// Source file extensions from positive traversePattern entries\n// e.g. '**/*.{tsx,ts,js,...}' → Set<'.tsx' | '.ts' | ...>\nconst getSourceExtensions = (): Set<string> => {\n if (cachedSourceExtensions) return cachedSourceExtensions;\n const traversePattern = getWorkspaceConfig()?.build.traversePattern ?? [];\n cachedSourceExtensions = new Set(\n traversePattern\n .filter((pattern) => !pattern.startsWith('!'))\n .flatMap((pattern) => {\n const match = pattern.match(/\\.\\{([^}]+)\\}/);\n return match?.[1]\n ? match[1].split(',').map((extension) => `.${extension.trim()}`)\n : [];\n })\n );\n return cachedSourceExtensions;\n};\n\n// Excluded directory names from '!**/name/**' negative patterns\nconst getExcludedDirs = (): Set<string> => {\n if (cachedExcludedDirs) return cachedExcludedDirs;\n const traversePattern = getWorkspaceConfig()?.build.traversePattern ?? [];\n cachedExcludedDirs = new Set(\n traversePattern\n .filter((pattern) => /^!\\*\\*\\/[^*/]+\\/\\*\\*$/.test(pattern))\n .map((pattern) => pattern.replace(/^!\\*\\*\\//, '').replace(/\\/\\*\\*$/, ''))\n );\n return cachedExcludedDirs;\n};\n\n// File-level exclusion regexes from remaining negative patterns (e.g. '!**/*.test.*')\nconst getFileExclusionRegexes = (): RegExp[] => {\n if (cachedFileExclusionRegexes) return cachedFileExclusionRegexes;\n const traversePattern = getWorkspaceConfig()?.build.traversePattern ?? [];\n cachedFileExclusionRegexes = traversePattern\n .filter(\n (pattern) =>\n pattern.startsWith('!') && !/^!\\*\\*\\/[^*/]+\\/\\*\\*$/.test(pattern)\n )\n .map((pattern) => {\n const inner = pattern.replace(/^!(\\*\\*\\/)*/, '');\n return new RegExp(\n `${inner.replace(/\\./g, '\\\\.').replace(/\\*/g, '[^\\\\/]*')}$`\n );\n });\n return cachedFileExclusionRegexes;\n};\n\nconst isContentFile = (uri: string): boolean => {\n const fileExtensions = getWorkspaceConfig()?.content.fileExtensions ?? [];\n return fileExtensions.some((extension) => uri.endsWith(extension));\n};\n\n// ---------------------------------------------------------------------------\n// Async I/O helpers\n// ---------------------------------------------------------------------------\n\n/** Read a file as UTF-8, returning null on any error. */\nconst tryReadFile = async (filePath: string): Promise<string | null> => {\n try {\n return await readFile(filePath, 'utf-8');\n } catch {\n return null;\n }\n};\n\n/**\n * Run `callback` over every item with at most `limit` tasks in-flight at once.\n * JS is single-threaded so `results.push` inside callback is race-free.\n */\nconst runConcurrent = async <T>(\n items: T[],\n callback: (item: T) => Promise<void>,\n limit = 50\n): Promise<void> => {\n if (items.length === 0) return;\n let index = 0;\n const worker = async () => {\n while (index < items.length) {\n await callback(items[index++]!);\n }\n };\n await Promise.all(\n Array.from({ length: Math.min(limit, items.length) }, worker)\n );\n};\n\n/**\n * Recursively collect all source-file paths under `directory`.\n * Uses async readdir so it does not block the Node.js event loop.\n */\nconst collectSourceFiles = async (directory: string): Promise<string[]> => {\n const results: string[] = [];\n const sourceExtensions = getSourceExtensions();\n const excludedDirs = getExcludedDirs();\n const fileExclusionRegexes = getFileExclusionRegexes();\n const contentExtensions = getWorkspaceConfig()?.content.fileExtensions ?? [];\n\n const visit = async (dir: string): Promise<void> => {\n let entries: Dirent[];\n\n try {\n entries = await readdir(dir, { withFileTypes: true });\n } catch {\n return;\n }\n\n // Process entries concurrently within a single directory level.\n await Promise.all(\n entries.map(async (entry) => {\n if (entry.isDirectory()) {\n if (!excludedDirs.has(entry.name)) {\n await visit(join(dir, entry.name));\n }\n } else if (\n sourceExtensions.has(extname(entry.name)) &&\n !contentExtensions.some((extension) =>\n entry.name.endsWith(extension)\n ) &&\n !fileExclusionRegexes.some((regularExpression) =>\n regularExpression.test(entry.name)\n )\n ) {\n results.push(join(dir, entry.name));\n }\n })\n );\n };\n\n await visit(directory);\n return results;\n};\n\n/** Find the `key: \"<key>\"` line inside a content file so the cursor lands on it. */\nconst getKeyRange = async (\n absolutePath: string,\n key: string\n): Promise<Range> => {\n try {\n const content = await readFile(absolutePath, 'utf-8');\n const lines = content.split(/\\r?\\n/);\n const keyRegularExpression = new RegExp(\n `key\\\\s*:\\\\s*['\"\\`]${key.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}['\"\\`]`\n );\n\n for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {\n const column = lines[lineIndex]?.search(keyRegularExpression);\n if (column !== undefined && column !== -1) {\n return Range.create(\n Position.create(lineIndex, column),\n Position.create(lineIndex, lines[lineIndex]!.length)\n );\n }\n }\n } catch {\n // fall through to the top of the file\n }\n\n return Range.create(Position.create(0, 0), Position.create(0, 0));\n};\n\n/**\n * Return every `.content` file location that declares the given key.\n * A key can be split across several content files (merged dictionaries), so we\n * return one `Location` per source file, pointing at the `key:` declaration.\n */\nconst getContentDeclarationLocations = async (\n key: string\n): Promise<Location[]> => {\n const config = getWorkspaceConfig();\n if (!config) return [];\n\n const unmergedDictionaries = getUnmergedDictionariesCached();\n const dictionaries = Object.values(unmergedDictionaries)\n .flat()\n .filter((dictionary) => dictionary?.key === key && dictionary.filePath);\n\n const locations: Location[] = [];\n\n await runConcurrent(dictionaries, async (dictionary) => {\n const filePath = dictionary.filePath!;\n const absolutePath = isAbsolute(filePath)\n ? filePath\n : join(config.system.baseDir, filePath);\n\n if (!existsSync(absolutePath)) return;\n\n locations.push(\n Location.create(\n pathToFileURL(absolutePath).href,\n await getKeyRange(absolutePath, key)\n )\n );\n });\n\n return locations;\n};\n\n/**\n * Return the cached source-file list, re-scanning only when the cache has\n * expired or been explicitly invalidated (config change, watched-file event).\n */\nconst getSourceFiles = async (directory: string): Promise<string[]> => {\n const now = Date.now();\n if (\n cachedSourceFiles &&\n now - sourceFilesCachedAt < SOURCE_FILES_CACHE_TTL_MS\n ) {\n return cachedSourceFiles;\n }\n const startTime = Date.now();\n cachedSourceFiles = await collectSourceFiles(directory);\n sourceFilesCachedAt = Date.now();\n log(\n `source files scanned: ${cachedSourceFiles.length} file(s) in ${Date.now() - startTime}ms`\n );\n return cachedSourceFiles;\n};\n\n/**\n * Return every source-file location that calls useIntlayer / getIntlayer with\n * the given key.\n */\nconst getKeyUsageLocations = async (key: string): Promise<Location[]> => {\n const config = getWorkspaceConfig();\n if (!config) return [];\n\n const usageRegularExpression = new RegExp(\n `\\\\b(?:useIntlayer|getIntlayer)\\\\b\\\\s*(?:<[^<>()]*>)?\\\\s*\\\\(\\\\s*(['\"\\`])${escapeRegularExpression(key)}\\\\1`,\n 'g'\n );\n\n const filePaths = await getSourceFiles(config.system.baseDir);\n const locations: Location[] = [];\n const startTime = Date.now();\n\n await runConcurrent(filePaths, async (filePath) => {\n const text = await tryReadFile(filePath);\n // Quick string check before running the regex — skips the vast majority of files\n if (!text?.includes(key)) return;\n\n for (const match of text.matchAll(usageRegularExpression)) {\n const matchStart = match.index!;\n const matchEnd = matchStart + match[0].length;\n locations.push(\n Location.create(\n pathToFileURL(filePath).href,\n offsetToRange(text, matchStart, matchEnd)\n )\n );\n }\n });\n\n log(\n `getKeyUsageLocations(\"${key}\") — ${filePaths.length} file(s) searched in ${Date.now() - startTime}ms → ${locations.length} hit(s)`\n );\n return locations;\n};\n\n/**\n * Return every source-file location that accesses the field at `fieldPath` on\n * the result of `useIntlayer(dictionaryKey)` / `getIntlayer(dictionaryKey)`.\n *\n * `fieldPath` is an array of property names from the content root to the\n * target field (e.g. `['searchInput', 'text']` for a nested field).\n * Single-element paths (e.g. `['greet']`) behave like the previous\n * single-`fieldName` API.\n */\nconst getFieldUsageLocations = async (\n dictionaryKey: string,\n fieldPath: string[]\n): Promise<Location[]> => {\n const config = getWorkspaceConfig();\n if (!config || fieldPath.length === 0) return [];\n\n const leafFieldName = fieldPath[fieldPath.length - 1]!;\n const escapedKey = escapeRegularExpression(dictionaryKey);\n const escapedLeaf = escapeRegularExpression(leafFieldName);\n const useCallRegularExpression = new RegExp(\n `\\\\b(?:useIntlayer|getIntlayer)\\\\b(?:<[^<>()]*>)?\\\\s*\\\\(\\\\s*['\"\\`]${escapedKey}['\"\\`]`\n );\n\n const filePaths = await getSourceFiles(config.system.baseDir);\n const locations: Location[] = [];\n const startTime = Date.now();\n\n await runConcurrent(filePaths, async (filePath) => {\n const text = await tryReadFile(filePath);\n if (!text) return;\n // Pre-filter: dictionaryKey and every segment of the path must appear in the file\n if (!text.includes(dictionaryKey)) return;\n if (fieldPath.some((segment) => !text.includes(segment))) return;\n if (!useCallRegularExpression.test(text)) return;\n\n for (const range of findFieldRangesInFile(\n text,\n escapedKey,\n leafFieldName,\n escapedLeaf,\n fieldPath\n )) {\n locations.push(Location.create(pathToFileURL(filePath).href, range));\n }\n });\n\n log(\n `getFieldUsageLocations(\"${dictionaryKey}.${fieldPath.join('.')}\") — ${filePaths.length} file(s) searched in ${Date.now() - startTime}ms → ${locations.length} hit(s)`\n );\n return locations;\n};\n\n/**\n * Find the line/column of `fieldName` as a property key inside a content file.\n * Looks for `fieldName:` (the field definition), not just any occurrence.\n * Falls back to the top of the file if the field cannot be located.\n */\nconst getFieldRangeInContentFile = async (\n absolutePath: string,\n fieldName: string\n): Promise<Range> => {\n const content = await tryReadFile(absolutePath);\n if (!content)\n return Range.create(Position.create(0, 0), Position.create(0, 0));\n\n const lines = content.split(/\\r?\\n/);\n // Match `fieldName:` at an object property key position (not a value)\n const fieldRegularExpression = new RegExp(\n `(?<![.\\\\w])${escapeRegularExpression(fieldName)}(?![\\\\w])\\\\s*:`\n );\n\n for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {\n const line = lines[lineIndex]!;\n const matchPosition = line.search(fieldRegularExpression);\n if (matchPosition === -1) continue;\n const column = line.indexOf(fieldName, matchPosition);\n if (column === -1) continue;\n return Range.create(\n Position.create(lineIndex, column),\n Position.create(lineIndex, column + fieldName.length)\n );\n }\n\n return Range.create(Position.create(0, 0), Position.create(0, 0));\n};\n\n/**\n * Return every content-file location where `fieldName` is defined as a\n * property of the dictionary with the given `dictionaryKey`.\n */\nconst getContentFieldLocations = async (\n dictionaryKey: string,\n fieldName: string\n): Promise<Location[]> => {\n const config = getWorkspaceConfig();\n if (!config) return [];\n\n const unmergedDictionaries = getUnmergedDictionariesCached();\n const dictionaries = Object.values(unmergedDictionaries)\n .flat()\n .filter(\n (dictionary) => dictionary?.key === dictionaryKey && dictionary.filePath\n );\n\n const locations: Location[] = [];\n\n await runConcurrent(dictionaries, async (dictionary) => {\n const filePath = dictionary.filePath!;\n const absolutePath = isAbsolute(filePath)\n ? filePath\n : join(config.system.baseDir, filePath);\n if (!existsSync(absolutePath)) return;\n\n locations.push(\n Location.create(\n pathToFileURL(absolutePath).href,\n await getFieldRangeInContentFile(absolutePath, fieldName)\n )\n );\n });\n\n return locations;\n};\n\nconnection.onInitialize((parameters: InitializeParams): InitializeResult => {\n workspaceRoot =\n parameters.rootPath ||\n (parameters.workspaceFolders?.[0]?.uri\n ? decodeURIComponent(new URL(parameters.workspaceFolders[0].uri).pathname)\n : undefined);\n invalidateConfigCaches();\n\n log(`initialized — workspaceRoot: ${workspaceRoot ?? '(none)'}`);\n\n if (parameters.workspaceFolders?.length) {\n log(\n `workspaceFolders: ${parameters.workspaceFolders.map((folder) => folder.name).join(', ')}`\n );\n }\n\n return {\n capabilities: {\n textDocumentSync: TextDocumentSyncKind.Incremental,\n definitionProvider: true,\n referencesProvider: true,\n },\n };\n});\n\nconnection.onDefinition(async (parameters) => {\n const uri = parameters.textDocument.uri;\n const position = parameters.position;\n const document = documents.get(uri);\n\n if (!document) {\n log(`onDefinition — document not found in cache: ${uri}`);\n return null;\n }\n\n const text = document.getText();\n const offset = document.offsetAt(position);\n const shortenedUri = uri\n .replace(/^file:\\/\\//, '')\n .split('/')\n .slice(-2)\n .join('/');\n\n log(`onDefinition — ${shortenedUri} ${position.line}:${position.character}`);\n\n if (isContentFile(uri)) {\n log('onDefinition — detected content file');\n\n // Cursor on `key: \"…\"` → navigate to useIntlayer(key) call sites\n const key = findKeyInContentFile(text, offset);\n if (key) {\n log(`onDefinition — key declaration: \"${key}\" → searching call sites`);\n const locations = await getKeyUsageLocations(key);\n\n log(\n `onDefinition — found ${locations.length} call site(s) for key \"${key}\"`\n );\n return locations.length ? locations : null;\n }\n\n // Cursor on a content field (e.g. `greet` or nested `searchInput.text`)\n // → navigate to field usage sites in source files\n const fieldInfo = findContentFieldAtOffset(text, offset);\n if (fieldInfo) {\n log(\n `onDefinition — field: \"${fieldInfo.dictionaryKey}.${fieldInfo.fieldPath.join('.')}\" → searching usages`\n );\n const locations = await getFieldUsageLocations(\n fieldInfo.dictionaryKey,\n fieldInfo.fieldPath\n );\n log(\n `onDefinition — found ${locations.length} usage(s) for field path \"${fieldInfo.fieldPath.join('.')}\"`\n );\n return locations.length ? locations : null;\n }\n\n log('onDefinition — cursor not on a key or field declaration, no result');\n return null;\n }\n\n // Cursor on the key string / function name → navigate to content declaration\n const key = findKeyAtOffset(text, offset);\n if (key) {\n log(\n `onDefinition — useIntlayer key: \"${key}\" → searching content declarations`\n );\n const locations = await getContentDeclarationLocations(key);\n log(\n `onDefinition — found ${locations.length} content declaration(s) for key \"${key}\"`\n );\n return locations.length ? locations : null;\n }\n\n // Cursor on a destructured property or member access property from useIntlayer\n // e.g. const { localeSwitcherLabel } = useIntlayer(\"locale-switcher\")\n // ^^^^^^^^^^^^^^^^^^ ← cursor here\n // e.g. t.localeSwitcherLabel where const t = useIntlayer(\"locale-switcher\")\n const usageField = findUsageFieldAtOffset(text, offset);\n if (usageField) {\n log(\n `onDefinition — usage field: \"${usageField.dictionaryKey}.${usageField.fieldName}\" → searching content field`\n );\n const locations = await getContentFieldLocations(\n usageField.dictionaryKey,\n usageField.fieldName\n );\n log(\n `onDefinition — found ${locations.length} content file(s) with field \"${usageField.fieldName}\"`\n );\n return locations.length ? locations : null;\n }\n\n log('onDefinition — no Intlayer key at cursor, no result');\n return null;\n});\n\n/**\n * \"Find All References\" handler (Shift+F12).\n *\n * From a content file, returns every source-file location that uses the key or\n * field at the cursor — the reverse direction of Go-to-Definition.\n *\n * From a source file, returns all content-file declarations for the key or\n * field at the cursor — complementing what Go-to-Definition already handles.\n */\nconnection.onReferences(async (parameters: ReferenceParams) => {\n const uri = parameters.textDocument.uri;\n const position = parameters.position;\n const document = documents.get(uri);\n\n if (!document) return null;\n\n const text = document.getText();\n const offset = document.offsetAt(position);\n const shortenedUri = uri\n .replace(/^file:\\/\\//, '')\n .split('/')\n .slice(-2)\n .join('/');\n\n log(`onReferences — ${shortenedUri} ${position.line}:${position.character}`);\n\n if (isContentFile(uri)) {\n // Cursor on `key: \"…\"` → all useIntlayer(key) call sites\n const key = findKeyInContentFile(text, offset);\n if (key) {\n const locations = await getKeyUsageLocations(key);\n log(`onReferences — key \"${key}\" → ${locations.length} call site(s)`);\n return locations.length ? locations : null;\n }\n\n // Cursor on a content field → all field usage sites\n const fieldInfo = findContentFieldAtOffset(text, offset);\n if (fieldInfo) {\n const locations = await getFieldUsageLocations(\n fieldInfo.dictionaryKey,\n fieldInfo.fieldPath\n );\n log(\n `onReferences — field \"${fieldInfo.fieldPath.join('.')}\" → ${locations.length} usage(s)`\n );\n return locations.length ? locations : null;\n }\n\n return null;\n }\n\n // Source file: key string → content-file declarations\n const key = findKeyAtOffset(text, offset);\n if (key) {\n const locations = await getContentDeclarationLocations(key);\n log(`onReferences — key \"${key}\" → ${locations.length} declaration(s)`);\n return locations.length ? locations : null;\n }\n\n // Source file: field property → content-file field definitions\n const usageField = findUsageFieldAtOffset(text, offset);\n if (usageField) {\n const locations = await getContentFieldLocations(\n usageField.dictionaryKey,\n usageField.fieldName\n );\n log(\n `onReferences — field \"${usageField.fieldName}\" → ${locations.length} content definition(s)`\n );\n return locations.length ? locations : null;\n }\n\n return null;\n});\n\n// Invalidate the source-file list whenever the client reports a file change.\n// The client registers a watcher for *.content.* files; any creation or\n// deletion means the project layout may have changed.\nconnection.onDidChangeWatchedFiles(() => {\n cachedSourceFiles = null;\n sourceFilesCachedAt = 0;\n cachedDictionaries = null;\n dictionariesCachedAt = 0;\n log(\n 'watched files changed — source file list and dictionary cache invalidated'\n );\n});\n\n// Make the text document manager listen on the connection\n// for open, change and close text document events\ndocuments.listen(connection);\n\n// Listen on the connection\nconnection.listen();\n"],"mappings":";;;;;;;;;;;;;;;AAmCA,MAAM,aAAa,iBAAiB,iBAAiB,GAAG;AAGxD,MAAM,YAAyC,IAAI,cAAc,YAAY;;;;;;AAO7E,MAAM,OAAO,YACX,WAAW,QAAQ,IAAI,kBAAkB,SAAS;AAEpD,IAAI;;;;;;AASJ,IAAI,eAAuC;;;;;AAM3C,IAAI,qBACF;AACF,IAAI,uBAAuB;AAC3B,MAAM,0BAA0B;AAKhC,IAAI,yBAA6C;AACjD,IAAI,qBAAyC;AAC7C,IAAI,6BAA8C;;;;;;;AAQlD,IAAI,oBAAqC;AACzC,IAAI,sBAAsB;AAC1B,MAAM,4BAA4B;AAElC,MAAM,+BAA+B;CACnC,eAAe;CACf,qBAAqB;CACrB,uBAAuB;CACvB,yBAAyB;CACzB,qBAAqB;CACrB,6BAA6B;CAC7B,oBAAoB;CACpB,sBAAsB;AACxB;AAEA,MAAM,2BAAmD;CACvD,IAAI,cAAc,OAAO;CACzB,IAAI,CAAC,eAAe;EAClB,IAAI,kDAAkD;EACtD,OAAO;CACT;CACA,IAAI;EACF,IAAI,6CAA6C,eAAe;EAChE,MAAM,EAAE,QAAQ,OAAO,YAAY,iBAAiB,EAClD,SAAS,cACX,CAAC;EACD,eAAe;GAAE;GAAQ;GAAO;EAAQ;EACxC,IACE,oCAAoC,OAAO,QAAQ,4BAA4B,OAAO,yBACxF;EACA,OAAO;CACT,SAAS,OAAO;EACd,IAAI,gCAAgC,OAAO;EAC3C,OAAO;CACT;AACF;AAEA,MAAM,sCAAsC;CAC1C,MAAM,MAAM,KAAK,IAAI;CACrB,IACE,sBACA,MAAM,uBAAuB,yBAE7B,OAAO;CAET,MAAM,SAAS,mBAAmB;CAClC,IAAI,CAAC,QAAQ,OAAO,CAAC;CACrB,qBAAqB,wBAAwB,MAAM;CACnD,uBAAuB;CACvB,OAAO;AACT;AAQA,MAAM,4BAAyC;CAC7C,IAAI,wBAAwB,OAAO;CACnC,MAAM,kBAAkB,mBAAmB,GAAG,MAAM,mBAAmB,CAAC;CACxE,yBAAyB,IAAI,IAC3B,gBACG,QAAQ,YAAY,CAAC,QAAQ,WAAW,GAAG,CAAC,EAC5C,SAAS,YAAY;EACpB,MAAM,QAAQ,QAAQ,MAAM,eAAe;EAC3C,OAAO,QAAQ,KACX,MAAM,GAAG,MAAM,GAAG,EAAE,KAAK,cAAc,IAAI,UAAU,KAAK,GAAG,IAC7D,CAAC;CACP,CAAC,CACL;CACA,OAAO;AACT;AAGA,MAAM,wBAAqC;CACzC,IAAI,oBAAoB,OAAO;CAC/B,MAAM,kBAAkB,mBAAmB,GAAG,MAAM,mBAAmB,CAAC;CACxE,qBAAqB,IAAI,IACvB,gBACG,QAAQ,YAAY,wBAAwB,KAAK,OAAO,CAAC,EACzD,KAAK,YAAY,QAAQ,QAAQ,YAAY,EAAE,EAAE,QAAQ,WAAW,EAAE,CAAC,CAC5E;CACA,OAAO;AACT;AAGA,MAAM,gCAA0C;CAC9C,IAAI,4BAA4B,OAAO;CAEvC,8BADwB,mBAAmB,GAAG,MAAM,mBAAmB,CAAC,GAErE,QACE,YACC,QAAQ,WAAW,GAAG,KAAK,CAAC,wBAAwB,KAAK,OAAO,CACpE,EACC,KAAK,YAAY;EAChB,MAAM,QAAQ,QAAQ,QAAQ,eAAe,EAAE;EAC/C,OAAO,IAAI,OACT,GAAG,MAAM,QAAQ,OAAO,KAAK,EAAE,QAAQ,OAAO,SAAS,EAAE,EAC3D;CACF,CAAC;CACH,OAAO;AACT;AAEA,MAAM,iBAAiB,QAAyB;CAE9C,QADuB,mBAAmB,GAAG,QAAQ,kBAAkB,CAAC,GAClD,MAAM,cAAc,IAAI,SAAS,SAAS,CAAC;AACnE;;AAOA,MAAM,cAAc,OAAO,aAA6C;CACtE,IAAI;EACF,OAAO,MAAM,SAAS,UAAU,OAAO;CACzC,QAAQ;EACN,OAAO;CACT;AACF;;;;;AAMA,MAAM,gBAAgB,OACpB,OACA,UACA,QAAQ,OACU;CAClB,IAAI,MAAM,WAAW,GAAG;CACxB,IAAI,QAAQ;CACZ,MAAM,SAAS,YAAY;EACzB,OAAO,QAAQ,MAAM,QACnB,MAAM,SAAS,MAAM,QAAS;CAElC;CACA,MAAM,QAAQ,IACZ,MAAM,KAAK,EAAE,QAAQ,KAAK,IAAI,OAAO,MAAM,MAAM,EAAE,GAAG,MAAM,CAC9D;AACF;;;;;AAMA,MAAM,qBAAqB,OAAO,cAAyC;CACzE,MAAM,UAAoB,CAAC;CAC3B,MAAM,mBAAmB,oBAAoB;CAC7C,MAAM,eAAe,gBAAgB;CACrC,MAAM,uBAAuB,wBAAwB;CACrD,MAAM,oBAAoB,mBAAmB,GAAG,QAAQ,kBAAkB,CAAC;CAE3E,MAAM,QAAQ,OAAO,QAA+B;EAClD,IAAI;EAEJ,IAAI;GACF,UAAU,MAAM,QAAQ,KAAK,EAAE,eAAe,KAAK,CAAC;EACtD,QAAQ;GACN;EACF;EAGA,MAAM,QAAQ,IACZ,QAAQ,IAAI,OAAO,UAAU;GAC3B,IAAI,MAAM,YAAY,GACpB;QAAI,CAAC,aAAa,IAAI,MAAM,IAAI,GAC9B,MAAM,MAAM,KAAK,KAAK,MAAM,IAAI,CAAC;GACnC,OACK,IACL,iBAAiB,IAAI,QAAQ,MAAM,IAAI,CAAC,KACxC,CAAC,kBAAkB,MAAM,cACvB,MAAM,KAAK,SAAS,SAAS,CAC/B,KACA,CAAC,qBAAqB,MAAM,sBAC1B,kBAAkB,KAAK,MAAM,IAAI,CACnC,GAEA,QAAQ,KAAK,KAAK,KAAK,MAAM,IAAI,CAAC;EAEtC,CAAC,CACH;CACF;CAEA,MAAM,MAAM,SAAS;CACrB,OAAO;AACT;;AAGA,MAAM,cAAc,OAClB,cACA,QACmB;CACnB,IAAI;EAEF,MAAM,SAAQ,MADQ,SAAS,cAAc,OAAO,GAC9B,MAAM,OAAO;EACnC,MAAM,uBAAuB,IAAI,OAC/B,qBAAqB,IAAI,QAAQ,uBAAuB,MAAM,EAAE,OAClE;EAEA,KAAK,IAAI,YAAY,GAAG,YAAY,MAAM,QAAQ,aAAa;GAC7D,MAAM,SAAS,MAAM,YAAY,OAAO,oBAAoB;GAC5D,IAAI,WAAW,UAAa,WAAW,IACrC,OAAO,MAAM,OACX,SAAS,OAAO,WAAW,MAAM,GACjC,SAAS,OAAO,WAAW,MAAM,WAAY,MAAM,CACrD;EAEJ;CACF,QAAQ,CAER;CAEA,OAAO,MAAM,OAAO,SAAS,OAAO,GAAG,CAAC,GAAG,SAAS,OAAO,GAAG,CAAC,CAAC;AAClE;;;;;;AAOA,MAAM,iCAAiC,OACrC,QACwB;CACxB,MAAM,SAAS,mBAAmB;CAClC,IAAI,CAAC,QAAQ,OAAO,CAAC;CAErB,MAAM,uBAAuB,8BAA8B;CAC3D,MAAM,eAAe,OAAO,OAAO,oBAAoB,EACpD,KAAK,EACL,QAAQ,eAAe,YAAY,QAAQ,OAAO,WAAW,QAAQ;CAExE,MAAM,YAAwB,CAAC;CAE/B,MAAM,cAAc,cAAc,OAAO,eAAe;EACtD,MAAM,WAAW,WAAW;EAC5B,MAAM,eAAe,WAAW,QAAQ,IACpC,WACA,KAAK,OAAO,OAAO,SAAS,QAAQ;EAExC,IAAI,CAAC,WAAW,YAAY,GAAG;EAE/B,UAAU,KACR,SAAS,OACP,cAAc,YAAY,EAAE,MAC5B,MAAM,YAAY,cAAc,GAAG,CACrC,CACF;CACF,CAAC;CAED,OAAO;AACT;;;;;AAMA,MAAM,iBAAiB,OAAO,cAAyC;CAErE,IACE,qBAFU,KAAK,IAGb,IAAI,sBAAsB,2BAE5B,OAAO;CAET,MAAM,YAAY,KAAK,IAAI;CAC3B,oBAAoB,MAAM,mBAAmB,SAAS;CACtD,sBAAsB,KAAK,IAAI;CAC/B,IACE,yBAAyB,kBAAkB,OAAO,cAAc,KAAK,IAAI,IAAI,UAAU,GACzF;CACA,OAAO;AACT;;;;;AAMA,MAAM,uBAAuB,OAAO,QAAqC;CACvE,MAAM,SAAS,mBAAmB;CAClC,IAAI,CAAC,QAAQ,OAAO,CAAC;CAErB,MAAM,yBAAyB,IAAI,OACjC,0EAA0E,wBAAwB,GAAG,EAAE,MACvG,GACF;CAEA,MAAM,YAAY,MAAM,eAAe,OAAO,OAAO,OAAO;CAC5D,MAAM,YAAwB,CAAC;CAC/B,MAAM,YAAY,KAAK,IAAI;CAE3B,MAAM,cAAc,WAAW,OAAO,aAAa;EACjD,MAAM,OAAO,MAAM,YAAY,QAAQ;EAEvC,IAAI,CAAC,MAAM,SAAS,GAAG,GAAG;EAE1B,KAAK,MAAM,SAAS,KAAK,SAAS,sBAAsB,GAAG;GACzD,MAAM,aAAa,MAAM;GACzB,MAAM,WAAW,aAAa,MAAM,GAAG;GACvC,UAAU,KACR,SAAS,OACP,cAAc,QAAQ,EAAE,MACxB,cAAc,MAAM,YAAY,QAAQ,CAC1C,CACF;EACF;CACF,CAAC;CAED,IACE,yBAAyB,IAAI,OAAO,UAAU,OAAO,uBAAuB,KAAK,IAAI,IAAI,UAAU,OAAO,UAAU,OAAO,QAC7H;CACA,OAAO;AACT;;;;;;;;;;AAWA,MAAM,yBAAyB,OAC7B,eACA,cACwB;CACxB,MAAM,SAAS,mBAAmB;CAClC,IAAI,CAAC,UAAU,UAAU,WAAW,GAAG,OAAO,CAAC;CAE/C,MAAM,gBAAgB,UAAU,UAAU,SAAS;CACnD,MAAM,aAAa,wBAAwB,aAAa;CACxD,MAAM,cAAc,wBAAwB,aAAa;CACzD,MAAM,2BAA2B,IAAI,OACnC,oEAAoE,WAAW,OACjF;CAEA,MAAM,YAAY,MAAM,eAAe,OAAO,OAAO,OAAO;CAC5D,MAAM,YAAwB,CAAC;CAC/B,MAAM,YAAY,KAAK,IAAI;CAE3B,MAAM,cAAc,WAAW,OAAO,aAAa;EACjD,MAAM,OAAO,MAAM,YAAY,QAAQ;EACvC,IAAI,CAAC,MAAM;EAEX,IAAI,CAAC,KAAK,SAAS,aAAa,GAAG;EACnC,IAAI,UAAU,MAAM,YAAY,CAAC,KAAK,SAAS,OAAO,CAAC,GAAG;EAC1D,IAAI,CAAC,yBAAyB,KAAK,IAAI,GAAG;EAE1C,KAAK,MAAM,SAAS,sBAClB,MACA,YACA,eACA,aACA,SACF,GACE,UAAU,KAAK,SAAS,OAAO,cAAc,QAAQ,EAAE,MAAM,KAAK,CAAC;CAEvE,CAAC;CAED,IACE,2BAA2B,cAAc,GAAG,UAAU,KAAK,GAAG,EAAE,OAAO,UAAU,OAAO,uBAAuB,KAAK,IAAI,IAAI,UAAU,OAAO,UAAU,OAAO,QAChK;CACA,OAAO;AACT;;;;;;AAOA,MAAM,6BAA6B,OACjC,cACA,cACmB;CACnB,MAAM,UAAU,MAAM,YAAY,YAAY;CAC9C,IAAI,CAAC,SACH,OAAO,MAAM,OAAO,SAAS,OAAO,GAAG,CAAC,GAAG,SAAS,OAAO,GAAG,CAAC,CAAC;CAElE,MAAM,QAAQ,QAAQ,MAAM,OAAO;CAEnC,MAAM,yBAAyB,IAAI,OACjC,cAAc,wBAAwB,SAAS,EAAE,eACnD;CAEA,KAAK,IAAI,YAAY,GAAG,YAAY,MAAM,QAAQ,aAAa;EAC7D,MAAM,OAAO,MAAM;EACnB,MAAM,gBAAgB,KAAK,OAAO,sBAAsB;EACxD,IAAI,kBAAkB,IAAI;EAC1B,MAAM,SAAS,KAAK,QAAQ,WAAW,aAAa;EACpD,IAAI,WAAW,IAAI;EACnB,OAAO,MAAM,OACX,SAAS,OAAO,WAAW,MAAM,GACjC,SAAS,OAAO,WAAW,SAAS,UAAU,MAAM,CACtD;CACF;CAEA,OAAO,MAAM,OAAO,SAAS,OAAO,GAAG,CAAC,GAAG,SAAS,OAAO,GAAG,CAAC,CAAC;AAClE;;;;;AAMA,MAAM,2BAA2B,OAC/B,eACA,cACwB;CACxB,MAAM,SAAS,mBAAmB;CAClC,IAAI,CAAC,QAAQ,OAAO,CAAC;CAErB,MAAM,uBAAuB,8BAA8B;CAC3D,MAAM,eAAe,OAAO,OAAO,oBAAoB,EACpD,KAAK,EACL,QACE,eAAe,YAAY,QAAQ,iBAAiB,WAAW,QAClE;CAEF,MAAM,YAAwB,CAAC;CAE/B,MAAM,cAAc,cAAc,OAAO,eAAe;EACtD,MAAM,WAAW,WAAW;EAC5B,MAAM,eAAe,WAAW,QAAQ,IACpC,WACA,KAAK,OAAO,OAAO,SAAS,QAAQ;EACxC,IAAI,CAAC,WAAW,YAAY,GAAG;EAE/B,UAAU,KACR,SAAS,OACP,cAAc,YAAY,EAAE,MAC5B,MAAM,2BAA2B,cAAc,SAAS,CAC1D,CACF;CACF,CAAC;CAED,OAAO;AACT;AAEA,WAAW,cAAc,eAAmD;CAC1E,gBACE,WAAW,aACV,WAAW,mBAAmB,IAAI,MAC/B,mBAAmB,IAAI,IAAI,WAAW,iBAAiB,GAAG,GAAG,EAAE,QAAQ,IACvE;CACN,uBAAuB;CAEvB,IAAI,gCAAgC,iBAAiB,UAAU;CAE/D,IAAI,WAAW,kBAAkB,QAC/B,IACE,qBAAqB,WAAW,iBAAiB,KAAK,WAAW,OAAO,IAAI,EAAE,KAAK,IAAI,GACzF;CAGF,OAAO,EACL,cAAc;EACZ,kBAAkB,qBAAqB;EACvC,oBAAoB;EACpB,oBAAoB;CACtB,EACF;AACF,CAAC;AAED,WAAW,aAAa,OAAO,eAAe;CAC5C,MAAM,MAAM,WAAW,aAAa;CACpC,MAAM,WAAW,WAAW;CAC5B,MAAM,WAAW,UAAU,IAAI,GAAG;CAElC,IAAI,CAAC,UAAU;EACb,IAAI,+CAA+C,KAAK;EACxD,OAAO;CACT;CAEA,MAAM,OAAO,SAAS,QAAQ;CAC9B,MAAM,SAAS,SAAS,SAAS,QAAQ;CAOzC,IAAI,kBANiB,IAClB,QAAQ,cAAc,EAAE,EACxB,MAAM,GAAG,EACT,MAAM,EAAE,EACR,KAAK,GAEyB,EAAE,GAAG,SAAS,KAAK,GAAG,SAAS,WAAW;CAE3E,IAAI,cAAc,GAAG,GAAG;EACtB,IAAI,sCAAsC;EAG1C,MAAM,MAAM,qBAAqB,MAAM,MAAM;EAC7C,IAAI,KAAK;GACP,IAAI,oCAAoC,IAAI,yBAAyB;GACrE,MAAM,YAAY,MAAM,qBAAqB,GAAG;GAEhD,IACE,wBAAwB,UAAU,OAAO,yBAAyB,IAAI,EACxE;GACA,OAAO,UAAU,SAAS,YAAY;EACxC;EAIA,MAAM,YAAY,yBAAyB,MAAM,MAAM;EACvD,IAAI,WAAW;GACb,IACE,0BAA0B,UAAU,cAAc,GAAG,UAAU,UAAU,KAAK,GAAG,EAAE,qBACrF;GACA,MAAM,YAAY,MAAM,uBACtB,UAAU,eACV,UAAU,SACZ;GACA,IACE,wBAAwB,UAAU,OAAO,4BAA4B,UAAU,UAAU,KAAK,GAAG,EAAE,EACrG;GACA,OAAO,UAAU,SAAS,YAAY;EACxC;EAEA,IAAI,oEAAoE;EACxE,OAAO;CACT;CAGA,MAAM,MAAM,gBAAgB,MAAM,MAAM;CACxC,IAAI,KAAK;EACP,IACE,oCAAoC,IAAI,mCAC1C;EACA,MAAM,YAAY,MAAM,+BAA+B,GAAG;EAC1D,IACE,wBAAwB,UAAU,OAAO,mCAAmC,IAAI,EAClF;EACA,OAAO,UAAU,SAAS,YAAY;CACxC;CAMA,MAAM,aAAa,uBAAuB,MAAM,MAAM;CACtD,IAAI,YAAY;EACd,IACE,gCAAgC,WAAW,cAAc,GAAG,WAAW,UAAU,4BACnF;EACA,MAAM,YAAY,MAAM,yBACtB,WAAW,eACX,WAAW,SACb;EACA,IACE,wBAAwB,UAAU,OAAO,+BAA+B,WAAW,UAAU,EAC/F;EACA,OAAO,UAAU,SAAS,YAAY;CACxC;CAEA,IAAI,qDAAqD;CACzD,OAAO;AACT,CAAC;;;;;;;;;;AAWD,WAAW,aAAa,OAAO,eAAgC;CAC7D,MAAM,MAAM,WAAW,aAAa;CACpC,MAAM,WAAW,WAAW;CAC5B,MAAM,WAAW,UAAU,IAAI,GAAG;CAElC,IAAI,CAAC,UAAU,OAAO;CAEtB,MAAM,OAAO,SAAS,QAAQ;CAC9B,MAAM,SAAS,SAAS,SAAS,QAAQ;CAOzC,IAAI,kBANiB,IAClB,QAAQ,cAAc,EAAE,EACxB,MAAM,GAAG,EACT,MAAM,EAAE,EACR,KAAK,GAEyB,EAAE,GAAG,SAAS,KAAK,GAAG,SAAS,WAAW;CAE3E,IAAI,cAAc,GAAG,GAAG;EAEtB,MAAM,MAAM,qBAAqB,MAAM,MAAM;EAC7C,IAAI,KAAK;GACP,MAAM,YAAY,MAAM,qBAAqB,GAAG;GAChD,IAAI,uBAAuB,IAAI,MAAM,UAAU,OAAO,cAAc;GACpE,OAAO,UAAU,SAAS,YAAY;EACxC;EAGA,MAAM,YAAY,yBAAyB,MAAM,MAAM;EACvD,IAAI,WAAW;GACb,MAAM,YAAY,MAAM,uBACtB,UAAU,eACV,UAAU,SACZ;GACA,IACE,yBAAyB,UAAU,UAAU,KAAK,GAAG,EAAE,MAAM,UAAU,OAAO,UAChF;GACA,OAAO,UAAU,SAAS,YAAY;EACxC;EAEA,OAAO;CACT;CAGA,MAAM,MAAM,gBAAgB,MAAM,MAAM;CACxC,IAAI,KAAK;EACP,MAAM,YAAY,MAAM,+BAA+B,GAAG;EAC1D,IAAI,uBAAuB,IAAI,MAAM,UAAU,OAAO,gBAAgB;EACtE,OAAO,UAAU,SAAS,YAAY;CACxC;CAGA,MAAM,aAAa,uBAAuB,MAAM,MAAM;CACtD,IAAI,YAAY;EACd,MAAM,YAAY,MAAM,yBACtB,WAAW,eACX,WAAW,SACb;EACA,IACE,yBAAyB,WAAW,UAAU,MAAM,UAAU,OAAO,uBACvE;EACA,OAAO,UAAU,SAAS,YAAY;CACxC;CAEA,OAAO;AACT,CAAC;AAKD,WAAW,8BAA8B;CACvC,oBAAoB;CACpB,sBAAsB;CACtB,qBAAqB;CACrB,uBAAuB;CACvB,IACE,2EACF;AACF,CAAC;AAID,UAAU,OAAO,UAAU;AAG3B,WAAW,OAAO"}
@@ -0,0 +1,60 @@
1
+ import { parseSync } from "oxc-parser";
2
+
3
+ //#region src/oxcUtils.ts
4
+ const INTLAYER_GETTERS = new Set(["useIntlayer", "getIntlayer"]);
5
+ /**
6
+ * Depth-first walk of an Oxc AST. Visitor is called for every node that has
7
+ * a `type` property;
8
+ * returning `true` prunes that subtree.
9
+ */
10
+ const walkAst = (node, visitor) => {
11
+ if (!node || typeof node !== "object") return;
12
+ if (Array.isArray(node)) {
13
+ for (const child of node) walkAst(child, visitor);
14
+ return;
15
+ }
16
+ const oxcNode = node;
17
+ if (typeof oxcNode["type"] !== "string") return;
18
+ if (visitor(oxcNode)) return;
19
+ for (const key of Object.keys(oxcNode)) {
20
+ if (key === "start" || key === "end" || key === "type") continue;
21
+ walkAst(oxcNode[key], visitor);
22
+ }
23
+ };
24
+ /** Extract the string value from the first argument of a call expression. */
25
+ const getFirstStringArg = (callExpressionNode) => {
26
+ const argumentsList = callExpressionNode["arguments"];
27
+ if (!argumentsList?.length) return null;
28
+ const firstArgument = argumentsList[0];
29
+ if ((firstArgument["type"] === "Literal" || firstArgument["type"] === "StringLiteral") && typeof firstArgument["value"] === "string") return firstArgument["value"];
30
+ if (firstArgument["type"] === "TemplateLiteral" && firstArgument["expressions"]?.length === 0) {
31
+ const valueNode = firstArgument["quasis"]?.[0]?.["value"];
32
+ return valueNode?.["cooked"] ?? valueNode?.["raw"] ?? null;
33
+ }
34
+ return null;
35
+ };
36
+ /** Returns true when `node` is a useIntlayer / getIntlayer CallExpression. */
37
+ const isIntlayerCall = (node) => {
38
+ if (node["type"] !== "CallExpression") return false;
39
+ const callee = node["callee"];
40
+ return callee?.["type"] === "Identifier" && INTLAYER_GETTERS.has(callee["name"]);
41
+ };
42
+ /**
43
+ * Parse `text` into an Oxc program AST.
44
+ * Returns null when the parse completely fails (e.g. unsupported syntax).
45
+ * Oxc is generally resilient to partial/in-progress edits and still
46
+ * returns a
47
+ * usable AST alongside error diagnostics, so this is rare.
48
+ */
49
+ const parseText = (text) => {
50
+ try {
51
+ const { program } = parseSync("file.tsx", text);
52
+ return program;
53
+ } catch {
54
+ return null;
55
+ }
56
+ };
57
+
58
+ //#endregion
59
+ export { INTLAYER_GETTERS, getFirstStringArg, isIntlayerCall, parseText, walkAst };
60
+ //# sourceMappingURL=oxcUtils.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"oxcUtils.mjs","names":[],"sources":["../../src/oxcUtils.ts"],"sourcesContent":["import { parseSync } from 'oxc-parser';\n\nexport const INTLAYER_GETTERS: Set<string> = new Set([\n 'useIntlayer',\n 'getIntlayer',\n]);\n\nexport type OxcNode = Record<string, unknown>;\n\n/**\n * Depth-first walk of an Oxc AST. Visitor is called for every node that has\n * a `type` property;\n * returning `true` prunes that subtree.\n */\nexport const walkAst = (\n node: unknown,\n visitor: (node: OxcNode) => boolean | void\n): void => {\n if (!node || typeof node !== 'object') return;\n\n if (Array.isArray(node)) {\n for (const child of node) walkAst(child, visitor);\n\n return;\n }\n const oxcNode = node as OxcNode;\n\n if (typeof oxcNode['type'] !== 'string') return;\n\n if (visitor(oxcNode)) return; // pruned\n\n for (const key of Object.keys(oxcNode)) {\n if (key === 'start' || key === 'end' || key === 'type') continue;\n walkAst(oxcNode[key], visitor);\n }\n};\n\n/** Extract the string value from the first argument of a call expression. */\nexport const getFirstStringArg = (\n callExpressionNode: OxcNode\n): string | null => {\n const argumentsList = callExpressionNode['arguments'] as\n | OxcNode[]\n | undefined;\n\n if (!argumentsList?.length) return null;\n const firstArgument = argumentsList[0]!;\n\n if (\n (firstArgument['type'] === 'Literal' ||\n firstArgument['type'] === 'StringLiteral') &&\n typeof firstArgument['value'] === 'string'\n ) {\n return firstArgument['value'];\n }\n\n if (\n firstArgument['type'] === 'TemplateLiteral' &&\n (firstArgument['expressions'] as unknown[])?.length === 0\n ) {\n const templateElements = firstArgument['quasis'] as OxcNode[] | undefined;\n const valueNode = templateElements?.[0]?.['value'] as OxcNode | undefined;\n\n return (\n (valueNode?.['cooked'] as string) ??\n (valueNode?.['raw'] as string) ??\n null\n );\n }\n\n return null;\n};\n\n/** Returns true when `node` is a useIntlayer / getIntlayer CallExpression. */\nexport const isIntlayerCall = (node: OxcNode): boolean => {\n if (node['type'] !== 'CallExpression') return false;\n const callee = node['callee'] as OxcNode | undefined;\n\n return (\n callee?.['type'] === 'Identifier' &&\n INTLAYER_GETTERS.has(callee['name'] as string)\n );\n};\n\n/**\n * Parse `text` into an Oxc program AST.\n * Returns null when the parse completely fails (e.g. unsupported syntax).\n * Oxc is generally resilient to partial/in-progress edits and still\n * returns a\n * usable AST alongside error diagnostics, so this is rare.\n */\nexport const parseText = (text: string): OxcNode | null => {\n try {\n const { program } = parseSync('file.tsx', text);\n\n return program as unknown as OxcNode;\n } catch {\n return null;\n }\n};\n"],"mappings":";;;AAEA,MAAa,mBAAgC,IAAI,IAAI,CACnD,eACA,aACF,CAAC;;;;;;AASD,MAAa,WACX,MACA,YACS;CACT,IAAI,CAAC,QAAQ,OAAO,SAAS,UAAU;CAEvC,IAAI,MAAM,QAAQ,IAAI,GAAG;EACvB,KAAK,MAAM,SAAS,MAAM,QAAQ,OAAO,OAAO;EAEhD;CACF;CACA,MAAM,UAAU;CAEhB,IAAI,OAAO,QAAQ,YAAY,UAAU;CAEzC,IAAI,QAAQ,OAAO,GAAG;CAEtB,KAAK,MAAM,OAAO,OAAO,KAAK,OAAO,GAAG;EACtC,IAAI,QAAQ,WAAW,QAAQ,SAAS,QAAQ,QAAQ;EACxD,QAAQ,QAAQ,MAAM,OAAO;CAC/B;AACF;;AAGA,MAAa,qBACX,uBACkB;CAClB,MAAM,gBAAgB,mBAAmB;CAIzC,IAAI,CAAC,eAAe,QAAQ,OAAO;CACnC,MAAM,gBAAgB,cAAc;CAEpC,KACG,cAAc,YAAY,aACzB,cAAc,YAAY,oBAC5B,OAAO,cAAc,aAAa,UAElC,OAAO,cAAc;CAGvB,IACE,cAAc,YAAY,qBACzB,cAAc,gBAA8B,WAAW,GACxD;EAEA,MAAM,YADmB,cAAc,YACF,KAAK;EAE1C,OACG,YAAY,aACZ,YAAY,UACb;CAEJ;CAEA,OAAO;AACT;;AAGA,MAAa,kBAAkB,SAA2B;CACxD,IAAI,KAAK,YAAY,kBAAkB,OAAO;CAC9C,MAAM,SAAS,KAAK;CAEpB,OACE,SAAS,YAAY,gBACrB,iBAAiB,IAAI,OAAO,OAAiB;AAEjD;;;;;;;;AASA,MAAa,aAAa,SAAiC;CACzD,IAAI;EACF,MAAM,EAAE,YAAY,UAAU,YAAY,IAAI;EAE9C,OAAO;CACT,QAAQ;EACN,OAAO;CACT;AACF"}
@@ -0,0 +1,6 @@
1
+ import { escapeRegularExpression, findFieldRangesInFile, offsetToRange } from "./findFieldInFile.mjs";
2
+ import { findKeyAtOffset } from "./findKeyAtOffset.mjs";
3
+ import { findContentFieldAtOffset, findKeyInContentFile } from "./findKeyInContentFile.mjs";
4
+ import { findUsageFieldAtOffset } from "./findUsageFieldAtOffset.mjs";
5
+
6
+ export { escapeRegularExpression, findContentFieldAtOffset, findFieldRangesInFile, findKeyAtOffset, findKeyInContentFile, findUsageFieldAtOffset, offsetToRange };