OverviewFilesCommitsIssuesDocsReleasesPeers

mirall-cli / node_modules / webpack-sources / lib / ReplaceSource.js

757 lines · javascript ref:
/*
	MIT License http://www.opensource.org/licenses/mit-license.php
	Author Tobias Koppers @sokra
*/

"use strict";

const Source = require("./Source");
const { getMap, getSourceAndMap } = require("./helpers/getFromStreamChunks");
const splitIntoLines = require("./helpers/splitIntoLines");
const streamChunks = require("./helpers/streamChunks");

/** @typedef {import("./Source").ClearCacheOptions} ClearCacheOptions */
/** @typedef {import("./Source").HashLike} HashLike */
/** @typedef {import("./Source").MapOptions} MapOptions */
/** @typedef {import("./Source").RawSourceMap} RawSourceMap */
/** @typedef {import("./Source").SourceAndMap} SourceAndMap */
/** @typedef {import("./Source").SourceValue} SourceValue */
/** @typedef {import("./helpers/getGeneratedSourceInfo").GeneratedSourceInfo} GeneratedSourceInfo */
/** @typedef {import("./helpers/streamChunks").OnChunk} OnChunk */
/** @typedef {import("./helpers/streamChunks").OnName} OnName */
/** @typedef {import("./helpers/streamChunks").OnSource} OnSource */
/** @typedef {import("./helpers/streamChunks").Options} Options */

// since v8 7.0, Array.prototype.sort is stable
const hasStableSort =
	typeof process === "object" &&
	process.versions &&
	typeof process.versions.v8 === "string" &&
	!/^[0-6]\./.test(process.versions.v8);

// This is larger than max string length
const MAX_SOURCE_POSITION = 0x20000000;

/**
 * Stable comparator hoisted to module scope so each `_sortReplacements()`
 * call doesn't allocate a fresh closure.
 * @param {Replacement} a a
 * @param {Replacement} b b
 * @returns {number} order
 */
const compareStable = (a, b) => {
	const diff1 = a.start - b.start;
	if (diff1 !== 0) return diff1;
	const diff2 = a.end - b.end;
	if (diff2 !== 0) return diff2;
	return 0;
};

/**
 * Index-stabilising comparator for v8 < 7.0 (pre-stable Array.prototype.sort).
 * Unreachable on any supported Node — the `hasStableSort` guard always
 * wins so coverage tools never see this execute.
 * @param {Replacement} a a
 * @param {Replacement} b b
 * @returns {number} order
 */
/* istanbul ignore next */
const compareUnstableFallback = (a, b) => {
	const diff1 = a.start - b.start;
	if (diff1 !== 0) return diff1;
	const diff2 = a.end - b.end;
	if (diff2 !== 0) return diff2;
	return /** @type {number} */ (a.index) - /** @type {number} */ (b.index);
};

class Replacement {
	/**
	 * @param {number} start start
	 * @param {number} end end
	 * @param {string} content content
	 * @param {string=} name name
	 */
	constructor(start, end, content, name) {
		this.start = start;
		this.end = end;
		this.content = content;
		this.name = name;
		// V8 < 7.0 only — unreachable on any supported Node.
		/* istanbul ignore if */
		if (!hasStableSort) {
			this.index = -1;
		}
	}
}

class ReplaceSource extends Source {
	/**
	 * @param {Source} source source
	 * @param {string=} name name
	 */
	constructor(source, name) {
		super();
		/**
		 * @private
		 * @type {Source}
		 */
		this._source = source;
		/**
		 * @private
		 * @type {string | undefined}
		 */
		this._name = name;
		/** @type {Replacement[]} */
		this._replacements = [];
		/**
		 * @private
		 * @type {boolean}
		 */
		this._isSorted = true;
	}

	getName() {
		return this._name;
	}

	getReplacements() {
		this._sortReplacements();
		return this._replacements;
	}

