@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/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";