├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE ├── README.md ├── benchmark.coffee ├── package.json ├── rope.js ├── simpleexample.js ├── test ├── helpers.coffee ├── mocha.opts └── test.coffee └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | .DS_Store 3 | node_modules 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | test 2 | *.coffee 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - stable 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Licensed under the standard MIT license: 2 | 3 | Copyright 2011-2016 Joseph Gentle. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | JumpRope 2 | ======== 3 | 4 | Jumprope is a fun little library for efficiently editing strings in Javascript. If you have long strings and you need to insert or delete into them, you should use jumprope. Its faster than splicing strings all the time if the strings are big: 5 | 6 | 5000000 random inserts on an empty string, resulting in the string of size 6M chars long: 7 | 8 | Rope took 5610 ms. 0.001122 ms per iteration, 891k iterations per second 9 | JS toString took 3463 ms. 0.003463 ms per iteration, 288k iterations per second 10 | 11 | (Tested on node v10.13.0) 12 | 13 | 14 | Ropes have insertion and deletion time of O(|s| * log(N)) where 15 | |s| is the length of the inserted / deleted region 16 | N is the length of the string 17 | 18 | In comparison, naive strings have insertion time of O(N + s) and deletion time of O(N - s). Javascript strings are special and complicated, and much fancier than naive strings. This library was written several years ago. NodeJS strings have gotten faster in the intervening time, and using pure JS strings is a reasonable choice for recent applications. 19 | 20 | 21 | Installing 22 | ---------- 23 | 24 | npm install jumprope 25 | 26 | Usage 27 | ----- 28 | 29 | ```javascript 30 | Rope = require('jumprope'); 31 | 32 | var r = new Rope('initial string'); 33 | r.insert(4, 'some text'); // Insert 'some text' at position 4 in the string 34 | r.del(4, 9); // Delete 9 characters from the string at position 4 35 | console.log("String contains: " + r.toString() + " length: " + r.length); 36 | ``` 37 | 38 | Output: 39 | 40 | String contains: 'initial string' length: 14 41 | 42 | API 43 | --- 44 | 45 | * `new Rope([initial text])` 46 | 47 | Create a new rope, optionally with the specified initial text. 48 | 49 | ```javascript 50 | Rope = require('jumprope'); 51 | 52 | var r = new Rope(); // Create a new Rope 53 | var r = new Rope('str'); // Create a new Rope with initial string 'str' 54 | ``` 55 | 56 | * `r.insert(position, text)` 57 | 58 | Insert text into the rope at the specified position. 59 | 60 | ```javascript 61 | r.insert(4, 'some text'); // Insert 'some text' at position 4. Position must be inside the string. 62 | ``` 63 | 64 | * `r.del(position, count, [callback])` 65 | 66 | Delete `count` characters from the rope at `position`. Delete can optionally take a callback, which is called with the deleted substring. 67 | 68 | ```javascript 69 | r.del(4, 10); // Delete 10 characters at position 4 70 | r.del(4, 10, function(str) { console.log(str); }); // Delete 10 characters, and print them out. 71 | ``` 72 | 73 | * `r.forEach(callback)` 74 | 75 | Iterate through the rope. The callback will be passed the whole string, a few characters at a time. This is the fastest way to read the string if you want to write it over a network stream, for example. 76 | 77 | ```javascript 78 | // Print the string out, a few characters at a time. 79 | r.forEach(function(str) { console.log(str); }) 80 | ``` 81 | 82 | * `r.toString()` 83 | 84 | Convert the rope into a javascript string. 85 | 86 | Internally, this just calls `forEach()` and `.join`'s the result. 87 | 88 | ```javascript 89 | console.log(r.toString()); 90 | ``` 91 | 92 | * `r.length` 93 | 94 | Get the number of characters in the rope. 95 | 96 | ```javascript 97 | r.del(r.length - 4, 4); // Delete the last 4 characters in the string. 98 | ``` 99 | 100 | * `r.substring(position, length)` 101 | 102 | Get a substring of the string. Kinda like splice, I guess. Maybe I should copy the JS API. 103 | 104 | ```javascript 105 | console.log(r.substring(4, 10)); // Print out 10 characters from position 4 onwards. 106 | ``` 107 | 108 | Speed 109 | ----- 110 | 111 | At least in V8 (Node / Chrome) it seems like the cross-over point where it becomes worth using jumpropes is when you're dealing with strings longer than about 5000 characters. Until then, the overheads of jumpropes makes them slower than just dealing with normal javascript strings. 112 | 113 | Of course, when your strings are that small it doesn't matter that much how you're using them. 114 | 115 | Once your strings get long, jumpropes become a lot faster. 116 | 117 | 118 | License 119 | ------- 120 | 121 | MIT licensed, so do what you want with it. 122 | 123 | 124 | Acknowledgements 125 | ---------------- 126 | 127 | Thanks to Ben Weaver for his node [closure library](https://github.com/weaver/scribbles/tree/master/node/google-closure/) 128 | -------------------------------------------------------------------------------- /benchmark.coffee: -------------------------------------------------------------------------------- 1 | helpers = require './test/helpers' 2 | {randomInt, randomStr, addHelpers} = helpers 3 | 4 | Rope = addHelpers(require '.') 5 | #helpers.addHelpers(Rope) 6 | 7 | time = (fn, iterations) -> 8 | start = Date.now() 9 | fn() for [0...iterations] 10 | Date.now() - start 11 | 12 | timeprint = (fn, iterations, name) -> 13 | console.log "Benchmarking #{iterations} iterations of #{name}..." 14 | result = time fn, iterations 15 | console.log "#{name} took #{result} ms. #{result / iterations} ms per iteration, #{(iterations/result)|0}k iterations per second" 16 | iterations/result 17 | 18 | permute = (r) -> 19 | random = helpers.useRandomWithSeed 100 20 | 21 | -> 22 | if random() < 0.95 23 | # Insert. 24 | text = randomStr(randomInt 2) 25 | pos = randomInt(r.length + 1) 26 | 27 | r.insert pos, text 28 | else 29 | # Delete 30 | pos = randomInt(r.length) 31 | length = Math.min(r.length - pos, randomInt(10)) 32 | 33 | r.del pos, length 34 | 35 | testToString = -> 36 | r = new Rope randomStr(20000) 37 | console.log r.length 38 | timeprint (-> r.toString()), 1000000, 'toString' 39 | 40 | testSizes = -> 41 | throw new Error "You need to uncomment the setSpliceSize line in Rope.coffee to use this test" unless Rope.setSpliceSize? 42 | size = 4 43 | while size < 20000 44 | Rope.setSplitSize size 45 | 46 | console.log "Split size #{size}" 47 | r = new Rope() 48 | timeprint permute(r), 100000, 'Rope' 49 | r.stats() 50 | 51 | size *= 2 52 | 53 | testBias = -> 54 | throw new Error "You need to uncomment the setBias line in Rope.coffee to use this test" unless Rope.setBias? 55 | results = [] 56 | for bias in [0.1..0.99] by 0.02 57 | Rope.setBias bias 58 | 59 | r = new Rope() 60 | results.push [bias, timeprint permute(r), 3000000, "Bias #{bias}"] 61 | console.log "" 62 | 63 | console.log results.map((v) -> v.join()).join('\n') 64 | 65 | 66 | naiveTest = -> 67 | r = new Rope() 68 | iterations = 5000000 69 | timeprint permute(r), iterations, 'Rope' 70 | # timeprint permute(helpers.Str()), iterations, 'Str' 71 | r.stats() 72 | 73 | testBias() 74 | # naiveTest() 75 | # testToString() 76 | 77 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jumprope", 3 | "description": "Fast string editing in Javascript using skip lists", 4 | "version": "1.2.1", 5 | "author": "Joseph Gentle (josephgentle.com)", 6 | "keywords": [ 7 | "rope", 8 | "string", 9 | "algorithm" 10 | ], 11 | "main": "rope.js", 12 | "directories": { 13 | "lib": "lib", 14 | "example": "example" 15 | }, 16 | "scripts": { 17 | "test": "mocha test/test.coffee" 18 | }, 19 | "licenses": [ 20 | { 21 | "type": "MIT", 22 | "url": "https://github.com/josephg/jumprope/blob/master/LICENSE" 23 | } 24 | ], 25 | "repository": { 26 | "type": "git", 27 | "url": "http://github.com/josephg/jumprope.git" 28 | }, 29 | "devDependencies": { 30 | "coffeescript": "^2.3.2", 31 | "mocha": "5" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /rope.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | // Rope implemented with skip lists! 3 | // 4 | // Each element in the skip list contains a string, an array of next pointers 5 | // and an array of subtree sizes. 6 | // 7 | // The next pointers work like normal skip lists. Here's some google results: 8 | // http://en.wikipedia.org/wiki/Skip_list 9 | // http://igoro.com/archive/skip-lists-are-fascinating/ 10 | // 11 | // The subtree size is the number of characters between the start of the current 12 | // element and the start of the next element at that level in the list. 13 | // 14 | // So, e.subtreesize[4] == e.str.length + no. chars between e and e.nexts[4]. 15 | // 16 | // 17 | // I use foo['bar'] syntax in a bunch of places to stop the closure compiler renaming 18 | // exported methods. 19 | 20 | 21 | // The split size is the maximum number of characters to have in each element 22 | // in the list before splitting it out into multiple elements. 23 | // Benchmarking reveals 512 to be a pretty good number for this. 24 | const SPLIT_SIZE = 512 25 | 26 | // Each skip list element has height >= H with P=bias^(H-1). 27 | // 28 | // I ran some benchmarks, expecting 0.5 to get the best speed. But, for some reason, 29 | // the speed is a bit better around 0.62 30 | const bias = 0.62 31 | 32 | //inspect = require('util').inspect 33 | 34 | const randomHeight = () => { 35 | let length = 1 36 | 37 | // This method uses successive bits of a random number to figure out whick skip lists 38 | // to be part of. It is faster than the method below, but doesn't support weird biases. 39 | // It turns out, it is slightly faster to have non-0.5 bias and that offsets the cost of 40 | // calling random() more times (at least in v8) 41 | // r = Math.random() * 2 42 | // while r > 1 43 | // r = (r - 1) * 2 44 | // length++ 45 | 46 | while (Math.random() > bias) length++ 47 | 48 | return length 49 | } 50 | 51 | class Rope { 52 | constructor(str) { 53 | if (!(this instanceof Rope)) return new Rope(str) 54 | 55 | this.head = { 56 | nexts: [], 57 | subtreesize: [] 58 | } 59 | this.length = 0 60 | 61 | if (str != null) this.insert(0, str) 62 | } 63 | 64 | forEach(fn) { 65 | for (const s of this) fn(s) 66 | } 67 | 68 | toString() { 69 | const strings = [] 70 | this.forEach(str => strings.push(str)) 71 | return strings.join('') 72 | } 73 | 74 | toJSON() { return this.toString() } 75 | 76 | *[Symbol.iterator]() { 77 | // Skip the head, since it has no string. 78 | let e = this.head.nexts[0] 79 | 80 | while (e) { 81 | yield e.str 82 | e = e.nexts[0] 83 | } 84 | } 85 | 86 | // Navigate to a particular position in the string. Returns a cursor at that position. 87 | seek(offset) { 88 | if (typeof offset !== 'number') throw new Error('position must be a number') 89 | if (offset < 0 || offset > this.length) { 90 | throw new Error("pos " + offset + " must be within the rope (" + this.length + ")") 91 | } 92 | 93 | let e = this.head 94 | const nodes = new Array(this.head.nexts.length) 95 | const subtreesize = new Array(this.head.nexts.length) 96 | if (e.nexts.length > 0) { 97 | // Iterate backwards through the list. 98 | let h = e.nexts.length 99 | while (h--) { 100 | while (offset > e.subtreesize[h]) { 101 | offset -= e.subtreesize[h] 102 | e = e.nexts[h] 103 | } 104 | subtreesize[h] = offset 105 | nodes[h] = e 106 | } 107 | } 108 | return [e, offset, nodes, subtreesize] 109 | } 110 | 111 | _spliceIn(nodes, subtreesize, insertPos, str) { 112 | // This function splices the given string into the rope at the specified 113 | // cursor. The cursor is moved to the end of the string. 114 | const height = randomHeight() 115 | const newE = { 116 | str: str, 117 | nexts: new Array(height), 118 | subtreesize: new Array(height) 119 | } 120 | 121 | for (let i = 0; i < height; i++) { 122 | if (i < this.head.nexts.length) { 123 | newE.nexts[i] = nodes[i].nexts[i] 124 | nodes[i].nexts[i] = newE 125 | newE.subtreesize[i] = str.length + nodes[i].subtreesize[i] - subtreesize[i] 126 | nodes[i].subtreesize[i] = subtreesize[i] 127 | } else { 128 | newE.nexts[i] = null 129 | newE.subtreesize[i] = this.length - insertPos + str.length 130 | this.head.nexts.push(newE) 131 | this.head.subtreesize.push(insertPos) 132 | } 133 | nodes[i] = newE 134 | subtreesize[i] = str.length 135 | } 136 | 137 | if (height < nodes.length) { 138 | for (let i = height; i < nodes.length; i++) { 139 | nodes[i].subtreesize[i] += str.length 140 | subtreesize[i] += str.length 141 | } 142 | } 143 | 144 | insertPos += str.length 145 | this.length += str.length 146 | 147 | return insertPos; 148 | } 149 | 150 | _updateLength(nodes, length) { 151 | for (let i = 0; i < nodes.length; i++) { 152 | nodes[i].subtreesize[i] += length 153 | } 154 | this.length += length 155 | } 156 | 157 | insert(insertPos, str) { 158 | if (typeof str !== 'string') throw new Error('inserted text must be a string') 159 | 160 | // The spread operator isn't in nodejs yet. 161 | const cursor = this.seek(insertPos) 162 | const [e, offset, nodes, subtreesize] = cursor 163 | 164 | if (e.str != null && e.str.length + str.length < SPLIT_SIZE) { 165 | // The new string will fit in the end of the current item 166 | e.str = e.str.slice(0, offset) + str + e.str.slice(offset) 167 | this._updateLength(nodes, str.length) 168 | } else { 169 | // Insert a new item 170 | 171 | // If there's stuff at the end of the current item, we'll remove it for now: 172 | let end = '' 173 | if (e.str != null && e.str.length > offset) { 174 | end = e.str.slice(offset) 175 | e.str = e.str.slice(0, offset) 176 | this._updateLength(nodes, -end.length) 177 | } 178 | 179 | // Split up the new string based on SPLIT_SIZE and insert each chunk. 180 | for (let i = 0; i < str.length; i += SPLIT_SIZE) { 181 | insertPos = this._spliceIn(nodes, subtreesize, insertPos, str.slice(i, i + SPLIT_SIZE)) 182 | } 183 | if (end !== '') this._spliceIn(nodes, subtreesize, insertPos, end) 184 | } 185 | 186 | // For chaining. 187 | return this 188 | } 189 | 190 | // Delete characters at the specified position. This function returns this 191 | // for chaining, but if you want the deleted characters back you can pass a 192 | // function to recieve them. It'll get called syncronously. 193 | del(delPos, length, getDeleted) { 194 | if (delPos < 0 || delPos + length > this.length) { 195 | throw new Error(`positions #{delPos} and #{delPos + length} must be within the rope (#{this.length})`) 196 | } 197 | 198 | // Only collect strings if we need to. 199 | let strings = getDeleted != null ? [] : null 200 | 201 | const cursor = this.seek(delPos) 202 | let e = cursor[0], offset = cursor[1], nodes = cursor[2] 203 | 204 | this.length -= length 205 | while (length > 0) { 206 | // Delete up to length from e. 207 | 208 | if (e.str == null || offset === e.str.length) { 209 | // Move along to the next node. 210 | e = nodes[0].nexts[0] 211 | offset = 0 212 | } 213 | 214 | let removed = Math.min(length, e.str.length - offset) 215 | if (removed < e.str.length) { 216 | // We aren't removing the whole node. 217 | 218 | if (strings != null) strings.push(e.str.slice(offset, offset + removed)) 219 | 220 | // Splice out the string 221 | e.str = e.str.slice(0, offset) + e.str.slice(offset + removed) 222 | for (let i = 0; i < nodes.length; i++) { 223 | if (i < e.nexts.length) { 224 | e.subtreesize[i] -= removed 225 | } else { 226 | nodes[i].subtreesize[i] -= removed 227 | } 228 | } 229 | } else { 230 | // Remove the whole node. 231 | 232 | if (strings != null) strings.push(e.str) 233 | 234 | // Unlink the element. 235 | for (let i = 0; i < nodes.length; i++) { 236 | let node = nodes[i] 237 | if (i < e.nexts.length) { 238 | node.subtreesize[i] = nodes[i].subtreesize[i] + e.subtreesize[i] - removed 239 | node.nexts[i] = e.nexts[i] 240 | } else { 241 | node.subtreesize[i] -= removed 242 | } 243 | } 244 | 245 | // It would probably be better to make a little object pool here. 246 | e = e.nexts[0] 247 | } 248 | length -= removed 249 | } 250 | if (getDeleted) getDeleted(strings.join('')) 251 | return this; 252 | } 253 | 254 | // Extract a substring at the specified offset and of the specified length 255 | substring(offsetIn, length) { 256 | if (offsetIn < 0 || offsetIn + length > this.length) { 257 | throw new Error(`Substring (#{offsetIn}-#{offsetIn+length} outside rope (length #{this.length})`); 258 | } 259 | 260 | let [e, offset] = this.seek(offsetIn) 261 | 262 | const strings = [] 263 | if (e.str == null) e = e.nexts[0] 264 | 265 | while (e && length > 0) { 266 | let s = e.str.slice(offset, offset + length) 267 | strings.push(s) 268 | offset = 0 269 | length -= s.length 270 | e = e.nexts[0] 271 | } 272 | return strings.join('') 273 | } 274 | 275 | // For backwards compatibility. 276 | each(fn) { this.forEach(fn) } 277 | search(offset) { return this.seek(offset) } 278 | } 279 | 280 | module.exports = Rope; 281 | 282 | // Uncomment these functions in order to run the split size test or the bias test. 283 | // They have been removed to keep the compiled size down. 284 | // Rope.setSplitSize = s => splitSize = s 285 | // Rope.setBias = n => bias = n 286 | 287 | -------------------------------------------------------------------------------- /simpleexample.js: -------------------------------------------------------------------------------- 1 | var Rope = require('jumprope'); 2 | 3 | var r = new Rope("G'day"); 4 | r.insert(0, 'Hi there\n') 5 | console.log(r.toString()); 6 | 7 | r.del(9, 5); 8 | console.log(r.toString()); 9 | -------------------------------------------------------------------------------- /test/helpers.coffee: -------------------------------------------------------------------------------- 1 | # These are some helper methods for testing. 2 | 3 | assert = require 'assert' 4 | inspect = require('util').inspect 5 | 6 | random = Math.random 7 | 8 | exports.useRandomWithSeed = (seed = 10) -> 9 | r = seed 10 | randomInt = (n) -> Math.abs((r = (r << 2) ^ (r << 1) - r + 1) % n) 11 | random = -> randomInt(100) / 100 12 | 13 | exports.randomInt = randomInt = (bound) -> Math.floor(random() * bound) 14 | 15 | alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ ' 16 | randomChar = -> alphabet[randomInt(alphabet.length)] 17 | 18 | # Generates a random string of length len 19 | exports.randomStr = (len = 10) -> (randomChar() for [1...len]).join('') + ' ' 20 | 21 | # We'll make an implementation of the rope API backed by strings 22 | exports.Str = (s = '') -> 23 | { 24 | insert: (pos, str) -> 25 | s = s[...pos] + str + s[pos..] 26 | @length = s.length 27 | del: (pos, len) -> 28 | s = s[...pos] + s[pos + len..] 29 | @length = s.length 30 | 31 | verify: -> 32 | toString: -> s 33 | print: -> console.log s 34 | length: s.length 35 | } 36 | 37 | # Some more methods for Rope which we'll use in tests. 38 | exports.addHelpers = (Impl) -> 39 | Impl::verify = -> 40 | nodes = (@head for [0...@head.nexts.length]) 41 | positions = @head.nexts.map -> 0 42 | 43 | pos = 0 44 | e = @head 45 | 46 | while e != null 47 | pos += e.str?.length ? 0 48 | e = e.nexts[0] || null 49 | 50 | for i in [0...nodes.length] 51 | if nodes[i].subtreesize[i] + positions[i] == pos 52 | assert.strictEqual nodes[i].nexts[i], e 53 | 54 | nodes[i] = e 55 | positions[i] = pos 56 | else 57 | assert.ok nodes[i].subtreesize[i] + positions[i] > pos, "asdf" 58 | 59 | assert.strictEqual pos, @length 60 | 61 | Impl::stats = -> 62 | numElems = 0 63 | e = @head 64 | while e != null 65 | e = e.nexts[0] 66 | numElems++ 67 | 68 | console.log "Length: #{@length}" 69 | console.log "Num elements: #{numElems}" 70 | console.log "Avg string length per element: #{@length / numElems}" 71 | 72 | Impl::print = -> 73 | console.log "Rope with string '#{@['toString']()}'" 74 | node = @head 75 | while node? 76 | console.log "#{inspect node}" 77 | node = node.nexts[0] 78 | 79 | this 80 | 81 | Impl 82 | 83 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | -r coffeescript/register 2 | --reporter spec 3 | --check-leaks 4 | -------------------------------------------------------------------------------- /test/test.coffee: -------------------------------------------------------------------------------- 1 | assert = require 'assert' 2 | 3 | Rope = require '../rope' 4 | 5 | helpers = require './helpers' 6 | helpers.addHelpers Rope 7 | 8 | {randomInt, randomStr} = helpers 9 | 10 | #Rope = helpers.Str 11 | 12 | check = (r, str) -> 13 | try 14 | r.verify() if r.verify 15 | assert.strictEqual r.toString(), str 16 | assert.strictEqual r.length, str.length 17 | 18 | strings = [] 19 | r.each (s) -> strings.push s 20 | assert.strictEqual strings.join(''), str 21 | 22 | strings = [] 23 | strings.push(s) for s from r 24 | assert.strictEqual strings.join(''), str 25 | 26 | catch e 27 | console.error 'Error when checking string:' 28 | r.print() if r.print 29 | throw e 30 | 31 | 32 | describe 'Rope', -> 33 | it 'has no content when empty', -> 34 | r = new Rope 35 | check r, '' 36 | 37 | it 'rope initialized with a string has that string as its content', -> 38 | str = 'Hi there' 39 | r = new Rope str 40 | check r, str 41 | 42 | it 'inserts at location', -> 43 | r = new Rope 44 | 45 | r.insert 0, 'AAA' 46 | check r, 'AAA' 47 | 48 | r.insert 0, 'BBB' 49 | check r, 'BBBAAA' 50 | 51 | r.insert 6, 'CCC' 52 | check r, 'BBBAAACCC' 53 | 54 | r.insert 5, 'DDD' 55 | check r, 'BBBAADDDACCC' 56 | 57 | it 'deletes at location', -> 58 | r = new Rope '012345678' 59 | 60 | r.del 8, 1 61 | check r, '01234567' 62 | 63 | r.del 0, 1 64 | check r, '1234567' 65 | 66 | r.del 5, 1 67 | check r, '123457' 68 | 69 | r.del 5, 1 70 | check r, '12345' 71 | 72 | r.del 0, 5 73 | check r, '' 74 | 75 | it 'delete calls callback with deleted text', -> 76 | r = new Rope 'abcde' 77 | called = false 78 | r.del 1, 3, (str) -> 79 | called = true 80 | assert.strictEqual str, 'bcd' 81 | 82 | assert called 83 | 84 | it 'does not call forEach with an empty string', -> 85 | r = new Rope 86 | # This probably won't call the method at all... 87 | r.forEach (str) -> 88 | throw Error 'should not be called' 89 | #assert.strictEqual str, '' 90 | 91 | it 'runs each correctly with small strings', -> 92 | str = 'howdy doody' 93 | r = new Rope str 94 | 95 | strings = [] 96 | r.each (s) -> strings.push s 97 | assert.strictEqual strings.join(''), str 98 | 99 | it 'substring with an empty string', -> 100 | r = new Rope 101 | s = r.substring 0, 0 102 | assert.strictEqual s, '' 103 | 104 | it 'JSON.stringifies to the embedded string', -> 105 | r = new Rope 'hi there' 106 | json = JSON.stringify r 107 | result = JSON.parse json 108 | assert.strictEqual result, r.toString() 109 | 110 | it 'substring', -> 111 | r = new Rope '0123456' 112 | 113 | assert.strictEqual '0', r.substring 0, 1 114 | assert.strictEqual '1', r.substring 1, 1 115 | assert.strictEqual '01', r.substring 0, 2 116 | assert.strictEqual '0123456', r.substring 0, 7 117 | assert.strictEqual '456', r.substring 4, 3 118 | 119 | it 'delete and insert with long strings works as expected', -> 120 | str = "some really long string. Look at me go! Oh my god this has to be the longest string I've ever seen. Holy cow. I can see space from up here. Hi everybody - check out my amazing string!\n" 121 | str = new Array(1001).join str 122 | 123 | r = new Rope str 124 | assert.strictEqual r.length, str.length 125 | assert.strictEqual r.toString(), str 126 | 127 | r.del 1, str.length - 2 128 | assert.strictEqual r.length, 2 129 | assert.strictEqual r.toString(), str[0] + str[str.length - 1] 130 | 131 | it 'randomized test', -> 132 | str = '' 133 | r = new Rope 134 | 135 | for [1..1000] 136 | if Math.random() < 0.9 137 | # Insert. 138 | text = randomStr(100) 139 | pos = randomInt(str.length + 1) 140 | 141 | # console.log "Inserting '#{text}' at #{pos}" 142 | 143 | r.insert pos, text 144 | str = str[0...pos] + text + str[pos..] 145 | else 146 | # Delete 147 | pos = randomInt(str.length) 148 | length = Math.min(str.length - pos, Math.floor(Math.random() * 10)) 149 | 150 | # console.log "Deleting #{length} chars (#{str[pos...pos + length]}) at #{pos}" 151 | 152 | deletedText = str[pos...pos + length] 153 | assert.strictEqual deletedText, r.substring pos, length 154 | 155 | callbackCalled = no 156 | r.del pos, length, (s) -> 157 | assert.strictEqual s, deletedText 158 | callbackCalled = yes 159 | 160 | str = str[0...pos] + str[(pos + length)...] 161 | 162 | assert.strictEqual callbackCalled, yes, 'didnt call the delete callback' 163 | 164 | check r, str 165 | assert.strictEqual str, r.toString() 166 | assert.strictEqual str.length, r.length 167 | 168 | r.stats() if r.stats? 169 | 170 | -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | balanced-match@^1.0.0: 6 | version "1.0.0" 7 | resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" 8 | integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= 9 | 10 | brace-expansion@^1.1.7: 11 | version "1.1.11" 12 | resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" 13 | integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== 14 | dependencies: 15 | balanced-match "^1.0.0" 16 | concat-map "0.0.1" 17 | 18 | browser-stdout@1.3.1: 19 | version "1.3.1" 20 | resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60" 21 | integrity sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw== 22 | 23 | coffeescript@^2.3.2: 24 | version "2.3.2" 25 | resolved "https://registry.yarnpkg.com/coffeescript/-/coffeescript-2.3.2.tgz#e854a7020dfe47b7cf4dd412042e32ef1e269810" 26 | integrity sha512-YObiFDoukx7qPBi/K0kUKyntEZDfBQiqs/DbrR1xzASKOBjGT7auD85/DiPeRr9k++lRj7l3uA9TNMLfyfcD/Q== 27 | 28 | commander@2.15.1: 29 | version "2.15.1" 30 | resolved "https://registry.yarnpkg.com/commander/-/commander-2.15.1.tgz#df46e867d0fc2aec66a34662b406a9ccafff5b0f" 31 | integrity sha512-VlfT9F3V0v+jr4yxPc5gg9s62/fIVWsd2Bk2iD435um1NlGMYdVCq+MjcXnhYq2icNOizHr1kK+5TI6H0Hy0ag== 32 | 33 | concat-map@0.0.1: 34 | version "0.0.1" 35 | resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" 36 | integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= 37 | 38 | debug@3.1.0: 39 | version "3.1.0" 40 | resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261" 41 | integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g== 42 | dependencies: 43 | ms "2.0.0" 44 | 45 | diff@3.5.0: 46 | version "3.5.0" 47 | resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12" 48 | integrity sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA== 49 | 50 | escape-string-regexp@1.0.5: 51 | version "1.0.5" 52 | resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" 53 | integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= 54 | 55 | fs.realpath@^1.0.0: 56 | version "1.0.0" 57 | resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" 58 | integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= 59 | 60 | glob@7.1.2: 61 | version "7.1.2" 62 | resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.2.tgz#c19c9df9a028702d678612384a6552404c636d15" 63 | integrity sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ== 64 | dependencies: 65 | fs.realpath "^1.0.0" 66 | inflight "^1.0.4" 67 | inherits "2" 68 | minimatch "^3.0.4" 69 | once "^1.3.0" 70 | path-is-absolute "^1.0.0" 71 | 72 | growl@1.10.5: 73 | version "1.10.5" 74 | resolved "https://registry.yarnpkg.com/growl/-/growl-1.10.5.tgz#f2735dc2283674fa67478b10181059355c369e5e" 75 | integrity sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA== 76 | 77 | has-flag@^3.0.0: 78 | version "3.0.0" 79 | resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" 80 | integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0= 81 | 82 | he@1.1.1: 83 | version "1.1.1" 84 | resolved "https://registry.yarnpkg.com/he/-/he-1.1.1.tgz#93410fd21b009735151f8868c2f271f3427e23fd" 85 | integrity sha1-k0EP0hsAlzUVH4howvJx80J+I/0= 86 | 87 | inflight@^1.0.4: 88 | version "1.0.6" 89 | resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" 90 | integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk= 91 | dependencies: 92 | once "^1.3.0" 93 | wrappy "1" 94 | 95 | inherits@2: 96 | version "2.0.3" 97 | resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" 98 | integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= 99 | 100 | minimatch@3.0.4, minimatch@^3.0.4: 101 | version "3.0.4" 102 | resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" 103 | integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA== 104 | dependencies: 105 | brace-expansion "^1.1.7" 106 | 107 | minimist@0.0.8: 108 | version "0.0.8" 109 | resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" 110 | integrity sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0= 111 | 112 | mkdirp@0.5.1: 113 | version "0.5.1" 114 | resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" 115 | integrity sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM= 116 | dependencies: 117 | minimist "0.0.8" 118 | 119 | mocha@5: 120 | version "5.2.0" 121 | resolved "https://registry.yarnpkg.com/mocha/-/mocha-5.2.0.tgz#6d8ae508f59167f940f2b5b3c4a612ae50c90ae6" 122 | integrity sha512-2IUgKDhc3J7Uug+FxMXuqIyYzH7gJjXECKe/w43IGgQHTSj3InJi+yAA7T24L9bQMRKiUEHxEX37G5JpVUGLcQ== 123 | dependencies: 124 | browser-stdout "1.3.1" 125 | commander "2.15.1" 126 | debug "3.1.0" 127 | diff "3.5.0" 128 | escape-string-regexp "1.0.5" 129 | glob "7.1.2" 130 | growl "1.10.5" 131 | he "1.1.1" 132 | minimatch "3.0.4" 133 | mkdirp "0.5.1" 134 | supports-color "5.4.0" 135 | 136 | ms@2.0.0: 137 | version "2.0.0" 138 | resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" 139 | integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= 140 | 141 | once@^1.3.0: 142 | version "1.4.0" 143 | resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" 144 | integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E= 145 | dependencies: 146 | wrappy "1" 147 | 148 | path-is-absolute@^1.0.0: 149 | version "1.0.1" 150 | resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" 151 | integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= 152 | 153 | supports-color@5.4.0: 154 | version "5.4.0" 155 | resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.4.0.tgz#1c6b337402c2137605efe19f10fec390f6faab54" 156 | integrity sha512-zjaXglF5nnWpsq470jSv6P9DwPvgLkuapYmfDm3JWOm0vkNTVF2tI4UrN2r6jH1qM/uc/WtxYY1hYoA2dOKj5w== 157 | dependencies: 158 | has-flag "^3.0.0" 159 | 160 | wrappy@1: 161 | version "1.0.2" 162 | resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" 163 | integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= 164 | --------------------------------------------------------------------------------