jumprope
Version:
Fast string editing in Javascript using skip lists
287 lines (237 loc) • 8.6 kB
JavaScript
"use strict";
// Rope implemented with skip lists!
//
// Each element in the skip list contains a string, an array of next pointers
// and an array of subtree sizes.
//
// The next pointers work like normal skip lists. Here's some google results:
// http://en.wikipedia.org/wiki/Skip_list
// http://igoro.com/archive/skip-lists-are-fascinating/
//
// The subtree size is the number of characters between the start of the current
// element and the start of the next element at that level in the list.
//
// So, e.subtreesize[4] == e.str.length + no. chars between e and e.nexts[4].
//
//
// I use foo['bar'] syntax in a bunch of places to stop the closure compiler renaming
// exported methods.
// The split size is the maximum number of characters to have in each element
// in the list before splitting it out into multiple elements.
// Benchmarking reveals 512 to be a pretty good number for this.
const SPLIT_SIZE = 512
// Each skip list element has height >= H with P=bias^(H-1).
//
// I ran some benchmarks, expecting 0.5 to get the best speed. But, for some reason,
// the speed is a bit better around 0.62
const bias = 0.62
//inspect = require('util').inspect
const randomHeight = () => {
let length = 1
// This method uses successive bits of a random number to figure out whick skip lists
// to be part of. It is faster than the method below, but doesn't support weird biases.
// It turns out, it is slightly faster to have non-0.5 bias and that offsets the cost of
// calling random() more times (at least in v8)
// r = Math.random() * 2
// while r > 1
// r = (r - 1) * 2
// length++
while (Math.random() > bias) length++
return length
}
class Rope {
constructor(str) {
if (!(this instanceof Rope)) return new Rope(str)
this.head = {
nexts: [],
subtreesize: []
}
this.length = 0
if (str != null) this.insert(0, str)
}
forEach(fn) {
for (const s of this) fn(s)
}
toString() {
const strings = []
this.forEach(str => strings.push(str))
return strings.join('')
}
toJSON() { return this.toString() }
*[Symbol.iterator]() {
// Skip the head, since it has no string.
let e = this.head.nexts[0]
while (e) {
yield e.str
e = e.nexts[0]
}
}
// Navigate to a particular position in the string. Returns a cursor at that position.
seek(offset) {
if (typeof offset !== 'number') throw new Error('position must be a number')
if (offset < 0 || offset > this.length) {
throw new Error("pos " + offset + " must be within the rope (" + this.length + ")")
}
let e = this.head
const nodes = new Array(this.head.nexts.length)
const subtreesize = new Array(this.head.nexts.length)
if (e.nexts.length > 0) {
// Iterate backwards through the list.
let h = e.nexts.length
while (h--) {
while (offset > e.subtreesize[h]) {
offset -= e.subtreesize[h]
e = e.nexts[h]
}
subtreesize[h] = offset
nodes[h] = e
}
}
return [e, offset, nodes, subtreesize]
}
_spliceIn(nodes, subtreesize, insertPos, str) {
// This function splices the given string into the rope at the specified
// cursor. The cursor is moved to the end of the string.
const height = randomHeight()
const newE = {
str: str,
nexts: new Array(height),
subtreesize: new Array(height)
}
for (let i = 0; i < height; i++) {
if (i < this.head.nexts.length) {
newE.nexts[i] = nodes[i].nexts[i]
nodes[i].nexts[i] = newE
newE.subtreesize[i] = str.length + nodes[i].subtreesize[i] - subtreesize[i]
nodes[i].subtreesize[i] = subtreesize[i]
} else {
newE.nexts[i] = null
newE.subtreesize[i] = this.length - insertPos + str.length
this.head.nexts.push(newE)
this.head.subtreesize.push(insertPos)
}
nodes[i] = newE
subtreesize[i] = str.length
}
if (height < nodes.length) {
for (let i = height; i < nodes.length; i++) {
nodes[i].subtreesize[i] += str.length
subtreesize[i] += str.length
}
}
insertPos += str.length
this.length += str.length
return insertPos;
}
_updateLength(nodes, length) {
for (let i = 0; i < nodes.length; i++) {
nodes[i].subtreesize[i] += length
}
this.length += length
}
insert(insertPos, str) {
if (typeof str !== 'string') throw new Error('inserted text must be a string')
// The spread operator isn't in nodejs yet.
const cursor = this.seek(insertPos)
const [e, offset, nodes, subtreesize] = cursor
if (e.str != null && e.str.length + str.length < SPLIT_SIZE) {
// The new string will fit in the end of the current item
e.str = e.str.slice(0, offset) + str + e.str.slice(offset)
this._updateLength(nodes, str.length)
} else {
// Insert a new item
// If there's stuff at the end of the current item, we'll remove it for now:
let end = ''
if (e.str != null && e.str.length > offset) {
end = e.str.slice(offset)
e.str = e.str.slice(0, offset)
this._updateLength(nodes, -end.length)
}
// Split up the new string based on SPLIT_SIZE and insert each chunk.
for (let i = 0; i < str.length; i += SPLIT_SIZE) {
insertPos = this._spliceIn(nodes, subtreesize, insertPos, str.slice(i, i + SPLIT_SIZE))
}
if (end !== '') this._spliceIn(nodes, subtreesize, insertPos, end)
}
// For chaining.
return this
}
// Delete characters at the specified position. This function returns this
// for chaining, but if you want the deleted characters back you can pass a
// function to recieve them. It'll get called syncronously.
del(delPos, length, getDeleted) {
if (delPos < 0 || delPos + length > this.length) {
throw new Error(`positions #{delPos} and #{delPos + length} must be within the rope (#{this.length})`)
}
// Only collect strings if we need to.
let strings = getDeleted != null ? [] : null
const cursor = this.seek(delPos)
let e = cursor[0], offset = cursor[1], nodes = cursor[2]
this.length -= length
while (length > 0) {
// Delete up to length from e.
if (e.str == null || offset === e.str.length) {
// Move along to the next node.
e = nodes[0].nexts[0]
offset = 0
}
let removed = Math.min(length, e.str.length - offset)
if (removed < e.str.length) {
// We aren't removing the whole node.
if (strings != null) strings.push(e.str.slice(offset, offset + removed))
// Splice out the string
e.str = e.str.slice(0, offset) + e.str.slice(offset + removed)
for (let i = 0; i < nodes.length; i++) {
if (i < e.nexts.length) {
e.subtreesize[i] -= removed
} else {
nodes[i].subtreesize[i] -= removed
}
}
} else {
// Remove the whole node.
if (strings != null) strings.push(e.str)
// Unlink the element.
for (let i = 0; i < nodes.length; i++) {
let node = nodes[i]
if (i < e.nexts.length) {
node.subtreesize[i] = nodes[i].subtreesize[i] + e.subtreesize[i] - removed
node.nexts[i] = e.nexts[i]
} else {
node.subtreesize[i] -= removed
}
}
// It would probably be better to make a little object pool here.
e = e.nexts[0]
}
length -= removed
}
if (getDeleted) getDeleted(strings.join(''))
return this;
}
// Extract a substring at the specified offset and of the specified length
substring(offsetIn, length) {
if (offsetIn < 0 || offsetIn + length > this.length) {
throw new Error(`Substring (#{offsetIn}-#{offsetIn+length} outside rope (length #{this.length})`);
}
let [e, offset] = this.seek(offsetIn)
const strings = []
if (e.str == null) e = e.nexts[0]
while (e && length > 0) {
let s = e.str.slice(offset, offset + length)
strings.push(s)
offset = 0
length -= s.length
e = e.nexts[0]
}
return strings.join('')
}
// For backwards compatibility.
each(fn) { this.forEach(fn) }
search(offset) { return this.seek(offset) }
}
module.exports = Rope;
// Uncomment these functions in order to run the split size test or the bias test.
// They have been removed to keep the compiled size down.
// Rope.setSplitSize = s => splitSize = s
// Rope.setBias = n => bias = n