@pieceful/ravel-projection 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/mapping.js ADDED
@@ -0,0 +1,369 @@
1
+ import {
2
+ affinities,
3
+ clone,
4
+ containsOffset,
5
+ deepFreeze,
6
+ isInteger,
7
+ mappingKinds,
8
+ rangesOverlap,
9
+ sameSourceLocation,
10
+ sourceLocation,
11
+ sourceRangeOffsets,
12
+ stableStringify,
13
+ validOffsetRange
14
+ } from "./internal.js";
15
+
16
+ const emptyFailure = (reason) => deepFreeze({ ok: false, reason, matches: [] });
17
+
18
+ const segmentSort = (left, right) =>
19
+ left.virtual.start - right.virtual.start ||
20
+ // Content wins before zero-width anchors at the same boundary.
21
+ (right.virtual.end - right.virtual.start) - (left.virtual.end - left.virtual.start) ||
22
+ String(left.occurrenceId ?? "").localeCompare(String(right.occurrenceId ?? ""));
23
+
24
+ const positionEqual = (left, right) => left?.offset === right?.offset &&
25
+ left?.line === right?.line && left?.column === right?.column;
26
+
27
+ const canCoalesce = (left, right) => {
28
+ if (left.virtual.end !== right.virtual.start ||
29
+ left.virtual.start === left.virtual.end || right.virtual.start === right.virtual.end ||
30
+ left.kind !== right.kind || left.role !== right.role ||
31
+ left.pieceId !== right.pieceId || left.occurrenceId !== right.occurrenceId ||
32
+ left.endAffinity !== right.startAffinity ||
33
+ stableStringify(left.expansionPath ?? []) !== stableStringify(right.expansionPath ?? []) ||
34
+ stableStringify(left.transformChain ?? []) !== stableStringify(right.transformChain ?? [])) {
35
+ return false;
36
+ }
37
+ if (!left.source && !right.source) return true;
38
+ return left.source?.uri === right.source?.uri &&
39
+ positionEqual(left.source?.range?.end, right.source?.range?.start);
40
+ };
41
+
42
+ /** Merge only adjacent ranges whose forward and reverse behavior is identical. */
43
+ export const coalesceProjectionSegments = (segments = []) => {
44
+ const ordered = segments.filter((segment) => validOffsetRange(segment?.virtual) &&
45
+ mappingKinds.has(segment?.kind)).map(clone).sort(segmentSort);
46
+ const content = [];
47
+ const anchors = [];
48
+ for (const segment of ordered) {
49
+ if (segment.virtual.start === segment.virtual.end) {
50
+ anchors.push(segment);
51
+ continue;
52
+ }
53
+ const previous = content.at(-1);
54
+ if (!previous || !canCoalesce(previous, segment)) {
55
+ content.push(segment);
56
+ continue;
57
+ }
58
+ previous.virtual.end = segment.virtual.end;
59
+ previous.endAffinity = segment.endAffinity;
60
+ if (previous.source && segment.source) previous.source.range.end = clone(segment.source.range.end);
61
+ }
62
+ return deepFreeze([...content, ...anchors].sort(segmentSort));
63
+ };
64
+
65
+ export const buildProjectionIndexes = (segments = [], occurrences = []) => {
66
+ const virtual = segments.map((_, index) => index).sort((left, right) =>
67
+ segmentSort(segments[left], segments[right]));
68
+ const virtualMaxEnds = [];
69
+ let virtualMaximum = 0;
70
+ for (const [position, index] of virtual.entries()) {
71
+ virtualMaximum = Math.max(virtualMaximum, segments[index].virtual.end);
72
+ virtualMaxEnds[position] = virtualMaximum;
73
+ }
74
+ const source = {};
75
+ for (const [index, segment] of segments.entries()) {
76
+ if (!segment.source?.uri || !sourceRangeOffsets(segment.source)) continue;
77
+ (source[segment.source.uri] ??= []).push(index);
78
+ }
79
+ for (const indexes of Object.values(source)) indexes.sort((left, right) => {
80
+ const leftRange = sourceRangeOffsets(segments[left].source);
81
+ const rightRange = sourceRangeOffsets(segments[right].source);
82
+ return leftRange.start - rightRange.start || leftRange.end - rightRange.end ||
83
+ segmentSort(segments[left], segments[right]);
84
+ });
85
+ const sourceMaxEnds = {};
86
+ for (const [uri, indexes] of Object.entries(source)) {
87
+ let maximum = 0;
88
+ sourceMaxEnds[uri] = indexes.map((index) => {
89
+ maximum = Math.max(maximum, sourceRangeOffsets(segments[index].source).end);
90
+ return maximum;
91
+ });
92
+ }
93
+ const occurrenceById = {};
94
+ const children = {};
95
+ for (const [index, occurrence] of occurrences.entries()) {
96
+ occurrenceById[occurrence.id] = index;
97
+ if (occurrence.parentOccurrenceId) {
98
+ (children[occurrence.parentOccurrenceId] ??= []).push(index);
99
+ }
100
+ }
101
+ return deepFreeze({ virtual, virtualMaxEnds, source, sourceMaxEnds, occurrenceById, children });
102
+ };
103
+
104
+ const selectionMatches = (document, segment, selection = {}) =>
105
+ (selection.targetId === undefined || selection.targetId === document.targetId) &&
106
+ (selection.artifactId === undefined || selection.artifactId === document.artifactId) &&
107
+ (selection.stage === undefined || selection.stage === document.stage) &&
108
+ (selection.occurrenceId === undefined || selection.occurrenceId === segment.occurrenceId);
109
+
110
+ const occurrenceFor = (document, id) => {
111
+ const index = document.indexes?.occurrenceById?.[id];
112
+ return isInteger(index) ? document.occurrences[index] : undefined;
113
+ };
114
+
115
+ const responsibleSource = (document, segment) => {
116
+ const occurrence = occurrenceFor(document, segment.occurrenceId);
117
+ return sourceLocation(
118
+ occurrence?.invocationSource ??
119
+ occurrence?.definitionSource ??
120
+ document.artifactSource
121
+ );
122
+ };
123
+
124
+ const exactSourceLocation = (document, segment, virtualStart, virtualEnd) => {
125
+ const source = sourceLocation(segment.source);
126
+ const sourceOffsets = sourceRangeOffsets(source);
127
+ const virtualLength = segment.virtual.end - segment.virtual.start;
128
+ if (!source || !sourceOffsets || sourceOffsets.end - sourceOffsets.start !== virtualLength) return undefined;
129
+ const relativeStart = virtualStart - segment.virtual.start;
130
+ const relativeEnd = virtualEnd - segment.virtual.start;
131
+ const segmentText = document.text.slice(segment.virtual.start, segment.virtual.end);
132
+ let start = clone(source.range.start);
133
+ let end = clone(source.range.start);
134
+ for (let index = 0; index < relativeEnd; index += 1) {
135
+ if (index === relativeStart) start = clone(end);
136
+ if (segmentText[index] === "\n") {
137
+ end.line += 1;
138
+ end.column = 0;
139
+ } else {
140
+ end.column += 1;
141
+ }
142
+ end.offset += 1;
143
+ }
144
+ if (relativeStart === relativeEnd) start = clone(end);
145
+ return { uri: source.uri, range: { start, end } };
146
+ };
147
+
148
+ const virtualCandidates = (document, offset, affinity) => {
149
+ const indexes = document.indexes?.virtual ?? document.mappings.map((_, index) => index);
150
+ const maxEnds = document.indexes?.virtualMaxEnds;
151
+ const result = [];
152
+ let low = 0;
153
+ let high = indexes.length;
154
+ while (low < high) {
155
+ const middle = (low + high) >>> 1;
156
+ if (document.mappings[indexes[middle]].virtual.start <= offset) low = middle + 1;
157
+ else high = middle;
158
+ }
159
+ for (let position = low - 1; position >= 0; position -= 1) {
160
+ if (maxEnds && maxEnds[position] < offset) break;
161
+ const index = indexes[position];
162
+ const segment = document.mappings[index];
163
+ if (containsOffset(segment.virtual, offset, affinity)) result.push([index, segment]);
164
+ }
165
+ return result.reverse();
166
+ };
167
+
168
+ const sourceCandidatesAt = (document, uri, offset, affinity) => {
169
+ const indexes = document.indexes?.source?.[uri] ?? [];
170
+ const maxEnds = document.indexes?.sourceMaxEnds?.[uri];
171
+ let low = 0;
172
+ let high = indexes.length;
173
+ while (low < high) {
174
+ const middle = (low + high) >>> 1;
175
+ if (sourceRangeOffsets(document.mappings[indexes[middle]].source).start <= offset) low = middle + 1;
176
+ else high = middle;
177
+ }
178
+ const result = [];
179
+ for (let position = low - 1; position >= 0; position -= 1) {
180
+ if (maxEnds && maxEnds[position] < offset) break;
181
+ const index = indexes[position];
182
+ const range = sourceRangeOffsets(document.mappings[index].source);
183
+ if (sourceCandidateContains(range, offset, affinity)) result.push(index);
184
+ }
185
+ return result.reverse();
186
+ };
187
+
188
+ const matchForVirtual = (document, index, segment, offset, affinity) => {
189
+ const isPrecise = segment.kind === "exact" || segment.kind === "transformed";
190
+ const source = isPrecise
191
+ ? exactSourceLocation(document, segment, offset, offset)
192
+ : sourceLocation(segment.source) ?? responsibleSource(document, segment);
193
+ return {
194
+ projectionId: document.id,
195
+ uri: document.uri,
196
+ projectionVersion: document.version,
197
+ snapshotId: document.snapshotId,
198
+ virtualOffset: offset,
199
+ virtual: clone(segment.virtual),
200
+ source,
201
+ ...(isPrecise && source ? { sourceOffset: source.range.start.offset } : {}),
202
+ pieceId: segment.pieceId,
203
+ occurrenceId: segment.occurrenceId,
204
+ quality: segment.kind,
205
+ role: segment.role ?? "content",
206
+ affinity,
207
+ writable: (segment.kind === "exact" || segment.kind === "transformed") &&
208
+ document.capabilities?.writableEdits !== false,
209
+ segmentIndex: index
210
+ };
211
+ };
212
+
213
+ /** Query a cursor offset. Invalid and stale inputs are typed failures, never exceptions. */
214
+ export const mapVirtualOffset = (document, offset, options = {}) => {
215
+ const affinity = options.affinity ?? "none";
216
+ if (!document || !isInteger(offset) || offset < 0 || offset > document.text?.length ||
217
+ !affinities.has(affinity)) return emptyFailure("invalid-position");
218
+ if (options.projectionVersion !== undefined && options.projectionVersion !== document.version) {
219
+ return emptyFailure("stale-projection");
220
+ }
221
+ const matches = virtualCandidates(document, offset, affinity)
222
+ .filter(([, segment]) => selectionMatches(document, segment, options))
223
+ .map(([index, segment]) => matchForVirtual(document, index, segment, offset, affinity));
224
+ return deepFreeze({ ok: true, matches });
225
+ };
226
+
227
+ const sourceCandidateContains = (range, offset, affinity) =>
228
+ containsOffset(range, offset, affinity);
229
+
230
+ const virtualOffsetForSource = (segment, sourceRange, offset) =>
231
+ segment.virtual.start + offset - sourceRange.start;
232
+
233
+ export const mapSourceOffset = (document, uri, offset, options = {}) => {
234
+ const affinity = options.affinity ?? "none";
235
+ if (!document || typeof uri !== "string" || !isInteger(offset) || offset < 0 ||
236
+ !affinities.has(affinity)) return emptyFailure("invalid-position");
237
+ if (options.projectionVersion !== undefined && options.projectionVersion !== document.version) {
238
+ return emptyFailure("stale-projection");
239
+ }
240
+ const indexes = sourceCandidatesAt(document, uri, offset, affinity);
241
+ const matches = [];
242
+ for (const index of indexes) {
243
+ const segment = document.mappings[index];
244
+ const sourceRange = sourceRangeOffsets(segment.source);
245
+ if (!sourceRange || !selectionMatches(document, segment, options)) continue;
246
+ const precise = (segment.kind === "exact" || segment.kind === "transformed") &&
247
+ sourceRange.end - sourceRange.start === segment.virtual.end - segment.virtual.start;
248
+ const virtualOffset = precise
249
+ ? virtualOffsetForSource(segment, sourceRange, offset)
250
+ : segment.virtual.start;
251
+ matches.push({
252
+ projectionId: document.id,
253
+ uri: document.uri,
254
+ projectionVersion: document.version,
255
+ snapshotId: document.snapshotId,
256
+ source: clone(segment.source),
257
+ sourceOffset: offset,
258
+ virtualOffset,
259
+ virtual: clone(segment.virtual),
260
+ pieceId: segment.pieceId,
261
+ occurrenceId: segment.occurrenceId,
262
+ quality: segment.kind,
263
+ role: segment.role ?? "content",
264
+ affinity,
265
+ writable: precise && document.capabilities?.writableEdits !== false,
266
+ segmentIndex: index
267
+ });
268
+ }
269
+ return deepFreeze({ ok: true, matches });
270
+ };
271
+
272
+ const rangeFailure = (document, range) => !document || !validOffsetRange(range) ||
273
+ range.end > document.text?.length;
274
+
275
+ export const mapVirtualRange = (document, range, options = {}) => {
276
+ if (rangeFailure(document, range)) return emptyFailure("invalid-range");
277
+ if (range.start === range.end) return mapVirtualOffset(document, range.start, options);
278
+ if (options.projectionVersion !== undefined && options.projectionVersion !== document.version) {
279
+ return emptyFailure("stale-projection");
280
+ }
281
+ const matches = [];
282
+ for (const [index, segment] of document.mappings.entries()) {
283
+ if (segment.virtual.start > range.start) break;
284
+ if (segment.virtual.start === segment.virtual.end || segment.virtual.start > range.start ||
285
+ segment.virtual.end < range.end || !selectionMatches(document, segment, options)) continue;
286
+ const precise = segment.kind === "exact" || segment.kind === "transformed";
287
+ const source = precise
288
+ ? exactSourceLocation(document, segment, range.start, range.end)
289
+ : sourceLocation(segment.source) ?? responsibleSource(document, segment);
290
+ matches.push({
291
+ projectionId: document.id,
292
+ uri: document.uri,
293
+ projectionVersion: document.version,
294
+ snapshotId: document.snapshotId,
295
+ virtual: clone(range),
296
+ relatedVirtual: clone(segment.virtual),
297
+ source,
298
+ pieceId: segment.pieceId,
299
+ occurrenceId: segment.occurrenceId,
300
+ quality: segment.kind,
301
+ role: segment.role ?? "content",
302
+ writable: Boolean(precise && source && document.capabilities?.writableEdits !== false),
303
+ segmentIndex: index
304
+ });
305
+ }
306
+ return deepFreeze({ ok: true, matches });
307
+ };
308
+
309
+ export const mapSourceRange = (document, uri, range, options = {}) => {
310
+ if (!document || typeof uri !== "string" || !validOffsetRange(range)) {
311
+ return emptyFailure("invalid-range");
312
+ }
313
+ if (range.start === range.end) return mapSourceOffset(document, uri, range.start, options);
314
+ if (options.projectionVersion !== undefined && options.projectionVersion !== document.version) {
315
+ return emptyFailure("stale-projection");
316
+ }
317
+ const matches = [];
318
+ for (const index of document.indexes?.source?.[uri] ?? []) {
319
+ const segment = document.mappings[index];
320
+ const sourceRange = sourceRangeOffsets(segment.source);
321
+ if (!sourceRange || !rangesOverlap(sourceRange, range) || !selectionMatches(document, segment, options)) continue;
322
+ const overlap = {
323
+ start: Math.max(sourceRange.start, range.start),
324
+ end: Math.min(sourceRange.end, range.end)
325
+ };
326
+ const precise = (segment.kind === "exact" || segment.kind === "transformed") &&
327
+ sourceRange.end - sourceRange.start === segment.virtual.end - segment.virtual.start;
328
+ const virtual = precise ? {
329
+ start: virtualOffsetForSource(segment, sourceRange, overlap.start),
330
+ end: virtualOffsetForSource(segment, sourceRange, overlap.end)
331
+ } : clone(segment.virtual);
332
+ matches.push({
333
+ projectionId: document.id,
334
+ uri: document.uri,
335
+ projectionVersion: document.version,
336
+ snapshotId: document.snapshotId,
337
+ source: clone(segment.source),
338
+ sourceOverlap: overlap,
339
+ virtual,
340
+ pieceId: segment.pieceId,
341
+ occurrenceId: segment.occurrenceId,
342
+ quality: segment.kind,
343
+ role: segment.role ?? "content",
344
+ writable: precise && document.capabilities?.writableEdits !== false,
345
+ segmentIndex: index
346
+ });
347
+ }
348
+ return deepFreeze({ ok: true, matches });
349
+ };
350
+
351
+ export const validateProjectionSegments = (text, segments = []) => {
352
+ const issues = [];
353
+ for (const [index, segment] of segments.entries()) {
354
+ if (!validOffsetRange(segment?.virtual) || segment.virtual.end > text.length) {
355
+ issues.push({ code: "RVP101", message: `mappings[${index}] has an invalid virtual range.` });
356
+ }
357
+ if (!mappingKinds.has(segment?.kind)) {
358
+ issues.push({ code: "RVP101", message: `mappings[${index}] has an invalid mapping kind.` });
359
+ }
360
+ if (segment.source && !sourceLocation(segment.source)) {
361
+ issues.push({ code: "RVP101", message: `mappings[${index}] has an invalid source range.` });
362
+ }
363
+ }
364
+ return deepFreeze(issues);
365
+ };
366
+
367
+ export const sameProjectionMapping = (left, right) =>
368
+ sameSourceLocation(left?.source, right?.source) &&
369
+ stableStringify({ ...left, source: undefined }) === stableStringify({ ...right, source: undefined });