@pieceful/ravel-language-service 0.2.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/LICENSE +21 -0
- package/README.md +180 -0
- package/package.json +48 -0
- package/src/edits.d.ts +29 -0
- package/src/edits.js +397 -0
- package/src/index.d.ts +48 -0
- package/src/index.js +881 -0
- package/src/ravel-index.d.ts +30 -0
- package/src/ravel-index.js +252 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,881 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BRIDGE_ERROR_CODES,
|
|
3
|
+
LanguageBridgeError,
|
|
4
|
+
requireLanguageRequestSupport,
|
|
5
|
+
supportsLanguageRequest,
|
|
6
|
+
throwIfAborted
|
|
7
|
+
} from "@pieceful/ravel-language-bridge";
|
|
8
|
+
import {
|
|
9
|
+
classifyWorkspaceEdit,
|
|
10
|
+
validateSourceEditVersions
|
|
11
|
+
} from "./edits.js";
|
|
12
|
+
|
|
13
|
+
const exactKinds = new Set(["exact", "identity"]);
|
|
14
|
+
const writableRequestKinds = new Set(["completionDetails", "rename"]);
|
|
15
|
+
const exactCursorRequestKinds = new Set([
|
|
16
|
+
"completion",
|
|
17
|
+
"completionDetails",
|
|
18
|
+
"signatureHelp",
|
|
19
|
+
"prepareRename",
|
|
20
|
+
"rename"
|
|
21
|
+
]);
|
|
22
|
+
const locationArrayKinds = new Set([
|
|
23
|
+
"definition",
|
|
24
|
+
"typeDefinition",
|
|
25
|
+
"references",
|
|
26
|
+
"documentSymbols",
|
|
27
|
+
"workspaceSymbols",
|
|
28
|
+
"prepareCallHierarchy"
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
const mappingKind = (match) =>
|
|
32
|
+
match?.kind ?? match?.mappingKind ?? match?.quality ?? match?.precision;
|
|
33
|
+
|
|
34
|
+
const projectionIdFor = (match) =>
|
|
35
|
+
match?.projectionId ?? match?.projection?.id ?? match?.id;
|
|
36
|
+
|
|
37
|
+
const virtualOffsetFor = (match) =>
|
|
38
|
+
match?.virtualOffset ?? match?.offset ?? match?.position?.offset ??
|
|
39
|
+
match?.virtual?.offset ?? match?.virtual?.start;
|
|
40
|
+
|
|
41
|
+
const occurrenceIdFor = (match) =>
|
|
42
|
+
match?.occurrenceId ?? match?.occurrence?.id;
|
|
43
|
+
|
|
44
|
+
const targetIdFor = (match, projection) =>
|
|
45
|
+
match?.targetId ?? match?.target?.id ?? projection?.targetId;
|
|
46
|
+
|
|
47
|
+
const artifactIdFor = (match, projection) =>
|
|
48
|
+
match?.artifactId ?? match?.artifact?.id ?? projection?.artifactId;
|
|
49
|
+
|
|
50
|
+
const asArray = (value) => Array.isArray(value) ? value : value == null ? [] : [value];
|
|
51
|
+
|
|
52
|
+
const rangeValue = (value) => Number.isInteger(value)
|
|
53
|
+
? value
|
|
54
|
+
: Number.isInteger(value?.offset)
|
|
55
|
+
? value.offset
|
|
56
|
+
: undefined;
|
|
57
|
+
|
|
58
|
+
const offsetRange = (range) => {
|
|
59
|
+
const start = rangeValue(range?.start);
|
|
60
|
+
const end = rangeValue(range?.end);
|
|
61
|
+
return Number.isInteger(start) && Number.isInteger(end) && start >= 0 && end >= start
|
|
62
|
+
? { start, end }
|
|
63
|
+
: null;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const sourceLocation = (match) => {
|
|
67
|
+
const source = match?.source ?? match?.sourceRange ?? match;
|
|
68
|
+
const uri = source?.uri ?? match?.uri;
|
|
69
|
+
const range = offsetRange(source?.range ?? source);
|
|
70
|
+
return typeof uri === "string" && range ? { uri, range } : null;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const projectionForUri = (service, uri, known) => {
|
|
74
|
+
const cached = known.get(uri);
|
|
75
|
+
if (cached) return cached;
|
|
76
|
+
const projection = service.getProjectionByUri?.(uri) ??
|
|
77
|
+
service.listProjections?.().find((entry) => entry.uri === uri) ??
|
|
78
|
+
(service.projections instanceof Map
|
|
79
|
+
? [...service.projections.values()].find((entry) => entry.uri === uri)
|
|
80
|
+
: undefined);
|
|
81
|
+
if (projection) known.set(uri, projection);
|
|
82
|
+
return projection;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const sourceMatchesForLocation = (service, projection, location) => {
|
|
86
|
+
const range = offsetRange(location?.range);
|
|
87
|
+
if (!projection || !range) return [];
|
|
88
|
+
return asArray(service.toSource(projection.id, range));
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const bestSourceMatch = (matches) =>
|
|
92
|
+
matches.find((match) => exactKinds.has(mappingKind(match)) && sourceLocation(match)) ??
|
|
93
|
+
matches.find((match) => sourceLocation(match));
|
|
94
|
+
|
|
95
|
+
const sourceLocationWithContext = (location, projection, match) => {
|
|
96
|
+
const source = sourceLocation(match);
|
|
97
|
+
if (!source) return null;
|
|
98
|
+
return {
|
|
99
|
+
...location,
|
|
100
|
+
uri: source.uri,
|
|
101
|
+
range: source.range,
|
|
102
|
+
generated: {
|
|
103
|
+
uri: projection.uri,
|
|
104
|
+
range: offsetRange(location.range),
|
|
105
|
+
projectionId: projection.id,
|
|
106
|
+
targetId: projection.targetId,
|
|
107
|
+
artifactId: projection.artifactId,
|
|
108
|
+
stage: projection.stage,
|
|
109
|
+
occurrenceId: match?.occurrenceId,
|
|
110
|
+
mappingKind: mappingKind(match)
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const mappedSelectionRange = (service, projection, location, primary) => {
|
|
116
|
+
const selectionRange = offsetRange(location?.selectionRange);
|
|
117
|
+
if (!selectionRange) return undefined;
|
|
118
|
+
const best = bestSourceMatch(asArray(service.toSource(projection.id, selectionRange)));
|
|
119
|
+
const selection = sourceLocation(best);
|
|
120
|
+
if (selection?.uri !== primary.uri ||
|
|
121
|
+
selection.range.start < primary.range.start ||
|
|
122
|
+
selection.range.end > primary.range.end) {
|
|
123
|
+
return primary.range;
|
|
124
|
+
}
|
|
125
|
+
return selection.range;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const mapLocation = (service, known, location) => {
|
|
129
|
+
if (!location || typeof location.uri !== "string") return location;
|
|
130
|
+
const projection = projectionForUri(service, location.uri, known);
|
|
131
|
+
if (!projection) return location;
|
|
132
|
+
const matches = sourceMatchesForLocation(service, projection, location);
|
|
133
|
+
const best = bestSourceMatch(matches);
|
|
134
|
+
if (!best) return {
|
|
135
|
+
...location,
|
|
136
|
+
generated: {
|
|
137
|
+
uri: projection.uri,
|
|
138
|
+
range: offsetRange(location.range),
|
|
139
|
+
projectionId: projection.id,
|
|
140
|
+
targetId: projection.targetId,
|
|
141
|
+
artifactId: projection.artifactId,
|
|
142
|
+
stage: projection.stage,
|
|
143
|
+
mappingKind: matches.map(mappingKind).find(Boolean) ?? "synthetic"
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
const mapped = sourceLocationWithContext(location, projection, best);
|
|
147
|
+
const selectionRange = mappedSelectionRange(service, projection, location, mapped);
|
|
148
|
+
return selectionRange ? { ...mapped, selectionRange } : mapped;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const mapLocations = (service, known, locations) =>
|
|
152
|
+
asArray(locations).map((location) => mapLocation(service, known, location));
|
|
153
|
+
|
|
154
|
+
const mapOptionalRange = (service, projection, value) => {
|
|
155
|
+
const range = offsetRange(value);
|
|
156
|
+
if (!range) return { range: value };
|
|
157
|
+
const best = bestSourceMatch(asArray(service.toSource(projection.id, range)));
|
|
158
|
+
const source = sourceLocation(best);
|
|
159
|
+
return source
|
|
160
|
+
? { range: source.range, sourceUri: source.uri, mappingKind: mappingKind(best) }
|
|
161
|
+
: { range: value, generatedOnly: true };
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const selectedReverseMapping = (selected) => Object.fromEntries([
|
|
165
|
+
["targetId", selected?.targetId],
|
|
166
|
+
["artifactId", selected?.artifactId],
|
|
167
|
+
["stage", selected?.stage],
|
|
168
|
+
["occurrenceId", selected?.occurrenceId],
|
|
169
|
+
["projectionVersion", selected?.projection?.version],
|
|
170
|
+
["affinity", selected?.match?.affinity]
|
|
171
|
+
].filter(([, value]) => value !== undefined));
|
|
172
|
+
|
|
173
|
+
const uniqueExactSourceDestinations = (matches) => {
|
|
174
|
+
const destinations = new Map();
|
|
175
|
+
for (const match of matches) {
|
|
176
|
+
if (!exactKinds.has(mappingKind(match)) || match?.writable === false) continue;
|
|
177
|
+
const source = sourceLocation(match);
|
|
178
|
+
if (!source) continue;
|
|
179
|
+
const key = JSON.stringify([
|
|
180
|
+
source.uri,
|
|
181
|
+
source.range.start,
|
|
182
|
+
source.range.end
|
|
183
|
+
]);
|
|
184
|
+
if (!destinations.has(key)) destinations.set(key, { match, source });
|
|
185
|
+
}
|
|
186
|
+
return [...destinations.values()];
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
// Completion replacement spans and prepare-rename ranges are primary authored
|
|
190
|
+
// edits. Reverse-map them only through the occurrence selected for the request;
|
|
191
|
+
// a best-effort destination from another occurrence is not safe to expose.
|
|
192
|
+
const mapWritablePrimaryRange = (service, selected, value) => {
|
|
193
|
+
const range = offsetRange(value);
|
|
194
|
+
if (!range) return { range: value, generatedOnly: true };
|
|
195
|
+
const destinations = uniqueExactSourceDestinations(asArray(service.toSource(
|
|
196
|
+
selected.projection.id,
|
|
197
|
+
range,
|
|
198
|
+
selectedReverseMapping(selected)
|
|
199
|
+
)));
|
|
200
|
+
if (destinations.length !== 1) {
|
|
201
|
+
return {
|
|
202
|
+
range: value,
|
|
203
|
+
generatedOnly: true,
|
|
204
|
+
...(destinations.length > 1 ? { ambiguous: true } : {})
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
const [{ match, source }] = destinations;
|
|
208
|
+
return {
|
|
209
|
+
range: source.range,
|
|
210
|
+
sourceUri: source.uri,
|
|
211
|
+
mappingKind: mappingKind(match)
|
|
212
|
+
};
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const mapCompletion = (service, selected, result) => ({
|
|
216
|
+
...result,
|
|
217
|
+
items: asArray(result?.items).map((item) => {
|
|
218
|
+
const replacement = mapWritablePrimaryRange(service, selected, item.replacementSpan);
|
|
219
|
+
return {
|
|
220
|
+
...item,
|
|
221
|
+
...(item.replacementSpan ? {
|
|
222
|
+
replacementSpan: replacement.range,
|
|
223
|
+
sourceUri: replacement.sourceUri,
|
|
224
|
+
mappingKind: replacement.mappingKind,
|
|
225
|
+
generatedOnly: replacement.generatedOnly,
|
|
226
|
+
ambiguous: replacement.ambiguous
|
|
227
|
+
} : {})
|
|
228
|
+
};
|
|
229
|
+
})
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const mapHover = (service, projection, result) => {
|
|
233
|
+
if (!result?.range) return result;
|
|
234
|
+
const mapped = mapOptionalRange(service, projection, result.range);
|
|
235
|
+
return { ...result, range: mapped.range, sourceUri: mapped.sourceUri,
|
|
236
|
+
mappingKind: mapped.mappingKind, generatedOnly: mapped.generatedOnly };
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const mapSignature = (service, projection, result) => {
|
|
240
|
+
if (!result?.applicableSpan) return result;
|
|
241
|
+
const mapped = mapOptionalRange(service, projection, result.applicableSpan);
|
|
242
|
+
return { ...result, applicableSpan: mapped.range, sourceUri: mapped.sourceUri,
|
|
243
|
+
mappingKind: mapped.mappingKind, generatedOnly: mapped.generatedOnly };
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const mapDiagnostics = (service, known, diagnostics) =>
|
|
247
|
+
asArray(diagnostics).map((diagnostic) => {
|
|
248
|
+
const mapped = mapLocation(service, known, diagnostic);
|
|
249
|
+
return {
|
|
250
|
+
...mapped,
|
|
251
|
+
related: asArray(diagnostic.related).map((entry) =>
|
|
252
|
+
mapLocation(service, known, entry)
|
|
253
|
+
)
|
|
254
|
+
};
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const mapIncomingCalls = (service, known, calls) => asArray(calls).map((entry) => ({
|
|
258
|
+
...entry,
|
|
259
|
+
from: mapLocation(service, known, entry.from),
|
|
260
|
+
fromRanges: asArray(entry.fromRanges).map((range) => {
|
|
261
|
+
const projection = projectionForUri(service, entry.from?.uri, known);
|
|
262
|
+
return projection ? mapOptionalRange(service, projection, range).range : range;
|
|
263
|
+
})
|
|
264
|
+
}));
|
|
265
|
+
|
|
266
|
+
const mapOutgoingCalls = (service, known, calls, sourceProjection) => asArray(calls).map((entry) => ({
|
|
267
|
+
...entry,
|
|
268
|
+
to: mapLocation(service, known, entry.to),
|
|
269
|
+
fromRanges: asArray(entry.fromRanges).map((range) =>
|
|
270
|
+
mapOptionalRange(service, sourceProjection, range).range
|
|
271
|
+
)
|
|
272
|
+
}));
|
|
273
|
+
|
|
274
|
+
const bridgeWorkspaceEdit = (result) => ({
|
|
275
|
+
documentChanges: asArray(result?.changes).map((change) => ({
|
|
276
|
+
textDocument: { uri: change.uri, version: change.version },
|
|
277
|
+
edits: asArray(change.textChanges).map((edit) => ({
|
|
278
|
+
range: edit.range,
|
|
279
|
+
newText: edit.text
|
|
280
|
+
}))
|
|
281
|
+
}))
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
const mapRename = (service, result, options) => {
|
|
285
|
+
if (result?.canRename === false) return result;
|
|
286
|
+
const classified = classifyWorkspaceEdit(bridgeWorkspaceEdit(result), {
|
|
287
|
+
projectionService: service,
|
|
288
|
+
sourceVersions: options.sourceVersions,
|
|
289
|
+
isWritableSource: options.isWritableSource,
|
|
290
|
+
importDestination: options.importDestination
|
|
291
|
+
});
|
|
292
|
+
return { ...result, classifiedEdit: classified };
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const mapCompletionDetails = (service, result, options) => ({
|
|
296
|
+
...result,
|
|
297
|
+
codeActions: asArray(result?.codeActions).map((action) => ({
|
|
298
|
+
...action,
|
|
299
|
+
classifiedEdit: classifyWorkspaceEdit({
|
|
300
|
+
documentChanges: asArray(action.changes).map((change) => ({
|
|
301
|
+
textDocument: { uri: change.uri, version: change.version },
|
|
302
|
+
edits: asArray(change.textChanges).map((edit) => ({
|
|
303
|
+
range: edit.range,
|
|
304
|
+
newText: edit.text
|
|
305
|
+
}))
|
|
306
|
+
}))
|
|
307
|
+
}, {
|
|
308
|
+
projectionService: service,
|
|
309
|
+
sourceVersions: options.sourceVersions,
|
|
310
|
+
isWritableSource: options.isWritableSource,
|
|
311
|
+
importDestination: options.importDestination
|
|
312
|
+
})
|
|
313
|
+
}))
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
const mapResponse = (service, known, kind, selected, result, options) => {
|
|
317
|
+
const projection = selected.projection;
|
|
318
|
+
if (locationArrayKinds.has(kind)) return mapLocations(service, known, result);
|
|
319
|
+
if (kind === "completion") return mapCompletion(service, selected, result);
|
|
320
|
+
if (kind === "completionDetails") return mapCompletionDetails(service, result, options);
|
|
321
|
+
if (kind === "hover") return mapHover(service, projection, result);
|
|
322
|
+
if (kind === "signatureHelp") return mapSignature(service, projection, result);
|
|
323
|
+
if (kind === "diagnostics") return mapDiagnostics(service, known, result);
|
|
324
|
+
if (kind === "incomingCalls") return mapIncomingCalls(service, known, result);
|
|
325
|
+
if (kind === "outgoingCalls") return mapOutgoingCalls(service, known, result, projection);
|
|
326
|
+
if (kind === "prepareRename") {
|
|
327
|
+
if (!result?.range) return result;
|
|
328
|
+
const mapped = mapWritablePrimaryRange(service, selected, result.range);
|
|
329
|
+
return { ...result, range: mapped.range, sourceUri: mapped.sourceUri,
|
|
330
|
+
mappingKind: mapped.mappingKind, generatedOnly: mapped.generatedOnly,
|
|
331
|
+
ambiguous: mapped.ambiguous };
|
|
332
|
+
}
|
|
333
|
+
if (kind === "rename") return mapRename(service, result, options);
|
|
334
|
+
return result;
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const targetSummary = (match, projection) => ({
|
|
338
|
+
targetId: targetIdFor(match, projection),
|
|
339
|
+
artifactId: artifactIdFor(match, projection),
|
|
340
|
+
projectionId: projection?.id,
|
|
341
|
+
occurrenceId: occurrenceIdFor(match),
|
|
342
|
+
pieceId: match?.pieceId ?? match?.piece?.id,
|
|
343
|
+
virtualOffset: virtualOffsetFor(match),
|
|
344
|
+
mappingKind: mappingKind(match),
|
|
345
|
+
stage: projection?.stage,
|
|
346
|
+
languageId: projection?.languageId
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
const uniqueTargets = (candidates) => {
|
|
350
|
+
const targets = new Map();
|
|
351
|
+
for (const candidate of candidates) {
|
|
352
|
+
const id = candidate.targetId ?? "";
|
|
353
|
+
if (!targets.has(id)) targets.set(id, candidate);
|
|
354
|
+
}
|
|
355
|
+
return [...targets.values()];
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const uniqueArtifacts = (candidates) => {
|
|
359
|
+
const artifacts = new Map();
|
|
360
|
+
for (const candidate of candidates) {
|
|
361
|
+
const id = (candidate.targetId ?? "") + "\u0000" + (candidate.artifactId ?? "");
|
|
362
|
+
if (!artifacts.has(id)) artifacts.set(id, candidate);
|
|
363
|
+
}
|
|
364
|
+
return [...artifacts.values()];
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const uniqueCandidateContexts = (candidates) => {
|
|
368
|
+
const contexts = new Map();
|
|
369
|
+
for (const candidate of candidates) {
|
|
370
|
+
const key = JSON.stringify([
|
|
371
|
+
candidate.targetId,
|
|
372
|
+
candidate.artifactId,
|
|
373
|
+
candidate.occurrenceId,
|
|
374
|
+
candidate.projection?.id,
|
|
375
|
+
candidate.offset,
|
|
376
|
+
candidate.mappingKind,
|
|
377
|
+
candidate.stage
|
|
378
|
+
]);
|
|
379
|
+
if (!contexts.has(key)) contexts.set(key, candidate);
|
|
380
|
+
}
|
|
381
|
+
return [...contexts.values()];
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
const uniqueOccurrences = (candidates) => {
|
|
385
|
+
const occurrences = new Map();
|
|
386
|
+
for (const candidate of candidates) {
|
|
387
|
+
const key = JSON.stringify([
|
|
388
|
+
candidate.targetId,
|
|
389
|
+
candidate.artifactId,
|
|
390
|
+
candidate.occurrenceId
|
|
391
|
+
]);
|
|
392
|
+
if (!occurrences.has(key)) occurrences.set(key, candidate);
|
|
393
|
+
}
|
|
394
|
+
return [...occurrences.values()];
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
const selectCandidates = (candidates, options) => {
|
|
398
|
+
let matching = uniqueCandidateContexts(candidates);
|
|
399
|
+
if (options.targetId) matching = matching.filter(({ targetId }) => targetId === options.targetId);
|
|
400
|
+
if (options.artifactId) matching = matching.filter(({ artifactId }) => artifactId === options.artifactId);
|
|
401
|
+
if (options.occurrenceId) matching = matching.filter(({ occurrenceId }) => occurrenceId === options.occurrenceId);
|
|
402
|
+
if (options.projectionId) matching = matching.filter(({ projection }) =>
|
|
403
|
+
projection?.id === options.projectionId
|
|
404
|
+
);
|
|
405
|
+
if (options.stage) matching = matching.filter(({ stage }) => stage === options.stage);
|
|
406
|
+
if (matching.length === 0) return { status: "unmapped", candidates };
|
|
407
|
+
const targets = uniqueTargets(matching);
|
|
408
|
+
if (!options.targetId && targets.length > 1) {
|
|
409
|
+
const configured = options.defaultTargetId &&
|
|
410
|
+
matching.filter(({ targetId }) => targetId === options.defaultTargetId);
|
|
411
|
+
if (configured?.length) matching = configured;
|
|
412
|
+
else return { status: "target-required", candidates: targets };
|
|
413
|
+
}
|
|
414
|
+
const artifacts = uniqueArtifacts(matching);
|
|
415
|
+
if (!options.artifactId && artifacts.length > 1) {
|
|
416
|
+
return { status: "target-required", candidates: artifacts };
|
|
417
|
+
}
|
|
418
|
+
if (matching.length > 1) {
|
|
419
|
+
const occurrences = uniqueOccurrences(matching);
|
|
420
|
+
return {
|
|
421
|
+
status: "target-required",
|
|
422
|
+
ambiguityKind: occurrences.length > 1 ? "occurrence" : "mapping",
|
|
423
|
+
candidates: occurrences.length > 1 ? occurrences : matching
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
return { status: "selected", candidate: matching[0], candidates: matching };
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
const fullTextChange = (previous, next) => [{
|
|
430
|
+
range: { start: 0, end: previous.text.length },
|
|
431
|
+
text: next.text
|
|
432
|
+
}];
|
|
433
|
+
|
|
434
|
+
const bridgeForLanguage = (bridges, languageId) =>
|
|
435
|
+
bridges.find((bridge) => bridge.languageIds.includes(languageId));
|
|
436
|
+
|
|
437
|
+
// Bridges receive only the generated-file view needed by native language
|
|
438
|
+
// tooling. Provenance maps, authored source text, line indexes, and router
|
|
439
|
+
// internals stay inside the trusted projection boundary.
|
|
440
|
+
const bridgeDocumentFor = (projection) => {
|
|
441
|
+
const metadata = projection?.metadata && typeof projection.metadata === "object"
|
|
442
|
+
? Object.fromEntries([
|
|
443
|
+
"fileName",
|
|
444
|
+
"artifactPath",
|
|
445
|
+
"outputPath",
|
|
446
|
+
"tsconfigPath"
|
|
447
|
+
].flatMap((key) => projection.metadata[key] === undefined
|
|
448
|
+
? []
|
|
449
|
+
: [[key, projection.metadata[key]]]))
|
|
450
|
+
: undefined;
|
|
451
|
+
return Object.freeze({
|
|
452
|
+
id: projection.id,
|
|
453
|
+
uri: projection.uri,
|
|
454
|
+
snapshotId: projection.snapshotId,
|
|
455
|
+
version: projection.version,
|
|
456
|
+
artifactId: projection.artifactId,
|
|
457
|
+
targetId: projection.targetId,
|
|
458
|
+
stage: projection.stage,
|
|
459
|
+
languageId: projection.languageId,
|
|
460
|
+
text: projection.text,
|
|
461
|
+
...(projection.fileName === undefined ? {} : { fileName: projection.fileName }),
|
|
462
|
+
...(projection.path === undefined ? {} : { path: projection.path }),
|
|
463
|
+
...(projection.tsconfigPath === undefined ? {} : { tsconfigPath: projection.tsconfigPath }),
|
|
464
|
+
...(metadata && Object.keys(metadata).length ? { metadata: Object.freeze(metadata) } : {})
|
|
465
|
+
});
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
const normalizeVirtualMatches = (service, known, matches) => asArray(matches)
|
|
469
|
+
.map((match) => {
|
|
470
|
+
const projection = match?.projection ?? service.getProjection(projectionIdFor(match));
|
|
471
|
+
if (!projection) return null;
|
|
472
|
+
known.set(projection.uri, projection);
|
|
473
|
+
return {
|
|
474
|
+
match,
|
|
475
|
+
projection,
|
|
476
|
+
offset: virtualOffsetFor(match),
|
|
477
|
+
mappingKind: mappingKind(match),
|
|
478
|
+
occurrenceId: occurrenceIdFor(match),
|
|
479
|
+
targetId: targetIdFor(match, projection),
|
|
480
|
+
artifactId: artifactIdFor(match, projection),
|
|
481
|
+
stage: projection.stage
|
|
482
|
+
};
|
|
483
|
+
})
|
|
484
|
+
.filter(({ offset } = {}) => Number.isInteger(offset));
|
|
485
|
+
|
|
486
|
+
const emitTrace = (sink, event) => {
|
|
487
|
+
if (typeof sink === "function") sink(event);
|
|
488
|
+
else sink?.event?.(event);
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
export const createLanguageRouter = (options = {}) => {
|
|
492
|
+
const projectionService = options.projectionService;
|
|
493
|
+
if (!projectionService) throw new TypeError("createLanguageRouter requires a projectionService.");
|
|
494
|
+
const bridges = [...(options.bridges ?? [])];
|
|
495
|
+
const knownProjections = new Map();
|
|
496
|
+
// Bridge objects are the stable identity here. Array indexes are not: removing
|
|
497
|
+
// one registered bridge shifts every later index and can alias document state.
|
|
498
|
+
const openDocuments = new Map();
|
|
499
|
+
const documentOperations = new Map();
|
|
500
|
+
let routerOperationTail = Promise.resolve();
|
|
501
|
+
let disposePromise;
|
|
502
|
+
let disposed = false;
|
|
503
|
+
|
|
504
|
+
const ensureActive = () => {
|
|
505
|
+
if (disposed) throw new LanguageBridgeError(
|
|
506
|
+
BRIDGE_ERROR_CODES.DISPOSED,
|
|
507
|
+
"The Ravel language router is disposed."
|
|
508
|
+
);
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
const waitForOperationOrAbort = (operation, signal) => {
|
|
512
|
+
if (!signal) return operation;
|
|
513
|
+
let removeAbortListener = () => {};
|
|
514
|
+
const cancellation = new Promise((_resolve, reject) => {
|
|
515
|
+
const abort = () => {
|
|
516
|
+
try {
|
|
517
|
+
throwIfAborted(signal);
|
|
518
|
+
} catch (error) {
|
|
519
|
+
reject(error);
|
|
520
|
+
}
|
|
521
|
+
};
|
|
522
|
+
if (signal.aborted) abort();
|
|
523
|
+
else {
|
|
524
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
525
|
+
removeAbortListener = () => signal.removeEventListener("abort", abort);
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
return Promise.race([operation, cancellation]).finally(removeAbortListener);
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
const enqueueRouterOperation = (operation, signal) => {
|
|
532
|
+
try {
|
|
533
|
+
ensureActive();
|
|
534
|
+
} catch (error) {
|
|
535
|
+
return Promise.reject(error);
|
|
536
|
+
}
|
|
537
|
+
const result = routerOperationTail.catch(() => undefined).then(operation);
|
|
538
|
+
routerOperationTail = result.catch(() => undefined);
|
|
539
|
+
// Cancellation settles this caller promptly, while `result` remains in the
|
|
540
|
+
// serialized tail and observes the same signal before doing any work.
|
|
541
|
+
return waitForOperationOrAbort(result, signal);
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
const enqueueDocumentOperation = (bridge, uri, operation) => {
|
|
545
|
+
let operations = documentOperations.get(bridge);
|
|
546
|
+
if (!operations) {
|
|
547
|
+
operations = new Map();
|
|
548
|
+
documentOperations.set(bridge, operations);
|
|
549
|
+
}
|
|
550
|
+
const previous = operations.get(uri) ?? Promise.resolve();
|
|
551
|
+
const result = previous.catch(() => undefined).then(operation);
|
|
552
|
+
const settled = result.catch(() => undefined);
|
|
553
|
+
operations.set(uri, settled);
|
|
554
|
+
void settled.then(() => {
|
|
555
|
+
if (operations.get(uri) !== settled) return;
|
|
556
|
+
operations.delete(uri);
|
|
557
|
+
if (operations.size === 0) documentOperations.delete(bridge);
|
|
558
|
+
});
|
|
559
|
+
return result;
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
const staleSynchronization = (projection, detail) => new LanguageBridgeError(
|
|
563
|
+
BRIDGE_ERROR_CODES.STALE_DOCUMENT,
|
|
564
|
+
`Cannot synchronize stale Ravel projection ${projection.uri}: ${detail}`,
|
|
565
|
+
{ retryable: true }
|
|
566
|
+
);
|
|
567
|
+
|
|
568
|
+
const requireCurrentProjection = (projection) => {
|
|
569
|
+
if (typeof projectionService.getProjection !== "function") return;
|
|
570
|
+
const current = projectionService.getProjection(projection.id);
|
|
571
|
+
if (!current || current.uri !== projection.uri ||
|
|
572
|
+
current.version !== projection.version || current.text !== projection.text ||
|
|
573
|
+
current.snapshotId !== projection.snapshotId) {
|
|
574
|
+
throw staleSynchronization(projection, "the projection changed before synchronization");
|
|
575
|
+
}
|
|
576
|
+
};
|
|
577
|
+
|
|
578
|
+
const syncDocument = (bridge, projection, signal) => enqueueDocumentOperation(
|
|
579
|
+
bridge,
|
|
580
|
+
projection.uri,
|
|
581
|
+
async () => {
|
|
582
|
+
throwIfAborted(signal);
|
|
583
|
+
requireCurrentProjection(projection);
|
|
584
|
+
let documents = openDocuments.get(bridge);
|
|
585
|
+
if (!documents) {
|
|
586
|
+
documents = new Map();
|
|
587
|
+
openDocuments.set(bridge, documents);
|
|
588
|
+
}
|
|
589
|
+
const previous = documents.get(projection.uri);
|
|
590
|
+
const document = bridgeDocumentFor(projection);
|
|
591
|
+
if (previous && document.version < previous.version) {
|
|
592
|
+
throw staleSynchronization(
|
|
593
|
+
projection,
|
|
594
|
+
`version ${document.version} is older than open version ${previous.version}`
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
if (previous && document.version === previous.version) {
|
|
598
|
+
if (previous.id !== document.id || previous.text !== document.text) {
|
|
599
|
+
throw staleSynchronization(
|
|
600
|
+
projection,
|
|
601
|
+
`version ${document.version} has conflicting identity or content`
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
return previous;
|
|
605
|
+
}
|
|
606
|
+
if (!previous) await bridge.open(document, signal);
|
|
607
|
+
else {
|
|
608
|
+
await bridge.change(previous, document, fullTextChange(previous, document), signal);
|
|
609
|
+
}
|
|
610
|
+
// Once a bridge mutation succeeds, record it even if cancellation arrived
|
|
611
|
+
// while the bridge was settling; otherwise router and bridge state diverge.
|
|
612
|
+
documents.set(projection.uri, document);
|
|
613
|
+
throwIfAborted(signal);
|
|
614
|
+
requireCurrentProjection(projection);
|
|
615
|
+
return document;
|
|
616
|
+
}
|
|
617
|
+
);
|
|
618
|
+
|
|
619
|
+
const syncTargetDocuments = async (bridge, selected, signal) => {
|
|
620
|
+
const listed = projectionService.listProjections?.() ?? [...knownProjections.values()];
|
|
621
|
+
const byUri = new Map();
|
|
622
|
+
for (const projection of [...listed, selected.projection]) {
|
|
623
|
+
if (!projection || projection.targetId !== selected.targetId ||
|
|
624
|
+
projection.stage !== selected.projection.stage ||
|
|
625
|
+
!bridge.languageIds.includes(projection.languageId)) continue;
|
|
626
|
+
byUri.set(projection.uri, projection);
|
|
627
|
+
}
|
|
628
|
+
const compatible = [...byUri.values()].sort((left, right) =>
|
|
629
|
+
String(left.artifactId).localeCompare(String(right.artifactId)) ||
|
|
630
|
+
String(left.id).localeCompare(String(right.id))
|
|
631
|
+
);
|
|
632
|
+
for (const projection of compatible) {
|
|
633
|
+
throwIfAborted(signal);
|
|
634
|
+
await syncDocument(bridge, projection, signal);
|
|
635
|
+
}
|
|
636
|
+
};
|
|
637
|
+
|
|
638
|
+
const closeProjection = async (projection) => {
|
|
639
|
+
const tasks = [];
|
|
640
|
+
for (const bridge of bridges) {
|
|
641
|
+
tasks.push(enqueueDocumentOperation(bridge, projection.uri, async () => {
|
|
642
|
+
const documents = openDocuments.get(bridge);
|
|
643
|
+
const current = documents?.get(projection.uri);
|
|
644
|
+
if (!current) return;
|
|
645
|
+
await bridge.close(current);
|
|
646
|
+
documents.delete(projection.uri);
|
|
647
|
+
if (documents.size === 0) openDocuments.delete(bridge);
|
|
648
|
+
}));
|
|
649
|
+
}
|
|
650
|
+
await Promise.all(tasks);
|
|
651
|
+
knownProjections.delete(projection.uri);
|
|
652
|
+
};
|
|
653
|
+
|
|
654
|
+
const performUpdate = async (snapshot, signal) => {
|
|
655
|
+
const delta = await projectionService.update(snapshot, signal);
|
|
656
|
+
for (const projection of projectionService.listProjections?.() ?? []) {
|
|
657
|
+
knownProjections.set(projection.uri, projection);
|
|
658
|
+
}
|
|
659
|
+
for (const closed of delta?.closed ?? delta?.removed ?? []) {
|
|
660
|
+
const projection = typeof closed === "string"
|
|
661
|
+
? projectionService.getProjection(closed) ?? knownProjections.get(closed)
|
|
662
|
+
: closed;
|
|
663
|
+
if (projection) await closeProjection(projection);
|
|
664
|
+
}
|
|
665
|
+
return delta;
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
const update = (snapshot, signal) => enqueueRouterOperation(
|
|
669
|
+
() => performUpdate(snapshot, signal),
|
|
670
|
+
signal
|
|
671
|
+
);
|
|
672
|
+
|
|
673
|
+
const performRequest = async (kind, source, requestOptions = {}, signal) => {
|
|
674
|
+
throwIfAborted(signal);
|
|
675
|
+
const started = Date.now();
|
|
676
|
+
const matches = normalizeVirtualMatches(
|
|
677
|
+
projectionService,
|
|
678
|
+
knownProjections,
|
|
679
|
+
projectionService.toVirtual(source, requestOptions)
|
|
680
|
+
);
|
|
681
|
+
const selection = selectCandidates(matches, requestOptions);
|
|
682
|
+
if (selection.status !== "selected") {
|
|
683
|
+
emitTrace(options.trace, {
|
|
684
|
+
kind: "language-request-unrouted",
|
|
685
|
+
requestKind: kind,
|
|
686
|
+
status: selection.status,
|
|
687
|
+
ambiguityKind: selection.ambiguityKind,
|
|
688
|
+
candidates: selection.candidates.map(({ match, projection }) =>
|
|
689
|
+
targetSummary(match, projection)
|
|
690
|
+
)
|
|
691
|
+
});
|
|
692
|
+
return {
|
|
693
|
+
status: selection.status,
|
|
694
|
+
...(selection.ambiguityKind
|
|
695
|
+
? { ambiguityKind: selection.ambiguityKind }
|
|
696
|
+
: {}),
|
|
697
|
+
candidates: selection.candidates.map(({ match, projection }) =>
|
|
698
|
+
targetSummary(match, projection)
|
|
699
|
+
)
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
const selected = selection.candidate;
|
|
703
|
+
if (exactCursorRequestKinds.has(kind) && !exactKinds.has(selected.mappingKind)) {
|
|
704
|
+
return {
|
|
705
|
+
status: "exact-mapping-required",
|
|
706
|
+
candidate: targetSummary(selected.match, selected.projection),
|
|
707
|
+
mappingKind: selected.mappingKind
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
const bridge = bridgeForLanguage(bridges, selected.projection.languageId);
|
|
711
|
+
if (!bridge) {
|
|
712
|
+
return {
|
|
713
|
+
status: "bridge-unavailable",
|
|
714
|
+
languageId: selected.projection.languageId,
|
|
715
|
+
candidate: targetSummary(selected.match, selected.projection)
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
if (!supportsLanguageRequest(bridge.capabilities, kind, selected.projection.stage)) {
|
|
719
|
+
return {
|
|
720
|
+
status: "capability-unavailable",
|
|
721
|
+
languageId: selected.projection.languageId,
|
|
722
|
+
requestKind: kind,
|
|
723
|
+
stage: selected.projection.stage
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
await syncTargetDocuments(bridge, selected, signal);
|
|
728
|
+
const bridgeRequest = {
|
|
729
|
+
...requestOptions.request,
|
|
730
|
+
kind,
|
|
731
|
+
documentUri: selected.projection.uri,
|
|
732
|
+
position: selected.offset
|
|
733
|
+
};
|
|
734
|
+
const { raw, retries } = await enqueueDocumentOperation(
|
|
735
|
+
bridge,
|
|
736
|
+
selected.projection.uri,
|
|
737
|
+
async () => {
|
|
738
|
+
throwIfAborted(signal);
|
|
739
|
+
const bridgeDocument = openDocuments.get(bridge)?.get(selected.projection.uri);
|
|
740
|
+
if (!bridgeDocument || bridgeDocument.version !== selected.projection.version ||
|
|
741
|
+
bridgeDocument.text !== selected.projection.text) {
|
|
742
|
+
throw staleSynchronization(
|
|
743
|
+
selected.projection,
|
|
744
|
+
"the selected bridge document is no longer current"
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
requireLanguageRequestSupport(bridge, bridgeRequest, {
|
|
748
|
+
document: bridgeDocument,
|
|
749
|
+
version: bridgeDocument.version
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
let raw;
|
|
753
|
+
let retries = 0;
|
|
754
|
+
while (true) {
|
|
755
|
+
try {
|
|
756
|
+
raw = await bridge.request(bridgeRequest, {
|
|
757
|
+
document: bridgeDocument,
|
|
758
|
+
version: bridgeDocument.version,
|
|
759
|
+
occurrenceId: selected.occurrenceId,
|
|
760
|
+
targetId: selected.targetId,
|
|
761
|
+
artifactId: selected.artifactId
|
|
762
|
+
}, signal);
|
|
763
|
+
break;
|
|
764
|
+
} catch (error) {
|
|
765
|
+
if (signal?.aborted || error?.code === BRIDGE_ERROR_CODES.ABORTED) {
|
|
766
|
+
throw error;
|
|
767
|
+
}
|
|
768
|
+
if (!error?.retryable || !bridge.restart ||
|
|
769
|
+
retries >= (requestOptions.maximumRetries ?? 1)) {
|
|
770
|
+
throw error;
|
|
771
|
+
}
|
|
772
|
+
retries += 1;
|
|
773
|
+
await bridge.restart();
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
return { raw, retries };
|
|
777
|
+
}
|
|
778
|
+
);
|
|
779
|
+
throwIfAborted(signal);
|
|
780
|
+
const current = projectionService.getProjection(selected.projection.id);
|
|
781
|
+
if (!current || current.version !== selected.projection.version) {
|
|
782
|
+
throw new LanguageBridgeError(
|
|
783
|
+
BRIDGE_ERROR_CODES.STALE_DOCUMENT,
|
|
784
|
+
"The target-language result belongs to a stale Ravel projection.",
|
|
785
|
+
{ retryable: true }
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
const mapped = mapResponse(
|
|
789
|
+
projectionService,
|
|
790
|
+
knownProjections,
|
|
791
|
+
kind,
|
|
792
|
+
selected,
|
|
793
|
+
raw,
|
|
794
|
+
requestOptions
|
|
795
|
+
);
|
|
796
|
+
emitTrace(options.trace, {
|
|
797
|
+
kind: "language-request-completed",
|
|
798
|
+
requestKind: kind,
|
|
799
|
+
projectionId: selected.projection.id,
|
|
800
|
+
targetId: selected.targetId,
|
|
801
|
+
artifactId: selected.artifactId,
|
|
802
|
+
occurrenceId: selected.occurrenceId,
|
|
803
|
+
version: selected.projection.version,
|
|
804
|
+
retries,
|
|
805
|
+
durationMs: Date.now() - started
|
|
806
|
+
});
|
|
807
|
+
return {
|
|
808
|
+
status: "ok",
|
|
809
|
+
result: mapped,
|
|
810
|
+
context: {
|
|
811
|
+
projectionId: selected.projection.id,
|
|
812
|
+
projectionUri: selected.projection.uri,
|
|
813
|
+
projectionVersion: selected.projection.version,
|
|
814
|
+
targetId: selected.targetId,
|
|
815
|
+
artifactId: selected.artifactId,
|
|
816
|
+
occurrenceId: selected.occurrenceId,
|
|
817
|
+
mappingKind: selected.mappingKind,
|
|
818
|
+
retries
|
|
819
|
+
}
|
|
820
|
+
};
|
|
821
|
+
};
|
|
822
|
+
|
|
823
|
+
const request = (kind, source, requestOptions = {}, signal) =>
|
|
824
|
+
enqueueRouterOperation(
|
|
825
|
+
() => performRequest(kind, source, requestOptions, signal),
|
|
826
|
+
signal
|
|
827
|
+
);
|
|
828
|
+
|
|
829
|
+
const dispose = () => {
|
|
830
|
+
if (disposePromise) return disposePromise;
|
|
831
|
+
disposed = true;
|
|
832
|
+
const bridgesToDispose = [...new Set(bridges)];
|
|
833
|
+
disposePromise = (async () => {
|
|
834
|
+
await routerOperationTail.catch(() => undefined);
|
|
835
|
+
const pendingDocumentOperations = [];
|
|
836
|
+
for (const operations of documentOperations.values()) {
|
|
837
|
+
pendingDocumentOperations.push(...operations.values());
|
|
838
|
+
}
|
|
839
|
+
await Promise.all(pendingDocumentOperations.map((operation) =>
|
|
840
|
+
operation.catch(() => undefined)
|
|
841
|
+
));
|
|
842
|
+
try {
|
|
843
|
+
await Promise.all(bridgesToDispose.map((bridge) => bridge.dispose?.()));
|
|
844
|
+
} finally {
|
|
845
|
+
openDocuments.clear();
|
|
846
|
+
documentOperations.clear();
|
|
847
|
+
knownProjections.clear();
|
|
848
|
+
}
|
|
849
|
+
})();
|
|
850
|
+
return disposePromise;
|
|
851
|
+
};
|
|
852
|
+
|
|
853
|
+
return Object.freeze({
|
|
854
|
+
update,
|
|
855
|
+
request,
|
|
856
|
+
dispose,
|
|
857
|
+
registerBridge(bridge) {
|
|
858
|
+
ensureActive();
|
|
859
|
+
bridges.push(bridge);
|
|
860
|
+
return () => {
|
|
861
|
+
const index = bridges.indexOf(bridge);
|
|
862
|
+
if (index !== -1) bridges.splice(index, 1);
|
|
863
|
+
};
|
|
864
|
+
},
|
|
865
|
+
listTargets(source, selection = {}) {
|
|
866
|
+
return normalizeVirtualMatches(
|
|
867
|
+
projectionService,
|
|
868
|
+
knownProjections,
|
|
869
|
+
projectionService.toVirtual(source, selection)
|
|
870
|
+
).map(({ match, projection }) => targetSummary(match, projection));
|
|
871
|
+
},
|
|
872
|
+
validateSourceEditVersions,
|
|
873
|
+
get disposed() { return disposed; }
|
|
874
|
+
});
|
|
875
|
+
};
|
|
876
|
+
|
|
877
|
+
export {
|
|
878
|
+
classifyWorkspaceEdit,
|
|
879
|
+
validateSourceEditVersions
|
|
880
|
+
} from "./edits.js";
|
|
881
|
+
export { createRavelSemanticIndex } from "./ravel-index.js";
|