	/**
	 * @param {number} start start
	 * @param {number} end end
	 * @param {string} newValue new value
	 * @param {string=} name name
	 * @returns {void}
	 */
	replace(start, end, newValue, name) {
		if (typeof newValue !== "string") {
			throw new Error(
				`insertion must be a string, but is a ${typeof newValue}`,
			);
		}
		this._replacements.push(new Replacement(start, end, newValue, name));
		this._isSorted = false;
	}

	/**
	 * @param {number} pos pos
	 * @param {string} newValue new value
	 * @param {string=} name name
	 * @returns {void}
	 */
	insert(pos, newValue, name) {
		if (typeof newValue !== "string") {
			throw new Error(
				`insertion must be a string, but is a ${typeof newValue}: ${newValue}`,
			);
		}
		this._replacements.push(new Replacement(pos, pos - 1, newValue, name));
		this._isSorted = false;
	}

	/**
	 * @returns {SourceValue} source
	 */
	source() {
		if (this._replacements.length === 0) {
			return this._source.source();
		}
		const current = /** @type {string} */ (this._source.source());
		let pos = 0;
		const result = [];

		this._sortReplacements();
		for (const replacement of this._replacements) {
			const start = Math.floor(replacement.start);
			const end = Math.floor(replacement.end + 1);
			if (pos < start) {
				// slice directly from the original string rather than repeatedly
				// producing smaller intermediate strings, which avoids O(n) copies.
				result.push(current.slice(pos, start));
				pos = start;
			}
			result.push(replacement.content);
			if (pos < end) {
				pos = end;
			}
		}
		if (pos < current.length) {
			result.push(pos === 0 ? current : current.slice(pos));
		}
		return result.join("");
	}

	/**
	 * @returns {Buffer} buffer
	 */
	buffer() {
		if (this._replacements.length === 0) {
			return this._source.buffer();
		}
		return super.buffer();
	}

	/**
	 * @returns {Buffer[]} buffers
	 */
	buffers() {
		if (this._replacements.length === 0) {
			// TODO remove in the next major release
			return typeof this._source.buffers === "function"
				? this._source.buffers()
				: [this._source.buffer()];
		}
		return [this.buffer()];
	}

	/**
	 * @param {MapOptions=} options map options
	 * @returns {RawSourceMap | null} map
	 */
	map(options) {
		if (this._replacements.length === 0) {
			return this._source.map(options);
		}
		return getMap(this, options);
	}

	/**
	 * @param {MapOptions=} options map options
	 * @returns {SourceAndMap} source and map
	 */
	sourceAndMap(options) {
		if (this._replacements.length === 0) {
			return this._source.sourceAndMap(options);
		}
		return getSourceAndMap(this, options);
	}

	original() {
		return this._source;
	}

	_sortReplacements() {
		if (this._isSorted) return;
		const replacements = this._replacements;
		// Replacements are usually appended in source order (ties keep
		// insertion order, matching a stable sort), so an O(n) pre-scan
		// often lets us skip the sort and its per-element comparator calls.
		let isPresorted = true;
		for (let i = 1; i < replacements.length; i++) {
			const prev = replacements[i - 1];
			const repl = replacements[i];
			if (
				repl.start < prev.start ||
				(repl.start === prev.start && repl.end < prev.end)
			) {
				isPresorted = false;
				break;
			}
		}
		if (isPresorted) {
			this._isSorted = true;
			return;
		}
		if (hasStableSort) {
			this._replacements.sort(compareStable);
		} else {
			// V8 < 7.0 only — unreachable on any supported Node.
			/* istanbul ignore next */
			for (const [i, repl] of this._replacements.entries()) repl.index = i;
			/* istanbul ignore next */
			this._replacements.sort(compareUnstableFallback);
		}
		this._isSorted = true;
	}

