Files
overlookmotel 603b904d72 perf(packages/codegen): check sourceText only once (#25887)
Follow-on after #25585. Small optimization.

Sourcemaps can't be generated unless `options.sourceText` is provided. This is already checked at the start. So everywhere else can then rely on `state.sourceText != null`, rather than re-checking it on each write.
2026-08-19 08:12:02 +00:00

403 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Source map generation.
//
// Mapped writes record output offsets and original positions while printing.
// `generateSourceMap` defined here converts them to generated positions
// and encodes a standard Source Map v3 in one pass at the end.
import { Buffer } from "node:buffer";
import { debugAssert } from "../asserts.ts";
import type { Options, SourceMap } from "./options.ts";
import type { State } from "../state.ts";
const BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
const BASE64_CODES = /* @__PURE__ */ Buffer.from(BASE64_CHARS, "ascii");
const LINE_SEARCH_ITERATIONS = 16;
const MIN_LF_FAST_PATH_MAPPINGS = 64;
const MAX_LF_FAST_PATH_CHARS_PER_MAPPING = 256;
const MAX_BACKWARD_SOURCE_SCAN = 4096;
const MAX_REPLAYED_SOURCE_SCAN = 16384;
const MAX_BITWISE_VLQ = 0x7fffffff;
const MIN_MAPPING_BUFFER_LENGTH = 64;
// Real-world fixtures use 5.2–5.5 bytes per mapping. Leave some headroom and grow for outliers.
const ESTIMATED_BYTES_PER_MAPPING = 6;
const MAX_MAPPING_SEGMENT_LENGTH = 64;
// Buffer's initial capacity must be greater than or equal to the maximum extra capacity required
// at a `growMappingBuffer` call. See that function for more details.
debugAssert(
MIN_MAPPING_BUFFER_LENGTH >= MAX_MAPPING_SEGMENT_LENGTH,
"`MIN_MAPPING_BUFFER_LENGTH` must be >= `MAX_MAPPING_SEGMENT_LENGTH`",
);
// `\r\n` must be one line terminator, rather than two.
const NEXT_LINE_TERMINATOR_REGEX = /\r\n|[\r\n\u2028\u2029]/g;
/**
* Convert deferred mapping data into a standard Source Map v3 object.
*/
export function generateSourceMap(state: State, options: Options): SourceMap {
debugAssert(
state.mapPositions !== null,
"Source map positions should exist when sourcemap generation is enabled",
);
const { output, mapPositions, mapNames, sourceText } = state;
const mappingCount = mapPositions.length >> 1;
debugAssert(sourceText !== null, "`sourceText` should be defined when producing a source map");
if (mappingCount === 0) {
return {
version: 3,
mappings: "",
names: [],
sources: [options.sourceFilename ?? ""],
sourcesContent: [sourceText],
};
}
let mappingBuffer: Buffer = Buffer.allocUnsafe(
Math.max(MIN_MAPPING_BUFFER_LENGTH, mappingCount * ESTIMATED_BYTES_PER_MAPPING),
);
let mappingLength = 0;
const names: string[] = [];
let nameIds: Map<string, number> | undefined;
let mapNameEntryIndex = 0;
let nextNamedMappingIndex = (mapNames?.[0] as number | undefined) ?? Infinity;
let sourceLineStarts: number[] | undefined;
let sourceScanOffset = 0;
let sourceLine = 0;
let sourceLineStart = 0;
let replayedSourceScanTotal = 0;
let lineStart = 0;
// Proving an output contains only `\n` takes a full scan. Amortize it only when enough mappings will benefit.
// Sparse maps and huge one-line literals stay on the single-pass regexp path.
const useOutputLineFeedFastPath =
mappingCount >= MIN_LF_FAST_PATH_MAPPINGS &&
output.length <= mappingCount * MAX_LF_FAST_PATH_CHARS_PER_MAPPING &&
!hasUncommonLineTerminator(output);
// Require mappings to cover a substantial part of the source, so looking for the first line break
// cannot scan a huge unmapped suffix. Reordered inputs conservatively take the slow path.
const useSourceLineBoundaryCache =
mappingCount >= MIN_LF_FAST_PATH_MAPPINGS &&
sourceText.length <= mappingCount * MAX_LF_FAST_PATH_CHARS_PER_MAPPING &&
mapPositions[mapPositions.length - 1] * 2 >= sourceText.length;
const useSourceLineFeedFastPath =
useSourceLineBoundaryCache && hasOnlyLineFeedsAndCrLf(sourceText);
let nextLineStart = findNextLineStart(output, 0, useOutputLineFeedFastPath);
let nextSourceLineStart = useSourceLineBoundaryCache
? findNextLineStart(sourceText, 0, useSourceLineFeedFastPath)
: Infinity;
let hasSegmentOnLine = false;
let previousGeneratedColumn = 0;
let previousOriginalLine = 0;
let previousOriginalColumn = 0;
let previousNameId = 0;
for (let index = 0, positionIndex = 0; index < mappingCount; index++, positionIndex += 2) {
const offset = mapPositions[positionIndex];
while (offset >= nextLineStart) {
lineStart = nextLineStart;
nextLineStart = findNextLineStart(output, lineStart, useOutputLineFeedFastPath);
previousGeneratedColumn = 0;
hasSegmentOnLine = false;
if (mappingLength === mappingBuffer.length) {
mappingBuffer = growMappingBuffer(mappingBuffer, mappingLength);
}
mappingBuffer[mappingLength++] = 59; // `;`
}
const generatedColumn = offset - lineStart;
// Rust end mappings use `span.end - 1`, which may land inside a multi-byte code point.
// The JS equivalent can land on a low surrogate - normalize it back to the code point's start.
let sourceOffset = mapPositions[positionIndex + 1];
if (sourceOffset > 0) {
const char = sourceText.charCodeAt(sourceOffset);
if (
char >= 0xdc00 &&
char <= 0xdfff &&
sourceText.charCodeAt(sourceOffset - 1) >= 0xd800 &&
sourceText.charCodeAt(sourceOffset - 1) <= 0xdbff
) {
sourceOffset--;
}
}
let originalLine: number;
let originalColumn: number;
if (sourceLineStarts === undefined) {
if (sourceOffset >= sourceScanOffset) {
// Mappings almost always advance through the source. Scan only the text between this mapping
// and the last one, instead of building a line table for the whole source.
if (useSourceLineBoundaryCache) {
while (sourceOffset >= nextSourceLineStart) {
sourceLineStart = nextSourceLineStart;
nextSourceLineStart = findNextLineStart(
sourceText,
sourceLineStart,
useSourceLineFeedFastPath,
);
sourceLine++;
}
sourceScanOffset = sourceOffset;
} else {
while (sourceScanOffset < sourceOffset) {
const char = sourceText.charCodeAt(sourceScanOffset);
if (char === 13 && sourceText.charCodeAt(sourceScanOffset + 1) === 10) {
// An offset on the `\n` of `\r\n` still belongs to the preceding line.
if (sourceScanOffset + 1 === sourceOffset) break;
sourceScanOffset += 2;
sourceLine++;
sourceLineStart = sourceScanOffset;
} else if (char === 10 || char === 13 || char === 0x2028 || char === 0x2029) {
sourceScanOffset++;
sourceLine++;
sourceLineStart = sourceScanOffset;
} else {
sourceScanOffset++;
}
}
}
} else if (
sourceOffset >= sourceLineStart ||
(sourceScanOffset - sourceOffset <= MAX_BACKWARD_SOURCE_SCAN &&
replayedSourceScanTotal + sourceScanOffset - sourceOffset <=
Math.max(MAX_REPLAYED_SOURCE_SCAN, sourceText.length))
) {
// Parent/end mappings and locally reordered nodes can step backwards.
// These moves are normally within the current line or a nearby one, so a short reverse scan
// is cheaper than allocating a complete line table.
//
// A same-line backtrack needs no scan and leaves the forward cursor where it is.
// When the mapping crosses a line, count the text a later forward step must replay.
// Once replaying costs as much as building a line table, the fallback below becomes cheaper.
if (sourceOffset < sourceLineStart) {
replayedSourceScanTotal += sourceScanOffset - sourceOffset;
while (sourceOffset < sourceLineStart) {
let index = sourceLineStart - 1;
if (sourceText.charCodeAt(index) === 10 && sourceText.charCodeAt(index - 1) === 13) {
index -= 2;
} else {
index--;
}
while (index >= 0) {
const char = sourceText.charCodeAt(index);
if (char === 10 || char === 13 || char === 0x2028 || char === 0x2029) break;
index--;
}
sourceLineStart = index + 1;
sourceLine--;
}
sourceScanOffset = sourceOffset;
if (useSourceLineBoundaryCache) {
nextSourceLineStart = findNextLineStart(
sourceText,
sourceLineStart,
useSourceLineFeedFastPath,
);
}
}
} else {
// A heavily transformed AST can jump arbitrarily far backwards or oscillate repeatedly.
// Build the table lazily once reverse scanning crosses either limit.
sourceLineStarts = getLineStarts(sourceText);
sourceLine = findSourceLine(sourceLineStarts, sourceOffset, previousOriginalLine);
sourceLineStart = sourceLineStarts[sourceLine];
sourceScanOffset = sourceOffset;
}
originalLine = sourceLine;
originalColumn = sourceOffset - sourceLineStart;
} else {
originalLine = findSourceLine(sourceLineStarts, sourceOffset, previousOriginalLine);
originalColumn = sourceOffset - sourceLineStarts[originalLine];
}
if (mappingLength + MAX_MAPPING_SEGMENT_LENGTH > mappingBuffer.length) {
mappingBuffer = growMappingBuffer(mappingBuffer, mappingLength);
}
if (hasSegmentOnLine) mappingBuffer[mappingLength++] = 44; // `,`
mappingLength = writeVlq(
mappingBuffer,
mappingLength,
generatedColumn - previousGeneratedColumn,
);
// All mappings point into the one source file, so the source-index delta is always zero (`A`)
mappingBuffer[mappingLength++] = 65;
mappingLength = writeVlq(mappingBuffer, mappingLength, originalLine - previousOriginalLine);
mappingLength = writeVlq(mappingBuffer, mappingLength, originalColumn - previousOriginalColumn);
if (index === nextNamedMappingIndex) {
nameIds ??= new Map<string, number>();
const name = mapNames![mapNameEntryIndex + 1] as string;
let nameId = nameIds.get(name);
if (nameId === undefined) {
nameId = names.length;
names.push(name);
nameIds.set(name, nameId);
}
mappingLength = writeVlq(mappingBuffer, mappingLength, nameId - previousNameId);
previousNameId = nameId;
mapNameEntryIndex += 2;
nextNamedMappingIndex = (mapNames![mapNameEntryIndex] as number | undefined) ?? Infinity;
}
hasSegmentOnLine = true;
previousGeneratedColumn = generatedColumn;
previousOriginalLine = originalLine;
previousOriginalColumn = originalColumn;
}
return {
version: 3,
mappings: mappingBuffer.toString("ascii", 0, mappingLength),
names,
sources: [options.sourceFilename ?? ""],
sourcesContent: [sourceText],
};
}
/**
* Build UTF-16 offsets to the start of every ECMAScript source line.
*/
function getLineStarts(sourceText: string): number[] {
const lineStarts = [0];
for (let index = 0; index < sourceText.length; index++) {
const char = sourceText.charCodeAt(index);
if (char !== 10 && char !== 13 && char !== 0x2028 && char !== 0x2029) continue;
if (char === 13 && sourceText.charCodeAt(index + 1) === 10) index++;
lineStarts.push(index + 1);
}
return lineStarts;
}
/**
* Find the source line containing `sourceOffset`, starting close to the previous result.
*/
function findSourceLine(lineStarts: number[], sourceOffset: number, previousLine: number): number {
// Source mappings normally advance through the original file. Search the next few lines linearly,
// as V8 optimizes this small predictable loop well, then fall back for large jumps or transformed ASTs
// whose source locations move backwards.
if (sourceOffset >= lineStarts[previousLine]) {
const end = Math.min(previousLine + LINE_SEARCH_ITERATIONS + 1, lineStarts.length);
for (let line = previousLine + 1; line < end; line++) {
if (lineStarts[line] > sourceOffset) return line - 1;
}
if (end === lineStarts.length) return lineStarts.length - 1;
} else {
const end = Math.max(previousLine - LINE_SEARCH_ITERATIONS, 0);
for (let line = previousLine - 1; line >= end; line--) {
if (lineStarts[line] <= sourceOffset) return line;
}
}
let low = 0;
let high = lineStarts.length;
while (low < high) {
const middle = (low + high) >> 1;
if (lineStarts[middle] <= sourceOffset) low = middle + 1;
else high = middle;
}
return low - 1;
}
/**
* Write one signed source-map delta as base64 VLQ, returning the next buffer position.
*/
function writeVlq(buffer: Buffer, index: number, value: number): number {
let vlq = value < 0 ? -value * 2 + 1 : value * 2;
if (vlq <= MAX_BITWISE_VLQ) {
do {
let digit = vlq & 31;
vlq >>>= 5;
if (vlq > 0) digit |= 32;
buffer[index++] = BASE64_CODES[digit];
} while (vlq > 0);
return index;
}
do {
let digit = vlq % 32;
vlq = Math.floor(vlq / 32);
if (vlq > 0) digit += 32;
buffer[index++] = BASE64_CODES[digit];
} while (vlq > 0);
return index;
}
/**
* Grow the mappings buffer by doubling, preserving its written prefix.
*
* The returned buffer is guaranteed to have at least `MAX_MAPPING_SEGMENT_LENGTH` spare capacity.
*/
function growMappingBuffer(buffer: Buffer, writtenLength: number): Buffer {
// Buffer starts with at least `MIN_MAPPING_BUFFER_LENGTH` bytes capacity.
// Maximum extra capacity required is `MAX_MAPPING_SEGMENT_LENGTH`, which is <= `MIN_MAPPING_BUFFER_LENGTH`,
// so doubling capacity is always enough.
const newBuffer = Buffer.allocUnsafe(buffer.length * 2);
buffer.copy(newBuffer, 0, 0, writtenLength);
return newBuffer;
}
/**
* Find the UTF-16 offset after the next ECMAScript line terminator.
*/
function findNextLineStart(output: string, from: number, useLineFeedFastPath: boolean): number {
if (useLineFeedFastPath) {
const index = output.indexOf("\n", from);
return index === -1 ? Infinity : index + 1;
}
// Let the regexp engine scan long generated lines. A JS `charCodeAt` loop is much slower for large literals,
// while `lastIndex` avoids allocating a substring just to start the search at `from`.
NEXT_LINE_TERMINATOR_REGEX.lastIndex = from;
const match = NEXT_LINE_TERMINATOR_REGEX.exec(output);
return match === null ? Infinity : match.index + match[0].length;
}
/**
* Whether output contains a line terminator other than `\n`.
*/
function hasUncommonLineTerminator(output: string): boolean {
// V8's specialized substring search is substantially faster than a regexp scan here, even when
// all three searches miss. This also avoids allocating regexp match state for the common path.
return (
output.indexOf("\r") !== -1 ||
output.indexOf("\u2028") !== -1 ||
output.indexOf("\u2029") !== -1
);
}
/**
* Whether every source line terminator can be found by searching for `\n`.
*/
function hasOnlyLineFeedsAndCrLf(sourceText: string): boolean {
if (sourceText.indexOf("\u2028") !== -1 || sourceText.indexOf("\u2029") !== -1) return false;
let carriageReturn = sourceText.indexOf("\r");
while (carriageReturn !== -1) {
if (sourceText.charCodeAt(carriageReturn + 1) !== 10) return false;
carriageReturn = sourceText.indexOf("\r", carriageReturn + 1);
}
return true;
}