@mmerterden/multi-agent-toolkit-mcp 3.9.0 → 3.12.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/CHANGELOG.md +239 -0
- package/README.md +79 -2
- package/README.tr.md +6 -3
- package/index.js +160 -21
- package/package.json +9 -3
- package/tools/code-intel/index.js +665 -0
- package/tools/code-intel/kotlin.js +159 -0
- package/tools/code-intel/lsp-client.js +422 -0
- package/tools/code-intel/pool.js +273 -0
- package/tools/code-intel/positions.js +195 -0
- package/tools/code-intel/swift.js +249 -0
- package/tools/ios-app-store-audit/context.js +160 -9
- package/tools/ios-app-store-audit/index.js +24 -1
- package/tools/offload/index.js +28 -8
- package/tools/pass-kit/index.js +432 -0
- package/tools/pass-kit/sign.js +255 -0
- package/tools/pass-kit/spec.js +317 -0
- package/tools/pass-kit/validate.js +329 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pool.js - one language server per workspace, kept warm, evicted politely.
|
|
3
|
+
*
|
|
4
|
+
* WHY POOL AT ALL. Measured against sourcekit-lsp 6.3.1 on a two-file package:
|
|
5
|
+
* `initialize` answers in ~600ms, but the background index that `references`
|
|
6
|
+
* and `workspace/symbol` depend on does not finish until ~5.8s. On a real
|
|
7
|
+
* package with dependencies the same sequence had not finished at 70s. Spawning
|
|
8
|
+
* per request pays that every time and throws the result away; the second
|
|
9
|
+
* question about the same repository should be fast, and it is only fast if the
|
|
10
|
+
* first one left something behind.
|
|
11
|
+
*
|
|
12
|
+
* WHY ONLY TWO. sourcekit-lsp with background indexing is a large resident
|
|
13
|
+
* process, and an MCP server quietly holding four of them is a bug report. Two
|
|
14
|
+
* covers the real pattern (one repo, sometimes a second) and the third eviction
|
|
15
|
+
* is the least recently used.
|
|
16
|
+
*
|
|
17
|
+
* WHY SERIALIZE PER ROOT. Every request about a file has to be preceded by that
|
|
18
|
+
* file being open on the server, and two tools interleaving didOpen/request on
|
|
19
|
+
* one server produce answers about the wrong document. Requests to DIFFERENT
|
|
20
|
+
* roots still run concurrently; only same-root work queues, and it queues behind
|
|
21
|
+
* something that is usually already warm.
|
|
22
|
+
*
|
|
23
|
+
* @module tools/code-intel/pool
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { readFileSync, statSync } from "node:fs";
|
|
27
|
+
import { pathToFileURL } from "node:url";
|
|
28
|
+
import { createLspClient } from "./lsp-client.js";
|
|
29
|
+
|
|
30
|
+
const IDLE_MS = 10 * 60 * 1000;
|
|
31
|
+
const MAX_SERVERS = 2;
|
|
32
|
+
const SWEEP_MS = 60 * 1000;
|
|
33
|
+
/** Three crashes this close together is a loop, not bad luck. */
|
|
34
|
+
const CRASH_WINDOW_MS = 60 * 1000;
|
|
35
|
+
const CRASH_LIMIT = 3;
|
|
36
|
+
const POISON_MS = 5 * 60 * 1000;
|
|
37
|
+
|
|
38
|
+
const servers = new Map();
|
|
39
|
+
/**
|
|
40
|
+
* One promise chain per key, held OUTSIDE the entry.
|
|
41
|
+
*
|
|
42
|
+
* It used to live on the entry as `entry.lock`, which meant a key with no entry
|
|
43
|
+
* yet had no lock at all: two concurrent calls for a cold root both walked
|
|
44
|
+
* straight past the barrier, both spawned a language server, and the second
|
|
45
|
+
* `servers.set` orphaned the first - never disposed, never swept, because the
|
|
46
|
+
* sweeper only walks this map. A leak of exactly the resident process this file
|
|
47
|
+
* caps at two.
|
|
48
|
+
*/
|
|
49
|
+
const locks = new Map();
|
|
50
|
+
/** Crash records survive their entry, so a poison window is not lost on evict. */
|
|
51
|
+
const crashHistory = new Map();
|
|
52
|
+
let sweeper = null;
|
|
53
|
+
|
|
54
|
+
function startSweeper() {
|
|
55
|
+
if (sweeper) return;
|
|
56
|
+
sweeper = setInterval(() => {
|
|
57
|
+
const now = Date.now();
|
|
58
|
+
for (const [key, e] of [...servers]) {
|
|
59
|
+
if (now - e.lastUsed > IDLE_MS) evict(key, "idle");
|
|
60
|
+
}
|
|
61
|
+
if (servers.size === 0) {
|
|
62
|
+
clearInterval(sweeper);
|
|
63
|
+
sweeper = null;
|
|
64
|
+
}
|
|
65
|
+
}, SWEEP_MS);
|
|
66
|
+
sweeper.unref?.();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function evict(key, why) {
|
|
70
|
+
const e = servers.get(key);
|
|
71
|
+
if (!e) return;
|
|
72
|
+
servers.delete(key);
|
|
73
|
+
e.evictedBecause = why;
|
|
74
|
+
try {
|
|
75
|
+
e.client.dispose();
|
|
76
|
+
} catch {
|
|
77
|
+
/* disposing a dead server is not an error */
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Every server, gone. Wired into the host's own shutdown. */
|
|
82
|
+
export function shutdownAllLsp() {
|
|
83
|
+
for (const key of [...servers.keys()]) evict(key, "shutdown");
|
|
84
|
+
locks.clear();
|
|
85
|
+
crashHistory.clear();
|
|
86
|
+
if (sweeper) {
|
|
87
|
+
clearInterval(sweeper);
|
|
88
|
+
sweeper = null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Roots with work queued or in flight right now.
|
|
94
|
+
*
|
|
95
|
+
* Exported because the queue is the one piece of pool state nothing else can
|
|
96
|
+
* see: `poolStats()` lists servers, and a key can hold a queue without ever
|
|
97
|
+
* having started one - a start that failed, or a call still waiting its turn.
|
|
98
|
+
* A number here that only ever grows is the leak this returns to zero.
|
|
99
|
+
*/
|
|
100
|
+
export function queuedKeys() {
|
|
101
|
+
return [...locks.keys()];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function poolStats() {
|
|
105
|
+
return [...servers.entries()].map(([key, e]) => ({
|
|
106
|
+
key,
|
|
107
|
+
pid: e.client.pid,
|
|
108
|
+
alive: e.client.alive,
|
|
109
|
+
openDocs: e.openDocs.size,
|
|
110
|
+
idleSec: Math.round((Date.now() - e.lastUsed) / 1000),
|
|
111
|
+
}));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Stop the servers a caller names, or all of them.
|
|
116
|
+
* @returns {{stopped: string[], remaining: number}}
|
|
117
|
+
*/
|
|
118
|
+
export function resetServers({ root, language } = {}) {
|
|
119
|
+
const stopped = [];
|
|
120
|
+
for (const [key, e] of [...servers]) {
|
|
121
|
+
if (root && e.root !== root) continue;
|
|
122
|
+
if (language && e.language !== language) continue;
|
|
123
|
+
stopped.push(key);
|
|
124
|
+
evict(key, "reset");
|
|
125
|
+
}
|
|
126
|
+
return { stopped, remaining: servers.size };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function keyFor(language, root) {
|
|
130
|
+
return `${language}:${root}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Run `fn` against a warm server for this root, one caller at a time.
|
|
135
|
+
*
|
|
136
|
+
* `fn` receives `{client, openDoc, root}`. It must not keep the client past its
|
|
137
|
+
* own return: the entry can be evicted the moment the lock is released.
|
|
138
|
+
*/
|
|
139
|
+
export async function withServer(spec, fn) {
|
|
140
|
+
const { language, root, bin, argv, initializeParams, initTimeoutMs = 120000 } = spec;
|
|
141
|
+
const key = keyFor(language, root);
|
|
142
|
+
startSweeper();
|
|
143
|
+
|
|
144
|
+
// Queue on this key before looking at anything. Reading the entry first and
|
|
145
|
+
// deciding whether to queue is the race: the decision is made on state that
|
|
146
|
+
// the call ahead is about to change.
|
|
147
|
+
const previous = locks.get(key) || Promise.resolve();
|
|
148
|
+
let release;
|
|
149
|
+
const mine = new Promise((r) => (release = r));
|
|
150
|
+
const chain = previous.then(
|
|
151
|
+
() => mine,
|
|
152
|
+
() => mine,
|
|
153
|
+
);
|
|
154
|
+
locks.set(key, chain);
|
|
155
|
+
await previous.catch(() => {});
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
let entry = servers.get(key);
|
|
159
|
+
if (entry?.poisonedUntil && Date.now() < entry.poisonedUntil) {
|
|
160
|
+
return {
|
|
161
|
+
error: `ERROR: ${language} language server crashed ${CRASH_LIMIT}x in a minute for ${root}; not restarting until ${new Date(entry.poisonedUntil).toISOString()}. Last stderr: ${entry.lastStderr || "(none)"}`,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
if (entry && !entry.client.alive) {
|
|
165
|
+
evict(key, "dead");
|
|
166
|
+
entry = undefined;
|
|
167
|
+
}
|
|
168
|
+
if (!entry) {
|
|
169
|
+
if (servers.size >= MAX_SERVERS) {
|
|
170
|
+
const oldest = [...servers.entries()]
|
|
171
|
+
.filter(([k]) => k !== key)
|
|
172
|
+
.sort((a, b) => a[1].lastUsed - b[1].lastUsed)[0];
|
|
173
|
+
if (oldest) evict(oldest[0], "lru");
|
|
174
|
+
}
|
|
175
|
+
const started = await start({ key, language, root, bin, argv, initializeParams, initTimeoutMs });
|
|
176
|
+
if (started.error) return started;
|
|
177
|
+
entry = started;
|
|
178
|
+
servers.set(key, entry);
|
|
179
|
+
}
|
|
180
|
+
entry.lastUsed = Date.now();
|
|
181
|
+
const result = await fn({
|
|
182
|
+
client: entry.client,
|
|
183
|
+
root,
|
|
184
|
+
openDoc: (file) => openDoc(entry, file, language),
|
|
185
|
+
});
|
|
186
|
+
entry.lastUsed = Date.now();
|
|
187
|
+
return result;
|
|
188
|
+
} finally {
|
|
189
|
+
release();
|
|
190
|
+
// The queue outlives the server it guards unless the last caller clears it,
|
|
191
|
+
// and a key is added for every distinct root ever asked about - a slow leak
|
|
192
|
+
// in a server that runs for days. A later caller replaces the chain
|
|
193
|
+
// synchronously, before its first await, so a chain that is still ours
|
|
194
|
+
// means nobody is behind us.
|
|
195
|
+
if (locks.get(key) === chain) locks.delete(key);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function start({ key, language, root, bin, argv, initializeParams, initTimeoutMs }) {
|
|
200
|
+
const prior = crashHistory.get(key);
|
|
201
|
+
const entry = {
|
|
202
|
+
language,
|
|
203
|
+
root,
|
|
204
|
+
openDocs: new Map(),
|
|
205
|
+
crashes: prior?.crashes || [],
|
|
206
|
+
poisonedUntil: prior?.poisonedUntil,
|
|
207
|
+
lastUsed: Date.now(),
|
|
208
|
+
lastStderr: prior?.lastStderr || "",
|
|
209
|
+
};
|
|
210
|
+
entry.client = createLspClient({
|
|
211
|
+
bin,
|
|
212
|
+
argv,
|
|
213
|
+
cwd: root,
|
|
214
|
+
initializeParams,
|
|
215
|
+
onCrash: (info) => {
|
|
216
|
+
// Mutate the entry in place. Replacing it with a copy broke identity with
|
|
217
|
+
// the `entry` an in-flight call is holding - its openDoc would go on
|
|
218
|
+
// writing to a record nothing else reads - and re-inserting it into the
|
|
219
|
+
// map resurrected a dead server that evict() had just removed.
|
|
220
|
+
entry.lastStderr = (info.stderr || "").trim().slice(-400);
|
|
221
|
+
const now = Date.now();
|
|
222
|
+
entry.crashes = [...entry.crashes.filter((t) => now - t < CRASH_WINDOW_MS), now];
|
|
223
|
+
if (entry.crashes.length >= CRASH_LIMIT) entry.poisonedUntil = now + POISON_MS;
|
|
224
|
+
// The crash record has to outlive the entry, or the poison window is lost
|
|
225
|
+
// the moment the dead entry is evicted.
|
|
226
|
+
crashHistory.set(key, { crashes: entry.crashes, poisonedUntil: entry.poisonedUntil, lastStderr: entry.lastStderr });
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
const init = await entry.client.initialize({ timeoutMs: initTimeoutMs });
|
|
230
|
+
if (init.error) {
|
|
231
|
+
try {
|
|
232
|
+
entry.client.dispose();
|
|
233
|
+
} catch {
|
|
234
|
+
/* ignore */
|
|
235
|
+
}
|
|
236
|
+
return { error: `ERROR: ${bin} failed to initialize: ${init.error.message}` };
|
|
237
|
+
}
|
|
238
|
+
return entry;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Make sure the server has this file, and has the version on disk.
|
|
243
|
+
*
|
|
244
|
+
* A stateless tool cannot track edits, so a file whose mtime moved is re-sent
|
|
245
|
+
* whole. `textDocumentSync.change: 2` means the server ACCEPTS incremental
|
|
246
|
+
* edits, not that it requires them.
|
|
247
|
+
*/
|
|
248
|
+
function openDoc(entry, file, language) {
|
|
249
|
+
let text;
|
|
250
|
+
let mtime;
|
|
251
|
+
try {
|
|
252
|
+
text = readFileSync(file, "utf8");
|
|
253
|
+
mtime = statSync(file).mtimeMs;
|
|
254
|
+
} catch (e) {
|
|
255
|
+
return { error: `ERROR: cannot read ${file}: ${e.message}` };
|
|
256
|
+
}
|
|
257
|
+
const uri = pathToFileURL(file).href;
|
|
258
|
+
const known = entry.openDocs.get(uri);
|
|
259
|
+
if (!known) {
|
|
260
|
+
entry.openDocs.set(uri, { mtime, version: 1 });
|
|
261
|
+
entry.client.notify("textDocument/didOpen", {
|
|
262
|
+
textDocument: { uri, languageId: language, version: 1, text },
|
|
263
|
+
});
|
|
264
|
+
} else if (known.mtime !== mtime) {
|
|
265
|
+
known.version += 1;
|
|
266
|
+
known.mtime = mtime;
|
|
267
|
+
entry.client.notify("textDocument/didChange", {
|
|
268
|
+
textDocument: { uri, version: known.version },
|
|
269
|
+
contentChanges: [{ text }],
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
return { uri, text };
|
|
273
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* positions.js - the arithmetic, with no I/O and no processes.
|
|
3
|
+
*
|
|
4
|
+
* Everything here is a pure function, because everything here is the kind of
|
|
5
|
+
* mistake that produces a confident wrong answer rather than an error:
|
|
6
|
+
*
|
|
7
|
+
* - LSP counts lines and characters from 0. Editors, ripgrep and every
|
|
8
|
+
* citation in this pipeline count lines from 1. One off, and the tool
|
|
9
|
+
* answers about the line above the one asked for, with no sign anything
|
|
10
|
+
* went wrong.
|
|
11
|
+
* - LSP `character` counts UTF-16 code units, not bytes and not code points.
|
|
12
|
+
* A Turkish identifier or an emoji earlier in the line shifts every column
|
|
13
|
+
* after it.
|
|
14
|
+
* - A caller that has to count columns will get it wrong. `symbol` resolves a
|
|
15
|
+
* name against the file's own symbol tree instead, which needs no build
|
|
16
|
+
* configuration at all.
|
|
17
|
+
*
|
|
18
|
+
* @module tools/code-intel/positions
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Extensions this family can answer for, and which server answers. */
|
|
22
|
+
export const LANGUAGE_BY_EXT = {
|
|
23
|
+
swift: "swift",
|
|
24
|
+
kt: "kotlin",
|
|
25
|
+
kts: "kotlin",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {string} file
|
|
30
|
+
* @returns {"swift"|"kotlin"|null}
|
|
31
|
+
*/
|
|
32
|
+
export function languageOf(file) {
|
|
33
|
+
const m = /\.([A-Za-z0-9]+)$/.exec(file || "");
|
|
34
|
+
if (!m) return null;
|
|
35
|
+
return LANGUAGE_BY_EXT[m[1].toLowerCase()] || null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A 1-based line/column, as a caller writes it, to an LSP position.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} text the file's contents
|
|
42
|
+
* @param {number} line 1-based
|
|
43
|
+
* @param {number} column 1-based, in characters
|
|
44
|
+
* @returns {{line: number, character: number}|{error: string}}
|
|
45
|
+
*/
|
|
46
|
+
export function toLspPosition(text, line, column) {
|
|
47
|
+
if (!Number.isInteger(line) || line < 1) return { error: `line ${line} is not a line number` };
|
|
48
|
+
const lines = text.split("\n");
|
|
49
|
+
if (line > lines.length) {
|
|
50
|
+
return { error: `line ${line} is past the end of the file (${lines.length} lines)` };
|
|
51
|
+
}
|
|
52
|
+
const col = Number.isInteger(column) && column >= 1 ? column : 1;
|
|
53
|
+
const src = lines[line - 1];
|
|
54
|
+
// Column counted in CHARACTERS by the caller, in UTF-16 units by LSP. Walk
|
|
55
|
+
// the code points rather than slicing, so an astral character counts 2.
|
|
56
|
+
let units = 0;
|
|
57
|
+
let seen = 0;
|
|
58
|
+
for (const ch of src) {
|
|
59
|
+
if (seen >= col - 1) break;
|
|
60
|
+
units += ch.length;
|
|
61
|
+
seen += 1;
|
|
62
|
+
}
|
|
63
|
+
return { line: line - 1, character: units };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* An LSP position back to what a human reads.
|
|
68
|
+
*
|
|
69
|
+
* @param {string|null} text the file's contents, when available
|
|
70
|
+
* @param {{line: number, character: number}} pos
|
|
71
|
+
* @returns {{line: number, column: number}} both 1-based
|
|
72
|
+
*/
|
|
73
|
+
export function fromLspPosition(text, pos) {
|
|
74
|
+
const line = (pos?.line ?? 0) + 1;
|
|
75
|
+
const units = pos?.character ?? 0;
|
|
76
|
+
if (typeof text !== "string") return { line, column: units + 1 };
|
|
77
|
+
const src = text.split("\n")[line - 1];
|
|
78
|
+
if (typeof src !== "string") return { line, column: units + 1 };
|
|
79
|
+
let consumed = 0;
|
|
80
|
+
let chars = 0;
|
|
81
|
+
for (const ch of src) {
|
|
82
|
+
if (consumed >= units) break;
|
|
83
|
+
consumed += ch.length;
|
|
84
|
+
chars += 1;
|
|
85
|
+
}
|
|
86
|
+
return { line, column: chars + 1 };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Find a symbol in a `textDocument/documentSymbol` result.
|
|
91
|
+
*
|
|
92
|
+
* Accepts either shape the spec allows: the hierarchical `DocumentSymbol[]`
|
|
93
|
+
* and the flat `SymbolInformation[]`. Matching is on the plain name or on a
|
|
94
|
+
* dotted path (`Greeter.greet`), so a caller can disambiguate without knowing
|
|
95
|
+
* the tree.
|
|
96
|
+
*
|
|
97
|
+
* Ambiguity is returned, never resolved: picking the first of two `reload`
|
|
98
|
+
* methods answers about the wrong one and looks identical to an answer about
|
|
99
|
+
* the right one.
|
|
100
|
+
*
|
|
101
|
+
* @returns {{match: object}|{candidates: object[]}|{error: string}}
|
|
102
|
+
*/
|
|
103
|
+
export function findSymbol(symbols, query) {
|
|
104
|
+
const wanted = String(query || "").trim();
|
|
105
|
+
if (!wanted) return { error: "no symbol given" };
|
|
106
|
+
const flat = [];
|
|
107
|
+
const walk = (nodes, prefix) => {
|
|
108
|
+
for (const s of nodes || []) {
|
|
109
|
+
if (!s || typeof s.name !== "string") continue;
|
|
110
|
+
const path = prefix ? `${prefix}.${s.name}` : s.name;
|
|
111
|
+
const range = s.selectionRange || s.range || s.location?.range;
|
|
112
|
+
flat.push({ name: s.name, path, kind: s.kind, range });
|
|
113
|
+
if (Array.isArray(s.children) && s.children.length) walk(s.children, path);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
walk(Array.isArray(symbols) ? symbols : [], "");
|
|
117
|
+
|
|
118
|
+
const exactPath = flat.filter((s) => s.path === wanted);
|
|
119
|
+
const byName = flat.filter((s) => s.name === wanted);
|
|
120
|
+
const hits = exactPath.length ? exactPath : byName;
|
|
121
|
+
|
|
122
|
+
if (hits.length === 1) return { match: hits[0] };
|
|
123
|
+
if (hits.length > 1) return { candidates: hits };
|
|
124
|
+
|
|
125
|
+
const near = flat
|
|
126
|
+
.filter((s) => s.name.toLowerCase().includes(wanted.toLowerCase()))
|
|
127
|
+
.slice(0, 8);
|
|
128
|
+
return {
|
|
129
|
+
error: near.length
|
|
130
|
+
? `no symbol named "${wanted}" in this file; nearest: ${near.map((n) => n.path).join(", ")}`
|
|
131
|
+
: `no symbol named "${wanted}" in this file`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Turn LSP locations into something a caller can read, and cap them.
|
|
137
|
+
*
|
|
138
|
+
* The cap is load-bearing rather than tidy: a tool that declares an
|
|
139
|
+
* `outputSchema` skips this server's automatic large-payload offload, so
|
|
140
|
+
* nothing else stands between a common symbol's four thousand references and
|
|
141
|
+
* the caller's context.
|
|
142
|
+
*/
|
|
143
|
+
export function shapeLocations(locations, { root, maxResults = 200, readFile } = {}) {
|
|
144
|
+
const list = Array.isArray(locations) ? locations : locations ? [locations] : [];
|
|
145
|
+
const total = list.length;
|
|
146
|
+
const kept = list.slice(0, maxResults);
|
|
147
|
+
const cache = new Map();
|
|
148
|
+
const results = kept.map((loc) => {
|
|
149
|
+
const uri = loc.uri || loc.targetUri || "";
|
|
150
|
+
const range = loc.range || loc.targetSelectionRange || loc.targetRange || {};
|
|
151
|
+
const abs = uriToPath(uri);
|
|
152
|
+
let text = null;
|
|
153
|
+
if (typeof readFile === "function") {
|
|
154
|
+
if (!cache.has(abs)) cache.set(abs, readFile(abs));
|
|
155
|
+
text = cache.get(abs);
|
|
156
|
+
}
|
|
157
|
+
const start = fromLspPosition(text, range.start);
|
|
158
|
+
const out = {
|
|
159
|
+
file: root && abs.startsWith(root + "/") ? abs.slice(root.length + 1) : abs,
|
|
160
|
+
line: start.line,
|
|
161
|
+
column: start.column,
|
|
162
|
+
};
|
|
163
|
+
if (typeof text === "string") {
|
|
164
|
+
const src = text.split("\n")[start.line - 1];
|
|
165
|
+
if (typeof src === "string") out.preview = src.trim().slice(0, 200);
|
|
166
|
+
}
|
|
167
|
+
return out;
|
|
168
|
+
});
|
|
169
|
+
return { count: total, truncated: total > kept.length, results };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** `file:///a/b` to `/a/b`, with percent-escapes undone. */
|
|
173
|
+
export function uriToPath(uri) {
|
|
174
|
+
if (typeof uri !== "string") return "";
|
|
175
|
+
if (!uri.startsWith("file://")) return uri;
|
|
176
|
+
try {
|
|
177
|
+
return decodeURIComponent(uri.slice("file://".length));
|
|
178
|
+
} catch {
|
|
179
|
+
return uri.slice("file://".length);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The three legal shapes of `Hover.contents`, as one string. */
|
|
184
|
+
export function markdownFromHover(contents) {
|
|
185
|
+
if (contents == null) return "";
|
|
186
|
+
if (typeof contents === "string") return contents;
|
|
187
|
+
if (Array.isArray(contents)) return contents.map(markdownFromHover).filter(Boolean).join("\n\n");
|
|
188
|
+
if (typeof contents === "object") {
|
|
189
|
+
if (typeof contents.value === "string") return contents.value;
|
|
190
|
+
if (typeof contents.language === "string" && typeof contents.value === "string") {
|
|
191
|
+
return contents.value;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return "";
|
|
195
|
+
}
|