diff --git a/src/decode/decode.test.ts b/src/decode/decode.test.ts index 8efe5b2..b5eb15a 100644 --- a/src/decode/decode.test.ts +++ b/src/decode/decode.test.ts @@ -53,7 +53,7 @@ class ItemEncoder { function createMap( { scopes, ranges, names = [] }: { scopes?: (string | null)[]; - ranges?: string; + ranges?: string | string[]; names?: string[]; }, ): SourceMapJson { @@ -62,7 +62,7 @@ function createMap( mappings: "", sources: new Array(scopes?.length ?? 0).fill(null), scopes, - ranges, + ranges: typeof ranges === "string" ? [ranges] : ranges, names, }; } @@ -428,12 +428,12 @@ describe("decode", () => { .build(); const map = encode(info); - assertExists(map.ranges); + assertExists(map.ranges?.[0]); - const parts = map.ranges.split(","); + const parts = map.ranges[0].split(","); parts[0] += encodeUnsigned(42); parts[0] += encodeSigned(-16); - map.ranges = parts.join(","); + map.ranges[0] = parts.join(","); assertEquals(decode(map), { ...info, hasVariableAndBindingInfo: false }); }); @@ -447,12 +447,12 @@ describe("decode", () => { }).endRange(10, 0).build(); const map = encode(info); - assertExists(map.ranges); + assertExists(map.ranges?.[0]); - const parts = map.ranges.split(","); + const parts = map.ranges[0].split(","); parts[1] += encodeUnsigned(42); parts[1] += encodeSigned(-16); - map.ranges = parts.join(","); + map.ranges[0] = parts.join(","); assertEquals(decode(map), { ...info, hasVariableAndBindingInfo: false }); }); @@ -821,6 +821,80 @@ describe("decode", () => { }); }); + it("decodes multiple range segments and resets state per segment", () => { + const map1 = encode( + new ScopeInfoBuilder() + .addNullSource() + .startSource() + .startScope(0, 0, { key: "s1" }) + .endScope(10, 0) + .endSource() + .startRange(0, 5, { scopeKey: "s1" }) + .endRange(2, 10) + .build(), + ); + const map2 = encode( + new ScopeInfoBuilder() + .addNullSource() + .startSource() + .startScope(0, 0, { key: "s1" }) + .endScope(10, 0) + .endSource() + .startRange(2, 10, { scopeKey: "s1" }) + .endRange(4, 20) + .build(), + ); + + const combinedMap: SourceMapJson = { + version: 3, + mappings: "", + sources: [null, null], + scopes: map1.scopes, + ranges: [...(map1.ranges ?? []), ...(map2.ranges ?? [])], + names: map1.names, + }; + + const info = decode(combinedMap, { mode: DecodeMode.STRICT }); + + assertEquals(info.ranges.length, 2); + assertEquals(info.ranges[0].start, { line: 0, column: 5 }); + assertEquals(info.ranges[0].end, { line: 2, column: 10 }); + assertStrictEquals(info.ranges[0].originalScope, info.scopes[1]?.[0]); + assertEquals(info.ranges[1].start, { line: 2, column: 10 }); + assertEquals(info.ranges[1].end, { line: 4, column: 20 }); + assertStrictEquals(info.ranges[1].originalScope, info.scopes[1]?.[0]); + }); + + it("throws in strict mode when a range segment starts before the previous segment ended", () => { + const seg1 = encode( + new ScopeInfoBuilder().startRange(0, 10).endRange(2, 20).build(), + ).ranges![0]; + const seg2 = encode( + new ScopeInfoBuilder().startRange(2, 15).endRange(3, 0).build(), + ).ranges![0]; + const map = createMap({ ranges: [seg1, seg2] }); + + assertThrows( + () => decode(map, { mode: DecodeMode.STRICT }), + Error, + "Range segment starts before the previous segment ended!", + ); + }); + + it("tolerates overlapping range segments in lax mode", () => { + const seg1 = encode( + new ScopeInfoBuilder().startRange(0, 10).endRange(2, 20).build(), + ).ranges![0]; + const seg2 = encode( + new ScopeInfoBuilder().startRange(2, 15).endRange(3, 0).build(), + ).ranges![0]; + const map = createMap({ ranges: [seg1, seg2] }); + + const info = decode(map, { mode: DecodeMode.LAX }); + + assertEquals(info.ranges.length, 2); + }); + describe("hasVariableAndBindingInfo", () => { it("is 'false' when no variables/bindings are present", () => { const map = encode( diff --git a/src/decode/decode.ts b/src/decode/decode.ts index ba18eb3..e0e5d60 100644 --- a/src/decode/decode.ts +++ b/src/decode/decode.ts @@ -19,6 +19,7 @@ import type { SourceMapJson, SubRangeBinding, } from "../scopes.ts"; +import { comparePositions } from "../util.ts"; import { TokenIterator } from "../vlq.ts"; /** @@ -81,7 +82,7 @@ function decodeMap( return new Decoder( sourceMap.scopes ?? [], - sourceMap.ranges ?? "", + sourceMap.ranges ?? [], sourceMap.names, options, ).decode(); @@ -127,9 +128,10 @@ const DEFAULT_RANGE_STATE = { class Decoder { readonly #encodedScopes: (string | null)[]; - readonly #encodedRanges: string; + readonly #encodedRanges: string[]; readonly #names: string[]; readonly #mode: DecodeMode; + readonly #generatedOffset: Position; #scopes: (OriginalScope[] | null)[] = []; #ranges: GeneratedRange[] = []; @@ -153,7 +155,7 @@ class Decoder { constructor( scopes: (string | null)[], - ranges: string, + ranges: string[], names: string[], options: DecodeOptions, ) { @@ -161,8 +163,7 @@ class Decoder { this.#encodedRanges = ranges; this.#names = names; this.#mode = options.mode; - this.#rangeState.line = options.generatedOffset.line; - this.#rangeState.column = options.generatedOffset.column; + this.#generatedOffset = options.generatedOffset; } decode(): DecodedScopeInfo { @@ -175,7 +176,9 @@ class Decoder { this.#decodeScopes(encodedScope); } - this.#decodeRanges(this.#encodedRanges); + for (const encodedRange of this.#encodedRanges) { + this.#decodeRanges(encodedRange); + } const info = { scopes: this.#scopes, @@ -270,6 +273,12 @@ class Decoder { } #decodeRanges(encodedRanges: string) { + Object.assign(this.#rangeState, DEFAULT_RANGE_STATE); + this.#rangeState.line = this.#generatedOffset.line; + this.#rangeState.column = this.#generatedOffset.column; + const prevLastRange = this.#ranges.at(-1); + const prevRangesLength = this.#ranges.length; + const iter = new TokenIterator( encodedRanges, this.#mode === DecodeMode.STRICT, @@ -369,6 +378,21 @@ class Decoder { this.#throwInStrictMode( "Encountered GENERATED_RANGE_START without matching END!", ); + this.#rangeStack.length = 0; + } + + if ( + prevLastRange && + this.#ranges.length > prevRangesLength && + comparePositions( + this.#ranges[prevRangesLength].start, + prevLastRange.end, + ) < + 0 + ) { + this.#throwInStrictMode( + "Range segment starts before the previous segment ended!", + ); } } diff --git a/src/encode/encode.test.ts b/src/encode/encode.test.ts index 97c2cff..7d7f9a2 100644 --- a/src/encode/encode.test.ts +++ b/src/encode/encode.test.ts @@ -49,7 +49,9 @@ describe("encode", () => { const info = builder.addNullSource().addNullSource().addNullSource() .build(); - assertEquals(encode(info).scopes, [null, null, null]); + const encoded = encode(info); + assertEquals(encoded.scopes, [null, null, null]); + assertEquals(encoded.ranges, []); }); it("throws when a subsequent root scope precedes the previous root scope in the same source", () => { diff --git a/src/encode/encoder.ts b/src/encode/encoder.ts index 1432278..102524b 100644 --- a/src/encode/encoder.ts +++ b/src/encode/encoder.ts @@ -55,7 +55,7 @@ export class Encoder { } } - encode(): { scopes: (string | null)[]; ranges: string } { + encode(): { scopes: (string | null)[]; ranges: string[] } { const encodedScopes = this.#info.scopes.map((scopes, sourceIdx) => { if (scopes === null) { return null; @@ -75,7 +75,9 @@ export class Encoder { return { scopes: encodedScopes, - ranges: this.#encodedItems.join(","), + ranges: this.#encodedItems.length > 0 + ? [this.#encodedItems.join(",")] + : [], }; } diff --git a/src/scopes.ts b/src/scopes.ts index 55c8bfa..ff6b7c0 100644 --- a/src/scopes.ts +++ b/src/scopes.ts @@ -192,5 +192,5 @@ export interface SourceMapJson { mappings: string; names?: string[]; scopes?: (string | null)[]; - ranges?: string; + ranges?: string[]; }