	/**
	 * @param {Options} options options
	 * @param {OnChunk} onChunk called for each chunk of code
	 * @param {OnSource} onSource called for each source
	 * @param {OnName} onName called for each name
	 * @returns {GeneratedSourceInfo} generated source info
	 */
	streamChunks(options, onChunk, onSource, onName) {
		this._sortReplacements();
		// When the consumer only wants the final source (map() /
		// sourceAndMap()), emit position-only chunks (chunk === undefined,
		// like OriginalSource and RawSource do) and hand back the whole
		// replaced source once at the end. This avoids allocating boundary
		// slices for emission and — more importantly — the per-chunk
		// `code += chunk` cons-string chain in every enclosing consumer.
		// With `source: false` the caller (getMap) additionally promises not
		// to read the returned source, so its assembly is skipped entirely;
		// streamAndGetSourceAndMap overrides that flag because it caches the
		// text.
		const finalSource = Boolean(options && options.finalSource);
		const needSource = !options || options.source !== false;
		const replacements = this._replacements;
		let pos = 0;
		let i = 0;
		let replacementEnd = -1;
		let nextReplacement =
			i < replacements.length
				? Math.floor(replacements[i].start)
				: MAX_SOURCE_POSITION;
		let generatedLineOffset = 0;
		let generatedColumnOffset = 0;
		let generatedColumnOffsetLine = 0;
		/** @type {(string | undefined)[]} */
		const sourceContents = [];
		/**
		 * Lazily-built line-start offsets per source content. One number per
		 * line instead of one substring per line (`splitIntoLines`), and the
		 * chunk comparison below runs allocation-free via `startsWith`.
		 * @type {(number[] | undefined)[]}
		 */
		const sourceContentLineStarts = [];
		/** @type {Map<string, number>} */
		const nameMapping = new Map();
		/** @type {number[]} */
		const nameIndexMapping = [];
		/**
		 * @param {number} sourceIndex source index
		 * @param {number} line line
		 * @param {number} column column
		 * @param {string} expectedChunk expected chunk
		 * @returns {boolean} result
		 */
		const checkOriginalContent = (sourceIndex, line, column, expectedChunk) => {
			const content =
				sourceIndex < sourceContents.length
					? sourceContents[sourceIndex]
					: undefined;
			if (content === undefined) return false;
			let lineStarts = sourceContentLineStarts[sourceIndex];
			if (lineStarts === undefined) {
				// Line boundaries mirror `splitIntoLines`: every line includes
				// its trailing "\n"; a final line without "\n" still counts.
				lineStarts = [];
				const { length } = content;
				let offset = 0;
				while (offset < length) {
					lineStarts.push(offset);
					const newline = content.indexOf("\n", offset);
					if (newline === -1) break;
					offset = newline + 1;
				}
				sourceContentLineStarts[sourceIndex] = lineStarts;
			}
			if (line > lineStarts.length) return false;
			const lineStart = lineStarts[line - 1];
			const lineEnd =
				line < lineStarts.length ? lineStarts[line] : content.length;
			// The expected chunk never spans lines, so a match must fit into
			// the current line (mirrors the old per-line slice comparison).
			if (column + expectedChunk.length > lineEnd - lineStart) return false;
			return content.startsWith(expectedChunk, lineStart + column);
		};
		const { generatedLine, generatedColumn } = streamChunks(
			this._source,
			{ ...options, finalSource: false },
			(
				_chunk,
				generatedLine,
				generatedColumn,
				sourceIndex,
				originalLine,
				originalColumn,
				nameIndex,
			) => {
				let chunkPos = 0;
				const chunk = /** @type {string} */ (_chunk);
				const endPos = pos + chunk.length;

				// Skip over when it has been replaced
				if (replacementEnd > pos) {
					// Skip over the whole chunk
					if (replacementEnd >= endPos) {
						const line = generatedLine + generatedLineOffset;
						if (chunk.endsWith("\n")) {
							generatedLineOffset--;
							if (generatedColumnOffsetLine === line) {
								// undo exiting corrections form the current line
								generatedColumnOffset += generatedColumn;
							}
						} else if (generatedColumnOffsetLine === line) {
							generatedColumnOffset -= chunk.length;
						} else {
							/* istanbul ignore next: pre-existing chunk-skipping cross-line case (also untested on main) */
							generatedColumnOffset = -chunk.length;
							/* istanbul ignore next: pre-existing chunk-skipping cross-line case (also untested on main) */
							generatedColumnOffsetLine = line;
						}
						pos = endPos;
						return;
					}

					// Partially skip over chunk
					chunkPos = replacementEnd - pos;
					if (
						checkOriginalContent(
							sourceIndex,
							originalLine,
							originalColumn,
							chunk.slice(0, chunkPos),
						)
					) {
						originalColumn += chunkPos;
					}
					pos += chunkPos;
					const line = generatedLine + generatedLineOffset;
					/* istanbul ignore else: pre-existing chunk-skipping cross-line case (also untested on main) */
					if (generatedColumnOffsetLine === line) {
						generatedColumnOffset -= chunkPos;
					} else {
						generatedColumnOffset = -chunkPos;
						generatedColumnOffsetLine = line;
					}
					generatedColumn += chunkPos;
				}

				// Is a replacement in the chunk?
				if (nextReplacement < endPos) {
					do {
						let line = generatedLine + generatedLineOffset;
						if (nextReplacement > pos) {
							// Emit chunk until replacement
							const offset = nextReplacement - pos;
							const chunkSlice = chunk.slice(chunkPos, chunkPos + offset);
							onChunk(
								finalSource ? undefined : chunkSlice,
								line,
								generatedColumn +
									(line === generatedColumnOffsetLine
										? generatedColumnOffset
										: 0),
								sourceIndex,
								originalLine,
								originalColumn,
								nameIndex < 0 || nameIndex >= nameIndexMapping.length
									? -1
									: nameIndexMapping[nameIndex],
							);
							generatedColumn += offset;
							chunkPos += offset;
							pos = nextReplacement;
							if (
								checkOriginalContent(
									sourceIndex,
									originalLine,
									originalColumn,
									chunkSlice,
								)
							) {
								originalColumn += chunkSlice.length;
							}
						}

						// Insert replacement content splitted into chunks by lines
						const { content, name } = replacements[i];
						let replacementNameIndex = nameIndex;
						if (sourceIndex >= 0 && name) {
							let globalIndex = nameMapping.get(name);
							if (globalIndex === undefined) {
								globalIndex = nameMapping.size;
								nameMapping.set(name, globalIndex);
								onName(globalIndex, name);
							}
							replacementNameIndex = globalIndex;
						}
						// Fast path: most replacements (renamed identifiers,
						// short inserts) carry single-line content. Skip
						// `splitIntoLines` — and its array allocation — when
						// we can tell the content has no embedded newline.
						// `splitIntoLines("")` returns `[]`; emitting a zero-
						// length chunk would still walk the loop, so handle
						// it as a no-op explicitly.
						if (content.length > 0 && !content.includes("\n")) {
							onChunk(
								finalSource ? undefined : content,
								line,
								generatedColumn +
									(line === generatedColumnOffsetLine
										? generatedColumnOffset
										: 0),
								sourceIndex,
								originalLine,
								originalColumn,
								replacementNameIndex,
							);
							if (generatedColumnOffsetLine === line) {
								generatedColumnOffset += content.length;
							} else {
								generatedColumnOffset = content.length;
								generatedColumnOffsetLine = line;
							}
						} else if (content.length === 0) {
							// Empty replacement: no chunk to emit, no column
							// movement. `splitIntoLines("")` is `[]` so the
							// existing loop already does nothing — explicit
							// guard skips the per-call array allocation.
						} else {
							const matches = splitIntoLines(content);
							for (let m = 0; m < matches.length; m++) {
								const contentLine = matches[m];
								onChunk(
									finalSource ? undefined : contentLine,
									line,
									generatedColumn +
										(line === generatedColumnOffsetLine
											? generatedColumnOffset
											: 0),
									sourceIndex,
									originalLine,
									originalColumn,
									replacementNameIndex,
								);

								// Only the first chunk has name assigned
								replacementNameIndex = -1;

								if (m === matches.length - 1 && !contentLine.endsWith("\n")) {
									/* istanbul ignore else: pre-existing multi-line replacement cross-line case (also untested on main) */
									if (generatedColumnOffsetLine === line) {
										generatedColumnOffset += contentLine.length;
									} else {
										generatedColumnOffset = contentLine.length;
										generatedColumnOffsetLine = line;
									}
								} else {
									generatedLineOffset++;
									line++;
									generatedColumnOffset = -generatedColumn;
									generatedColumnOffsetLine = line;
								}
							}
						}

						// Remove replaced content by settings this variable
						replacementEnd = Math.max(
							replacementEnd,
							Math.floor(replacements[i].end + 1),
						);

						// Move to next replacement
						i++;
						nextReplacement =
							i < replacements.length
								? Math.floor(replacements[i].start)
								: MAX_SOURCE_POSITION;

						// Skip over when it has been replaced
						const offset = chunk.length - endPos + replacementEnd - chunkPos;
						if (offset > 0) {
							// Skip over whole chunk
							if (replacementEnd >= endPos) {
								const line = generatedLine + generatedLineOffset;
								if (chunk.endsWith("\n")) {
									generatedLineOffset--;
									if (generatedColumnOffsetLine === line) {
										// undo exiting corrections form the current line
										generatedColumnOffset += generatedColumn;
									}
								} else if (generatedColumnOffsetLine === line) {
									generatedColumnOffset -= chunk.length - chunkPos;
								} else {
									generatedColumnOffset = chunkPos - chunk.length;
									generatedColumnOffsetLine = line;
								}
								pos = endPos;
								return;
							}

							// Partially skip over chunk
							const line = generatedLine + generatedLineOffset;
							if (
								checkOriginalContent(
									sourceIndex,
									originalLine,
									originalColumn,
									chunk.slice(chunkPos, chunkPos + offset),
								)
							) {
								originalColumn += offset;
							}
							chunkPos += offset;
							pos += offset;
							if (generatedColumnOffsetLine === line) {
								generatedColumnOffset -= offset;
							} else {
								generatedColumnOffset = -offset;
								generatedColumnOffsetLine = line;
							}
							generatedColumn += offset;
						}
					} while (nextReplacement < endPos);
				}

				// Emit remaining chunk
				if (chunkPos < chunk.length) {
					const chunkSlice = finalSource
						? undefined
						: chunkPos === 0
							? chunk
							: chunk.slice(chunkPos);
					const line = generatedLine + generatedLineOffset;
					onChunk(
						chunkSlice,
						line,
						generatedColumn +
							(line === generatedColumnOffsetLine ? generatedColumnOffset : 0),
						sourceIndex,
						originalLine,
						originalColumn,
						nameIndex < 0 ? -1 : nameIndexMapping[nameIndex],
					);
				}
				pos = endPos;
			},
			(sourceIndex, source, sourceContent) => {
				/* istanbul ignore next: non-sequential sourceIndex emission is not produced by any in-tree Source */
				while (sourceContents.length < sourceIndex) {
					sourceContents.push(undefined);
				}
				sourceContents[sourceIndex] = sourceContent;
				onSource(sourceIndex, source, sourceContent);
			},
			(nameIndex, name) => {
				let globalIndex = nameMapping.get(name);
				if (globalIndex === undefined) {
					globalIndex = nameMapping.size;
					nameMapping.set(name, globalIndex);
					onName(globalIndex, name);
				}
				nameIndexMapping[nameIndex] = globalIndex;
			},
		);

		// Handle remaining replacements
		let remainer = "";
		for (; i < replacements.length; i++) {
			remainer += replacements[i].content;
		}

		// Insert remaining replacements content splitted into chunks by lines
		let line = /** @type {number} */ (generatedLine) + generatedLineOffset;
		// Fast path mirroring the in-chunk replacement loop above: skip
		// splitIntoLines + per-match loop when the trailing content has no
		// newlines (the common case when remaining replacements are single
		// inserts).
		if (remainer.length > 0 && !remainer.includes("\n")) {
			onChunk(
				finalSource ? undefined : remainer,
				line,
				/** @type {number} */
				(generatedColumn) +
					(line === generatedColumnOffsetLine ? generatedColumnOffset : 0),
				-1,
				-1,
				-1,
				-1,
			);
			/* istanbul ignore else: trailing-remainer cross-line case (also untested on main) */
			if (generatedColumnOffsetLine === line) {
				generatedColumnOffset += remainer.length;
			} else {
				generatedColumnOffset = remainer.length;
				generatedColumnOffsetLine = line;
			}
		} else {
			const matches = splitIntoLines(remainer);
			for (let m = 0; m < matches.length; m++) {
				const contentLine = matches[m];
				onChunk(
					finalSource ? undefined : contentLine,
					line,
					/** @type {number} */
					(generatedColumn) +
						(line === generatedColumnOffsetLine ? generatedColumnOffset : 0),
					-1,
					-1,
					-1,
					-1,
				);

				if (m === matches.length - 1 && !contentLine.endsWith("\n")) {
					/* istanbul ignore else: trailing-remainer multi-line cross-line case (also untested on main) */
					if (generatedColumnOffsetLine === line) {
						generatedColumnOffset += contentLine.length;
					} else {
						generatedColumnOffset = contentLine.length;
						generatedColumnOffsetLine = line;
					}
				} else {
					generatedLineOffset++;
					line++;
					generatedColumnOffset = -(/** @type {number} */ (generatedColumn));
					generatedColumnOffsetLine = line;
				}
			}
		}

		return {
			generatedLine: line,
			generatedColumn:
				/** @type {number} */
				(generatedColumn) +
				(line === generatedColumnOffsetLine ? generatedColumnOffset : 0),
			// The streamed chunks reproduce source() exactly, so the final
			// source can be assembled in O(replacements) string operations
			// instead of re-concatenating every emitted chunk downstream.
			source:
				finalSource && needSource
					? /** @type {string} */ (this.source())
					: undefined,
		};
	}

