UNPKG

typescript

Version:

TypeScript is a language for application scale JavaScript development

417 lines • 20 kB
import { SpanMapFeature } from "../../enums/spanMapFeature.js"; import { SpanMapFidelity } from "../../enums/spanMapFidelity.js"; import { SpanMapKind } from "../../enums/spanMapKind.js"; export { SpanMapFeature, SpanMapFidelity, SpanMapKind }; /** Provides bidirectional span-aware mapping between virtual and original text. */ export class SpanMap { segments; originalIndex; /** Copies and sorts segments by virtual start, normalizing omitted features to `All`. */ constructor(segments) { this.segments = segments .map(segment => ({ ...segment, features: segment.features ?? SpanMapFeature.All })) .sort((left, right) => left.virtualStart - right.virtualStart); } /** Reports whether a mapping is a precise, edit-safe projection through one verbatim segment. */ static isExact(fidelity) { return fidelity === SpanMapFidelity.Exact; } /** Reports whether a mapping lies in one verbatim or atom segment. */ static isSingleSegment(fidelity) { return fidelity === SpanMapFidelity.Exact || fidelity === SpanMapFidelity.Atom; } /** Reports whether the input had no counterpart in the target text. */ static isNone(fidelity) { return fidelity === SpanMapFidelity.None; } /** * Maps a virtual range to original text. Gaps map to insertion points with `None` fidelity, * and ranges crossing segment boundaries map their endpoints with `Approximate` fidelity. */ virtualToOriginalSpan(range) { return this.mapRange(range, this.segments, false); } /** Maps a visible LS result only when every covered segment participates in `feature`. */ virtualToOriginalSpanForFeature(range, feature) { const mapped = this.virtualToOriginalSpan(range); return this.virtualRangeSupportsFeature(range, feature) ? mapped : { ...mapped, fidelity: SpanMapFidelity.None }; } /** Maps a virtual position to original text, using `None` fidelity for synthesized gaps. */ virtualToOriginalPosition(position) { return this.mapPoint(position, this.segments, false); } virtualToOriginalPositionForFeature(position, feature) { const mapped = this.virtualToOriginalPosition(position); const [index, inside] = segmentIndexAt(this.segments, position, false); return inside && supportsFeature(this.segments[index], feature) ? mapped : { ...mapped, fidelity: SpanMapFidelity.None }; } /** * Returns every virtual projection of an original position whose segment participates in `feature`. * Segment ends are inclusive for point mapping, so adjacent spans may both produce projections. * Results are ordered by virtual position; uncovered or disabled positions produce no results. */ originalToVirtualPositions(position, feature) { const groups = segmentGroupsAtOriginalPosition(this.getOriginalIndex(), position); const results = []; for (const group of groups) { for (const segment of group.segments) { if (!supportsFeature(segment, feature)) continue; const mapped = segment.kind === SpanMapKind.Verbatim ? { position: mapVerbatimPosition(segment, position, true), fidelity: SpanMapFidelity.Exact } : { position: group.atEnd ? segment.virtualEnd : segment.virtualStart, fidelity: SpanMapFidelity.Atom }; if (!results.some(result => result.position === mapped.position && result.fidelity === mapped.fidelity)) { results.push(mapped); } } } return results.sort((left, right) => left.position - right.position); } /** * Returns every feature-compatible virtual projection of an original range. * A range contained by one or more segments produces one exact or atom result per matching segment. * * A range that starts in one group and ends in another can have several possible virtual ranges. For * example, suppose two original segments are each copied twice into the virtual text: * * ```text * original: [ A ][ B ] * [---) range from inside A to inside B * * virtual: [ A ][ B ] [ A ][ B ] * ^ ^ ^ ^ * start end start end * 1 3 11 13 * ``` * * The map says that the range may start at 1 or 11 and end at 3 or 13, but it does not say which copy of A * belongs with which copy of B. We choose the smallest range around each possible location, producing [1,3) * and [11,13). We do not return [1,13), because it contains both smaller candidates and would include code * that may be unrelated to the original range. These cross-group results have approximate fidelity. */ originalToVirtualSpans(range, feature) { const start = range.pos; const end = Math.max(range.end, start); if (start === end) { return this.originalToVirtualPositions(start, feature).map(({ position, fidelity }) => ({ range: { pos: position, end: position }, fidelity, })); } const lastCharacter = end - 1; const originalIndex = this.getOriginalIndex(); const startSegments = segmentsAtOriginalPosition(originalIndex, start); const endSegments = segmentsAtOriginalPosition(originalIndex, lastCharacter); if (!startSegments || !endSegments) return []; const containing = startSegments.filter(segment => end <= segment.originalEnd); if (containing.length > 0) { const results = [...originalToVirtualSpansInSegments(containing, start, end, feature)]; if (results.length > 0) return results.sort((left, right) => left.range.pos - right.range.pos); } const starts = [...originalStartProjections(startSegments, start, feature)].sort((left, right) => left - right); const ends = [...originalEndProjections(endSegments, end, feature)].sort((left, right) => left - right); if (starts.length === 0 || ends.length === 0) return []; return starts.flatMap((virtualStart, index) => { const virtualEnd = ends.find(end => end >= virtualStart); return virtualEnd === undefined || index + 1 < starts.length && starts[index + 1] <= virtualEnd ? [] : [{ range: { pos: virtualStart, end: virtualEnd }, fidelity: SpanMapFidelity.Approximate }]; }); } /** Maps one range through an ordered segment index in the direction selected by `reverse`. */ mapRange(range, segments, reverse) { const start = range.pos; const end = Math.max(range.end, start); if (start === end) { const { position, fidelity } = this.mapPoint(start, segments, reverse); return { range: { pos: position, end: position }, fidelity }; } const [startIndex, startInside] = segmentIndexAt(segments, start, reverse); const endProbe = end - 1; const [endIndex, endInside] = segmentIndexAt(segments, endProbe, reverse); if (startIndex === endIndex && startInside === endInside) { if (startInside) { const segment = segments[startIndex]; if (segment.kind === SpanMapKind.Verbatim) { const mappedStart = mapVerbatimPosition(segment, start, reverse); const mappedEnd = Math.max(mappedStart, mapVerbatimPosition(segment, end, reverse)); return { range: { pos: mappedStart, end: mappedEnd }, fidelity: SpanMapFidelity.Exact }; } return { range: targetRange(segment, reverse), fidelity: SpanMapFidelity.Atom }; } const position = insertionPoint(segments, startIndex, reverse); return { range: { pos: position, end: position }, fidelity: SpanMapFidelity.None }; } const mappedStart = mapBoundary(segments, start, startIndex, startInside, reverse, false); const mappedEnd = Math.max(mappedStart, mapBoundary(segments, end, endIndex, endInside, reverse, true)); return { range: { pos: mappedStart, end: mappedEnd }, fidelity: SpanMapFidelity.Approximate }; } /** Maps one position through an ordered segment index in the direction selected by `reverse`. */ mapPoint(position, segments, reverse) { const [index, inside] = segmentIndexAt(segments, position, reverse); if (!inside) { return { position: insertionPoint(segments, index, reverse), fidelity: SpanMapFidelity.None }; } const segment = segments[index]; if (segment.kind === SpanMapKind.Verbatim) { return { position: mapVerbatimPosition(segment, position, reverse), fidelity: SpanMapFidelity.Exact }; } return { position: reverse ? segment.virtualStart : segment.originalStart, fidelity: SpanMapFidelity.Atom, }; } /** Returns the lazily built original-text interval index. */ getOriginalIndex() { if (this.originalIndex) return this.originalIndex; const segments = [...this.segments].sort((left, right) => left.originalStart - right.originalStart || left.originalEnd - right.originalEnd || left.virtualStart - right.virtualStart); let leafCount = 1; while (leafCount < segments.length) leafCount *= 2; const maxEnds = new Array(2 * leafCount).fill(0); for (let i = 0; i < segments.length; i++) maxEnds[leafCount + i] = segments[i].originalEnd; for (let i = leafCount - 1; i > 0; i--) maxEnds[i] = Math.max(maxEnds[2 * i], maxEnds[2 * i + 1]); return this.originalIndex = { segments, leafCount, maxEnds }; } virtualRangeSupportsFeature(range, feature) { const start = range.pos; const end = Math.max(range.end, start); if (start === end) { const [index, inside] = segmentIndexAt(this.segments, start, false); return inside && supportsFeature(this.segments[index], feature); } let [index, inside] = segmentIndexAt(this.segments, start, false); if (!inside) return false; let coveredThrough = start; while (index < this.segments.length && coveredThrough < end) { const segment = this.segments[index]; if (segment.virtualStart > coveredThrough || segment.virtualEnd <= coveredThrough || !supportsFeature(segment, feature)) return false; coveredThrough = segment.virtualEnd; index++; } return coveredThrough >= end; } } /** * Maps the inclusive start of an original range through every matching segment. Verbatim segments preserve * the offset within the segment; atoms map to their virtual start. * * ```text * original: [---------) * ^ start * * virtual: [---------) [---------) * ^ ^ * result result * ``` */ function originalStartProjections(segments, start, feature) { return segments .filter(segment => supportsFeature(segment, feature)) .map(segment => segment.kind === SpanMapKind.Verbatim ? mapVerbatimPosition(segment, start, true) : segment.virtualStart); } /** * Maps the exclusive end of an original range through every matching segment. The caller uses `end - 1` * to find the segment containing the final character, while this helper maps the `end` boundary itself. * * ```text * original: [---------)[ next segment ) * ^`-- end * `--- end - 1 * * virtual: [---------) [---------) * ^ ^ * result result * ``` */ function originalEndProjections(segments, end, feature) { return segments .filter(segment => supportsFeature(segment, feature)) .map(segment => segment.kind === SpanMapKind.Verbatim ? mapVerbatimPosition(segment, end, true) : segment.virtualEnd); } /** Maps a range fully contained by each segment. */ function originalToVirtualSpansInSegments(segments, start, end, feature) { return segments .filter(segment => supportsFeature(segment, feature)) .map(segment => { if (segment.kind === SpanMapKind.Verbatim) { const mappedStart = mapVerbatimPosition(segment, start, true); const mappedEnd = Math.max(mappedStart, mapVerbatimPosition(segment, end, true)); return { range: { pos: mappedStart, end: mappedEnd }, fidelity: SpanMapFidelity.Exact }; } return { range: { pos: segment.virtualStart, end: segment.virtualEnd }, fidelity: SpanMapFidelity.Atom }; }); } /** Reports whether two segments belong to the same duplicate group. */ function sameOriginalRange(left, right) { return left.originalStart === right.originalStart && left.originalEnd === right.originalEnd; } /** * Returns every mapping segment containing the original-text `position`. * Segment ends are exclusive; starts, including zero-length segment starts, are included. */ function segmentsAtOriginalPosition(index, position) { // Query intervals that contain position strictly before their exclusive end. Segments starting exactly at // position are appended separately so zero-length segments are included while maxEnd <= position is pruned. const start = firstOriginalSegmentAtOrAfter(index.segments, position); const results = segmentsEndingAtOrAfter(index, start, position, false); const end = firstOriginalSegmentAfter(index.segments, position); results.push(...index.segments.slice(start, end)); return results.length > 0 ? results : undefined; } /** Returns segments among `[0, limit)` whose original end reaches `position`. */ function segmentsEndingAtOrAfter(index, limit, position, includeEnd) { const results = []; collectSegmentsEndingAtOrAfter(index, 1, 0, index.leafCount, limit, position, includeEnd, results); return results; } /** Walks the flat max-end tree left-to-right, preserving original-text order. */ function collectSegmentsEndingAtOrAfter(index, node, start, end, limit, position, includeEnd, results) { const maxEnd = index.maxEnds[node]; if (start >= limit || maxEnd < position || !includeEnd && maxEnd === position) return; if (end - start === 1) { results.push(index.segments[start]); return; } const middle = start + ((end - start) >>> 1); collectSegmentsEndingAtOrAfter(index, 2 * node, start, middle, limit, position, includeEnd, results); collectSegmentsEndingAtOrAfter(index, 2 * node + 1, middle, end, limit, position, includeEnd, results); } /** Returns the first original-ordered segment whose start is greater than or equal to `position`. */ function firstOriginalSegmentAtOrAfter(segments, position) { let low = 0; let high = segments.length; while (low < high) { const middle = (low + high) >>> 1; if (segments[middle].originalStart < position) low = middle + 1; else high = middle; } return low; } /** Returns the first original-ordered segment whose start is greater than `position`. */ function firstOriginalSegmentAfter(segments, position) { let low = 0; let high = segments.length; while (low < high) { const middle = (low + high) >>> 1; if (segments[middle].originalStart <= position) low = middle + 1; else high = middle; } return low; } /** * Returns every group of equal-range mapping segments containing or touching the original-text `position`. * Segment ends are included for point mapping: * * ```text * original: [--- A ---)[--- B ---) * ^ position * * virtual: [ A1 ) [ A2 ) [ B1 ) [ B2 ) * left group right group * atEnd: true atEnd: false * ``` */ function segmentGroupsAtOriginalPosition(index, position) { const limit = firstOriginalSegmentAfter(index.segments, position); const segments = segmentsEndingAtOrAfter(index, limit, position, true); const groups = []; for (let start = 0; start < segments.length;) { let end = start + 1; while (end < segments.length && sameOriginalRange(segments[start], segments[end])) end++; const segment = segments[start]; if (position <= segment.originalEnd) { groups.push({ segments: segments.slice(start, end), atEnd: position === segment.originalEnd && position !== segment.originalStart, }); } start = end; } return groups; } /** Reports whether a segment participates in an original-to-virtual query for `features`. */ function supportsFeature(segment, feature) { return (segment.features & feature) !== 0; } /** * Finds the segment containing `position`, or the preceding segment when `position` is in a gap. * The boolean distinguishes containment from a gap; `reverse` selects original rather than virtual coordinates. */ function segmentIndexAt(segments, position, reverse) { let low = 0; let high = segments.length; while (low < high) { const middle = (low + high) >>> 1; const start = reverse ? segments[middle].originalStart : segments[middle].virtualStart; if (start < position) low = middle + 1; else high = middle; } if (low < segments.length && (reverse ? segments[low].originalStart : segments[low].virtualStart) === position) { return [low, true]; } const previous = low - 1; if (previous >= 0) { const end = reverse ? segments[previous].originalEnd : segments[previous].virtualEnd; if (position < end || previous === segments.length - 1 && position === end) return [previous, true]; } return [previous, false]; } /** Returns the target insertion point for a gap following `previous`, or zero before the first segment. */ function insertionPoint(segments, previous, reverse) { if (previous < 0) return 0; return reverse ? segments[previous].virtualEnd : segments[previous].originalEnd; } /** Linearly maps and clamps a position within a length-preserving verbatim segment. */ function mapVerbatimPosition(segment, position, reverse) { const sourceStart = reverse ? segment.originalStart : segment.virtualStart; const targetStart = reverse ? segment.virtualStart : segment.originalStart; const targetEnd = reverse ? segment.virtualEnd : segment.originalEnd; return clamp(targetStart + position - sourceStart, targetStart, targetEnd); } /** Maps a range boundary, using insertion points for gaps and the selected endpoint for atoms. */ function mapBoundary(segments, position, index, inside, reverse, high) { if (!inside) return insertionPoint(segments, index, reverse); const segment = segments[index]; if (segment.kind === SpanMapKind.Verbatim) return mapVerbatimPosition(segment, position, reverse); if (reverse) return high ? segment.virtualEnd : segment.virtualStart; return high ? segment.originalEnd : segment.originalStart; } /** Returns the complete target range of a segment in the selected direction. */ function targetRange(segment, reverse) { return reverse ? { pos: segment.virtualStart, end: segment.virtualEnd } : { pos: segment.originalStart, end: segment.originalEnd }; } /** Confines `value` to the inclusive interval [`low`, `high`]. */ function clamp(value, low, high) { return Math.max(low, Math.min(value, high)); } //# sourceMappingURL=spanMap.js.map