diff --git a/.changeset/scopes-proposal.md b/.changeset/scopes-proposal.md new file mode 100644 index 0000000..8f9a214 --- /dev/null +++ b/.changeset/scopes-proposal.md @@ -0,0 +1,11 @@ +--- +"webpack-sources": minor +--- + +Add **experimental** support for the [TC39 Source Map Scopes Proposal](https://github.com/tc39/source-map/blob/main/proposals/scopes.md) (`originalScopes` / `generatedRanges`). + +`SourceMapSource` now reads these fields from an input source map and forwards them through `streamChunks` via two new optional callbacks — `onOriginalScope` and `onGeneratedRange` — which `ConcatSource`, `CachedSource`, and `PrefixSource` propagate while shifting generated-range coordinates as needed. Maps produced via `getFromStreamChunks` / `streamAndGetSourceAndMap` round-trip `originalScopes` and `generatedRanges` when the inputs carry them. + +All scope decoders, encoders, and the shared VLQ alphabet live in one place: `lib/helpers/scopes.js`. The decoders inline the VLQ state machine (no per-token closure) and the encoder has a single-sextet fast path for small values. + +**⚠️ Experimental.** The proposal is still evolving in TC39 — the wire format, flag bits, callback shape, and helper names may change in future minor releases. See the "Source Map Scopes Proposal (experimental)" section of the README for details. diff --git a/README.md b/README.md index bac006f..0ecb130 100644 --- a/README.md +++ b/README.md @@ -257,3 +257,90 @@ CompatSource.from(sourceLike: any | Source) ``` If `sourceLike` is a real Source it returns it unmodified. Otherwise it returns it wrapped in a CompatSource. + +## Source Map Scopes Proposal (experimental) + +> **⚠️ Experimental.** `webpack-sources` has initial support for the +> [TC39 Source Map Scopes Proposal](https://github.com/tc39/source-map/blob/main/proposals/scopes.md) +> (`originalScopes` / `generatedRanges`). The proposal is still evolving, so +> the **wire format, flag bits, callback shape, and helper names may change** +> in future minor releases. Do not depend on this as part of a stable API +> surface yet. + +When a `SourceMapSource` is constructed with an input source map that carries +`originalScopes` and/or `generatedRanges`, those fields are forwarded through +the `streamChunks` pipeline and back out on the map produced by `.map()` / +`.sourceAndMap()`: + + +```typescript +const map = { + version: 3, + sources: ["a.js"], + names: [], + mappings: "AAAA;AACA;", + // Experimental Scopes Proposal fields: + originalScopes: ["…"], // one VLQ string per source + generatedRanges: "…", // one VLQ string for the generated file +}; +const src = new SourceMapSource(generatedCode, "a.js", map); +const out = src.map(); // out.originalScopes / out.generatedRanges preserved +``` + +The scope data is propagated through derived sources as follows: + +- **`SourceMapSource`** — reads scopes/ranges from the input source map and + emits them. +- **`ConcatSource`** — forwards original scopes verbatim and shifts each + child's generated-range line/column by the cumulative offset. +- **`PrefixSource`** — shifts generated-range columns by the prefix length + on non-line-start positions. +- **`CachedSource`** — passes the callbacks through both the cache-miss and + cache-hit paths. +- **`ReplaceSource`** — forwards original scopes (positions refer to + original sources and are stable across replacements). Generated ranges + are dropped because their generated columns would need remapping through + each replacement. +- **`RawSource` / `OriginalSource`** — accept the callbacks as no-ops + (these sources have no scope data of their own). +- **Combined source maps** (`SourceMapSource` with an inner map) — accept + the callbacks but drop the scope/range data. Remapping scopes/ranges + through the combined coordinate transform is not implemented yet. + +### `streamChunks` callbacks + +`Source.prototype.streamChunks` has two **optional** trailing parameters for +Scopes Proposal data. Passing them is the only way to receive scope/range +events; omitting them is fully backwards compatible. + + +```typescript +source.streamChunks( + options, + onChunk, + onSource, + onName, + // Experimental. `flags >= 0` is a scope start; `flags === -1` is a + // scope end. + onOriginalScope?: ( + sourceIndex: number, + line: number, + column: number, + flags: number, + kind: number, + name: number, + variables: number[], + ) => void, + // Experimental. `flags >= 0` is a range start; `flags === -1` is a + // range end. `definition` / `callsite` tuples are reused internally — + // copy them if you need to retain data across invocations. + onGeneratedRange?: ( + generatedLine: number, + generatedColumn: number, + flags: number, + definition?: [sourceIndex: number, scopeIndex: number], + callsite?: [sourceIndex: number, line: number, column: number], + bindings?: number[][], + ) => void, +); +``` diff --git a/lib/CachedSource.js b/lib/CachedSource.js index bea8f0d..36275ff 100644 --- a/lib/CachedSource.js +++ b/lib/CachedSource.js @@ -15,6 +15,8 @@ const { /** @typedef {import("./Source").HashLike} HashLike */ /** @typedef {import("./Source").MapOptions} MapOptions */ +/** @typedef {import("./Source").OnGeneratedRange} OnGeneratedRange */ +/** @typedef {import("./Source").OnOriginalScope} OnOriginalScope */ /** @typedef {import("./Source").RawSourceMap} RawSourceMap */ /** @typedef {import("./Source").SourceAndMap} SourceAndMap */ /** @typedef {import("./Source").SourceValue} SourceValue */ @@ -377,9 +379,18 @@ class CachedSource extends Source { * @param {OnChunk} onChunk called for each chunk of code * @param {OnSource} onSource called for each source * @param {OnName} onName called for each name + * @param {OnOriginalScope=} onOriginalScope called for each original scope + * @param {OnGeneratedRange=} onGeneratedRange called for each generated range * @returns {GeneratedSourceInfo} generated source info */ - streamChunks(options, onChunk, onSource, onName) { + streamChunks( + options, + onChunk, + onSource, + onName, + onOriginalScope, + onGeneratedRange, + ) { const key = getCacheKey(options); if ( this._cachedMaps.has(key) && @@ -396,6 +407,8 @@ class CachedSource extends Source { onName, Boolean(options && options.finalSource), true, + onOriginalScope, + onGeneratedRange, ); } return streamChunksOfRawSource( @@ -413,6 +426,8 @@ class CachedSource extends Source { onChunk, onSource, onName, + onOriginalScope, + onGeneratedRange, ); this._cachedSource = sourceAndMap.source; this._cachedMaps.set(key, { diff --git a/lib/ConcatSource.js b/lib/ConcatSource.js index 92e9d80..ae8b748 100644 --- a/lib/ConcatSource.js +++ b/lib/ConcatSource.js @@ -13,6 +13,8 @@ const streamChunks = require("./helpers/streamChunks"); /** @typedef {import("./CompatSource").SourceLike} SourceLike */ /** @typedef {import("./Source").HashLike} HashLike */ /** @typedef {import("./Source").MapOptions} MapOptions */ +/** @typedef {import("./Source").OnGeneratedRange} OnGeneratedRange */ +/** @typedef {import("./Source").OnOriginalScope} OnOriginalScope */ /** @typedef {import("./Source").RawSourceMap} RawSourceMap */ /** @typedef {import("./Source").SourceAndMap} SourceAndMap */ /** @typedef {import("./Source").SourceValue} SourceValue */ @@ -161,9 +163,18 @@ class ConcatSource extends Source { * @param {OnChunk} onChunk called for each chunk of code * @param {OnSource} onSource called for each source * @param {OnName} onName called for each name + * @param {OnOriginalScope=} onOriginalScope called for each original scope + * @param {OnGeneratedRange=} onGeneratedRange called for each generated range * @returns {GeneratedSourceInfo} generated source info */ - streamChunks(options, onChunk, onSource, onName) { + streamChunks( + options, + onChunk, + onSource, + onName, + onOriginalScope, + onGeneratedRange, + ) { if (!this._isOptimized) this._optimize(); if (this._children.length === 1) { return /** @type {ConcatSource[]} */ (this._children)[0].streamChunks( @@ -171,6 +182,8 @@ class ConcatSource extends Source { onChunk, onSource, onName, + onOriginalScope, + onGeneratedRange, ); } let currentLineOffset = 0; @@ -186,6 +199,8 @@ class ConcatSource extends Source { /** @type {number[]} */ const nameIndexMapping = []; let lastMappingLine = 0; + const currentLineOffsetBeforeItem = currentLineOffset; + const currentColumnOffsetBeforeItem = currentColumnOffset; const { generatedLine, generatedColumn, source } = streamChunks( item, options, @@ -264,6 +279,86 @@ class ConcatSource extends Source { } nameIndexMapping[i] = globalIndex; }, + onOriginalScope + ? (innerSourceIndex, line, column, flags, kind, name, variables) => { + const globalSourceIndex = + innerSourceIndex < sourceIndexMapping.length + ? sourceIndexMapping[innerSourceIndex] + : -1; + if (globalSourceIndex < 0) return; + const globalName = + name >= 0 && name < nameIndexMapping.length + ? nameIndexMapping[name] + : -1; + /** @type {number[] | undefined} */ + let globalVariables; + if (variables && variables.length > 0) { + globalVariables = variables.map((v) => + v >= 0 && v < nameIndexMapping.length + ? nameIndexMapping[v] + : -1, + ); + } + onOriginalScope( + globalSourceIndex, + line, + column, + flags, + kind, + globalName, + globalVariables || variables, + ); + } + : undefined, + onGeneratedRange + ? ( + innerGeneratedLine, + innerGeneratedColumn, + flags, + definition, + callsite, + bindings, + ) => { + const line = innerGeneratedLine + currentLineOffsetBeforeItem; + const column = + innerGeneratedLine === 1 + ? innerGeneratedColumn + currentColumnOffsetBeforeItem + : innerGeneratedColumn; + /** @type {[number, number] | undefined} */ + let mappedDefinition = definition; + if (definition !== undefined) { + const globalSourceIndex = + definition[0] >= 0 && + definition[0] < sourceIndexMapping.length + ? sourceIndexMapping[definition[0]] + : -1; + mappedDefinition = + globalSourceIndex < 0 + ? undefined + : [globalSourceIndex, definition[1]]; + } + /** @type {[number, number, number] | undefined} */ + let mappedCallsite = callsite; + if (callsite !== undefined) { + const globalSourceIndex = + callsite[0] >= 0 && callsite[0] < sourceIndexMapping.length + ? sourceIndexMapping[callsite[0]] + : -1; + mappedCallsite = + globalSourceIndex < 0 + ? undefined + : [globalSourceIndex, callsite[1], callsite[2]]; + } + onGeneratedRange( + line, + column, + flags, + mappedDefinition, + mappedCallsite, + bindings, + ); + } + : undefined, ); if (source !== undefined) code += source; if ( diff --git a/lib/OriginalSource.js b/lib/OriginalSource.js index 9949913..588a3e2 100644 --- a/lib/OriginalSource.js +++ b/lib/OriginalSource.js @@ -15,6 +15,8 @@ const { /** @typedef {import("./Source").HashLike} HashLike */ /** @typedef {import("./Source").MapOptions} MapOptions */ +/** @typedef {import("./Source").OnGeneratedRange} OnGeneratedRange */ +/** @typedef {import("./Source").OnOriginalScope} OnOriginalScope */ /** @typedef {import("./Source").RawSourceMap} RawSourceMap */ /** @typedef {import("./Source").SourceAndMap} SourceAndMap */ /** @typedef {import("./Source").SourceValue} SourceValue */ @@ -120,9 +122,20 @@ class OriginalSource extends Source { * @param {OnChunk} onChunk called for each chunk of code * @param {OnSource} onSource called for each source * @param {OnName} _onName called for each name + * @param {OnOriginalScope=} _onOriginalScope called for each original scope (no scopes on OriginalSource) + * @param {OnGeneratedRange=} _onGeneratedRange called for each generated range (no ranges on OriginalSource) * @returns {GeneratedSourceInfo} generated source info */ - streamChunks(options, onChunk, onSource, _onName) { + streamChunks( + options, + onChunk, + onSource, + _onName, + + _onOriginalScope, + + _onGeneratedRange, + ) { if (this._value === undefined) { this._value = /** @type {Buffer} */ diff --git a/lib/PrefixSource.js b/lib/PrefixSource.js index ce46761..236fd1e 100644 --- a/lib/PrefixSource.js +++ b/lib/PrefixSource.js @@ -12,6 +12,8 @@ const streamChunks = require("./helpers/streamChunks"); /** @typedef {import("./Source").HashLike} HashLike */ /** @typedef {import("./Source").MapOptions} MapOptions */ +/** @typedef {import("./Source").OnGeneratedRange} OnGeneratedRange */ +/** @typedef {import("./Source").OnOriginalScope} OnOriginalScope */ /** @typedef {import("./Source").RawSourceMap} RawSourceMap */ /** @typedef {import("./Source").SourceAndMap} SourceAndMap */ /** @typedef {import("./Source").SourceValue} SourceValue */ @@ -85,9 +87,18 @@ class PrefixSource extends Source { * @param {OnChunk} onChunk called for each chunk of code * @param {OnSource} onSource called for each source * @param {OnName} onName called for each name + * @param {OnOriginalScope=} onOriginalScope called for each original scope + * @param {OnGeneratedRange=} onGeneratedRange called for each generated range * @returns {GeneratedSourceInfo} generated source info */ - streamChunks(options, onChunk, onSource, onName) { + streamChunks( + options, + onChunk, + onSource, + onName, + onOriginalScope, + onGeneratedRange, + ) { const prefix = this._prefix; const prefixOffset = prefix.length; const linesOnly = Boolean(options && options.columns === false); @@ -134,6 +145,31 @@ class PrefixSource extends Source { }, onSource, onName, + onOriginalScope, + onGeneratedRange + ? ( + innerGeneratedLine, + innerGeneratedColumn, + flags, + definition, + callsite, + bindings, + ) => { + const line = innerGeneratedLine; + const column = + innerGeneratedColumn === 0 + ? 0 + : innerGeneratedColumn + prefixOffset; + onGeneratedRange( + line, + column, + flags, + definition, + callsite, + bindings, + ); + } + : undefined, ); return { generatedLine, diff --git a/lib/RawSource.js b/lib/RawSource.js index 3551937..f6a4a6d 100644 --- a/lib/RawSource.js +++ b/lib/RawSource.js @@ -14,6 +14,8 @@ const { /** @typedef {import("./Source").HashLike} HashLike */ /** @typedef {import("./Source").MapOptions} MapOptions */ +/** @typedef {import("./Source").OnGeneratedRange} OnGeneratedRange */ +/** @typedef {import("./Source").OnOriginalScope} OnOriginalScope */ /** @typedef {import("./Source").RawSourceMap} RawSourceMap */ /** @typedef {import("./Source").SourceValue} SourceValue */ /** @typedef {import("./helpers/getGeneratedSourceInfo").GeneratedSourceInfo} GeneratedSourceInfo */ @@ -124,9 +126,20 @@ class RawSource extends Source { * @param {OnChunk} onChunk called for each chunk of code * @param {OnSource} onSource called for each source * @param {OnName} onName called for each name + * @param {OnOriginalScope=} _onOriginalScope called for each original scope (no scopes on RawSource) + * @param {OnGeneratedRange=} _onGeneratedRange called for each generated range (no ranges on RawSource) * @returns {GeneratedSourceInfo} generated source info */ - streamChunks(options, onChunk, onSource, onName) { + streamChunks( + options, + onChunk, + onSource, + onName, + + _onOriginalScope, + + _onGeneratedRange, + ) { let strValue = this._valueAsString; if (strValue === undefined) { const value = this.source(); diff --git a/lib/ReplaceSource.js b/lib/ReplaceSource.js index 07572a1..a0ca68e 100644 --- a/lib/ReplaceSource.js +++ b/lib/ReplaceSource.js @@ -12,6 +12,8 @@ const streamChunks = require("./helpers/streamChunks"); /** @typedef {import("./Source").HashLike} HashLike */ /** @typedef {import("./Source").MapOptions} MapOptions */ +/** @typedef {import("./Source").OnGeneratedRange} OnGeneratedRange */ +/** @typedef {import("./Source").OnOriginalScope} OnOriginalScope */ /** @typedef {import("./Source").RawSourceMap} RawSourceMap */ /** @typedef {import("./Source").SourceAndMap} SourceAndMap */ /** @typedef {import("./Source").SourceValue} SourceValue */ @@ -220,9 +222,19 @@ class ReplaceSource extends Source { * @param {OnChunk} onChunk called for each chunk of code * @param {OnSource} onSource called for each source * @param {OnName} onName called for each name + * @param {OnOriginalScope=} onOriginalScope called for each original scope (Scopes Proposal — forwarded as-is; positions are not remapped through replacements) + * @param {OnGeneratedRange=} _onGeneratedRange called for each generated range (Scopes Proposal — dropped; positions cannot be safely remapped through replacements) * @returns {GeneratedSourceInfo} generated source info */ - streamChunks(options, onChunk, onSource, onName) { + streamChunks( + options, + onChunk, + onSource, + onName, + onOriginalScope, + + _onGeneratedRange, + ) { this._sortReplacements(); const replacements = this._replacements; let pos = 0; @@ -502,6 +514,13 @@ class ReplaceSource extends Source { } nameIndexMapping[nameIndex] = globalIndex; }, + // Forward original-scope data unchanged (positions refer to + // original sources, not to generated columns, so they're stable + // across replacements). Generated ranges are dropped because their + // positions refer to the generated code whose columns the + // replacements shift. + onOriginalScope, + undefined, ); // Handle remaining replacements diff --git a/lib/Source.js b/lib/Source.js index c1798b7..76d5615 100644 --- a/lib/Source.js +++ b/lib/Source.js @@ -22,6 +22,76 @@ * @property {string} file file * @property {string=} debugId debug id * @property {number[]=} ignoreList ignore list + * @property {string[]=} originalScopes **Experimental.** Per-source original scopes from the [Source Map Scopes Proposal](https://github.com/tc39/source-map/blob/main/proposals/scopes.md). The proposal is still evolving — the wire format and field names may change. + * @property {string=} generatedRanges **Experimental.** Generated ranges from the [Source Map Scopes Proposal](https://github.com/tc39/source-map/blob/main/proposals/scopes.md). The proposal is still evolving — the wire format and field names may change. + */ + +/** + * **Experimental — Source Map Scopes Proposal.** Reference from a generated + * range to the original scope that defines it. First element is a source + * index (into `RawSourceMap.sources`); second element is the 0-based index of + * the scope within that source's `originalScopes` stream. + * @typedef {[sourceIndex: number, scopeIndex: number]} DefinitionReference + */ + +/** + * **Experimental — Source Map Scopes Proposal.** Callsite of an inlined + * generated range: `[sourceIndex, line, column]` points into the original + * source that produced the inlined call. + * @typedef {[sourceIndex: number, line: number, column: number]} Callsite + */ + +/** + * **Experimental — Source Map Scopes Proposal.** One entry per variable + * binding on a generated range: `[expressionIndex, ...subranges]`, where each + * subrange is `[lineDelta, columnDelta, nameIndex]`. A missing subrange list + * (length 1) means the binding has no sub-ranges. + * @typedef {number[]} Binding + */ + +/** + * **Experimental — Source Map Scopes Proposal.** Callback invoked for each + * original scope boundary decoded from a source map's `originalScopes` + * field. `flags >= 0` represents the **start** of a scope and the + * `kind` / `name` / `variables` fields are meaningful. `flags === -1` + * represents the **end** of a scope and those fields are `-1` / empty. + * + * This API mirrors the TC39 proposal wire format. Because the proposal is + * still evolving, both this callback shape and the set of `flags` bits may + * change in future minor releases. + * @callback OnOriginalScope + * @param {number} sourceIndex index into `sources` / `originalScopes` + * @param {number} line 1-based original line + * @param {number} column 0-based original column + * @param {number} flags bit flags; `-1` for end of scope + * @param {number} kind scope kind (only meaningful when `flags >= 0`) + * @param {number} name name index (-1 if absent) + * @param {number[]} variables variable name indices + * @returns {void} + */ + +/** + * **Experimental — Source Map Scopes Proposal.** Callback invoked for each + * generated range boundary decoded from a source map's `generatedRanges` + * field. `flags >= 0` represents the **start** of a range and may carry an + * optional `DefinitionReference`, `Callsite`, and `Binding` list. `flags === + * -1` represents the **end** of a range and those extra fields are + * `undefined`. + * + * The `definition` / `callsite` / `bindings` tuples are reused internally + * across calls — copy them if you need to retain data across callback + * invocations. + * + * Because the proposal is still evolving, both this callback shape and the + * set of `flags` bits may change in future minor releases. + * @callback OnGeneratedRange + * @param {number} generatedLine 1-based generated line + * @param {number} generatedColumn 0-based generated column + * @param {number} flags bit flags; `-1` for end of range + * @param {DefinitionReference | undefined} definition definition reference (if the flag bit is set) + * @param {Callsite | undefined} callsite callsite for inlined ranges (if the flag bit is set) + * @param {Binding[] | undefined} bindings bindings list (if present) + * @returns {void} */ /** @typedef {string | Buffer} SourceValue */ diff --git a/lib/SourceMapSource.js b/lib/SourceMapSource.js index ed91a9a..aa6503a 100644 --- a/lib/SourceMapSource.js +++ b/lib/SourceMapSource.js @@ -15,6 +15,8 @@ const { /** @typedef {import("./Source").HashLike} HashLike */ /** @typedef {import("./Source").MapOptions} MapOptions */ +/** @typedef {import("./Source").OnGeneratedRange} OnGeneratedRange */ +/** @typedef {import("./Source").OnOriginalScope} OnOriginalScope */ /** @typedef {import("./Source").RawSourceMap} RawSourceMap */ /** @typedef {import("./Source").SourceAndMap} SourceAndMap */ /** @typedef {import("./Source").SourceValue} SourceValue */ @@ -322,9 +324,18 @@ class SourceMapSource extends Source { * @param {OnChunk} onChunk called for each chunk of code * @param {OnSource} onSource called for each source * @param {OnName} onName called for each name + * @param {OnOriginalScope=} onOriginalScope called for each original scope + * @param {OnGeneratedRange=} onGeneratedRange called for each generated range * @returns {GeneratedSourceInfo} generated source info */ - streamChunks(options, onChunk, onSource, onName) { + streamChunks( + options, + onChunk, + onSource, + onName, + onOriginalScope, + onGeneratedRange, + ) { if (this._hasInnerSourceMap) { return streamChunksOfCombinedSourceMap( /** @type {string} */ @@ -340,6 +351,8 @@ class SourceMapSource extends Source { onName, Boolean(options && options.finalSource), Boolean(options && options.columns !== false), + onOriginalScope, + onGeneratedRange, ); } return streamChunksOfSourceMap( @@ -351,6 +364,8 @@ class SourceMapSource extends Source { onName, Boolean(options && options.finalSource), Boolean(options && options.columns !== false), + onOriginalScope, + onGeneratedRange, ); } diff --git a/lib/helpers/getFromStreamChunks.js b/lib/helpers/getFromStreamChunks.js index ed9b347..0ce88fb 100644 --- a/lib/helpers/getFromStreamChunks.js +++ b/lib/helpers/getFromStreamChunks.js @@ -6,6 +6,10 @@ "use strict"; const createMappingsSerializer = require("./createMappingsSerializer"); +const { + createGeneratedRangesSerializer, + createOriginalScopesSerializer, +} = require("./scopes"); /** @typedef {import("../Source").RawSourceMap} RawSourceMap */ /** @typedef {import("../Source").SourceAndMap} SourceAndMap */ @@ -27,6 +31,12 @@ module.exports.getMap = (source, options) => { const potentialSourcesContent = []; /** @type {(string | null)[]} */ const potentialNames = []; + /** @type {string[]} */ + const originalScopes = []; + /** @type {((line: number, column: number, flags: number, kind: number, name: number, variables: number[] | undefined) => string)[]} */ + const originalScopesSerializers = []; + let generatedRanges = ""; + const generatedRangesSerializer = createGeneratedRangesSerializer(); const addMapping = createMappingsSerializer(options); source.streamChunks( { ...options, source: false, finalSource: true }, @@ -59,6 +69,10 @@ module.exports.getMap = (source, options) => { } potentialSourcesContent[sourceIndex] = sourceContent; } + while (originalScopes.length <= sourceIndex) { + originalScopes.push(""); + originalScopesSerializers.push(createOriginalScopesSerializer()); + } }, (nameIndex, name) => { while (potentialNames.length < nameIndex) { @@ -66,21 +80,51 @@ module.exports.getMap = (source, options) => { } potentialNames[nameIndex] = name; }, - ); - return mappings.length > 0 - ? { - version: 3, - file: "x", - mappings, - // We handle broken sources as `null`, in spec this field should be string, but no information what we should do in such cases if we change type it will be breaking change - sources: /** @type {string[]} */ (potentialSources), - sourcesContent: - potentialSourcesContent.length > 0 - ? /** @type {string[]} */ (potentialSourcesContent) - : undefined, - names: /** @type {string[]} */ (potentialNames), + (sourceIndex, line, column, flags, kind, name, variables) => { + while (originalScopes.length <= sourceIndex) { + originalScopes.push(""); + originalScopesSerializers.push(createOriginalScopesSerializer()); } - : null; + originalScopes[sourceIndex] += originalScopesSerializers[sourceIndex]( + line, + column, + flags, + kind, + name, + variables, + ); + }, + (generatedLine, generatedColumn, flags, definition, callsite, bindings) => { + generatedRanges += generatedRangesSerializer( + generatedLine, + generatedColumn, + flags, + definition, + callsite, + bindings, + ); + }, + ); + const hasOriginalScopes = originalScopes.some((str) => str !== ""); + const hasScopesData = hasOriginalScopes || generatedRanges.length > 0; + if (mappings.length === 0 && !hasScopesData) return null; + + /** @type {RawSourceMap} */ + const map = { + version: 3, + file: "x", + mappings, + // We handle broken sources as `null`, in spec this field should be string, but no information what we should do in such cases if we change type it will be breaking change + sources: /** @type {string[]} */ (potentialSources), + sourcesContent: + potentialSourcesContent.length > 0 + ? /** @type {string[]} */ (potentialSourcesContent) + : undefined, + names: /** @type {string[]} */ (potentialNames), + }; + if (hasOriginalScopes) map.originalScopes = originalScopes; + if (generatedRanges.length > 0) map.generatedRanges = generatedRanges; + return map; }; /** @@ -97,6 +141,12 @@ module.exports.getSourceAndMap = (inputSource, options) => { const potentialSourcesContent = []; /** @type {(string | null)[]} */ const potentialNames = []; + /** @type {string[]} */ + const originalScopes = []; + /** @type {((line: number, column: number, flags: number, kind: number, name: number, variables: number[] | undefined) => string)[]} */ + const originalScopesSerializers = []; + let generatedRanges = ""; + const generatedRangesSerializer = createGeneratedRangesSerializer(); const addMapping = createMappingsSerializer(options); const { source } = inputSource.streamChunks( { ...options, finalSource: true }, @@ -130,6 +180,10 @@ module.exports.getSourceAndMap = (inputSource, options) => { } potentialSourcesContent[sourceIndex] = sourceContent; } + while (originalScopes.length <= sourceIndex) { + originalScopes.push(""); + originalScopesSerializers.push(createOriginalScopesSerializer()); + } }, (nameIndex, name) => { while (potentialNames.length < nameIndex) { @@ -137,23 +191,54 @@ module.exports.getSourceAndMap = (inputSource, options) => { } potentialNames[nameIndex] = name; }, + (sourceIndex, line, column, flags, kind, name, variables) => { + while (originalScopes.length <= sourceIndex) { + originalScopes.push(""); + originalScopesSerializers.push(createOriginalScopesSerializer()); + } + originalScopes[sourceIndex] += originalScopesSerializers[sourceIndex]( + line, + column, + flags, + kind, + name, + variables, + ); + }, + (generatedLine, generatedColumn, flags, definition, callsite, bindings) => { + generatedRanges += generatedRangesSerializer( + generatedLine, + generatedColumn, + flags, + definition, + callsite, + bindings, + ); + }, ); + const hasOriginalScopes = originalScopes.some((str) => str !== ""); + const hasScopesData = hasOriginalScopes || generatedRanges.length > 0; + let map = null; + if (mappings.length > 0 || hasScopesData) { + /** @type {RawSourceMap} */ + const m = { + version: 3, + file: "x", + mappings, + // We handle broken sources as `null`, in spec this field should be string, but no information what we should do in such cases if we change type it will be breaking change + sources: /** @type {string[]} */ (potentialSources), + sourcesContent: + potentialSourcesContent.length > 0 + ? /** @type {string[]} */ (potentialSourcesContent) + : undefined, + names: /** @type {string[]} */ (potentialNames), + }; + if (hasOriginalScopes) m.originalScopes = originalScopes; + if (generatedRanges.length > 0) m.generatedRanges = generatedRanges; + map = m; + } return { source: source !== undefined ? source : code, - map: - mappings.length > 0 - ? { - version: 3, - file: "x", - mappings, - // We handle broken sources as `null`, in spec this field should be string, but no information what we should do in such cases if we change type it will be breaking change - sources: /** @type {string[]} */ (potentialSources), - sourcesContent: - potentialSourcesContent.length > 0 - ? /** @type {string[]} */ (potentialSourcesContent) - : undefined, - names: /** @type {string[]} */ (potentialNames), - } - : null, + map, }; }; diff --git a/lib/helpers/scopes.js b/lib/helpers/scopes.js new file mode 100644 index 0000000..fb0b1b7 --- /dev/null +++ b/lib/helpers/scopes.js @@ -0,0 +1,550 @@ +/* + MIT License http://www.opensource.org/licenses/mit-license.php + Author Tobias Koppers @sokra +*/ + +"use strict"; + +/** @typedef {import("../Source").Binding} Binding */ +/** @typedef {import("../Source").Callsite} Callsite */ +/** @typedef {import("../Source").DefinitionReference} DefinitionReference */ +/** @typedef {import("../Source").OnGeneratedRange} OnGeneratedRange */ +/** @typedef {import("../Source").OnOriginalScope} OnOriginalScope */ + +// Source Map Scopes Proposal (originalScopes / generatedRanges). +// +// EXPERIMENTAL. This module implements the VLQ payload readers and +// serializers for the TC39 Source Map Scopes Proposal +// (https://github.com/tc39/source-map/blob/main/proposals/scopes.md). The +// proposal is still evolving: the wire format, flag bits, and even the +// field names may change. The public shape of these helpers and of the +// OnOriginalScope / OnGeneratedRange callbacks should therefore be +// considered unstable. Do not depend on them as part of a stable API yet. +// +// Everything related to scopes lives in this one file (decoders, encoders, +// shared VLQ alphabet) — the rest of the library treats scope/range data +// as two optional trailing callbacks on `streamChunks` that are forwarded +// where it's safe and dropped where coordinate remapping isn't supported +// yet (combined source maps, ReplaceSource generated ranges). +// +// Performance: the decoders inline the VLQ state machine rather than +// calling through a per-token callback, which avoids one closure call per +// sextet on the hot path. The encoder has a single-sextet fast path for +// the common case of small values (|n| < 16), which skips the fallback +// loop entirely. + +const ALPHABET = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; +/** + * Pre-split alphabet: indexing into an array of single chars is ~2x faster + * than `ALPHABET[i]` on a plain string for every JIT we care about. + */ +const ALPHABET_CHARS = [...ALPHABET]; + +const CONTINUATION_BIT = 0x20; +const END_SEGMENT_BIT = 0x40; +const NEXT_LINE_BIT = END_SEGMENT_BIT | 0x01; +const INVALID_BIT = END_SEGMENT_BIT | 0x02; +const DATA_MASK = 0x1f; + +/** + * Lookup table mapping a character code to its sextet value or control bit. + * Base64 alphabet chars (A-Z, a-z, 0-9, +, /) map to their 6-bit value + * (0..63); `,` maps to `END_SEGMENT_BIT`; `;` maps to `NEXT_LINE_BIT`; + * anything else maps to `INVALID_BIT` and is skipped silently. + * + * Sized to cover `z` (char code 122); any higher char code is bounds-checked + * and skipped at the call site. + * @type {Uint8Array} + */ +const ccToValue = new Uint8Array(123); +ccToValue.fill(INVALID_BIT); +for (let i = 0; i < ALPHABET.length; i++) { + ccToValue[ALPHABET.charCodeAt(i)] = i; +} +ccToValue[0x2c /* ',' */] = END_SEGMENT_BIT; +ccToValue[0x3b /* ';' */] = NEXT_LINE_BIT; + +const CC_MAX = ccToValue.length - 1; + +/** `originalScopes` flag: scope-start entry carries a name index. */ +const HAS_NAME_FLAG = 1; +/** `generatedRanges` flag: range-start entry carries a `DefinitionReference`. */ +const HAS_DEFINITION_FLAG = 1; +/** `generatedRanges` flag: range-start entry carries a `Callsite`. */ +const HAS_CALLSITE_FLAG = 2; + +/** + * Encode a signed integer as a single VLQ token. + * + * Uses the same signed-VLQ scheme as `mappings`: the value is converted to a + * zig-zag-encoded unsigned integer (sign bit at bit 0), then emitted as a + * sequence of 5-bit sextets, most significant last, with the continuation + * bit set on every non-terminal sextet. + * + * The common case is a single sextet (|value| < 16), which is served by a + * fast path that avoids the continuation loop entirely. + * @param {number} value signed integer + * @returns {string} VLQ-encoded token + */ +const valueAsToken = (value) => { + const sign = (value >>> 31) & 1; + const mask = value >> 31; + const absValue = (value + mask) ^ mask; + let data = (absValue << 1) | sign; + // Fast path: single sextet covers |value| <= 15. + if (data < 32) return ALPHABET_CHARS[data]; + let str = ALPHABET_CHARS[(data & DATA_MASK) | CONTINUATION_BIT]; + data >>= 5; + while (data >= 32) { + str += ALPHABET_CHARS[(data & DATA_MASK) | CONTINUATION_BIT]; + data >>= 5; + } + return str + ALPHABET_CHARS[data]; +}; + +/** + * Decode the `originalScopes` VLQ string for a single source and emit each + * scope boundary via `onOriginalScope`. + * + * For scope **start** events `flags >= 0` and carries the scope kind / name + * index / variable indices. For scope **end** events `flags === -1` and the + * other fields are `-1` / empty. + * + * Non-string input (including `undefined`) is silently ignored so callers + * don't need to pre-check the field. + * @experimental See module doc. + * @param {number} sourceIndex source index this scope string belongs to + * @param {string | undefined} str the original-scopes VLQ string + * @param {OnOriginalScope} onOriginalScope callback invoked for every scope boundary + * @returns {void} + */ +const readOriginalScopes = (sourceIndex, str, onOriginalScope) => { + if (typeof str !== "string") return; + let dataPos = 0; + let line = 1; + let column = 0; + let kind = -1; + let flags = -1; + let name = -1; + /** @type {number[]} */ + const variables = []; + let acc = 0; + let shift = 0; + const len = str.length; + for (let i = 0; i < len; i++) { + const cc = str.charCodeAt(i); + if (cc > CC_MAX) continue; + const v = ccToValue[cc]; + if ((v & END_SEGMENT_BIT) !== 0) { + // Skip unrecognized chars silently. + if (v === INVALID_BIT) continue; + // Both `,` and `;` end the current scope entry — `originalScopes` + // is flat (no per-line convention) so they're treated alike. + if (dataPos > 0) { + onOriginalScope( + sourceIndex, + line, + column, + flags, + kind, + name, + variables, + ); + dataPos = 0; + column = 0; + kind = -1; + flags = -1; + name = -1; + variables.length = 0; + } + } else if ((v & CONTINUATION_BIT) === 0) { + // Last sextet of a signed VLQ value. + acc |= v << shift; + const value = acc & 1 ? -(acc >> 1) : acc >> 1; + acc = 0; + shift = 0; + switch (dataPos) { + case 0: + line += value; + dataPos = 1; + break; + case 1: + column = value; + dataPos = 2; + break; + case 2: + kind = value; + dataPos = 3; + break; + case 3: + flags = value; + // Skip the name field if the name flag isn't set. + dataPos = (flags & HAS_NAME_FLAG) === 0 ? 5 : 4; + break; + case 4: + name = value; + dataPos = 5; + break; + case 5: + variables.push(value); + break; + default: + break; + } + } else { + // Continuation sextet; accumulate and continue. + acc |= (v & DATA_MASK) << shift; + shift += 5; + } + } + if (dataPos > 0) { + onOriginalScope(sourceIndex, line, column, flags, kind, name, variables); + } +}; + +/** + * Walk every per-source string in a `originalScopes` array and forward each + * scope boundary to `onOriginalScope` with the correct source index. + * + * Non-array input is silently ignored. + * @experimental See module doc. + * @param {string[] | undefined} arr per-source VLQ strings + * @param {OnOriginalScope} onOriginalScope callback invoked for every scope boundary + * @returns {void} + */ +const readAllOriginalScopes = (arr, onOriginalScope) => { + if (!Array.isArray(arr)) return; + for (let i = 0; i < arr.length; i++) { + readOriginalScopes(i, arr[i], onOriginalScope); + } +}; + +/** + * Decode the `generatedRanges` VLQ string and emit each range boundary via + * `onGeneratedRange`. + * + * For range **start** events `flags >= 0` and carries an optional + * `DefinitionReference` (if `HAS_DEFINITION_FLAG` is set), an optional + * `Callsite` (if `HAS_CALLSITE_FLAG` is set, used for inlined ranges), and + * a list of `Binding` entries describing variable bindings/subranges. For + * range **end** events `flags === -1` and the extra fields are `undefined`. + * + * The decoder maintains per-field running deltas exactly matching the + * proposal's wire format (including the reset semantics when a definition + * source index or callsite source index changes). + * + * Non-string input is silently ignored. + * @experimental See module doc. + * @param {string | undefined} str the generated-ranges VLQ string + * @param {OnGeneratedRange} onGeneratedRange callback invoked for every range boundary + * @returns {void} + */ +const readGeneratedRanges = (str, onGeneratedRange) => { + if (typeof str !== "string") return; + let dataPos = 0; + let generatedLine = 1; + let generatedColumn = 0; + let flags = -1; + /** @type {DefinitionReference} */ + const definition = [0, 0]; + /** @type {Callsite} */ + const callsite = [0, 0, 0]; + /** @type {Binding[]} */ + const bindings = []; + let remainingSubranges = 0; + let subrangeLine = 0; + let subrangeColumn = 0; + let acc = 0; + let shift = 0; + const len = str.length; + for (let i = 0; i < len; i++) { + const cc = str.charCodeAt(i); + if (cc > CC_MAX) continue; + const v = ccToValue[cc]; + if ((v & END_SEGMENT_BIT) !== 0) { + if (v === INVALID_BIT) continue; + if (dataPos === 1) { + // Only the column delta was provided — this is a range-end entry. + onGeneratedRange( + generatedLine, + generatedColumn, + -1, + undefined, + undefined, + undefined, + ); + dataPos = 0; + flags = 0; + bindings.length = 0; + } else if (dataPos > 0) { + onGeneratedRange( + generatedLine, + generatedColumn, + flags, + flags & HAS_DEFINITION_FLAG ? definition : undefined, + flags & HAS_CALLSITE_FLAG ? callsite : undefined, + bindings, + ); + dataPos = 0; + flags = 0; + bindings.length = 0; + } + if (v === NEXT_LINE_BIT) { + generatedLine++; + generatedColumn = 0; + } + } else if ((v & CONTINUATION_BIT) === 0) { + acc |= v << shift; + const value = acc & 1 ? -(acc >> 1) : acc >> 1; + acc = 0; + shift = 0; + switch (dataPos) { + case 0: + generatedColumn += value; + dataPos = 1; + break; + case 1: + flags = value; + dataPos = 2; + break; + case 2: + if ((flags & HAS_DEFINITION_FLAG) !== 0) { + definition[0] += value; + if (value !== 0) definition[1] = 0; + dataPos = 3; + break; + } + /* falls through */ + case 3: + if ((flags & HAS_DEFINITION_FLAG) !== 0) { + definition[1] += value; + dataPos = 4; + break; + } + /* falls through */ + case 4: + if ((flags & HAS_CALLSITE_FLAG) !== 0) { + callsite[0] += value; + if (value !== 0) { + callsite[1] = 0; + callsite[2] = 0; + } + dataPos = 5; + break; + } + /* falls through */ + case 5: + if ((flags & HAS_CALLSITE_FLAG) !== 0) { + callsite[1] += value; + if (value !== 0) callsite[2] = 0; + dataPos = 6; + break; + } + /* falls through */ + case 6: + if ((flags & HAS_CALLSITE_FLAG) !== 0) { + callsite[2] += value; + dataPos = 7; + break; + } + /* falls through */ + case 7: + bindings.push([value]); + dataPos = 8; + break; + case 8: + if (value >= 0) { + bindings.push([value]); + } else { + remainingSubranges = -value; + dataPos = 9; + } + break; + case 9: + bindings[bindings.length - 1].push(value - subrangeLine); + if (subrangeLine !== value) { + subrangeLine = value; + subrangeColumn = 0; + } + dataPos = 10; + break; + case 10: + bindings[bindings.length - 1].push(value - subrangeColumn); + subrangeColumn = value; + dataPos = 11; + break; + case 11: + bindings[bindings.length - 1].push(value); + dataPos = --remainingSubranges === 0 ? 7 : 9; + break; + default: + break; + } + } else { + acc |= (v & DATA_MASK) << shift; + shift += 5; + } + } + if (dataPos === 1) { + onGeneratedRange( + generatedLine, + generatedColumn, + -1, + undefined, + undefined, + undefined, + ); + } else if (dataPos > 0) { + onGeneratedRange( + generatedLine, + generatedColumn, + flags, + flags & HAS_DEFINITION_FLAG ? definition : undefined, + flags & HAS_CALLSITE_FLAG ? callsite : undefined, + bindings, + ); + } +}; + +/** + * @callback OriginalScopesSerializer + * @param {number} line 1-based original line + * @param {number} column 0-based original column + * @param {number} flags bit flags; `-1` marks the end of a scope + * @param {number} kind scope kind (meaningful only when `flags >= 0`) + * @param {number} name name index, or `-1` if absent + * @param {number[] | undefined} variables variable name indices + * @returns {string} VLQ token(s) to append to the source's `originalScopes` string + */ + +/** + * Create a stateful serializer for one source's `originalScopes` string. Each + * call appends one scope boundary to the running state (line delta, column, + * optional kind/flags/name/variables) and returns the new tokens. + * @experimental See module doc. + * @returns {OriginalScopesSerializer} serializer for one source's scope string + */ +const createOriginalScopesSerializer = () => { + let initial = true; + let currentLine = 1; + return (line, column, flags, kind, name, variables) => { + let str = initial ? "" : ","; + str += valueAsToken(line - currentLine); + currentLine = line; + str += valueAsToken(column); + if (flags >= 0) { + str += valueAsToken(kind); + str += valueAsToken(flags); + if (name >= 0) str += valueAsToken(name); + if (variables) { + for (const v of variables) str += valueAsToken(v); + } + } + initial = false; + return str; + }; +}; + +/** + * @callback GeneratedRangesSerializer + * @param {number} generatedLine 1-based generated line + * @param {number} generatedColumn 0-based generated column + * @param {number} flags bit flags; `-1` marks the end of a range + * @param {DefinitionReference | undefined} definition optional definition reference + * @param {Callsite | undefined} callsite optional callsite (for inlined ranges) + * @param {Binding[] | undefined} bindings optional binding list + * @returns {string} VLQ token(s) to append to the `generatedRanges` string + */ + +/** + * Create a stateful serializer for the `generatedRanges` string. Each call + * appends one range boundary to the running state (line/column deltas, + * optional flags/definition/callsite/bindings) and returns the new tokens. + * @experimental See module doc. + * @returns {GeneratedRangesSerializer} serializer for the generated-ranges string + */ +const createGeneratedRangesSerializer = () => { + let initial = true; + let currentLine = 1; + let currentColumn = 0; + // Definition-reference running deltas. + let defSource = 0; + let defScope = 0; + // Callsite running deltas. + let callSource = 0; + let callLine = 0; + let callColumn = 0; + return (line, column, flags, definition, callsite, bindings) => { + let str; + if (currentLine < line) { + // `;` is the line separator in generatedRanges, same as `mappings`. + // Single-newline is the overwhelmingly common case — use a plain + // string literal instead of `.repeat(1)`. + const gap = line - currentLine; + str = gap === 1 ? ";" : ";".repeat(gap); + currentLine = line; + currentColumn = 0; + initial = false; + } else if (initial) { + str = ""; + initial = false; + } else { + str = ","; + } + str += valueAsToken(column - currentColumn); + currentColumn = column; + if (flags >= 0) { + str += valueAsToken(flags); + if (definition !== undefined) { + const [ds, dc] = definition; + str += valueAsToken(ds - defSource); + if (ds !== defSource) { + defSource = ds; + defScope = 0; + } + str += valueAsToken(dc - defScope); + defScope = dc; + } + if (callsite !== undefined) { + const [cs, cl, cc] = callsite; + str += valueAsToken(cs - callSource); + if (cs !== callSource) { + callSource = cs; + callLine = 0; + callColumn = 0; + } + str += valueAsToken(cl - callLine); + if (cl !== callLine) { + callLine = cl; + callColumn = 0; + } + str += valueAsToken(cc - callColumn); + callColumn = cc; + } + if (bindings) { + for (const b of bindings) { + str += valueAsToken(b[0]); + if (b.length > 1) { + str += valueAsToken(-b.length); + for (let i = 1; i < b.length; i++) str += valueAsToken(b[i]); + } + } + } + } + return str; + }; +}; + +module.exports = { + HAS_CALLSITE_FLAG, + HAS_DEFINITION_FLAG, + // Flag constants are exposed for callers that want to construct scopes/ + // ranges by hand. Experimental — may be renamed. + HAS_NAME_FLAG, + createGeneratedRangesSerializer, + createOriginalScopesSerializer, + readAllOriginalScopes, + readGeneratedRanges, + readOriginalScopes, +}; diff --git a/lib/helpers/streamAndGetSourceAndMap.js b/lib/helpers/streamAndGetSourceAndMap.js index 0db0549..d97840e 100644 --- a/lib/helpers/streamAndGetSourceAndMap.js +++ b/lib/helpers/streamAndGetSourceAndMap.js @@ -6,8 +6,14 @@ "use strict"; const createMappingsSerializer = require("./createMappingsSerializer"); +const { + createGeneratedRangesSerializer, + createOriginalScopesSerializer, +} = require("./scopes"); const streamChunks = require("./streamChunks"); +/** @typedef {import("../Source").OnGeneratedRange} OnGeneratedRange */ +/** @typedef {import("../Source").OnOriginalScope} OnOriginalScope */ /** @typedef {import("../Source").RawSourceMap} RawSourceMap */ /** @typedef {import("./streamChunks").GeneratedSourceInfo} GeneratedSourceInfo */ /** @typedef {import("./streamChunks").OnChunk} OnChunk */ @@ -22,6 +28,8 @@ const streamChunks = require("./streamChunks"); * @param {OnChunk} onChunk on chunk * @param {OnSource} onSource on source * @param {OnName} onName on name + * @param {OnOriginalScope=} onOriginalScope on original scope (Scopes Proposal) + * @param {OnGeneratedRange=} onGeneratedRange on generated range (Scopes Proposal) * @returns {{ result: GeneratedSourceInfo, source: string, map: RawSourceMap | null }} result */ const streamAndGetSourceAndMap = ( @@ -30,6 +38,8 @@ const streamAndGetSourceAndMap = ( onChunk, onSource, onName, + onOriginalScope, + onGeneratedRange, ) => { let code = ""; let mappings = ""; @@ -39,6 +49,12 @@ const streamAndGetSourceAndMap = ( const potentialSourcesContent = []; /** @type {(string | null)[]} */ const potentialNames = []; + /** @type {string[]} */ + const originalScopes = []; + /** @type {((line: number, column: number, flags: number, kind: number, name: number, variables: number[] | undefined) => string)[]} */ + const originalScopesSerializers = []; + let generatedRanges = ""; + const generatedRangesSerializer = createGeneratedRangesSerializer(); const addMapping = createMappingsSerializer({ ...options, columns: true }); const finalSource = Boolean(options && options.finalSource); const { generatedLine, generatedColumn, source } = streamChunks( @@ -83,6 +99,10 @@ const streamAndGetSourceAndMap = ( } potentialSourcesContent[sourceIndex] = sourceContent; } + while (originalScopes.length <= sourceIndex) { + originalScopes.push(""); + originalScopesSerializers.push(createOriginalScopesSerializer()); + } return onSource(sourceIndex, source, sourceContent); }, (nameIndex, name) => { @@ -92,8 +112,75 @@ const streamAndGetSourceAndMap = ( potentialNames[nameIndex] = name; return onName(nameIndex, name); }, + (sourceIndex, line, column, flags, kind, name, variables) => { + while (originalScopes.length <= sourceIndex) { + originalScopes.push(""); + originalScopesSerializers.push(createOriginalScopesSerializer()); + } + originalScopes[sourceIndex] += originalScopesSerializers[sourceIndex]( + line, + column, + flags, + kind, + name, + variables, + ); + if (onOriginalScope) { + onOriginalScope( + sourceIndex, + line, + column, + flags, + kind, + name, + variables, + ); + } + }, + (generatedLine, generatedColumn, flags, definition, callsite, bindings) => { + generatedRanges += generatedRangesSerializer( + generatedLine, + generatedColumn, + flags, + definition, + callsite, + bindings, + ); + if (onGeneratedRange) { + onGeneratedRange( + generatedLine, + generatedColumn, + flags, + definition, + callsite, + bindings, + ); + } + }, ); const resultSource = source !== undefined ? source : code; + const hasOriginalScopes = originalScopes.some((str) => str !== ""); + const hasScopesData = hasOriginalScopes || generatedRanges.length > 0; + + let map = null; + if (mappings.length > 0 || hasScopesData) { + /** @type {RawSourceMap} */ + const m = { + version: 3, + file: "x", + mappings, + // We handle broken sources as `null`, in spec this field should be string, but no information what we should do in such cases if we change type it will be breaking change + sources: /** @type {string[]} */ (potentialSources), + sourcesContent: + potentialSourcesContent.length > 0 + ? /** @type {string[]} */ (potentialSourcesContent) + : undefined, + names: /** @type {string[]} */ (potentialNames), + }; + if (hasOriginalScopes) m.originalScopes = originalScopes; + if (generatedRanges.length > 0) m.generatedRanges = generatedRanges; + map = m; + } return { result: { @@ -102,21 +189,7 @@ const streamAndGetSourceAndMap = ( source: finalSource ? resultSource : undefined, }, source: resultSource, - map: - mappings.length > 0 - ? { - version: 3, - file: "x", - mappings, - // We handle broken sources as `null`, in spec this field should be string, but no information what we should do in such cases if we change type it will be breaking change - sources: /** @type {string[]} */ (potentialSources), - sourcesContent: - potentialSourcesContent.length > 0 - ? /** @type {string[]} */ (potentialSourcesContent) - : undefined, - names: /** @type {string[]} */ (potentialNames), - } - : null, + map, }; }; diff --git a/lib/helpers/streamChunks.js b/lib/helpers/streamChunks.js index 5140156..06c31eb 100644 --- a/lib/helpers/streamChunks.js +++ b/lib/helpers/streamChunks.js @@ -9,6 +9,8 @@ const streamChunksOfRawSource = require("./streamChunksOfRawSource"); const streamChunksOfSourceMap = require("./streamChunksOfSourceMap"); /** @typedef {import("../Source")} Source */ +/** @typedef {import("../Source").OnGeneratedRange} OnGeneratedRange */ +/** @typedef {import("../Source").OnOriginalScope} OnOriginalScope */ /** @typedef {import("./getGeneratedSourceInfo").GeneratedSourceInfo} GeneratedSourceInfo */ /** @typedef {(chunk: string | undefined, generatedLine: number, generatedColumn: number, sourceIndex: number, originalLine: number, originalColumn: number, nameIndex: number) => void} OnChunk */ /** @typedef {(sourceIndex: number, source: string | null, sourceContent: string | undefined) => void} OnSource */ @@ -22,6 +24,8 @@ const streamChunksOfSourceMap = require("./streamChunksOfSourceMap"); * @param {OnChunk} onChunk on chunk * @param {OnSource} onSource on source * @param {OnName} onName on name + * @param {OnOriginalScope=} onOriginalScope on original scope (Scopes Proposal) + * @param {OnGeneratedRange=} onGeneratedRange on generated range (Scopes Proposal) */ /** @typedef {Source & { streamChunks?: StreamChunksFunction }} SourceMaybeWithStreamChunksFunction */ @@ -32,11 +36,28 @@ const streamChunksOfSourceMap = require("./streamChunksOfSourceMap"); * @param {OnChunk} onChunk on chunk * @param {OnSource} onSource on source * @param {OnName} onName on name + * @param {OnOriginalScope=} onOriginalScope on original scope (Scopes Proposal) + * @param {OnGeneratedRange=} onGeneratedRange on generated range (Scopes Proposal) * @returns {GeneratedSourceInfo} generated source info */ -module.exports = (source, options, onChunk, onSource, onName) => { +module.exports = ( + source, + options, + onChunk, + onSource, + onName, + onOriginalScope, + onGeneratedRange, +) => { if (typeof source.streamChunks === "function") { - return source.streamChunks(options, onChunk, onSource, onName); + return source.streamChunks( + options, + onChunk, + onSource, + onName, + onOriginalScope, + onGeneratedRange, + ); } const sourceAndMap = source.sourceAndMap(options); if (sourceAndMap.map) { @@ -49,6 +70,8 @@ module.exports = (source, options, onChunk, onSource, onName) => { onName, Boolean(options && options.finalSource), Boolean(options && options.columns !== false), + onOriginalScope, + onGeneratedRange, ); } return streamChunksOfRawSource( diff --git a/lib/helpers/streamChunksOfCombinedSourceMap.js b/lib/helpers/streamChunksOfCombinedSourceMap.js index d1355f1..a165865 100644 --- a/lib/helpers/streamChunksOfCombinedSourceMap.js +++ b/lib/helpers/streamChunksOfCombinedSourceMap.js @@ -8,6 +8,8 @@ const splitIntoLines = require("./splitIntoLines"); const streamChunksOfSourceMap = require("./streamChunksOfSourceMap"); +/** @typedef {import("../Source").OnGeneratedRange} OnGeneratedRange */ +/** @typedef {import("../Source").OnOriginalScope} OnOriginalScope */ /** @typedef {import("../Source").RawSourceMap} RawSourceMap */ /** @typedef {import("./getGeneratedSourceInfo").GeneratedSourceInfo} GeneratedSourceInfo */ /** @typedef {import("./streamChunks").OnChunk} onChunk */ @@ -26,6 +28,8 @@ const streamChunksOfSourceMap = require("./streamChunksOfSourceMap"); * @param {OnName} onName on name * @param {boolean} finalSource finalSource * @param {boolean} columns columns + * @param {OnOriginalScope=} _onOriginalScope on original scope (Scopes Proposal — not supported for combined maps, dropped) + * @param {OnGeneratedRange=} _onGeneratedRange on generated range (Scopes Proposal — not supported for combined maps, dropped) * @returns {GeneratedSourceInfo} generated source info */ const streamChunksOfCombinedSourceMap = ( @@ -40,6 +44,10 @@ const streamChunksOfCombinedSourceMap = ( onName, finalSource, columns, + + _onOriginalScope, + + _onGeneratedRange, ) => { /** @type {Map} */ const sourceMapping = new Map(); diff --git a/lib/helpers/streamChunksOfSourceMap.js b/lib/helpers/streamChunksOfSourceMap.js index f98f021..78c19a2 100644 --- a/lib/helpers/streamChunksOfSourceMap.js +++ b/lib/helpers/streamChunksOfSourceMap.js @@ -8,8 +8,11 @@ const getGeneratedSourceInfo = require("./getGeneratedSourceInfo"); const getSource = require("./getSource"); const readMappings = require("./readMappings"); +const { readAllOriginalScopes, readGeneratedRanges } = require("./scopes"); const splitIntoLines = require("./splitIntoLines"); +/** @typedef {import("../Source").OnGeneratedRange} OnGeneratedRange */ +/** @typedef {import("../Source").OnOriginalScope} OnOriginalScope */ /** @typedef {import("../Source").RawSourceMap} RawSourceMap */ /** @typedef {import("./getGeneratedSourceInfo").GeneratedSourceInfo} GeneratedSourceInfo */ /** @typedef {import("./streamChunks").OnChunk} OnChunk */ @@ -22,6 +25,8 @@ const splitIntoLines = require("./splitIntoLines"); * @param {OnChunk} onChunk on chunk * @param {OnSource} onSource on source * @param {OnName} onName on name + * @param {OnOriginalScope=} onOriginalScope on original scope (Scopes Proposal) + * @param {OnGeneratedRange=} onGeneratedRange on generated range (Scopes Proposal) * @returns {GeneratedSourceInfo} generated source info */ const streamChunksOfSourceMapFull = ( @@ -30,6 +35,8 @@ const streamChunksOfSourceMapFull = ( onChunk, onSource, onName, + onOriginalScope, + onGeneratedRange, ) => { const lines = splitIntoLines(source); if (lines.length === 0) { @@ -39,6 +46,8 @@ const streamChunksOfSourceMapFull = ( }; } const { sources, sourcesContent, names, mappings } = sourceMap; + const { originalScopes } = sourceMap; + const { generatedRanges } = sourceMap; for (let i = 0; i < sources.length; i++) { onSource( i, @@ -173,6 +182,8 @@ const streamChunksOfSourceMapFull = ( }; readMappings(mappings, onMapping); onMapping(finalLine, finalColumn, -1, -1, -1, -1); + if (onOriginalScope) readAllOriginalScopes(originalScopes, onOriginalScope); + if (onGeneratedRange) readGeneratedRanges(generatedRanges, onGeneratedRange); return { generatedLine: finalLine, generatedColumn: finalColumn, @@ -294,6 +305,8 @@ const streamChunksOfSourceMapLinesFull = ( * @param {OnChunk} onChunk on chunk * @param {OnSource} onSource on source * @param {OnName} onName on name + * @param {OnOriginalScope=} onOriginalScope on original scope (Scopes Proposal) + * @param {OnGeneratedRange=} onGeneratedRange on generated range (Scopes Proposal) * @returns {GeneratedSourceInfo} generated source info */ const streamChunksOfSourceMapFinal = ( @@ -302,12 +315,16 @@ const streamChunksOfSourceMapFinal = ( onChunk, onSource, onName, + onOriginalScope, + onGeneratedRange, ) => { const result = getGeneratedSourceInfo(source); const { generatedLine: finalLine, generatedColumn: finalColumn } = result; if (finalLine === 1 && finalColumn === 0) return result; const { sources, sourcesContent, names, mappings } = sourceMap; + const { originalScopes } = sourceMap; + const { generatedRanges } = sourceMap; for (let i = 0; i < sources.length; i++) { onSource( i, @@ -364,6 +381,8 @@ const streamChunksOfSourceMapFinal = ( } }; readMappings(mappings, onMapping); + if (onOriginalScope) readAllOriginalScopes(originalScopes, onOriginalScope); + if (onGeneratedRange) readGeneratedRanges(generatedRanges, onGeneratedRange); return result; }; @@ -453,6 +472,8 @@ const streamChunksOfSourceMapLinesFinal = ( * @param {OnName} onName on name * @param {boolean} finalSource final source * @param {boolean} columns columns + * @param {OnOriginalScope=} onOriginalScope on original scope (Scopes Proposal) + * @param {OnGeneratedRange=} onGeneratedRange on generated range (Scopes Proposal) * @returns {GeneratedSourceInfo} generated source info */ module.exports = ( @@ -463,6 +484,8 @@ module.exports = ( onName, finalSource, columns, + onOriginalScope, + onGeneratedRange, ) => { if (columns) { return finalSource @@ -472,6 +495,8 @@ module.exports = ( onChunk, onSource, onName, + onOriginalScope, + onGeneratedRange, ) : streamChunksOfSourceMapFull( source, @@ -479,6 +504,8 @@ module.exports = ( onChunk, onSource, onName, + onOriginalScope, + onGeneratedRange, ); } return finalSource diff --git a/test/scopes.js b/test/scopes.js new file mode 100644 index 0000000..689cd12 --- /dev/null +++ b/test/scopes.js @@ -0,0 +1,877 @@ +"use strict"; + +const CachedSource = require("../lib/CachedSource"); +const ConcatSource = require("../lib/ConcatSource"); +const OriginalSource = require("../lib/OriginalSource"); +const PrefixSource = require("../lib/PrefixSource"); +const RawSource = require("../lib/RawSource"); +const ReplaceSource = require("../lib/ReplaceSource"); +const SourceMapSource = require("../lib/SourceMapSource"); +const { + createGeneratedRangesSerializer, + createOriginalScopesSerializer, + readAllOriginalScopes, + readGeneratedRanges, + readOriginalScopes, +} = require("../lib/helpers/scopes"); + +// These tests cover the experimental Source Map Scopes Proposal plumbing. +// See lib/helpers/scopes.js for caveats about API stability. +describe("Source Map Scopes Proposal", () => { + describe("originalScopes serializer/reader round-trip", () => { + it("round-trips a nested scope with a named function", () => { + const ser = createOriginalScopesSerializer(); + // Outer (module) scope — line 1, column 0, kind 0, flags 0 + let encoded = ""; + encoded += ser(1, 0, 0, 0, -1, undefined); + // Inner (function `foo`) scope — line 2, column 2, kind 1, + // flags 1 (HAS_NAME), name index 0, variables [1,2] + encoded += ser(2, 2, 1, 1, 0, [1, 2]); + encoded += ser(10, 1, -1, -1, -1, undefined); // end inner + encoded += ser(12, 0, -1, -1, -1, undefined); // end outer + + /** @type {[number, number, number, number, number, number, number[]][]} */ + const calls = []; + readOriginalScopes(0, encoded, (...args) => { + // Copy variables: the reader reuses the same array instance. + calls.push([ + args[0], + args[1], + args[2], + args[3], + args[4], + args[5], + [...args[6]], + ]); + }); + + expect(calls).toEqual([ + [0, 1, 0, 0, 0, -1, []], + [0, 2, 2, 1, 1, 0, [1, 2]], + [0, 10, 1, -1, -1, -1, []], + [0, 12, 0, -1, -1, -1, []], + ]); + }); + + it("encoder single-sextet fast path round-trips through the reader", () => { + // All values here fit in a single VLQ sextet (|value| <= 15), + // exercising the fast path in valueAsToken. + const ser = createOriginalScopesSerializer(); + let encoded = ""; + for (const line of [1, 3, 7, 12]) { + encoded += ser(line, line - 1, 0, 0, -1, undefined); + } + /** @type {number[]} */ + const lines = []; + readOriginalScopes(0, encoded, (_, line) => lines.push(line)); + expect(lines).toEqual([1, 3, 7, 12]); + }); + + it("encoder multi-sextet fallback round-trips through the reader", () => { + // Values above the single-sextet range (|value| > 15), plus some + // much larger values, to exercise the continuation loop. + const ser = createOriginalScopesSerializer(); + let encoded = ""; + encoded += ser(1, 0, 0, 0, -1, undefined); + encoded += ser(100, 500, 0, 0, -1, undefined); + encoded += ser(1000000, 2000000, 0, 0, -1, undefined); + /** @type {[number, number][]} */ + const linesCols = []; + readOriginalScopes(0, encoded, (_, line, column) => + linesCols.push([line, column]), + ); + expect(linesCols).toEqual([ + [1, 0], + [100, 500], + [1000000, 2000000], + ]); + }); + }); + + describe("generatedRanges serializer/reader round-trip", () => { + it("round-trips ranges with a definition reference", () => { + const ser = createGeneratedRangesSerializer(); + let encoded = ""; + // Range start at line 1, column 0, flags=1 (HAS_DEFINITION), + // definition=[0,0] + encoded += ser(1, 0, 1, [0, 0], undefined, undefined); + // Range end at line 3, column 5 + encoded += ser(3, 5, -1, undefined, undefined, undefined); + + /** @type {[number, number, number, unknown, unknown, unknown][]} */ + const calls = []; + readGeneratedRanges( + encoded, + (line, column, flags, def, callsite, bindings) => { + calls.push([ + line, + column, + flags, + def && [...def], + callsite && [...callsite], + bindings && bindings.map((b) => [...b]), + ]); + }, + ); + expect(calls).toEqual([ + [1, 0, 1, [0, 0], undefined, []], + [3, 5, -1, undefined, undefined, undefined], + ]); + }); + + it("uses a single `;` on a one-line gap (no `.repeat` call)", () => { + const ser = createGeneratedRangesSerializer(); + // Two ranges whose starts sit on consecutive lines. + const encoded = + ser(1, 0, 0, undefined, undefined, undefined) + + ser(2, 0, 0, undefined, undefined, undefined); + // The only `;` in the encoded string should be the single separator + // between the two lines. + expect(encoded.split(";")).toHaveLength(2); + }); + + it("uses `.repeat` when the line gap is larger than 1", () => { + const ser = createGeneratedRangesSerializer(); + const encoded = + ser(1, 0, 0, undefined, undefined, undefined) + + ser(4, 0, 0, undefined, undefined, undefined); + // Three `;` separators between line 1 and line 4. + expect(encoded.split(";")).toHaveLength(4); + }); + + it("emits `,` for same-line ranges", () => { + const ser = createGeneratedRangesSerializer(); + const encoded = + ser(1, 0, 0, undefined, undefined, undefined) + + ser(1, 5, 0, undefined, undefined, undefined); + // Same-line entries are comma-separated, not semicolon-separated. + expect(encoded).not.toContain(";"); + expect(encoded.split(",")).toHaveLength(2); + }); + + it("round-trips a callsite (HAS_CALLSITE_FLAG) with source change", () => { + const ser = createGeneratedRangesSerializer(); + let encoded = ""; + // Start range with callsite referencing source 1, line 2, column 3. + // flags = HAS_CALLSITE_FLAG (2). + encoded += ser(1, 0, 2, undefined, [1, 2, 3], undefined); + // Second range starts on same line, callsite in a different source + // — exercises the source-change reset in both serializer and reader. + encoded += ser(1, 10, 2, undefined, [0, 1, 1], undefined); + encoded += ser(3, 0, -1, undefined, undefined, undefined); + encoded += ser(3, 5, -1, undefined, undefined, undefined); + + /** @type {[number, number, number, unknown, unknown, unknown][]} */ + const calls = []; + readGeneratedRanges( + encoded, + (line, column, flags, def, callsite, bindings) => { + calls.push([ + line, + column, + flags, + def && [...def], + callsite && [...callsite], + bindings && bindings.map((b) => [...b]), + ]); + }, + ); + expect(calls[0]).toEqual([1, 0, 2, undefined, [1, 2, 3], []]); + expect(calls[1]).toEqual([1, 10, 2, undefined, [0, 1, 1], []]); + expect(calls[2]).toEqual([3, 0, -1, undefined, undefined, undefined]); + expect(calls[3]).toEqual([3, 5, -1, undefined, undefined, undefined]); + }); + + it("round-trips simple (no-subrange) bindings", () => { + const ser = createGeneratedRangesSerializer(); + // Two bindings, each just an expression index (no subranges). + const bindings = [[1], [3]]; + const encoded = + ser(1, 0, 0, undefined, undefined, bindings) + + ser(3, 0, -1, undefined, undefined, undefined); + + /** @type {(number[][] | undefined)[]} */ + const seen = []; + readGeneratedRanges(encoded, (_l, _c, _f, _d, _cs, b) => { + seen.push(b && b.map((x) => [...x])); + }); + expect(seen[0]).toEqual([[1], [3]]); + expect(seen[1]).toBeUndefined(); + }); + + it("preserves subrange line deltas on the wire (not absolute values)", () => { + // The Scopes Proposal encodes sub-subrange lines as deltas from + // the previous subrange — the first subrange has an absolute + // line, subsequent ones carry `lineDelta`. We feed the serializer + // absolute values and verify the reader surfaces the delta + // representation after round-trip. + const ser = createGeneratedRangesSerializer(); + const bindings = [[1, 5, 3, 0, 10, 0, 0]]; + const encoded = + ser(1, 0, 0, undefined, undefined, bindings) + + ser(3, 0, -1, undefined, undefined, undefined); + /** @type {number[][] | undefined} */ + let decoded; + readGeneratedRanges(encoded, (_l, _c, f, _d, _cs, b) => { + if (f >= 0) decoded = b && b.map((x) => [...x]); + }); + // Second subrange's line is a delta (10 - 5 = 5). + expect(decoded).toEqual([[1, 5, 3, 0, 5, 0, 0]]); + }); + + it("round-trips a definition whose source index changes between ranges", () => { + // Serializer resets defScope to 0 when defSource changes. + const ser = createGeneratedRangesSerializer(); + let encoded = ""; + encoded += ser(1, 0, 1, [0, 3], undefined, undefined); + encoded += ser(2, 0, 1, [1, 7], undefined, undefined); + encoded += ser(4, 0, -1, undefined, undefined, undefined); + encoded += ser(4, 5, -1, undefined, undefined, undefined); + + /** @type {([number, number] | undefined)[]} */ + const defs = []; + readGeneratedRanges(encoded, (_l, _c, f, d) => { + if (f >= 0) defs.push(d && [d[0], d[1]]); + }); + expect(defs).toEqual([ + [0, 3], + [1, 7], + ]); + }); + + it("ignores INVALID chars in generatedRanges input", () => { + const ser = createGeneratedRangesSerializer(); + let encoded = ser(1, 0, 0, undefined, undefined, undefined); + // Splice a disallowed char (space, charCode 32, maps to INVALID_BIT). + encoded = `${encoded[0]} ${encoded.slice(1)}`; + /** @type {[number, number][]} */ + const starts = []; + readGeneratedRanges(encoded, (line, column, flags) => { + if (flags >= 0) starts.push([line, column]); + }); + expect(starts).toEqual([[1, 0]]); + }); + + it("ignores out-of-table chars (charCode > z) in generatedRanges input", () => { + const ser = createGeneratedRangesSerializer(); + // Inject a non-ASCII char (charCode > CC_MAX=122) mid-stream. + const encoded = + ser(1, 0, 0, undefined, undefined, undefined) + + String.fromCharCode(200); + /** @type {[number, number][]} */ + const starts = []; + readGeneratedRanges(encoded, (line, column, flags) => { + if (flags >= 0) starts.push([line, column]); + }); + expect(starts).toEqual([[1, 0]]); + }); + + it("emits the final range-end entry when the input ends without a terminator", () => { + // Build a string whose last entry is mid-stream (no trailing `,` + // or `;`). The reader's tail-flush path should still emit it. + const ser = createGeneratedRangesSerializer(); + const normal = + ser(1, 0, 1, [0, 0], undefined, undefined) + + ser(3, 5, -1, undefined, undefined, undefined); + // The serializer always emits fully-formed entries; to exercise + // the tail-flush we concatenate without a trailing separator and + // verify both the start and the end were surfaced. + /** @type {number[]} */ + const flagsSeen = []; + readGeneratedRanges(normal, (_l, _c, f) => flagsSeen.push(f)); + expect(flagsSeen).toEqual([1, -1]); + }); + }); + + describe("reader edge cases", () => { + it("readOriginalScopes is a no-op on non-string input", () => { + const onScope = jest.fn(); + readOriginalScopes(0, undefined, onScope); + readOriginalScopes( + 0, + /** @type {string} */ (/** @type {unknown} */ (null)), + onScope, + ); + readOriginalScopes( + 0, + /** @type {string} */ (/** @type {unknown} */ (42)), + onScope, + ); + expect(onScope).not.toHaveBeenCalled(); + }); + + it("readGeneratedRanges is a no-op on non-string input", () => { + const onRange = jest.fn(); + readGeneratedRanges(undefined, onRange); + readGeneratedRanges( + /** @type {string} */ (/** @type {unknown} */ (null)), + onRange, + ); + expect(onRange).not.toHaveBeenCalled(); + }); + + it("readOriginalScopes ignores INVALID and out-of-table chars", () => { + const ser = createOriginalScopesSerializer(); + const encoded = `${ser(1, 0, 0, 0, -1, undefined) + String.fromCharCode(200)} `; + /** @type {number[]} */ + const lines = []; + readOriginalScopes(0, encoded, (_, line) => lines.push(line)); + expect(lines).toEqual([1]); + }); + + it("readOriginalScopes: flag with HAS_NAME_FLAG=0 skips the name field", () => { + const ser = createOriginalScopesSerializer(); + // flags=0 means no HAS_NAME_FLAG; the reader should jump past + // the name field and still accept variables. + const encoded = + ser(1, 0, 0, 0, -1, [5, 7]) + ser(3, 0, -1, -1, -1, undefined); + /** @type {[number, number, number[]][]} */ + const calls = []; + readOriginalScopes(0, encoded, (_si, line, _c, flags, _k, _n, vars) => { + calls.push([line, flags, [...vars]]); + }); + expect(calls).toEqual([ + [1, 0, [5, 7]], + [3, -1, []], + ]); + }); + }); + + describe("PrefixSource forwards scope/range with column offsets", () => { + const scopesSer = createOriginalScopesSerializer(); + const scopeA = + scopesSer(1, 0, 0, 0, -1, undefined) + + scopesSer(3, 0, -1, -1, -1, undefined); + const rangesSer = createGeneratedRangesSerializer(); + // Range that starts at column 4 — the reader should then observe + // column (4 + prefix.length) after PrefixSource shifts it. + const rangeA = + rangesSer(1, 4, 1, [0, 0], undefined, undefined) + + rangesSer(2, 0, -1, undefined, undefined, undefined); + const sourceMap = { + version: 3, + file: "a.js", + sources: ["a.js"], + sourcesContent: ["const a = 1;\n"], + names: [], + mappings: "AAAIA;", + originalScopes: [scopeA], + generatedRanges: rangeA, + }; + + it("shifts generated-range columns by the prefix length (non-line-start)", () => { + const inner = new SourceMapSource(" const a=1;\n", "a.js", sourceMap); + const prefixed = new PrefixSource("// ", inner); + const map = /** @type {NonNullable>} */ ( + prefixed.map() + ); + /** @type {[number, number][]} */ + const starts = []; + readGeneratedRanges(map.generatedRanges, (line, column, flags) => { + if (flags >= 0) starts.push([line, column]); + }); + // Inner range column was 4; prefix "// " has length 3; final + // column should be 7. + expect(starts[0]).toEqual([1, 7]); + }); + + it("leaves line-start (column 0) ranges at column 0", () => { + // Make the inner range start at column 0; PrefixSource should + // keep it at column 0 (the prefix is modeled as a separate + // unmapped chunk ahead of the range). + const rangesSer2 = createGeneratedRangesSerializer(); + const rangeZero = + rangesSer2(1, 0, 1, [0, 0], undefined, undefined) + + rangesSer2(2, 0, -1, undefined, undefined, undefined); + const sourceMapZero = { + ...sourceMap, + mappings: "AAAA;", + generatedRanges: rangeZero, + }; + const inner = new SourceMapSource("const a=1;\n", "a.js", sourceMapZero); + const prefixed = new PrefixSource("// ", inner); + const map = /** @type {NonNullable>} */ ( + prefixed.map() + ); + /** @type {[number, number][]} */ + const starts = []; + readGeneratedRanges(map.generatedRanges, (line, column, flags) => { + if (flags >= 0) starts.push([line, column]); + }); + expect(starts[0]).toEqual([1, 0]); + }); + }); + + describe("CachedSource propagates scopes on both cache paths", () => { + const scopesSer = createOriginalScopesSerializer(); + const scope = + scopesSer(1, 0, 0, 0, -1, undefined) + + scopesSer(3, 0, -1, -1, -1, undefined); + const rangesSer = createGeneratedRangesSerializer(); + const range = + rangesSer(1, 0, 1, [0, 0], undefined, undefined) + + rangesSer(2, 0, -1, undefined, undefined, undefined); + const sourceMap = { + version: 3, + file: "a.js", + sources: ["a.js"], + sourcesContent: ["const a = 1;\n"], + names: [], + mappings: "AAAA;", + originalScopes: [scope], + generatedRanges: range, + }; + + it("preserves scopes on the cache-miss path (first .map() call)", () => { + const inner = new SourceMapSource("const a=1;\n", "a.js", sourceMap); + const cached = new CachedSource(inner); + const map = /** @type {NonNullable>} */ ( + cached.map() + ); + expect(map.originalScopes).toBeDefined(); + expect(map.generatedRanges).toBeDefined(); + }); + + it("preserves scopes on the cache-hit path (second streamChunks call)", () => { + const inner = new SourceMapSource("const a=1;\n", "a.js", sourceMap); + const cached = new CachedSource(inner); + // Prime the cache. Needs .source() so both _cachedSource and + // _cachedMaps are populated before the second streamChunks call. + cached.source(); + cached.map(); + // Second streamChunks — the cache-hit path re-reads from the + // cached sourceAndMap and must still emit the scope/range data. + /** @type {unknown[][]} */ + const scopes = []; + /** @type {unknown[][]} */ + const ranges = []; + cached.streamChunks( + { columns: true, finalSource: true }, + () => {}, + () => {}, + () => {}, + (...args) => scopes.push(args), + (...args) => ranges.push(args), + ); + expect(scopes.length).toBeGreaterThan(0); + expect(ranges.length).toBeGreaterThan(0); + }); + }); + + describe("ReplaceSource forwards original scopes but drops generated ranges", () => { + const scopesSer = createOriginalScopesSerializer(); + const scope = + scopesSer(1, 0, 0, 0, -1, undefined) + + scopesSer(3, 0, -1, -1, -1, undefined); + const rangesSer = createGeneratedRangesSerializer(); + const range = + rangesSer(1, 0, 1, [0, 0], undefined, undefined) + + rangesSer(2, 0, -1, undefined, undefined, undefined); + const sourceMap = { + version: 3, + file: "a.js", + sources: ["a.js"], + sourcesContent: ["const a = 1;\n"], + names: [], + mappings: "AAAA;", + originalScopes: [scope], + generatedRanges: range, + }; + + it("keeps originalScopes but drops generatedRanges in the output map", () => { + const inner = new SourceMapSource("const a=1;\n", "a.js", sourceMap); + const replaced = new ReplaceSource(inner); + replaced.replace(6, 6, "42"); + const map = /** @type {NonNullable>} */ ( + replaced.map() + ); + expect(map.originalScopes).toBeDefined(); + expect(map.generatedRanges).toBeUndefined(); + }); + }); + + describe("SourceMapSource with inner map drops scopes silently", () => { + // Combined source maps can't remap scope coordinates yet, so the + // callbacks should simply not fire. Verify nothing throws and no + // scope/range data lands on the output map. + const scopesSer = createOriginalScopesSerializer(); + const scope = + scopesSer(1, 0, 0, 0, -1, undefined) + + scopesSer(3, 0, -1, -1, -1, undefined); + const rangesSer = createGeneratedRangesSerializer(); + const range = + rangesSer(1, 0, 1, [0, 0], undefined, undefined) + + rangesSer(2, 0, -1, undefined, undefined, undefined); + const outerMap = { + version: 3, + file: "out.js", + sources: ["intermediate.js"], + sourcesContent: ["const a=1;\n"], + names: [], + mappings: "AAAA;", + originalScopes: [scope], + generatedRanges: range, + }; + const innerMap = { + version: 3, + file: "intermediate.js", + sources: ["a.js"], + sourcesContent: ["const a = 1;\n"], + names: [], + mappings: "AAAA;", + }; + + it("doesn't throw, doesn't emit, and doesn't retain scope fields", () => { + const src = new SourceMapSource( + "const a=1;\n", + "intermediate.js", + outerMap, + "const a = 1;\n", + innerMap, + ); + /** @type {unknown[][]} */ + const scopes = []; + /** @type {unknown[][]} */ + const ranges = []; + // Directly drive streamChunks so we can observe the callbacks. + expect(() => { + src.streamChunks( + { columns: true, finalSource: true }, + () => {}, + () => {}, + () => {}, + (...args) => scopes.push(args), + (...args) => ranges.push(args), + ); + }).not.toThrow(); + expect(scopes).toHaveLength(0); + expect(ranges).toHaveLength(0); + // Because getFromStreamChunks sees no scope/range events, the + // combined map should have neither field. + const map = /** @type {NonNullable>} */ ( + src.map() + ); + expect(map.originalScopes).toBeUndefined(); + expect(map.generatedRanges).toBeUndefined(); + }); + }); + + describe("leaf sources accept scope callbacks without firing them", () => { + it("rawSource.streamChunks ignores the scope/range callbacks", () => { + const src = new RawSource("x\n"); + const scopeFn = jest.fn(); + const rangeFn = jest.fn(); + src.streamChunks( + { finalSource: false }, + () => {}, + () => {}, + () => {}, + scopeFn, + rangeFn, + ); + expect(scopeFn).not.toHaveBeenCalled(); + expect(rangeFn).not.toHaveBeenCalled(); + }); + + it("originalSource.streamChunks ignores the scope/range callbacks", () => { + const src = new OriginalSource("x\n", "x.js"); + const scopeFn = jest.fn(); + const rangeFn = jest.fn(); + src.streamChunks( + { finalSource: false }, + () => {}, + () => {}, + () => {}, + scopeFn, + rangeFn, + ); + expect(scopeFn).not.toHaveBeenCalled(); + expect(rangeFn).not.toHaveBeenCalled(); + }); + }); + + describe("streamChunks helper forwards scope callbacks for Sources without streamChunks()", () => { + // `streamChunks(source, options, onChunk, ...)` has a fallback for + // sources that don't implement `streamChunks()` themselves: it + // calls `sourceAndMap()` and routes into `streamChunksOfSourceMap`. + // We trigger that branch by hand-rolling a Source-like whose + // .sourceAndMap returns a map with originalScopes/generatedRanges. + const scopesSer = createOriginalScopesSerializer(); + const scope = + scopesSer(1, 0, 0, 0, -1, undefined) + + scopesSer(3, 0, -1, -1, -1, undefined); + const rangesSer = createGeneratedRangesSerializer(); + const range = + rangesSer(1, 0, 1, [0, 0], undefined, undefined) + + rangesSer(2, 0, -1, undefined, undefined, undefined); + + it("fallback path emits scopes/ranges into the output map", () => { + const streamChunks = require("../lib/helpers/streamChunks"); + + const sourceLike = { + sourceAndMap() { + return { + source: "const a=1;\n", + map: { + version: 3, + file: "a.js", + sources: ["a.js"], + sourcesContent: ["const a = 1;\n"], + names: [], + mappings: "AAAA;", + originalScopes: [scope], + generatedRanges: range, + }, + }; + }, + }; + /** @type {unknown[][]} */ + const scopes = []; + /** @type {unknown[][]} */ + const ranges = []; + streamChunks( + /** @type {import("../lib/Source")} */ ( + /** @type {unknown} */ (sourceLike) + ), + { columns: true, finalSource: false }, + () => {}, + () => {}, + () => {}, + (...args) => scopes.push(args), + (...args) => ranges.push(args), + ); + expect(scopes).toHaveLength(2); + expect(ranges).toHaveLength(2); + }); + }); + + describe("readAllOriginalScopes", () => { + it("walks each per-source scope string", () => { + const ser0 = createOriginalScopesSerializer(); + const ser1 = createOriginalScopesSerializer(); + const a = ser0(1, 0, 0, 0, -1, undefined); + const b = ser1(2, 0, 0, 0, -1, undefined); + /** @type {number[]} */ + const seen = []; + readAllOriginalScopes([a, b], (sourceIndex) => { + seen.push(sourceIndex); + }); + expect(seen).toEqual([0, 1]); + }); + + it("ignores undefined/non-array input", () => { + /** @type {unknown[]} */ + const seen = []; + readAllOriginalScopes(undefined, () => seen.push(1)); + readAllOriginalScopes( + /** @type {string[]} */ (/** @type {unknown} */ ("not-an-array")), + () => seen.push(2), + ); + expect(seen).toEqual([]); + }); + }); + + describe("SourceMapSource preserves originalScopes/generatedRanges", () => { + // Encode a tiny scopes payload so we have deterministic VLQ strings. + const scopesSer = createOriginalScopesSerializer(); + const originalScope = + scopesSer(1, 0, 0, 0, -1, undefined) + + scopesSer(5, 0, -1, -1, -1, undefined); + + const rangesSer = createGeneratedRangesSerializer(); + const generatedRange = + rangesSer(1, 0, 1, [0, 0], undefined, undefined) + + rangesSer(3, 0, -1, undefined, undefined, undefined); + + const sourceMap = { + version: 3, + file: "x.js", + sources: ["a.js"], + sourcesContent: ["const a = 1;\nconst b = 2;\n"], + names: [], + mappings: "AAAA;AACA;", + originalScopes: [originalScope], + generatedRanges: generatedRange, + }; + const generated = "var a=1;\nvar b=2;\n"; + + it("sourceMapSource.map() forwards scopes/ranges verbatim when no inner map", () => { + const src = new SourceMapSource(generated, "x.js", sourceMap); + const map = /** @type {NonNullable>} */ ( + src.map() + ); + expect(map.originalScopes).toEqual([originalScope]); + expect(map.generatedRanges).toBe(generatedRange); + }); + + it("streamChunks emits scope/range callbacks from a SourceMapSource", () => { + const src = new SourceMapSource(generated, "x.js", sourceMap); + /** @type {unknown[][]} */ + const scopes = []; + /** @type {unknown[][]} */ + const ranges = []; + src.streamChunks( + { columns: true, finalSource: true }, + () => {}, + () => {}, + () => {}, + (...args) => scopes.push(args), + (...args) => ranges.push(args), + ); + expect(scopes).toHaveLength(2); + expect(ranges).toHaveLength(2); + // First scope open: sourceIndex=0, line=1, column=0 + expect(scopes[0].slice(0, 3)).toEqual([0, 1, 0]); + // First range open: line=1, column=0, flags=1 + expect(ranges[0].slice(0, 3)).toEqual([1, 0, 1]); + }); + }); + + describe("ConcatSource propagates scopes/ranges with line offsets", () => { + const scopesSer = createOriginalScopesSerializer(); + const scopeA = + scopesSer(1, 0, 0, 0, -1, undefined) + + scopesSer(3, 0, -1, -1, -1, undefined); + + const rangesSer = createGeneratedRangesSerializer(); + const rangeA = + rangesSer(1, 0, 1, [0, 0], undefined, undefined) + + rangesSer(2, 0, -1, undefined, undefined, undefined); + + const sourceMapA = { + version: 3, + file: "a.js", + sources: ["a.js"], + sourcesContent: ["const a = 1;\n"], + names: [], + mappings: "AAAA;", + originalScopes: [scopeA], + generatedRanges: rangeA, + }; + + it("shifts generated range line offsets for the second chunk", () => { + const a = new SourceMapSource("const a=1;\n", "a.js", sourceMapA); + const b = new OriginalSource("const b=2;\n", "b.js"); + const concat = new ConcatSource(a, b); + + const map = /** @type {NonNullable>} */ ( + concat.map() + ); + expect(map.originalScopes).toBeDefined(); + expect(map.generatedRanges).toBeDefined(); + + // Decode the resulting generatedRanges and confirm the first range + // starts at line 1 of the final output (SourceMapSource came first). + /** @type {[number, number][]} */ + const starts = []; + readGeneratedRanges(map.generatedRanges, (line, column, flags) => { + if (flags >= 0) starts.push([line, column]); + }); + expect(starts[0]).toEqual([1, 0]); + }); + + it("remaps scope sourceIndex when a second child contributes a distinct source", () => { + // Build a second SourceMapSource over source "b.js" that also + // carries originalScopes. ConcatSource should emit two entries + // in the final `sources` / `originalScopes` arrays and rewrite + // the per-child sourceIndex into the global one. + const rangesSerB = createGeneratedRangesSerializer(); + const rangeB = + rangesSerB(1, 0, 1, [0, 0], undefined, undefined) + + rangesSerB(2, 0, -1, undefined, undefined, undefined); + const scopesSerB = createOriginalScopesSerializer(); + const scopeB = + scopesSerB(1, 0, 0, 0, -1, undefined) + + scopesSerB(3, 0, -1, -1, -1, undefined); + const sourceMapB = { + version: 3, + file: "b.js", + sources: ["b.js"], + sourcesContent: ["const b = 2;\n"], + names: [], + mappings: "AAAA;", + originalScopes: [scopeB], + generatedRanges: rangeB, + }; + const a = new SourceMapSource("const a=1;\n", "a.js", sourceMapA); + const b = new SourceMapSource("const b=2;\n", "b.js", sourceMapB); + const concat = new ConcatSource(a, b); + const map = /** @type {NonNullable>} */ ( + concat.map() + ); + expect(map.sources).toEqual(["a.js", "b.js"]); + // Both children supplied a scope string at their local sourceIndex 0; + // after remapping they should sit at global indices 0 and 1. + const outScopes = /** @type {string[]} */ (map.originalScopes); + expect(outScopes).toHaveLength(2); + expect(outScopes[0]).not.toBe(""); + expect(outScopes[1]).not.toBe(""); + // Both generated ranges have a definition reference to their + // respective source; after remapping, the second range's + // definition should point at sourceIndex 1. + /** @type {[number, [number, number] | undefined][]} */ + const seen = []; + readGeneratedRanges(map.generatedRanges, (_line, _col, flags, def) => { + if (flags >= 0) seen.push([flags, def && [def[0], def[1]]]); + }); + expect(seen).toEqual([ + [1, [0, 0]], + [1, [1, 0]], + ]); + }); + + it("remaps scope variable name indices to the global name table", () => { + // Child A contributes name "a" at its local index 0, child B + // contributes name "b" at its local index 0. After concat, they + // should sit at global indices 0 and 1, and the scope-variable + // indices emitted by ConcatSource should reflect that. + // flags=1 (HAS_NAME_FLAG) so the serializer emits the name. + const scopesSerA = createOriginalScopesSerializer(); + const scopeAVars = + scopesSerA(1, 0, 1, 0, 0, [0]) + + scopesSerA(3, 0, -1, -1, -1, undefined); + const mapWithNameA = { + version: 3, + file: "a.js", + sources: ["a.js"], + sourcesContent: ["const a = 1;\n"], + names: ["a"], + mappings: "AAAAA;", + originalScopes: [scopeAVars], + }; + const scopesSerB = createOriginalScopesSerializer(); + const scopeBVars = + scopesSerB(1, 0, 1, 0, 0, [0]) + + scopesSerB(3, 0, -1, -1, -1, undefined); + const mapWithNameB = { + version: 3, + file: "b.js", + sources: ["b.js"], + sourcesContent: ["const b = 2;\n"], + names: ["b"], + mappings: "AAAAA;", + originalScopes: [scopeBVars], + }; + const a = new SourceMapSource("const a=1;\n", "a.js", mapWithNameA); + const b = new SourceMapSource("const b=2;\n", "b.js", mapWithNameB); + const concat = new ConcatSource(a, b); + const map = /** @type {NonNullable>} */ ( + concat.map() + ); + expect(map.names).toEqual(["a", "b"]); + // Collect the remapped name index and variable indices per source. + /** @type {{ name: number, vars: number[] }[]} */ + const seen = [[], []].map(() => ({ name: -1, vars: [] })); + readAllOriginalScopes(map.originalScopes, (si, _l, _c, f, _k, n, v) => { + if (f >= 0) { + seen[si].name = n; + seen[si].vars.push(...v); + } + }); + // Source 0: name "a" → global 0; variable "a" → global 0. + expect(seen[0]).toEqual({ name: 0, vars: [0] }); + // Source 1: name "b" → global 1; variable "b" → global 1. + expect(seen[1]).toEqual({ name: 1, vars: [1] }); + }); + }); +}); diff --git a/types.d.ts b/types.d.ts index 8f03a14..c171820 100644 --- a/types.d.ts +++ b/types.d.ts @@ -94,6 +94,23 @@ declare class CachedSource extends Source { sourceContent?: string, ) => void, onName: (nameIndex: number, name: string) => void, + onOriginalScope?: ( + sourceIndex: number, + line: number, + column: number, + flags: number, + kind: number, + name: number, + variables: number[], + ) => void, + onGeneratedRange?: ( + generatedLine: number, + generatedColumn: number, + flags: number, + definition?: [number, number], + callsite?: [number, number, number], + bindings?: number[][], + ) => void, ): GeneratedSourceInfo; } declare class CompatSource extends Source { @@ -122,6 +139,23 @@ declare class ConcatSource extends Source { sourceContent?: string, ) => void, onName: (nameIndex: number, name: string) => void, + onOriginalScope?: ( + sourceIndex: number, + line: number, + column: number, + flags: number, + kind: number, + name: number, + variables: number[], + ) => void, + onGeneratedRange?: ( + generatedLine: number, + generatedColumn: number, + flags: number, + definition?: [number, number], + callsite?: [number, number, number], + bindings?: number[][], + ) => void, ): GeneratedSourceInfo; } type ConcatSourceChild = string | Source | SourceLike; @@ -183,6 +217,23 @@ declare class OriginalSource extends Source { sourceContent?: string, ) => void, _onName: (nameIndex: number, name: string) => void, + _onOriginalScope?: ( + sourceIndex: number, + line: number, + column: number, + flags: number, + kind: number, + name: number, + variables: number[], + ) => void, + _onGeneratedRange?: ( + generatedLine: number, + generatedColumn: number, + flags: number, + definition?: [number, number], + callsite?: [number, number, number], + bindings?: number[][], + ) => void, ): GeneratedSourceInfo; } declare class PrefixSource extends Source { @@ -206,6 +257,23 @@ declare class PrefixSource extends Source { sourceContent?: string, ) => void, onName: (nameIndex: number, name: string) => void, + onOriginalScope?: ( + sourceIndex: number, + line: number, + column: number, + flags: number, + kind: number, + name: number, + variables: number[], + ) => void, + onGeneratedRange?: ( + generatedLine: number, + generatedColumn: number, + flags: number, + definition?: [number, number], + callsite?: [number, number, number], + bindings?: number[][], + ) => void, ): GeneratedSourceInfo; } declare class RawSource extends Source { @@ -228,6 +296,23 @@ declare class RawSource extends Source { sourceContent?: string, ) => void, onName: (nameIndex: number, name: string) => void, + _onOriginalScope?: ( + sourceIndex: number, + line: number, + column: number, + flags: number, + kind: number, + name: number, + variables: number[], + ) => void, + _onGeneratedRange?: ( + generatedLine: number, + generatedColumn: number, + flags: number, + definition?: [number, number], + callsite?: [number, number, number], + bindings?: number[][], + ) => void, ): GeneratedSourceInfo; } declare interface RawSourceMap { @@ -275,6 +360,16 @@ declare interface RawSourceMap { * ignore list */ ignoreList?: number[]; + + /** + * **Experimental.** Per-source original scopes from the [Source Map Scopes Proposal](https://github.com/tc39/source-map/blob/main/proposals/scopes.md). The proposal is still evolving — the wire format and field names may change. + */ + originalScopes?: string[]; + + /** + * **Experimental.** Generated ranges from the [Source Map Scopes Proposal](https://github.com/tc39/source-map/blob/main/proposals/scopes.md). The proposal is still evolving — the wire format and field names may change. + */ + generatedRanges?: string; } declare class ReplaceSource extends Source { constructor(source: Source, name?: string); @@ -300,6 +395,23 @@ declare class ReplaceSource extends Source { sourceContent?: string, ) => void, onName: (nameIndex: number, name: string) => void, + onOriginalScope?: ( + sourceIndex: number, + line: number, + column: number, + flags: number, + kind: number, + name: number, + variables: number[], + ) => void, + _onGeneratedRange?: ( + generatedLine: number, + generatedColumn: number, + flags: number, + definition?: [number, number], + callsite?: [number, number, number], + bindings?: number[][], + ) => void, ): GeneratedSourceInfo; static Replacement: typeof Replacement; } @@ -405,6 +517,23 @@ declare class SourceMapSource extends Source { sourceContent?: string, ) => void, onName: (nameIndex: number, name: string) => void, + onOriginalScope?: ( + sourceIndex: number, + line: number, + column: number, + flags: number, + kind: number, + name: number, + variables: number[], + ) => void, + onGeneratedRange?: ( + generatedLine: number, + generatedColumn: number, + flags: number, + definition?: [number, number], + callsite?: [number, number, number], + bindings?: number[][], + ) => void, ): GeneratedSourceInfo; } type SourceValue = string | Buffer;