From b3142d02a95233e5ca8693b2822adc8bc55ae654 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Z=C3=BCnd?= Date: Mon, 28 Sep 2026 07:51:52 +0000 Subject: [PATCH] feat!: split scopes and ranges in SourceMapJson and remove EMPTY tag Per tc39/ecma426#266 and tc39/ecma426#274, original scopes and generated ranges are now stored in separate 'scopes' ((string | null)[]) and 'ranges' (string) fields in the source map JSON rather than a single combined string. Each source's scopes entry is decoded with a fresh ScopeState, root scopes within the same source keep relative line/column offsets instead of resetting to 0, and missing scope information is represented by 'null' in the 'scopes' array instead of Tag.EMPTY ('A'). --- src/codec.ts | 2 - src/decode/decode.test.ts | 254 ++++++++++++++++++++++++++------------ src/decode/decode.ts | 126 +++++++++++++------ src/encode/encode.test.ts | 13 +- src/encode/encode.ts | 4 +- src/encode/encoder.ts | 20 +-- src/roundtrip.test.ts | 14 ++- src/scopes.ts | 3 +- 8 files changed, 307 insertions(+), 129 deletions(-) diff --git a/src/codec.ts b/src/codec.ts index b7833ae..55630e3 100644 --- a/src/codec.ts +++ b/src/codec.ts @@ -3,7 +3,6 @@ // found in the LICENSE file. export const enum Tag { - EMPTY = 0x0, ORIGINAL_SCOPE_START = 0x1, ORIGINAL_SCOPE_END = 0x2, ORIGINAL_SCOPE_VARIABLES = 0x3, @@ -16,7 +15,6 @@ export const enum Tag { } export const enum EncodedTag { - EMPTY = "A", // 0x0 ORIGINAL_SCOPE_START = "B", // 0x1 ORIGINAL_SCOPE_END = "C", // 0x2 ORIGINAL_SCOPE_VARIABLES = "D", // 0x3 diff --git a/src/decode/decode.test.ts b/src/decode/decode.test.ts index a819cb1..8efe5b2 100644 --- a/src/decode/decode.test.ts +++ b/src/decode/decode.test.ts @@ -50,12 +50,19 @@ class ItemEncoder { } } -function createMap(scopes: string, names: string[]): SourceMapJson { +function createMap( + { scopes, ranges, names = [] }: { + scopes?: (string | null)[]; + ranges?: string; + names?: string[]; + }, +): SourceMapJson { return { version: 3, mappings: "", - sources: [], + sources: new Array(scopes?.length ?? 0).fill(null), scopes, + ranges, names, }; } @@ -69,9 +76,9 @@ describe("decode", () => { .build(); const map = encode(info); - assertExists(map.scopes); + assertExists(map.scopes?.[0]); - const parts = map.scopes.split(","); + const parts = map.scopes[0].split(","); const items = [ encodeUnsigned(42) + encodeUnsigned(5), parts[0], @@ -79,7 +86,7 @@ describe("decode", () => { parts[1], encodeUnsigned(256), ]; - map.scopes = items.join(","); + map.scopes[0] = items.join(","); assertEquals(decode(map), { ...info, hasVariableAndBindingInfo: false }); }); @@ -91,12 +98,12 @@ describe("decode", () => { .build(); const map = encode(info); - assertExists(map.scopes); + assertExists(map.scopes?.[0]); - const parts = map.scopes.split(","); + const parts = map.scopes[0].split(","); parts[0] += encodeUnsigned(42); parts[0] += encodeSigned(-16); - map.scopes = parts.join(","); + map.scopes[0] = parts.join(","); assertEquals(decode(map), { ...info, hasVariableAndBindingInfo: false }); }); @@ -109,12 +116,12 @@ describe("decode", () => { .build(); const map = encode(info); - assertExists(map.scopes); + assertExists(map.scopes?.[0]); - const parts = map.scopes.split(","); + const parts = map.scopes[0].split(","); parts[1] += encodeUnsigned(42); parts[1] += encodeSigned(-16); - map.scopes = parts.join(","); + map.scopes[0] = parts.join(","); assertEquals(decode(map), { ...info, hasVariableAndBindingInfo: false }); }); @@ -128,7 +135,7 @@ describe("decode", () => { 0, ).addSignedVLQs(2) .finishItem().addUnsignedVLQs(Tag.ORIGINAL_SCOPE_END, 5, 0).finishItem(); - const map = createMap(encoder.encode(), []); + const map = createMap({ scopes: [encoder.encode()] }); const info = decode(map); @@ -145,7 +152,7 @@ describe("decode", () => { 0, ).addSignedVLQs(2) .finishItem().addUnsignedVLQs(Tag.ORIGINAL_SCOPE_END, 5, 0).finishItem(); - const map = createMap(encoder.encode(), []); + const map = createMap({ scopes: [encoder.encode()] }); const info = decode(map); @@ -159,7 +166,7 @@ describe("decode", () => { // Replace the last digit with 'g', whose continuation bit is set. The // string now ends while a VLQ still expects another digit. const truncated = encoder.encode().slice(0, -1) + "g"; - const map = createMap(truncated, []); + const map = createMap({ scopes: [truncated] }); assertThrows(() => decode(map, { mode: DecodeMode.STRICT })); }); @@ -168,7 +175,7 @@ describe("decode", () => { const encoder = new ItemEncoder(); encoder.addUnsignedVLQs(Tag.ORIGINAL_SCOPE_START, 0, 0, 0).finishItem(); const truncated = encoder.encode().slice(0, -1) + "g"; - const map = createMap(truncated, []); + const map = createMap({ scopes: [truncated] }); // Lax mode must not throw; it decodes best-effort. decode(map, { mode: DecodeMode.LAX }); @@ -177,7 +184,7 @@ describe("decode", () => { it("throws when encountering an ORIGINAL_SCOPE_END without start in strict mode", () => { const encoder = new ItemEncoder(); encoder.addUnsignedVLQs(Tag.ORIGINAL_SCOPE_END, 0, 0).finishItem(); - const map = createMap(encoder.encode(), []); + const map = createMap({ scopes: [encoder.encode()] }); assertThrows(() => decode(map, { mode: DecodeMode.STRICT })); }); @@ -185,17 +192,17 @@ describe("decode", () => { it("ignores miss-matched ORIGINAL_SCOPE_END items", () => { const encoder = new ItemEncoder(); encoder.addUnsignedVLQs(Tag.ORIGINAL_SCOPE_END, 0, 0).finishItem(); - const map = createMap(encoder.encode(), []); + const map = createMap({ scopes: [encoder.encode()] }); const info = decode(map, { mode: DecodeMode.LAX }); - assertEquals(info.scopes, []); + assertEquals(info.scopes, [[]]); }); it("throws in strict mode when there are 'open' scopes left at the end", () => { const encoder = new ItemEncoder(); encoder.addUnsignedVLQs(Tag.ORIGINAL_SCOPE_START, 0, 0, 0).finishItem(); - const map = createMap(encoder.encode(), []); + const map = createMap({ scopes: [encoder.encode()] }); assertThrows(() => decode(map, { mode: DecodeMode.STRICT })); }); @@ -203,18 +210,18 @@ describe("decode", () => { it("ignores 'open' scopes left at the end in lax mode", () => { const encoder = new ItemEncoder(); encoder.addUnsignedVLQs(Tag.ORIGINAL_SCOPE_START, 0, 0, 0).finishItem(); - const map = createMap(encoder.encode(), []); + const map = createMap({ scopes: [encoder.encode()] }); const info = decode(map, { mode: DecodeMode.LAX }); - assertEquals(info.scopes, []); + assertEquals(info.scopes, [[]]); }); it("throws in strict mode when encountering an GENERATED_RANGE_END without START", () => { const encoder = new ItemEncoder(); encoder.addUnsignedVLQs(Tag.GENERATED_RANGE_END); encoder.addSignedVLQs(42).finishItem(); - const map = createMap(encoder.encode(), []); + const map = createMap({ ranges: encoder.encode() }); assertThrows(() => decode(map, { mode: DecodeMode.STRICT })); }); @@ -223,7 +230,7 @@ describe("decode", () => { const encoder = new ItemEncoder(); encoder.addUnsignedVLQs(Tag.GENERATED_RANGE_END); encoder.addSignedVLQs(42).finishItem(); - const map = createMap(encoder.encode(), []); + const map = createMap({ ranges: encoder.encode() }); const info = decode(map, { mode: DecodeMode.LAX }); @@ -234,7 +241,7 @@ describe("decode", () => { const encoder = new ItemEncoder(); encoder.addUnsignedVLQs(Tag.GENERATED_RANGE_START, 0); encoder.addSignedVLQs(42).finishItem(); - const map = createMap(encoder.encode(), []); + const map = createMap({ ranges: encoder.encode() }); assertThrows(() => decode(map, { mode: DecodeMode.STRICT })); }); @@ -243,7 +250,7 @@ describe("decode", () => { const encoder = new ItemEncoder(); encoder.addUnsignedVLQs(Tag.GENERATED_RANGE_START, 0); encoder.addSignedVLQs(42).finishItem(); - const map = createMap(encoder.encode(), []); + const map = createMap({ ranges: encoder.encode() }); const info = decode(map, { mode: DecodeMode.LAX }); @@ -254,7 +261,10 @@ describe("decode", () => { const encoder = new ItemEncoder(); encoder.addUnsignedVLQs(Tag.ORIGINAL_SCOPE_VARIABLES); encoder.addSignedVLQs(0, 1).finishItem(); - const map = createMap(encoder.encode(), ["foo", "bar"]); + const map = createMap({ + scopes: [encoder.encode()], + names: ["foo", "bar"], + }); assertThrows(() => decode(map, { mode: DecodeMode.STRICT })); }); @@ -263,18 +273,21 @@ describe("decode", () => { const encoder = new ItemEncoder(); encoder.addUnsignedVLQs(Tag.ORIGINAL_SCOPE_VARIABLES); encoder.addSignedVLQs(0, 1).finishItem(); - const map = createMap(encoder.encode(), ["foo", "bar"]); + const map = createMap({ + scopes: [encoder.encode()], + names: ["foo", "bar"], + }); const info = decode(map, { mode: DecodeMode.LAX }); - assertEquals(info.scopes, []); + assertEquals(info.scopes, [[]]); }); it("throws for free GENERATED_RANGE_BINDINGS items in strict mode", () => { const encoder = new ItemEncoder(); encoder.addUnsignedVLQs(Tag.GENERATED_RANGE_BINDINGS); encoder.addSignedVLQs(0, -1).finishItem(); - const map = createMap(encoder.encode(), ["foo"]); + const map = createMap({ ranges: encoder.encode(), names: ["foo"] }); assertThrows(() => decode(map, { mode: DecodeMode.STRICT })); }); @@ -283,7 +296,7 @@ describe("decode", () => { const encoder = new ItemEncoder(); encoder.addUnsignedVLQs(Tag.GENERATED_RANGE_BINDINGS); encoder.addSignedVLQs(0, -1).finishItem(); - const map = createMap(encoder.encode(), ["foo"]); + const map = createMap({ ranges: encoder.encode(), names: ["foo"] }); const info = decode(map, { mode: DecodeMode.LAX }); @@ -296,7 +309,7 @@ describe("decode", () => { encoder.addUnsignedVLQs(Tag.ORIGINAL_SCOPE_VARIABLES); encoder.addSignedVLQs(0, 2).finishItem(); // The '2' is illegal as we only have 1 name. encoder.addUnsignedVLQs(Tag.ORIGINAL_SCOPE_END, 1, 0).finishItem(); - const map = createMap(encoder.encode(), ["foo"]); + const map = createMap({ scopes: [encoder.encode()], names: ["foo"] }); assertThrows( () => decode(map, { mode: DecodeMode.STRICT }), @@ -311,7 +324,7 @@ describe("decode", () => { encoder.addUnsignedVLQs(Tag.ORIGINAL_SCOPE_VARIABLES); encoder.addSignedVLQs(0, -1).finishItem(); // The '-1' is illegal as we only have 1 name. encoder.addUnsignedVLQs(Tag.ORIGINAL_SCOPE_END, 1, 0).finishItem(); - const map = createMap(encoder.encode(), ["foo"]); + const map = createMap({ scopes: [encoder.encode()], names: ["foo"] }); assertThrows( () => decode(map, { mode: DecodeMode.STRICT }), @@ -326,7 +339,7 @@ describe("decode", () => { encoder.addUnsignedVLQs(Tag.ORIGINAL_SCOPE_VARIABLES); encoder.addSignedVLQs(0, 2).finishItem(); // The '2' is illegal as we only have 1 name. encoder.addUnsignedVLQs(Tag.ORIGINAL_SCOPE_END, 1, 0).finishItem(); - const map = createMap(encoder.encode(), ["foo"]); + const map = createMap({ scopes: [encoder.encode()], names: ["foo"] }); const info = decode(map, { mode: DecodeMode.LAX }); @@ -339,7 +352,7 @@ describe("decode", () => { encoder.addUnsignedVLQs(Tag.ORIGINAL_SCOPE_VARIABLES); encoder.addSignedVLQs(0, -1).finishItem(); // The '-1' is illegal as we only have 1 name. encoder.addUnsignedVLQs(Tag.ORIGINAL_SCOPE_END, 1, 0).finishItem(); - const map = createMap(encoder.encode(), ["foo"]); + const map = createMap({ scopes: [encoder.encode()], names: ["foo"] }); const info = decode(map, { mode: DecodeMode.LAX }); @@ -355,7 +368,7 @@ describe("decode", () => { 0, ).addSignedVLQs(1).finishItem(); // The last '1' is the illegal name index. encoder.addUnsignedVLQs(Tag.ORIGINAL_SCOPE_END, 1, 0).finishItem(); - const map = createMap(encoder.encode(), ["foo"]); + const map = createMap({ scopes: [encoder.encode()], names: ["foo"] }); assertThrows( () => decode(map, { mode: DecodeMode.STRICT }), @@ -373,7 +386,7 @@ describe("decode", () => { 0, ).addSignedVLQs(1).finishItem(); // The last '1' is the illegal name index. encoder.addUnsignedVLQs(Tag.ORIGINAL_SCOPE_END, 1, 0).finishItem(); - const map = createMap(encoder.encode(), ["foo"]); + const map = createMap({ scopes: [encoder.encode()], names: ["foo"] }); assertThrows( () => decode(map, { mode: DecodeMode.STRICT }), @@ -388,7 +401,7 @@ describe("decode", () => { encoder.addUnsignedVLQs(Tag.GENERATED_RANGE_BINDINGS).addSignedVLQs(2) .finishItem(); encoder.addUnsignedVLQs(Tag.GENERATED_RANGE_END, 2).finishItem(); - const map = createMap(encoder.encode(), ["foo"]); + const map = createMap({ ranges: encoder.encode(), names: ["foo"] }); assertThrows( () => decode(map, { mode: DecodeMode.STRICT }), @@ -403,7 +416,7 @@ describe("decode", () => { encoder.addUnsignedVLQs(Tag.GENERATED_RANGE_BINDINGS).addSignedVLQs(2) .finishItem(); encoder.addUnsignedVLQs(Tag.GENERATED_RANGE_END, 2).finishItem(); - const map = createMap(encoder.encode(), ["foo"]); + const map = createMap({ ranges: encoder.encode(), names: ["foo"] }); const info = decode(map, { mode: DecodeMode.LAX }); @@ -415,12 +428,12 @@ describe("decode", () => { .build(); const map = encode(info); - assertExists(map.scopes); + assertExists(map.ranges); - const parts = map.scopes.split(","); + const parts = map.ranges.split(","); parts[0] += encodeUnsigned(42); parts[0] += encodeSigned(-16); - map.scopes = parts.join(","); + map.ranges = parts.join(","); assertEquals(decode(map), { ...info, hasVariableAndBindingInfo: false }); }); @@ -434,12 +447,12 @@ describe("decode", () => { }).endRange(10, 0).build(); const map = encode(info); - assertExists(map.scopes); + assertExists(map.ranges); - const parts = map.scopes.split(","); - parts[3] += encodeUnsigned(42); - parts[3] += encodeSigned(-16); - map.scopes = parts.join(","); + const parts = map.ranges.split(","); + parts[1] += encodeUnsigned(42); + parts[1] += encodeSigned(-16); + map.ranges = parts.join(","); assertEquals(decode(map), { ...info, hasVariableAndBindingInfo: false }); }); @@ -452,7 +465,7 @@ describe("decode", () => { 0, ).addSignedVLQs(1).addUnsignedVLQs(0).finishItem(); encoder.addUnsignedVLQs(Tag.GENERATED_RANGE_END, 2).finishItem(); - const map = createMap(encoder.encode(), []); + const map = createMap({ ranges: encoder.encode() }); assertThrows(() => decode(map, { mode: DecodeMode.STRICT })); }); @@ -465,7 +478,7 @@ describe("decode", () => { 0, ).addSignedVLQs(1).addUnsignedVLQs(0).finishItem(); encoder.addUnsignedVLQs(Tag.GENERATED_RANGE_END, 2).finishItem(); - const map = createMap(encoder.encode(), []); + const map = createMap({ ranges: encoder.encode() }); const info = decode(map, { mode: DecodeMode.LAX }); @@ -474,31 +487,43 @@ describe("decode", () => { }); it("throws if GENERATED_RANGE_START.definition is not a valid original scope in strict mode", () => { - const encoder = new ItemEncoder(); - encoder.addUnsignedVLQs(Tag.ORIGINAL_SCOPE_START, 0, 0, 0).finishItem(); - encoder.addUnsignedVLQs(Tag.ORIGINAL_SCOPE_END, 1, 0).finishItem(); - encoder.addUnsignedVLQs( + const scopeEncoder = new ItemEncoder(); + scopeEncoder.addUnsignedVLQs(Tag.ORIGINAL_SCOPE_START, 0, 0, 0) + .finishItem(); + scopeEncoder.addUnsignedVLQs(Tag.ORIGINAL_SCOPE_END, 1, 0).finishItem(); + + const rangeEncoder = new ItemEncoder(); + rangeEncoder.addUnsignedVLQs( Tag.GENERATED_RANGE_START, GeneratedRangeFlags.HAS_DEFINITION, 0, ).addSignedVLQs(0, 1).finishItem(); - encoder.addUnsignedVLQs(Tag.GENERATED_RANGE_END, 2).finishItem(); - const map = createMap(encoder.encode(), []); + rangeEncoder.addUnsignedVLQs(Tag.GENERATED_RANGE_END, 2).finishItem(); + const map = createMap({ + scopes: [scopeEncoder.encode()], + ranges: rangeEncoder.encode(), + }); assertThrows(() => decode(map, { mode: DecodeMode.STRICT })); }); it("ignores if GENERATED_RANGE_START.definition is not a valid original scope in lax mode", () => { - const encoder = new ItemEncoder(); - encoder.addUnsignedVLQs(Tag.ORIGINAL_SCOPE_START, 0, 0, 0).finishItem(); - encoder.addUnsignedVLQs(Tag.ORIGINAL_SCOPE_END, 1, 0).finishItem(); - encoder.addUnsignedVLQs( + const scopeEncoder = new ItemEncoder(); + scopeEncoder.addUnsignedVLQs(Tag.ORIGINAL_SCOPE_START, 0, 0, 0) + .finishItem(); + scopeEncoder.addUnsignedVLQs(Tag.ORIGINAL_SCOPE_END, 1, 0).finishItem(); + + const rangeEncoder = new ItemEncoder(); + rangeEncoder.addUnsignedVLQs( Tag.GENERATED_RANGE_START, GeneratedRangeFlags.HAS_DEFINITION, 0, ).addSignedVLQs(0, 1).finishItem(); - encoder.addUnsignedVLQs(Tag.GENERATED_RANGE_END, 2).finishItem(); - const map = createMap(encoder.encode(), []); + rangeEncoder.addUnsignedVLQs(Tag.GENERATED_RANGE_END, 2).finishItem(); + const map = createMap({ + scopes: [scopeEncoder.encode()], + ranges: rangeEncoder.encode(), + }); const info = decode(map, { mode: DecodeMode.LAX }); @@ -538,7 +563,7 @@ describe("decode", () => { const encoder = new ItemEncoder(); encoder.addUnsignedVLQs(Tag.GENERATED_RANGE_CALL_SITE); encoder.addSignedVLQs(0, 0, 0).finishItem(); - const map = createMap(encoder.encode(), []); + const map = createMap({ ranges: encoder.encode() }); assertThrows(() => decode(map, { mode: DecodeMode.STRICT })); }); @@ -547,7 +572,7 @@ describe("decode", () => { const encoder = new ItemEncoder(); encoder.addUnsignedVLQs(Tag.GENERATED_RANGE_CALL_SITE); encoder.addSignedVLQs(0, 0, 0).finishItem(); - const map = createMap(encoder.encode(), []); + const map = createMap({ ranges: encoder.encode() }); const info = decode(map, { mode: DecodeMode.LAX }); @@ -564,7 +589,7 @@ describe("decode", () => { encoder.addUnsignedVLQs(Tag.GENERATED_RANGE_SUBRANGE_BINDING, 0, 1, 2, 0) .finishItem(); encoder.addUnsignedVLQs(Tag.GENERATED_RANGE_END, 2, 0).finishItem(); - const map = createMap(encoder.encode(), ["foo"]); + const map = createMap({ ranges: encoder.encode(), names: ["foo"] }); assertThrows( () => decode(map, { mode: DecodeMode.STRICT }), @@ -574,29 +599,47 @@ describe("decode", () => { }); it("ignores multiple GENERATED_RANGE_SUBRANGE_BINDING items for the same variable in lax mode", () => { - const encoder = new ItemEncoder(); + const scopeEncoder = new ItemEncoder(); // Original scope with 1 variable. - encoder.addUnsignedVLQs(Tag.ORIGINAL_SCOPE_START, 0, 0, 0).finishItem(); - encoder.addUnsignedVLQs(Tag.ORIGINAL_SCOPE_VARIABLES, 0).finishItem(); - encoder.addUnsignedVLQs(Tag.ORIGINAL_SCOPE_END, 1, 0).finishItem(); + scopeEncoder.addUnsignedVLQs(Tag.ORIGINAL_SCOPE_START, 0, 0, 0) + .finishItem(); + scopeEncoder.addUnsignedVLQs(Tag.ORIGINAL_SCOPE_VARIABLES, 0).finishItem(); + scopeEncoder.addUnsignedVLQs(Tag.ORIGINAL_SCOPE_END, 1, 0).finishItem(); + const rangeEncoder = new ItemEncoder(); // Generated range from 0,0 to 3,0, referencing the original scope. - encoder.addUnsignedVLQs( + rangeEncoder.addUnsignedVLQs( Tag.GENERATED_RANGE_START, GeneratedRangeFlags.HAS_DEFINITION, 0, ).addSignedVLQs(0, 0).finishItem(); // Initial binding for the variable is "bar" (index 2). - encoder.addUnsignedVLQs(Tag.GENERATED_RANGE_BINDINGS, 2).finishItem(); + rangeEncoder.addUnsignedVLQs(Tag.GENERATED_RANGE_BINDINGS, 2).finishItem(); // 1st sub-range binding for variable 0. from 1,0, value is "var1" (index 1) - encoder.addUnsignedVLQs(Tag.GENERATED_RANGE_SUBRANGE_BINDING, 0, 1, 0, 1) + rangeEncoder.addUnsignedVLQs( + Tag.GENERATED_RANGE_SUBRANGE_BINDING, + 0, + 1, + 0, + 1, + ) .finishItem(); // 2nd sub-range binding for variable 0. from 2,0, value is "baz" (index 3) - encoder.addUnsignedVLQs(Tag.GENERATED_RANGE_SUBRANGE_BINDING, 0, 1, 0, 3) + rangeEncoder.addUnsignedVLQs( + Tag.GENERATED_RANGE_SUBRANGE_BINDING, + 0, + 1, + 0, + 3, + ) .finishItem(); - encoder.addUnsignedVLQs(Tag.GENERATED_RANGE_END, 3, 0).finishItem(); - const map = createMap(encoder.encode(), ["var1", "bar", "baz"]); + rangeEncoder.addUnsignedVLQs(Tag.GENERATED_RANGE_END, 3, 0).finishItem(); + const map = createMap({ + scopes: [scopeEncoder.encode()], + ranges: rangeEncoder.encode(), + names: ["var1", "bar", "baz"], + }); const info = decode(map, { mode: DecodeMode.LAX }); @@ -686,10 +729,15 @@ describe("decode", () => { const encoder = new ItemEncoder(); encoder.addUnsignedVLQs(Tag.VENDOR_EXTENSION, 0); encoder.finishItem(); - const map = createMap(encoder.encode(), ["x_ext_item"]); + const ext = encoder.encode(); + const map = createMap({ + scopes: [ext], + ranges: ext, + names: ["x_ext_item"], + }); assertEquals(decode(map, { mode: DecodeMode.STRICT }), { - scopes: [], + scopes: [[]], ranges: [], hasVariableAndBindingInfo: false, }); @@ -699,7 +747,7 @@ describe("decode", () => { const encoder = new ItemEncoder(); encoder.addUnsignedVLQs(42, 1, 2, 3); encoder.finishItem(); - const map = createMap(encoder.encode(), []); + const map = createMap({ scopes: [encoder.encode()] }); assertThrows( () => decode(map, { mode: DecodeMode.STRICT }), @@ -712,7 +760,59 @@ describe("decode", () => { const encoder = new ItemEncoder(); encoder.addUnsignedVLQs(42, 1, 2, 3); encoder.finishItem(); - const map = createMap(encoder.encode(), []); + const map = createMap({ scopes: [encoder.encode()] }); + + assertEquals(decode(map), { + scopes: [[]], + ranges: [], + hasVariableAndBindingInfo: false, + }); + }); + + it("throws for range item tags in scopes in strict mode", () => { + const encoder = new ItemEncoder(); + encoder.addUnsignedVLQs(Tag.GENERATED_RANGE_START, 0, 0).finishItem(); + encoder.addUnsignedVLQs(Tag.GENERATED_RANGE_END, 10).finishItem(); + const map = createMap({ scopes: [encoder.encode()] }); + + assertThrows( + () => decode(map, { mode: DecodeMode.STRICT }), + Error, + `Encountered illegal item tag ${Tag.GENERATED_RANGE_START}`, + ); + }); + + it("ignores range item tags in scopes in lax mode", () => { + const encoder = new ItemEncoder(); + encoder.addUnsignedVLQs(Tag.GENERATED_RANGE_START, 0, 0).finishItem(); + encoder.addUnsignedVLQs(Tag.GENERATED_RANGE_END, 10).finishItem(); + const map = createMap({ scopes: [encoder.encode()] }); + + assertEquals(decode(map), { + scopes: [[]], + ranges: [], + hasVariableAndBindingInfo: false, + }); + }); + + it("throws for scope item tags in ranges in strict mode", () => { + const encoder = new ItemEncoder(); + encoder.addUnsignedVLQs(Tag.ORIGINAL_SCOPE_START, 0, 0, 0).finishItem(); + encoder.addUnsignedVLQs(Tag.ORIGINAL_SCOPE_END, 10, 0).finishItem(); + const map = createMap({ ranges: encoder.encode() }); + + assertThrows( + () => decode(map, { mode: DecodeMode.STRICT }), + Error, + `Encountered illegal item tag ${Tag.ORIGINAL_SCOPE_START}`, + ); + }); + + it("ignores scope item tags in ranges in lax mode", () => { + const encoder = new ItemEncoder(); + encoder.addUnsignedVLQs(Tag.ORIGINAL_SCOPE_START, 0, 0, 0).finishItem(); + encoder.addUnsignedVLQs(Tag.ORIGINAL_SCOPE_END, 10, 0).finishItem(); + const map = createMap({ ranges: encoder.encode() }); assertEquals(decode(map), { scopes: [], diff --git a/src/decode/decode.ts b/src/decode/decode.ts index ccd84e3..ba18eb3 100644 --- a/src/decode/decode.ts +++ b/src/decode/decode.ts @@ -75,11 +75,16 @@ function decodeMap( sourceMap: SourceMapJson, options: DecodeOptions, ): DecodedScopeInfo { - if (!sourceMap.scopes || !sourceMap.names) { + if ((!sourceMap.scopes && !sourceMap.ranges) || !sourceMap.names) { return { scopes: [], ranges: [], hasVariableAndBindingInfo: false }; } - return new Decoder(sourceMap.scopes, sourceMap.names, options).decode(); + return new Decoder( + sourceMap.scopes ?? [], + sourceMap.ranges ?? "", + sourceMap.names, + options, + ).decode(); } function decodeIndexMap( @@ -121,7 +126,8 @@ const DEFAULT_RANGE_STATE = { }; class Decoder { - readonly #encodedScopes: string; + readonly #encodedScopes: (string | null)[]; + readonly #encodedRanges: string; readonly #names: string[]; readonly #mode: DecodeMode; @@ -134,6 +140,7 @@ class Decoder { readonly #scopeStack: OriginalScope[] = []; readonly #rangeStack: GeneratedRange[] = []; + #currentRootScopes: OriginalScope[] = []; #flatOriginalScopes: (OriginalScope[] | null)[] = []; #currentFlatScopes: OriginalScope[] = []; #subRangeBindingsForRange = new Map< @@ -144,8 +151,14 @@ class Decoder { #seenOriginalScopeVariables = false; #seenGeneratedRangeBindings = false; - constructor(scopes: string, names: string[], options: DecodeOptions) { + constructor( + scopes: (string | null)[], + ranges: string, + names: string[], + options: DecodeOptions, + ) { this.#encodedScopes = scopes; + this.#encodedRanges = ranges; this.#names = names; this.#mode = options.mode; this.#rangeState.line = options.generatedOffset.line; @@ -153,19 +166,48 @@ class Decoder { } decode(): DecodedScopeInfo { + for (const encodedScope of this.#encodedScopes) { + if (encodedScope === null) { + this.#scopes.push(null); + this.#flatOriginalScopes.push(null); + continue; + } + this.#decodeScopes(encodedScope); + } + + this.#decodeRanges(this.#encodedRanges); + + const info = { + scopes: this.#scopes, + ranges: this.#ranges, + hasVariableAndBindingInfo: this.#seenOriginalScopeVariables && + this.#seenGeneratedRangeBindings, + }; + + this.#scopes = []; + this.#ranges = []; + this.#currentRootScopes = []; + this.#flatOriginalScopes = []; + this.#currentFlatScopes = []; + this.#seenOriginalScopeVariables = false; + this.#seenGeneratedRangeBindings = false; + + return info; + } + + #decodeScopes(encodedScope: string) { + Object.assign(this.#scopeState, DEFAULT_SCOPE_STATE); + this.#currentRootScopes = []; + this.#currentFlatScopes = []; + const iter = new TokenIterator( - this.#encodedScopes, + encodedScope, this.#mode === DecodeMode.STRICT, ); while (iter.hasNext()) { const tag = iter.nextUnsignedVLQ(); switch (tag) { - case Tag.EMPTY: { - this.#scopes.push(null); - this.#flatOriginalScopes.push(null); - break; - } case Tag.ORIGINAL_SCOPE_START: { const item: OriginalScopeStartItem = { flags: iter.nextUnsignedVLQ(), @@ -201,6 +243,41 @@ class Decoder { ); break; } + case Tag.VENDOR_EXTENSION: { + const _extensionNameIdx = iter.nextUnsignedVLQ(); + break; + } + default: { + this.#throwInStrictMode(`Encountered illegal item tag ${tag}`); + break; + } + } + + // Consume any trailing VLQ and the "," + while (iter.hasNext() && iter.peek() !== ",") iter.nextUnsignedVLQ(); + if (iter.hasNext()) iter.nextChar(); + } + + if (this.#scopeStack.length > 0) { + this.#throwInStrictMode( + "Encountered ORIGINAL_SCOPE_START without matching END!", + ); + this.#scopeStack.length = 0; + } + + this.#scopes.push(this.#currentRootScopes); + this.#flatOriginalScopes.push(this.#currentFlatScopes); + } + + #decodeRanges(encodedRanges: string) { + const iter = new TokenIterator( + encodedRanges, + this.#mode === DecodeMode.STRICT, + ); + + while (iter.hasNext()) { + const tag = iter.nextUnsignedVLQ(); + switch (tag) { case Tag.GENERATED_RANGE_START: { const flags = iter.nextUnsignedVLQ(); const line = flags & GeneratedRangeFlags.HAS_LINE @@ -283,37 +360,16 @@ class Decoder { } } - // Consume any trailing VLQ and the the "," + // Consume any trailing VLQ and the "," while (iter.hasNext() && iter.peek() !== ",") iter.nextUnsignedVLQ(); if (iter.hasNext()) iter.nextChar(); } - if (this.#scopeStack.length > 0) { - this.#throwInStrictMode( - "Encountered ORIGINAL_SCOPE_START without matching END!", - ); - } if (this.#rangeStack.length > 0) { this.#throwInStrictMode( "Encountered GENERATED_RANGE_START without matching END!", ); } - - const info = { - scopes: this.#scopes, - ranges: this.#ranges, - hasVariableAndBindingInfo: this.#seenOriginalScopeVariables && - this.#seenGeneratedRangeBindings, - }; - - this.#scopes = []; - this.#ranges = []; - this.#flatOriginalScopes = []; - this.#currentFlatScopes = []; - this.#seenOriginalScopeVariables = false; - this.#seenGeneratedRangeBindings = false; - - return info; } #throwInStrictMode(message: string) { @@ -393,11 +449,7 @@ class Decoder { scope.parent = parent; parent.children.push(scope); } else { - this.#scopes.push([scope]); - this.#flatOriginalScopes.push(this.#currentFlatScopes); - this.#currentFlatScopes = []; - this.#scopeState.line = 0; - this.#scopeState.column = 0; + this.#currentRootScopes.push(scope); } } diff --git a/src/encode/encode.test.ts b/src/encode/encode.test.ts index 73fd2e4..97c2cff 100644 --- a/src/encode/encode.test.ts +++ b/src/encode/encode.test.ts @@ -4,7 +4,7 @@ import { beforeEach, describe, it } from "@std/testing/bdd"; import type { ScopeInfo, SourceMapJson } from "../scopes.ts"; -import { assertStrictEquals, assertThrows } from "@std/assert"; +import { assertEquals, assertStrictEquals, assertThrows } from "@std/assert"; import { encode } from "./encode.ts"; import { ScopeInfoBuilder } from "../builder/builder.ts"; @@ -49,7 +49,16 @@ describe("encode", () => { const info = builder.addNullSource().addNullSource().addNullSource() .build(); - assertStrictEquals(encode(info).scopes, "A,A,A"); + assertEquals(encode(info).scopes, [null, null, null]); + }); + + it("throws when a subsequent root scope precedes the previous root scope in the same source", () => { + const info = builder.startSource() + .startScope(10, 0).endScope(20, 0) + .startScope(5, 0).endScope(15, 0) + .endSource().build(); + + assertThrows(() => encode(info)); }); it("throws when a child scope' start is not nested properly within its parent", () => { diff --git a/src/encode/encode.ts b/src/encode/encode.ts index d8cae84..8aa5e54 100644 --- a/src/encode/encode.ts +++ b/src/encode/encode.ts @@ -28,8 +28,10 @@ export function encode( ); } - inputSourceMap.scopes = new Encoder(scopesInfo, inputSourceMap.names) + const { scopes, ranges } = new Encoder(scopesInfo, inputSourceMap.names) .encode(); + inputSourceMap.scopes = scopes; + inputSourceMap.ranges = ranges; return inputSourceMap; } diff --git a/src/encode/encoder.ts b/src/encode/encoder.ts index 6c8c914..1432278 100644 --- a/src/encode/encoder.ts +++ b/src/encode/encoder.ts @@ -55,24 +55,28 @@ export class Encoder { } } - encode(): string { - this.#encodedItems = []; - this.#info.scopes.forEach((scopes, sourceIdx) => { + encode(): { scopes: (string | null)[]; ranges: string } { + const encodedScopes = this.#info.scopes.map((scopes, sourceIdx) => { if (scopes === null) { - this.#encodedItems.push(EncodedTag.EMPTY); - return; + return null; } + this.#encodedItems = []; this.#currentSourceIdx = sourceIdx; this.#scopeCounter = 0; - this.#scopeState.line = 0; - this.#scopeState.column = 0; + Object.assign(this.#scopeState, DEFAULT_SCOPE_STATE); scopes.forEach((scope) => this.#encodeOriginalScope(scope)); + return this.#encodedItems.join(","); }); + + this.#encodedItems = []; this.#info.ranges.forEach((range) => { this.#encodeGeneratedRange(range); }); - return this.#encodedItems.join(","); + return { + scopes: encodedScopes, + ranges: this.#encodedItems.join(","), + }; } #encodeOriginalScope(scope: OriginalScope): void { diff --git a/src/roundtrip.test.ts b/src/roundtrip.test.ts index f65f351..f844bb0 100644 --- a/src/roundtrip.test.ts +++ b/src/roundtrip.test.ts @@ -38,13 +38,25 @@ describe("round trip", () => { assertCodec(builder.build()); }); - it("handles two simple top-level OriginalScopes", () => { + it("handles two simple top-level OriginalScopes across sources", () => { builder.startSource().startScope(0, 0).endScope(10, 1).endSource(); builder.startSource().startScope(0, 0).endScope(15, 1).endSource(); assertCodec(builder.build()); }); + it("handles multiple root OriginalScopes in the same source", () => { + builder.startSource() + .startScope(0, 0, { name: "first", key: "first" }).endScope(5, 10) + .startScope(5, 15, { name: "second", key: "second" }).endScope(10, 0) + .startScope(12, 4, { name: "third", key: "third" }).endScope(20, 0) + .endSource() + .startRange(0, 0, { scopeKey: "third" }).endRange(0, 10) + .startRange(0, 10, { scopeKey: "first" }).endRange(0, 20); + + assertCodec(builder.build()); + }); + it("handles a simple nested OriginalScope", () => { builder.startSource().startScope(0, 0).startScope(5, 1).endScope(10, 1) .endScope(15, 0).endSource(); diff --git a/src/scopes.ts b/src/scopes.ts index 71dd1f2..55c8bfa 100644 --- a/src/scopes.ts +++ b/src/scopes.ts @@ -191,5 +191,6 @@ export interface SourceMapJson { sources: (string | null)[]; mappings: string; names?: string[]; - scopes?: string; + scopes?: (string | null)[]; + ranges?: string; }