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