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