/** * Some of the internal operations of micromark do lots of editing * operations on very large arrays. This runs into problems with two * properties of most circa-2020 JavaScript interpreters: * * - Array-length modifications at the high end of an array (push/pop) are * expected to be common and are implemented in (amortized) time * proportional to the number of elements added or removed, whereas * other operations (shift/unshift and splice) are much less efficient. * - Function arguments are passed on the stack, so adding tens of thousands * of elements to an array with `arr.push(...newElements)` will frequently * cause stack overflows. (see ) * * SpliceBuffers are an implementation of gap buffers, which are a * generalization of the "queue made of two stacks" idea. The splice buffer * maintains a cursor, and moving the cursor has cost proportional to the * distance the cursor moves, but inserting, deleting, or splicing in * new information at the cursor is as efficient as the push/pop operation. * This allows for an efficient sequence of splices (or pushes, pops, shifts, * or unshifts) as long such edits happen at the same part of the array or * generally sweep through the array from the beginning to the end. * * The interface for splice buffers also supports large numbers of inputs by * passing a single array argument rather passing multiple arguments on the * function call stack. * * @template T * Item type. */ export class SpliceBuffer { /** * @param {ReadonlyArray | null | undefined} [initial] * Initial items (optional). * @returns * Splice buffer. */ constructor(initial) { /** @type {Array} */ this.left = initial ? [...initial] : []; /** @type {Array} */ this.right = []; } /** * Array access; * does not move the cursor. * * @param {number} index * Index. * @return {T} * Item. */ get(index) { if (index < 0 || index >= this.left.length + this.right.length) { throw new RangeError('Cannot access index `' + index + '` in a splice buffer of size `' + (this.left.length + this.right.length) + '`'); } if (index < this.left.length) return this.left[index]; return this.right[this.right.length - index + this.left.length - 1]; } /** * The length of the splice buffer, one greater than the largest index in the * array. */ get length() { return this.left.length + this.right.length; } /** * Remove and return `list[0]`; * moves the cursor to `0`. * * @returns {T | undefined} * Item, optional. */ shift() { this.setCursor(0); return this.right.pop(); } /** * Slice the buffer to get an array; * does not move the cursor. * * @param {number} start * Start. * @param {number | null | undefined} [end] * End (optional). * @returns {Array} * Array of items. */ slice(start, end) { /** @type {number} */ const stop = end === null || end === undefined ? Number.POSITIVE_INFINITY : end; if (stop < this.left.length) { return this.left.slice(start, stop); } if (start > this.left.length) { return this.right.slice(this.right.length - stop + this.left.length, this.right.length - start + this.left.length).reverse(); } return this.left.slice(start).concat(this.right.slice(this.right.length - stop + this.left.length).reverse()); } /** * Mimics the behavior of Array.prototype.splice() except for the change of * interface necessary to avoid segfaults when patching in very large arrays. * * This operation moves cursor is moved to `start` and results in the cursor * placed after any inserted items. * * @param {number} start * Start; * zero-based index at which to start changing the array; * negative numbers count backwards from the end of the array and values * that are out-of bounds are clamped to the appropriate end of the array. * @param {number | null | undefined} [deleteCount=0] * Delete count (default: `0`); * maximum number of elements to delete, starting from start. * @param {Array | null | undefined} [items=[]] * Items to include in place of the deleted items (default: `[]`). * @return {Array} * Any removed items. */ splice(start, deleteCount, items) { /** @type {number} */ const count = deleteCount || 0; this.setCursor(Math.trunc(start)); const removed = this.right.splice(this.right.length - count, Number.POSITIVE_INFINITY); if (items) chunkedPush(this.left, items); return removed.reverse(); } /** * Remove and return the highest-numbered item in the array, so * `list[list.length - 1]`; * Moves the cursor to `length`. * * @returns {T | undefined} * Item, optional. */ pop() { this.setCursor(Number.POSITIVE_INFINITY); return this.left.pop(); } /** * Inserts a single item to the high-numbered side of the array; * moves the cursor to `length`. * * @param {T} item * Item. * @returns {undefined} * Nothing. */ push(item) { this.setCursor(Number.POSITIVE_INFINITY); this.left.push(item); } /** * Inserts many items to the high-numbered side of the array. * Moves the cursor to `length`. * * @param {Array} items * Items. * @returns {undefined} * Nothing. */ pushMany(items) { this.setCursor(Number.POSITIVE_INFINITY); chunkedPush(this.left, items); } /** * Inserts a single item to the low-numbered side of the array; * Moves the cursor to `0`. * * @param {T} item * Item. * @returns {undefined} * Nothing. */ unshift(item) { this.setCursor(0); this.right.push(item); } /** * Inserts many items to the low-numbered side of the array; * moves the cursor to `0`. * * @param {Array} items * Items. * @returns {undefined} * Nothing. */ unshiftMany(items) { this.setCursor(0); chunkedPush(this.right, items.reverse()); } /** * Move the cursor to a specific position in the array. Requires * time proportional to the distance moved. * * If `n < 0`, the cursor will end up at the beginning. * If `n > length`, the cursor will end up at the end. * * @param {number} n * Position. * @return {undefined} * Nothing. */ setCursor(n) { if (n === this.left.length || n > this.left.length && this.right.length === 0 || n < 0 && this.left.length === 0) return; if (n < this.left.length) { // Move cursor to the this.left const removed = this.left.splice(n, Number.POSITIVE_INFINITY); chunkedPush(this.right, removed.reverse()); } else { // Move cursor to the this.right const removed = this.right.splice(this.left.length + this.right.length - n, Number.POSITIVE_INFINITY); chunkedPush(this.left, removed.reverse()); } } } /** * Avoid stack overflow by pushing items onto the stack in segments * * @template T * Item type. * @param {Array} list * List to inject into. * @param {ReadonlyArray} right * Items to inject. * @return {undefined} * Nothing. */ function chunkedPush(list, right) { /** @type {number} */ let chunkStart = 0; if (right.length < 10000) { list.push(...right); } else { while (chunkStart < right.length) { list.push(...right.slice(chunkStart, chunkStart + 10000)); chunkStart += 10000; } } }