	/**
	 * Release cached data held by this source. clearCache is a memory
	 * hint: it never affects correctness or output, only how expensive
	 * the next read is. Subclasses override; the base is a no-op so
	 * every Source supports the call. Composite sources always recurse
	 * into wrapped sources. When the same child is reachable via several
	 * parents (e.g. modules shared across webpack chunks), pass a shared
	 * `visited` WeakSet so each subtree is walked at most once.
	 * Not safe to call concurrently with source/map/sourceAndMap/
	 * streamChunks/updateHash on the same instance.
	 * @param {ClearCacheOptions=} options selectors
	 * @param {WeakSet<Source>=} visited de-duplication set shared across calls
	 * @returns {void}
	 */
	clearCache(options, visited) {
		if (visited !== undefined && visited.has(this)) return;
		let v = visited;
		if (v === undefined) v = new WeakSet();
		v.add(this);
		this._source.clearCache(options, v);
	}

	/**
	 * @param {HashLike} hash hash
	 * @returns {void}
	 */
	updateHash(hash) {
		this._sortReplacements();
		hash.update("ReplaceSource");
		this._source.updateHash(hash);
		hash.update(this._name || "");
		// Feed each replacement as multiple updates instead of building one
		// combined template literal per replacement. The resulting digest is
		// identical (hash.update is additive over bytes), but we avoid
		// allocating a new string per replacement.
		for (const repl of this._replacements) {
			hash.update(`${repl.start}${repl.end}`);
			hash.update(repl.content);
			if (repl.name) hash.update(repl.name);
		}
	}
}

module.exports = ReplaceSource;
module.exports.Replacement = Replacement;