From 1859d1441605516ecdad0a69be9c4242efae0513 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 21 Sep 2012 18:28:10 +0000 Subject: [PATCH] implement lines module, with tests --- lib/lines.js | 433 ++++++++++++++++++++++++++++++++++++++++++++++++++ test/lines.js | 298 ++++++++++++++++++++++++++++++++++ 2 files changed, 731 insertions(+) create mode 100644 lib/lines.js create mode 100644 test/lines.js diff --git a/lib/lines.js b/lib/lines.js new file mode 100644 index 00000000..737c1bcc --- /dev/null +++ b/lib/lines.js @@ -0,0 +1,433 @@ +var assert = require("assert"); + +// Goals: +// 1. Minimize new string creation. +// 2. Keep (de)identation O(1) time. +// 3. Permit negative indentations. +// 4. Enforce immutability. +// 5. No newline characters. + +function Lines(infos) { + var self = this; + + assert.ok(self instanceof Lines); + assert.ok(infos.length > 0); + + function setSecret() { + secret = { + lines: self, + infos: infos + }; + } + + Object.defineProperties(self, { + length: { value: infos.length }, + setSecret: { value: setSecret } + }); +} + +// Exposed for instanceof checks. The fromString function should be used +// to create new Lines objects. +exports.Lines = Lines; + +var Lp = Lines.prototype, + leadingSpaceExp = /^\s*/, + secret; + +function getSecret(lines) { + secret = null; + try { + lines.setSecret(); + assert.strictEqual(typeof secret, "object"); + assert.strictEqual(secret.lines, lines); + return secret; + } finally { + secret = null; + } +} + +function getLineInfo(line) { + var indent = leadingSpaceExp.exec(line)[0].length; + return { + line: line, + indent: indent, + sliceStart: indent, + sliceEnd: line.length + }; +} + +function copyLineInfo(info) { + return { + line: info.line, + indent: info.indent, + sliceStart: info.sliceStart, + sliceEnd: info.sliceEnd + }; +} + +var fromStringCache = {}, + maxCacheKeyLen = 10; + +function fromString(string) { + if (string instanceof Lines) + return string; + + string += ""; + + if (fromStringCache.hasOwnProperty(string)) + return fromStringCache[string]; + + var lines = new Lines(string.split("\n").map(getLineInfo)); + + if (string.length <= maxCacheKeyLen) + fromStringCache[string] = lines; + + return lines; +} +exports.fromString = fromString; + +Lp.toString = function() { + var secret = getSecret(this); + return secret.infos.map(function(info, i) { + var toJoin = new Array(Math.max(info.indent, 0)); + + toJoin.push(info.line.slice( + info.sliceStart, + info.sliceEnd)); + + return toJoin.join(" "); + }).join("\n"); +}; + +Lp.bootstrapCharAt = function(pos) { + assert.strictEqual(typeof pos, "object"); + assert.strictEqual(typeof pos.line, "number"); + assert.strictEqual(typeof pos.column, "number"); + + var line = pos.line, + column = pos.column, + strings = this.toString().split("\n"), + string = strings[line - 1]; + + if (typeof string === "undefined") + return ""; + + if (column === string.length && + line < strings.length) + return "\n"; + + return string.charAt(column); +}; + +Lp.charAt = function(pos) { + assert.strictEqual(typeof pos, "object"); + assert.strictEqual(typeof pos.line, "number"); + assert.strictEqual(typeof pos.column, "number"); + + var line = pos.line, + column = pos.column, + secret = getSecret(this), + infos = secret.infos, + info = infos[line - 1], + c = column; + + if (typeof info === "undefined" || c < 0) + return ""; + + var indent = this.getIndentAt(line); + if (c < indent) + return " "; + + c += info.sliceStart - indent; + if (c === info.sliceEnd && + line < this.length) + return "\n"; + + return info.line.charAt(c); +}; + +Lp.indent = function(by) { + if (by === 0) + return this; + + var infos = getSecret(this).infos; + + return new Lines(infos.map(function(info) { + info = copyLineInfo(info); + info.indent += by; + return info + })); +}; + +Lp.indentTail = function(by) { + if (by === 0) + return this; + + var infos = getSecret(this).infos; + + return new Lines(infos.map(function(info, i) { + if (i > 0) { + info = copyLineInfo(info); + info.indent += by; + } + + return info; + })); +}; + +Lp.getIndentAt = function(line) { + var secret = getSecret(this), + info = secret.infos[line - 1]; + return Math.max(info.indent, 0); +}; + +Lp.getLineLength = function(line) { + var secret = getSecret(this), + info = secret.infos[line - 1]; + return this.getIndentAt(line) + info.sliceEnd - info.sliceStart; +}; + +Lp.nextPos = function(pos) { + var l = Math.max(pos.line, 0), + c = Math.max(pos.column, 0); + + if (c < this.getLineLength(l)) { + pos.column += 1; + return true; + } + + if (l < this.length) { + pos.line += 1; + pos.column = 0; + return true; + } + + return false; +}; + +Lp.prevPos = function(pos) { + var l = pos.line, + c = pos.column; + + if (c < 1) { + l -= 1; + + if (l < 1) + return false; + + c = this.getLineLength(l); + + } else { + c = Math.min(c - 1, this.getLineLength(l)); + } + + pos.line = l; + pos.column = c; + + return true; +}; + +Lp.firstPos = function() { + // Trivial, but provided for completeness. + return { line: 1, column: 0 }; +}; + +Lp.firstNonSpacePos = function() { + var lines = this, + pos = lines.firstPos(); + + while (!/\S/.test(lines.charAt(pos))) + if (!lines.nextPos(pos)) + return null; + + return pos; +}; + +Lp.lastPos = function() { + return { + line: this.length, + column: this.getLineLength(this.length) + }; +}; + +Lp.lastNonSpacePos = function(lines) { + var lines = this, + pos = lines.lastPos(); + + while (lines.prevPos(pos)) + if (/\S/.test(lines.charAt(pos))) + return pos; + + return null; +}; + +Lp.trimLeft = function() { + var pos = this.firstNonSpacePos(); + if (pos === null) + return fromString(""); + return this.slice(pos); +}; + +Lp.trimRight = function() { + var pos = this.lastNonSpacePos(); + if (pos === null) + return fromString(""); + assert.ok(this.nextPos(pos)); + return this.slice(this.firstPos(), pos); +}; + +Lp.trim = function() { + var start = this.firstNonSpacePos(); + if (start === null) + return fromString(""); + + var end = this.lastNonSpacePos(); + assert.notStrictEqual(end, null); + assert.ok(this.nextPos(end)); + + return this.slice(start, end); +}; + +Lp.eachPos = function(callback, startPos) { + var pos = this.firstPos(); + + if (startPos) { + pos.line = startPos.line, + pos.column = startPos.column + } + + do callback.call(this, pos); + while (this.nextPos(pos)); +}; + +Lp.bootstrapSlice = function(start, end) { + var strings = this.toString().split("\n").slice( + start.line - 1, end.line); + + strings.push(strings.pop().slice(0, end.column)); + strings[0] = strings[0].slice(start.column); + + return fromString(strings.join("\n")); +}; + +Lp.slice = function(start, end) { + var argc = arguments.length; + if (argc < 1) + // The client seems to want a copy of this Lines object, but Lines + // objects are immutable, so it's perfectly adequate to return the + // same object. + return this; + + if (argc < 2) + // Slice to the end if no end position was provided. + end = this.lastPos(); + + var secret = getSecret(this), + sliced = secret.infos.slice(start.line - 1, end.line), + info = copyLineInfo(sliced.pop()), + indent = this.getIndentAt(end.line), + sc = start.column, + ec = end.column; + + if (start.line === end.line) { + // If the same line is getting sliced from both ends, make sure + // end.column is not less than start.column. + ec = Math.max(sc, ec); + } + + if (ec < indent) { + info.indent -= indent - ec; + info.sliceEnd = info.sliceStart; + } else { + info.sliceEnd = info.sliceStart + ec - indent; + } + + assert.ok(info.sliceStart <= info.sliceEnd); + + sliced.push(info); + + if (sliced.length > 1) { + sliced[0] = info = copyLineInfo(sliced[0]); + indent = this.getIndentAt(start.line); + } else { + assert.strictEqual(info, sliced[0]); + } + + if (sc < indent) { + info.indent -= sc; + } else { + sc -= indent; + info.indent = 0; + info.sliceStart += sc; + } + + assert.ok(info.sliceStart <= info.sliceEnd); + + return new Lines(sliced); +}; + +Lp.isEmpty = function() { + return this.length < 2 && this.getLineLength(1) < 1; +}; + +Lp.join = function(elements) { + var separator = this, + separatorSecret = getSecret(separator), + infos = [], + prevInfo; + + function appendSecret(secret) { + if (secret === null) + return; + + if (prevInfo) { + var info = secret.infos[0], + indent = new Array(info.indent + 1).join(" "); + + prevInfo.line = prevInfo.line.slice( + 0, prevInfo.sliceEnd) + indent + info.line.slice( + info.sliceStart, info.sliceEnd); + + prevInfo.sliceEnd = prevInfo.line.length; + } + + secret.infos.forEach(function(info, i) { + if (!prevInfo || i > 0) { + prevInfo = copyLineInfo(info); + infos.push(prevInfo); + } + }); + } + + function appendWithSeparator(secret, i) { + if (i > 0) + appendSecret(separatorSecret); + appendSecret(secret); + } + + elements.map(function(elem) { + var lines = fromString(elem); + if (lines.isEmpty()) + return null; + return getSecret(lines); + }).forEach(separator.isEmpty() + ? appendSecret + : appendWithSeparator); + + if (infos.length < 1) + return fromString(""); + + return new Lines(infos); +}; + +exports.concat = function(elements) { + return fromString("").join(elements); +}; + +Lp.concat = function(other) { + var args = arguments, + list = [this]; + list.push.apply(list, args); + assert.strictEqual(list.length, args.length + 1); + return fromString("").join(list); +}; diff --git a/test/lines.js b/test/lines.js new file mode 100644 index 00000000..c586426e --- /dev/null +++ b/test/lines.js @@ -0,0 +1,298 @@ +var assert = require("assert"), + linesModule = require("../lib/lines"), + fromString = linesModule.fromString, + concat = linesModule.concat; + +function check(a, b) { + assert.strictEqual(a.toString(), b.toString()); +} + +exports.testFromString = function(t) { + function checkIsCached(s) { + assert.strictEqual(fromString(s), fromString(s)); + check(fromString(s), s); + } + + checkIsCached(""); + checkIsCached(","); + checkIsCached("\n"); + checkIsCached("this"); + checkIsCached(", "); + checkIsCached(": "); + + var longer = "This is a somewhat longer string that we do not want to cache."; + assert.notStrictEqual( + fromString(longer), + fromString(longer)); + + // Since Lines objects are immutable, if one is passed to fromString, + // we can return it as-is without having to make a defensive copy. + var longerLines = fromString(longer); + assert.strictEqual(fromString(longerLines), longerLines); + + t.finish(); +}; + +exports.testToString = function(t) { + var code = arguments.callee + "", + lines = fromString(code); + + check(lines, code); + check(lines.indentTail(5) + .indentTail(-7) + .indentTail(2), + code); + + t.finish(); +}; + +function testEachPosHelper(lines, code) { + var lengths = []; + + check(lines, code); + + var chars = [], + emptyCount = 0; + + lines.eachPos(function(pos) { + var ch = lines.charAt(pos); + if (ch === "") + emptyCount += 1; + chars.push(ch); + }); + + // The character at the position just past the end (as returned by + // lastPos) should be the only empty string. + assert.strictEqual(emptyCount, 1); + + var joined = chars.join(""); + assert.strictEqual(joined.length, code.length); + assert.strictEqual(joined, code); +} + +exports.testEachPos = function(t) { + var code = arguments.callee + "", + lines = fromString(code); + + testEachPosHelper(lines, code); + + lines = lines.indentTail(5); + testEachPosHelper(lines, lines.toString()); + + lines = lines.indentTail(-9); + testEachPosHelper(lines, lines.toString()); + + lines = lines.indentTail(4); + testEachPosHelper(lines, code); + + t.finish(); +}; + +exports.testCharAt = function(t) { + var code = arguments.callee + "", + lines = fromString(code); + + function compare(pos) { + assert.strictEqual( + lines.charAt(pos), + lines.bootstrapCharAt(pos)); + } + + lines.eachPos(compare); + + // Try a bunch of crazy positions to verify equivalence for + // out-of-bounds input positions. + fromString(exports.testBasic).eachPos(compare); + + var original = fromString(" ab\n c"), + indented = original.indentTail(4), + reference = fromString(" ab\n c"); + + function compareIndented(pos) { + var c = indented.charAt(pos); + check(c, reference.charAt(pos)); + check(c, indented.bootstrapCharAt(pos)); + check(c, reference.bootstrapCharAt(pos)); + } + + indented.eachPos(compareIndented); + + indented = indented.indentTail(-4); + reference = original; + + indented.eachPos(compareIndented); + + t.finish(); +}; + +exports.testConcat = function(t) { + var strings = ["asdf", "zcxv", "qwer"], + lines = fromString(strings.join("\n")), + indented = lines.indentTail(4); + + assert.strictEqual(lines.length, 3); + + check(indented, strings.join("\n ")); + + assert.strictEqual(5, concat([lines, indented]).length); + assert.strictEqual(5, concat([indented, lines]).length); + + check(concat([lines, indented]), + lines.toString() + indented.toString()); + + check(concat([lines, indented]).indentTail(4), + strings.join("\n ") + + strings.join("\n ")); + + check(concat([indented, lines]), + strings.join("\n ") + lines.toString()); + + check(concat([lines, indented]), + lines.concat(indented)); + + check(concat([indented, lines]), + indented.concat(lines)); + + check(concat([]), fromString("")); + assert.strictEqual(concat([]), fromString("")); + + check(fromString(" ").join([ + fromString("var"), + fromString("foo") + ]), fromString("var foo")); + + check(fromString(" ").join(["var", "foo"]), + fromString("var foo")); + + check(concat([ + fromString("var"), + fromString(" "), + fromString("foo") + ]), fromString("var foo")); + + check(concat(["var", " ", "foo"]), + fromString("var foo")); + + check(concat([ + fromString("debugger"), ";" + ]), fromString("debugger;")); + + t.finish(); +}; + +exports.testEmpty = function(t) { + function c(s) { + var lines = fromString(s); + check(lines, s); + assert.strictEqual( + lines.isEmpty(), + s.length === 0); + + assert.ok(lines.trimLeft().isEmpty()); + assert.ok(lines.trimRight().isEmpty()); + assert.ok(lines.trim().isEmpty()); + } + + c(""); + c(" "); + c(" "); + c(" \n"); + c("\n "); + c(" \n "); + c("\n \n "); + c(" \n\n "); + c(" \n \n "); + c(" \n \n\n"); + + t.finish(); +}; + +exports.testSingleLine = function(t) { + var string = "asdf", + line = fromString(string); + + check(line, string); + check(line.indentTail(4), string); + check(line.indentTail(-4), string); + + check(line.concat(line), string + string); + check(line.indentTail(4).concat(line), string + string); + check(line.concat(line.indentTail(4)), string + string); + check(line.indentTail(8).concat(line.indentTail(4)), string + string); + + line.eachPos(function(start) { + line.eachPos(function(end) { + check(line.slice(start, end), + string.slice(start.column, end.column)); + }, start); + }); + + t.finish(); +}; + +exports.testSlice = function(t) { + var code = arguments.callee + "", + lines = fromString(code); + + lines.eachPos(function(start) { + lines.eachPos(function(end) { + check(lines.slice(start, end), + lines.bootstrapSlice(start, end)); + }, start); + }); + + t.finish(); +}; + +function getSourceLocation(lines) { + return { start: lines.firstPos(), + end: lines.lastPos() }; +} + +exports.testGetSourceLocation = function(t) { + var code = arguments.callee + "", + lines = fromString(code); + + function verify(indent) { + var indented = lines.indentTail(indent), + loc = getSourceLocation(indented), + string = indented.toString(), + strings = string.split("\n"), + lastLine = strings[strings.length - 1]; + + assert.strictEqual(loc.end.line, strings.length); + assert.strictEqual(loc.end.column, lastLine.length); + + assert.deepEqual(loc, getSourceLocation( + indented.slice(loc.start, loc.end))); + } + + verify(0); + verify(4); + verify(-4); + + t.finish(); +}; + +exports.testTrim = function(t) { + var string = " xxx \n ", + lines = fromString(string); + + function test(string) { + var lines = fromString(string); + check(lines.trimLeft(), fromString(string.replace(/^\s+/, ""))); + check(lines.trimRight(), fromString(string.replace(/\s+$/, ""))); + check(lines.trim(), fromString(string.replace(/^\s+|\s+$/g, ""))); + } + + test(""); + test(" "); + test(" xxx \n "); + test(" xxx"); + test("xxx "); + test("\nx\nx\nx\n"); + test("\t\nx\nx\nx\n\t\n"); + test("xxx"); + + t.finish(); +};