cspell-lib
Version:
A library of useful functions used across various cspell tools.
469 lines • 17.4 kB
JavaScript
import assert from 'node:assert';
class SourceMapCursorImpl {
sourceMap;
idx;
begin0;
begin1;
/**
* The delta in the source
*/
d0;
/**
* The delta in the transformed text.
*/
d1;
/**
* Indicates whether the current segment is linear (1:1) or non-linear.
* A linear segment has equal deltas in the source and transformed text,
* while a non-linear segment has different deltas.
* It is possible that a non-linear segment has the same deltas,
* but it is not possible for a linear segment to have different deltas.
*/
linear;
/**
* indicates that the cursor has reached the end of the source map.
*/
done;
constructor(sourceMap) {
this.sourceMap = sourceMap;
this.idx = -2;
this.begin0 = 0;
this.begin1 = 0;
this.d0 = 0;
this.d1 = 0;
this.linear = true;
this.done = false;
this.next();
}
next() {
if (this.done)
return false;
this.idx += 2;
this.begin0 += this.d0;
this.begin1 += this.d1;
this.d0 = this.sourceMap[this.idx] || 0;
this.d1 = this.sourceMap[this.idx + 1] || 0;
this.linear = this.d0 === this.d1;
this.done = this.idx >= this.sourceMap.length;
if (this.d0 === 0 && this.d1 === 0 && !this.done) {
this.next();
this.linear = this.done;
}
return !this.done;
}
mapOffsetToDest(offsetInSrc) {
if (offsetInSrc < this.begin0)
this.reset();
while (!this.done && offsetInSrc >= this.begin0 + this.d0) {
this.next();
}
if (this.linear) {
return offsetInSrc - this.begin0 + this.begin1;
}
// For a non-linear segment, the offset in the source maps to the start of the segment in the transformed text.
return this.begin1;
}
mapOffsetToSrc(offsetInDst) {
if (offsetInDst < this.begin1)
this.reset();
while (!this.done && offsetInDst >= this.begin1 + this.d1) {
this.next();
}
if (this.linear) {
return offsetInDst - this.begin1 + this.begin0;
}
// For a non-linear segment, the offset in the transformed text maps to the start of the segment in the source text.
return this.begin0;
}
mapRangeToSrc(rangeInDst) {
return [this.mapOffsetToSrc(rangeInDst[0]), this.mapOffsetToSrc(rangeInDst[1])];
}
reset() {
this.idx = -2;
this.begin0 = 0;
this.begin1 = 0;
this.next();
}
}
export function createSourceMapCursor(sourceMap) {
if (!sourceMap)
return undefined;
assert((sourceMap.length & 1) === 0, 'Map must be pairs of values.');
return new SourceMapCursorImpl(sourceMap);
}
/**
* Calculated the transformed offset in the destination text based on the source map and the offset in the source text.
* @param cursor - The cursor to use for the mapping. If undefined or empty, the input offset is returned, assuming it is a 1:1 mapping.
* @param offsetInSrc - the offset in the source text to map to the transformed text. The offset is relative to the start of the text range.
* @returns The offset in the transformed text corresponding to the input offset in the source text. The offset is relative to the start of the text range.
*/
export function calcOffsetInDst(cursor, offsetInSrc) {
if (!cursor?.sourceMap.length) {
return offsetInSrc;
}
return cursor.mapOffsetToDest(offsetInSrc);
}
/**
* Calculated the transformed offset in the source text based on the source map and the offset in the transformed text.
* @param cursor - The cursor to use for the mapping. If undefined or empty, the input offset is returned, assuming it is a 1:1 mapping.
* @param offsetInDst - the offset in the transformed text to map to the source text. The offset is relative to the start of the text range.
* @returns The offset in the source text corresponding to the input offset in the transformed text. The offset is relative to the start of the text range.
*/
export function calcOffsetInSrc(cursor, offsetInDst) {
if (!cursor?.sourceMap.length) {
return offsetInDst;
}
return cursor.mapOffsetToSrc(offsetInDst);
}
/**
* Map offset pairs to a source map. The input map is expected to be pairs of absolute offsets in the source and transformed text.
* The output map is pairs of lengths.
* @param map - The input map to convert. The map must be pairs of values (even, odd) where the even values are offsets in the source
* text and the odd values are offsets in the transformed text. The offsets are absolute offsets from the start of the text range.
* @returns a SourceMap
*/
export function mapOffsetPairsToSourceMap(map) {
if (!map)
return undefined;
assert((map.length & 1) === 0, 'Map must be pairs of values.');
const srcMap = [];
let base0 = 0;
let base1 = 0;
for (let i = 0; i < map.length; i += 2) {
const d0 = map[i] - base0;
const d1 = map[i + 1] - base1;
base0 += d0;
base1 += d1;
if (d0 === 0 && d1 === 0)
continue;
srcMap.push(d0, d1);
}
return srcMap;
}
/**
* Merge two source maps into a single source map. The first map transforms from the
* original text to an intermediate text, and the second map transforms from the intermediate
* text to the final text. The resulting map represents the transformation directly from the
* original text to the final text.
*
* Concept:
* [markdown codeblock] -> <first map> -> [JavaScript code] -> <second map> -> [string value]
*
* Some kinds of transforms:
* - markdown code block extraction
* - unicode normalization
* - html entity substitution
* - url decoding
* - etc.
*
* The result of each transform is a {@link SourceMap}. When multiple transforms are applied,
* the source maps can be merged to create a single map that represents the cumulative effect
* of all transforms. This is useful for accurately mapping positions in the final transformed
* text back to their corresponding positions in the original text, which is essential for
* reporting spelling issues in the correct context.
*
* @param first - The first transformation map from the original text to the intermediate.
* @param second - The second transformation map from the intermediate, to the final text.
*/
export function mergeSourceMaps(first, second) {
if (!second?.length)
return first?.length ? first : undefined;
if (!first?.length)
return second?.length ? second : undefined;
assert((first.length & 1) === 0, 'First map must be pairs of values.');
assert((second.length & 1) === 0, 'Second map must be pairs of values.');
const result = [];
const cursor1 = new SourceMapMergeCursor(first);
const cursor2 = new SourceMapMergeCursor(second);
while (advanceCursors(cursor1, cursor2, result)) {
// empty
}
return result;
}
class SourceMapMergeCursor {
sourceMap;
idx;
begin0;
begin1;
end0;
end1;
/**
* The last position emitted in the source text.
*/
p0;
/**
* The last position emitted in the transformed text.
*/
p1;
/**
* The delta in the source
*/
d0;
/**
* The delta in the transformed text.
*/
d1;
/**
* Indicates whether the current segment is linear (1:1) or non-linear.
* A linear segment has equal deltas in the source and transformed text,
* while a non-linear segment has different deltas.
* It is possible that a non-linear segment has the same deltas,
* but it is not possible for a linear segment to have different deltas.
*/
linear;
/**
* indicates that the cursor has reached the end of the source map.
*/
done;
constructor(sourceMap, idx = 0) {
this.sourceMap = sourceMap;
this.idx = idx;
this.begin0 = 0;
this.begin1 = 0;
this.p0 = 0;
this.p1 = 0;
this.d0 = this.sourceMap[this.idx] || 0;
this.d1 = this.sourceMap[this.idx + 1] || 0;
this.end0 = this.begin0 + this.d0;
this.end1 = this.begin1 + this.d1;
this.linear = this.d0 === this.d1;
const forceNonLinear = this.d0 === 0 && this.d1 === 0;
this.done = this.idx >= this.sourceMap.length;
if (forceNonLinear) {
this.next(false);
this.linear = this.done;
}
}
/**
*
* @param moveP - Reset the current position.
* @returns true if not done.
*/
next(moveP) {
if (this.done) {
if (moveP) {
this.p0 = this.end0;
this.p1 = this.end1;
}
return false;
}
this.idx += 2;
this.begin0 += this.d0;
this.begin1 += this.d1;
if (moveP) {
this.p0 = this.begin0;
this.p1 = this.begin1;
}
this.d0 = this.sourceMap[this.idx] || 0;
this.d1 = this.sourceMap[this.idx + 1] || 0;
this.end0 = this.begin0 + this.d0;
this.end1 = this.begin1 + this.d1;
this.linear = this.d0 === this.d1;
const forceNonLinear = this.d0 === 0 && this.d1 === 0;
this.done = this.idx >= this.sourceMap.length;
if (forceNonLinear) {
this.next(false);
this.linear = this.done;
}
return !this.done;
}
}
/**
* Advance one or both cursors to the next position in their respective source maps and push the corresponding delta pair(s) to the target map.
* The function compares the end positions of the two cursors and advances the cursor(s) that have the smaller end position.
* If both cursors have the same end position, both are advanced.
* The delta pair(s) pushed to the target map represent the change in offsets from the current position to the next position in the source map(s).
*
* Cursor1 represents the transformation from A to B, and Cursor2 represents the transformation from B to C.
* The target map represents the transformation from A to C. B is the shared edge between the two transformations.
*
* - A - cursor1.0
* - B - cursor1.1 and cursor2.0. <- shared edge
* - C - cursor2.1
*
* There are twelve cases to consider when advancing the cursors.
*
* ```
* Linear Shared End. C0 < C1 C0 > C1
* C0 *---* ?~~~* ?~~* ?~~~*
* C1 *---* ?~~~* ?~~~* ?~~*
*
* C0 *---* ?~~~* ?~~* ?~~~*
* C1 *--* ?---* ?---* ?--*
*
* C0 *--* ?---* ?--* ?---*
* C1 *---* ?~~~* ?~~~* ?~~*
*
* ```
* - `-` represents a linear segment where the deltas in the source and transformed text are equal.
* - `~` represents a non-linear segment where the deltas in the source and transformed text are different.
*
* @param cursor1 - The cursor for the first transformation map from source to intermediate.
* @param cursor2 - The cursor for the second transformation map from intermediate to destination.
* @param target - The target source map from source to destination to push the delta pair(s) to.
* @return true if there is more work to do, false if both cursors are at the end of their respective source maps.
*/
function advanceCursors(cursor1, cursor2, target) {
if (cursor1.done) {
const adjA = cursor1.end0 - cursor1.p0;
const adjB = cursor1.end1 - cursor1.p1;
const adjustment = adjA - adjB;
const dA = cursor2.end0 - cursor2.p0 + adjustment;
const dC = cursor2.end1 - cursor2.p1;
if (!cursor2.done || dA !== 0 || dC !== 0) {
if (dA === dC && (!cursor2.linear || adjA || adjB)) {
target.push(0, 0);
}
target.push(dA, dC);
}
cursor1.next(true);
return cursor2.next(true);
}
if (cursor2.done) {
const adjB = cursor2.end0 - cursor2.p0;
const adjC = cursor2.end1 - cursor2.p1;
const adjustment = adjB - adjC;
const dA = cursor1.end0 - cursor1.p0;
const dC = cursor1.end1 - cursor1.p1 + adjustment;
if (dA === dC && (!cursor1.linear || adjustment !== 0)) {
target.push(0, 0);
}
target.push(dA, dC);
cursor2.next(true);
return cursor1.next(true);
}
assert(cursor1.p1 === cursor2.p0, 'The shared edge must match between the two cursors.');
if (cursor1.linear && cursor2.linear) {
const p = Math.min(cursor1.end1, cursor2.end0);
const dB = p - cursor1.p1;
const pA = cursor1.begin0 + dB;
const pC = cursor2.begin1 + dB;
const dA = pA - cursor1.p0;
const dC = pC - cursor2.p1;
cursor1.p0 = pA;
cursor1.p1 = p;
cursor2.p0 = p;
cursor2.p1 = pC;
if (cursor1.p1 === cursor1.end1) {
cursor1.next(true);
}
if (cursor2.p0 === cursor2.end0) {
cursor2.next(true);
}
target.push(dA, dC);
return true;
}
// Non-linear
if (cursor1.end1 === cursor2.end0) {
const dA = cursor1.end0 - cursor1.p0;
const dC = cursor2.end1 - cursor2.p1;
if (dA === dC) {
// Force a non-linear map when the deltas are the same but one of the cursors is non-linear.
// This is needed to ensure that the merged map accurately represents the transformations,
// even when they result in no change in length.
target.push(0, 0);
}
target.push(dA, dC);
cursor1.next(true);
cursor2.next(true);
return true;
}
if (cursor1.linear) {
if (cursor1.end1 < cursor2.end0) {
// The linear segment in inside the non-linear segment.
// Advance cursor 1 to the end of the linear segment.
cursor1.next(false);
return true;
}
// Split cursor 1 at the end of cursor 2 to maintain linearity.
const p = cursor2.end0;
const dB = p - cursor1.begin1;
const pA = cursor1.begin0 + dB;
const dA = pA - cursor1.p0;
const dC = cursor2.end1 - cursor2.p1;
if (dA === dC) {
// Force a non-linear map when the deltas are the same but one of the cursors is non-linear.
// This is needed to ensure that the merged map accurately represents the transformations,
// even when they result in no change in length.
target.push(0, 0);
}
target.push(dA, dC);
cursor1.p0 = pA;
cursor1.p1 = p;
cursor2.next(true);
return true;
}
if (cursor2.linear) {
if (cursor2.end0 < cursor1.end1) {
// The linear segment in inside the non-linear segment.
// Advance cursor 2 to the end of the linear segment.
cursor2.next(false);
return true;
}
// Split cursor 2 at the end of cursor 1 to maintain linearity.
const p = cursor1.end1;
const dB = p - cursor2.begin0;
const pC = cursor2.begin1 + dB;
const dA = cursor1.end0 - cursor1.p0;
const dC = pC - cursor2.p1;
if (dA === dC) {
// Force a non-linear map when the deltas are the same but one of the cursors is non-linear.
// This is needed to ensure that the merged map accurately represents the transformations,
// even when they result in no change in length.
target.push(0, 0);
}
target.push(dA, dC);
cursor1.next(true);
cursor2.p0 = p;
cursor2.p1 = pC;
return true;
}
// Two non-linear segments. They cannot be split.
if (cursor1.end1 < cursor2.end0) {
cursor1.next(false);
return true;
}
cursor2.next(false);
return true;
}
export function sliceSourceMapToSourceRange(map, range) {
if (!map?.length)
return map;
const cursor = createSourceMapCursor(map);
const [start, end] = range;
const startDst = cursor.mapOffsetToDest(start);
if (cursor.done)
return [];
const startIdx = cursor.idx;
const startOffsetSrc = cursor.begin0;
const startOffsetDst = cursor.begin1;
const startLinear = cursor.linear;
cursor.mapOffsetToDest(end);
let endIdx = cursor.idx;
if (startIdx === endIdx && startLinear) {
return [];
}
if (!cursor.linear || cursor.begin0 < end) {
endIdx += 2;
}
const newMap = map.slice(startIdx, endIdx);
// Only adjust the first pair of offsets, the rest are relative to the first pair.
newMap[0] -= start - startOffsetSrc;
newMap[1] -= startDst - startOffsetDst;
if (newMap[0] === newMap[1] && !startLinear) {
// Force a non-linear map when the deltas are the same but the segment is non-linear.
newMap.unshift(0, 0);
}
return newMap;
}
export function reverseSourceMap(map) {
if (!map?.length)
return map;
assert((map.length & 1) === 0, 'Map must be pairs of values.');
const reversed = [];
for (let i = 0; i < map.length; i += 2) {
reversed.push(map[i + 1], map[i]);
}
return reversed;
}
//# sourceMappingURL=SourceMap.js.map