@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.
- package/dist/esm/_virtual/_rolldown/runtime.mjs +36 -0
- package/dist/esm/callers/index.mjs +12 -0
- package/dist/esm/callers/index.mjs.map +1 -0
- package/dist/esm/callers/patterns.mjs +37 -0
- package/dist/esm/callers/patterns.mjs.map +1 -0
- package/dist/esm/dictionaryUtils.mjs +84 -0
- package/dist/esm/dictionaryUtils.mjs.map +1 -0
- package/dist/esm/findFieldInFile.mjs +53 -0
- package/dist/esm/findFieldInFile.mjs.map +1 -0
- package/dist/esm/findKeyAtOffset.mjs +20 -10
- package/dist/esm/findKeyAtOffset.mjs.map +1 -1
- package/dist/esm/findKeyInContentFile.mjs +350 -0
- package/dist/esm/findKeyInContentFile.mjs.map +1 -0
- package/dist/esm/findTCallAtOffset.mjs +33 -0
- package/dist/esm/findTCallAtOffset.mjs.map +1 -0
- package/dist/esm/findUsageFieldAtOffset.mjs +33 -0
- package/dist/esm/findUsageFieldAtOffset.mjs.map +1 -0
- package/dist/esm/index.mjs +533 -38
- package/dist/esm/index.mjs.map +1 -1
- package/dist/esm/oxcUtils.mjs +130 -0
- package/dist/esm/oxcUtils.mjs.map +1 -0
- package/dist/esm/usageAnalyzer.mjs +692 -0
- package/dist/esm/usageAnalyzer.mjs.map +1 -0
- package/dist/esm/utils.mjs +17 -0
- package/dist/types/callers/index.d.ts +8 -0
- package/dist/types/callers/index.d.ts.map +1 -0
- package/dist/types/callers/patterns.d.ts +30 -0
- package/dist/types/callers/patterns.d.ts.map +1 -0
- package/dist/types/dictionaryUtils.d.ts +12 -0
- package/dist/types/dictionaryUtils.d.ts.map +1 -0
- package/dist/types/findFieldInFile.d.ts +28 -0
- package/dist/types/findFieldInFile.d.ts.map +1 -0
- package/dist/types/findKeyAtOffset.d.ts +14 -3
- package/dist/types/findKeyAtOffset.d.ts.map +1 -1
- package/dist/types/findKeyInContentFile.d.ts +33 -0
- package/dist/types/findKeyInContentFile.d.ts.map +1 -0
- package/dist/types/findTCallAtOffset.d.ts +24 -0
- package/dist/types/findTCallAtOffset.d.ts.map +1 -0
- package/dist/types/findUsageFieldAtOffset.d.ts +23 -0
- package/dist/types/findUsageFieldAtOffset.d.ts.map +1 -0
- package/dist/types/oxcUtils.d.ts +53 -0
- package/dist/types/oxcUtils.d.ts.map +1 -0
- package/dist/types/usageAnalyzer.d.ts +129 -0
- package/dist/types/usageAnalyzer.d.ts.map +1 -0
- package/dist/types/utils.d.ts +19 -0
- package/package.json +18 -24
package/dist/esm/index.mjs
CHANGED
|
@@ -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 {
|
|
4
|
-
import {
|
|
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
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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 =
|
|
115
|
+
const getContentDeclarationLocations = async (key, absolutePath) => {
|
|
116
|
+
const config = getProjectConfig(absolutePath);
|
|
38
117
|
if (!config) return [];
|
|
39
|
-
const unmergedDictionaries =
|
|
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
|
-
|
|
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))
|
|
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
|
-
/**
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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((
|
|
71
|
-
const
|
|
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
|
|
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
|
|