diff --git a/CHANGELOG.md b/CHANGELOG.md index 14653a33c41..43195c22732 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,8 +36,14 @@ * `opAttributeValue()` * `opIterator()`: Deprecated in favor of the new `deserializeOps()` generator function. + * `opAssembler()`: Deprecated in favor of the new `serializeOps()` function. + * `mergingOpAssembler()`: Deprecated in favor of the new `squashOps()` + generator function (combined with `serializeOps()`). + * `smartOpAssembler()`: Deprecated in favor of the new `canonicalizeOps()` + generator function (combined with `serializeOps()`). * `appendATextToAssembler()`: Deprecated in favor of the new `opsFromAText()` generator function. + * `builder()`: Deprecated in favor of the new `Builder` class. * `newOp()`: Deprecated in favor of the new `Op` class. # 1.8.16 diff --git a/src/node/db/API.js b/src/node/db/API.js index 040abf5a6f6..7493ccce8e5 100644 --- a/src/node/db/API.js +++ b/src/node/db/API.js @@ -538,7 +538,7 @@ exports.restoreRevision = async (padID, rev) => { }; // create a new changeset with a helper builder object - const builder = Changeset.builder(oldText.length); + const builder = new Changeset.Builder(oldText.length); // assemble each line into the builder eachAttribRun(atext.attribs, (start, end, attribs) => { diff --git a/src/node/db/Pad.js b/src/node/db/Pad.js index b7bbdb867bd..545c35e3ca1 100644 --- a/src/node/db/Pad.js +++ b/src/node/db/Pad.js @@ -494,21 +494,20 @@ Pad.prototype.copyPadWithoutHistory = async function (destinationID, force) { const oldAText = this.atext; - // based on Changeset.makeSplice - const assem = Changeset.smartOpAssembler(); - for (const op of Changeset.opsFromAText(oldAText)) assem.append(op); - assem.endDocument(); + let newLength; + const serializedOps = Changeset.serializeOps((function* () { + newLength = yield* Changeset.canonicalizeOps(Changeset.opsFromAText(oldAText), true); + })()); // although we have instantiated the newPad with '\n', an additional '\n' is // added internally, so the pad text on the revision 0 is "\n\n" const oldLength = 2; - const newLength = assem.getLengthChange(); const newText = oldAText.text; // create a changeset that removes the previous text and add the newText with // all atributes present on the source pad - const changeset = Changeset.pack(oldLength, newLength, assem.toString(), newText); + const changeset = Changeset.pack(oldLength, newLength, serializedOps, newText); newPad.appendRevision(changeset); await hooks.aCallAll('padCopy', {originalPad: this, destinationID}); diff --git a/src/node/handler/PadMessageHandler.js b/src/node/handler/PadMessageHandler.js index 3460983bdad..03db4ad444f 100644 --- a/src/node/handler/PadMessageHandler.js +++ b/src/node/handler/PadMessageHandler.js @@ -582,11 +582,10 @@ const handleUserChanges = async (socket, message) => { const wireApool = (new AttributePool()).fromJsonable(apool); const pad = await padManager.getPad(thisSession.padId); - // Verify that the changeset has valid syntax and is in canonical form - Changeset.checkRep(changeset); + const cs = Changeset.unpack(changeset).validate(); // Validate all added 'author' attribs to be the same value as the current user - for (const op of Changeset.deserializeOps(Changeset.unpack(changeset).ops)) { + for (const op of Changeset.deserializeOps(cs.ops)) { // + can add text with attribs // = can change or add attribs // - can have attribs, but they are discarded and don't show up in the attribs - @@ -629,10 +628,10 @@ const handleUserChanges = async (socket, message) => { const prevText = pad.text(); - if (Changeset.oldLen(rebasedChangeset) !== prevText.length) { + if (Changeset.unpack(rebasedChangeset).oldLen !== prevText.length) { throw new Error( `Can't apply changeset ${rebasedChangeset} with oldLen ` + - `${Changeset.oldLen(rebasedChangeset)} to document of length ${prevText.length}`); + `${Changeset.unpack(rebasedChangeset).oldLen} to document of length ${prevText.length}`); } const newRev = await pad.appendRevision(rebasedChangeset, thisSession.author); @@ -759,7 +758,7 @@ const _correctMarkersInPad = (atext, apool) => { // create changeset that removes these bad markers offset = 0; - const builder = Changeset.builder(text.length); + const builder = new Changeset.Builder(text.length); badMarkers.forEach((pos) => { builder.keepText(text.substring(offset, pos)); diff --git a/src/node/utils/ExportHtml.js b/src/node/utils/ExportHtml.js index 3d5f4cc720d..0d585a88a96 100644 --- a/src/node/utils/ExportHtml.js +++ b/src/node/utils/ExportHtml.js @@ -125,7 +125,7 @@ const getHTMLFromAtext = async (pad, atext, authorColors) => { // becomes // Just bold Bold and italics Just italics const taker = Changeset.stringIterator(text); - const assem = Changeset.stringAssembler(); + let assem = ''; const openTags = []; const getSpanClassFor = (i) => { @@ -161,16 +161,7 @@ const getHTMLFromAtext = async (pad, atext, authorColors) => { const emitOpenTag = (i) => { openTags.unshift(i); const spanClass = getSpanClassFor(i); - - if (spanClass) { - assem.append(''); - } else { - assem.append('<'); - assem.append(tags[i]); - assem.append('>'); - } + assem += spanClass ? `` : `<${tags[i]}>`; }; // this closes an open tag and removes its reference from openTags @@ -178,14 +169,7 @@ const getHTMLFromAtext = async (pad, atext, authorColors) => { openTags.shift(); const spanClass = getSpanClassFor(i); const spanWithData = isSpanWithData(i); - - if (spanClass || spanWithData) { - assem.append(''); - } else { - assem.append(''); - } + assem += spanClass || spanWithData ? '' : ``; }; const urls = padutils.findURLs(text); @@ -246,7 +230,7 @@ const getHTMLFromAtext = async (pad, atext, authorColors) => { // from but they break the abiword parser and are completly useless s = s.replace(String.fromCharCode(12), ''); - assem.append(_encodeWhitespace(Security.escapeHTML(s))); + assem += _encodeWhitespace(Security.escapeHTML(s)); } // end iteration over spans in line // close all the tags that are open after the last op @@ -269,14 +253,14 @@ const getHTMLFromAtext = async (pad, atext, authorColors) => { // https://html.spec.whatwg.org/multipage/links.html#link-type-noopener // https://mathiasbynens.github.io/rel-noopener/ // https://github.com/ether/etherpad-lite/pull/3636 - assem.append(``); + assem += ``; processNextChars(urlLength); - assem.append(''); + assem += ''; }); } processNextChars(text.length - idx); - return _processSpaces(assem.toString()); + return _processSpaces(assem); }; // end getLineHTML const pieces = [css]; diff --git a/src/node/utils/ExportTxt.js b/src/node/utils/ExportTxt.js index 9511dd0e7a3..07c2a67e3f2 100644 --- a/src/node/utils/ExportTxt.js +++ b/src/node/utils/ExportTxt.js @@ -67,7 +67,7 @@ const getTXTFromAtext = (pad, atext, authorColors) => { // becomes // Just bold Bold and italics Just italics const taker = Changeset.stringIterator(text); - const assem = Changeset.stringAssembler(); + let assem = ''; let idx = 0; @@ -161,7 +161,7 @@ const getTXTFromAtext = (pad, atext, authorColors) => { // plugins from being able to display * at the beginning of a line // s = s.replace("*", ""); // Then remove it - assem.append(s); + assem += s; } // end iteration over spans in line const tags2close = []; @@ -175,7 +175,7 @@ const getTXTFromAtext = (pad, atext, authorColors) => { // end processNextChars processNextChars(text.length - idx); - return (assem.toString()); + return assem; }; // end getLineHTML diff --git a/src/node/utils/ImportHtml.js b/src/node/utils/ImportHtml.js index 12a99ef796b..30c8c999ae7 100644 --- a/src/node/utils/ImportHtml.js +++ b/src/node/utils/ImportHtml.js @@ -64,7 +64,7 @@ exports.setPadHTML = async (pad, html) => { const newAttribs = `${result.lineAttribs.join('|1+1')}|1+1`; // create a new changeset with a helper builder object - const builder = Changeset.builder(1); + const builder = new Changeset.Builder(1); // assemble each line into the builder let textIndex = 0; diff --git a/src/node/utils/padDiff.js b/src/node/utils/padDiff.js index 4ab276b4b66..9db116e56a2 100644 --- a/src/node/utils/padDiff.js +++ b/src/node/utils/padDiff.js @@ -69,7 +69,7 @@ PadDiff.prototype._createClearAuthorship = async function (rev) { const atext = await this._pad.getInternalRevisionAText(rev); // build clearAuthorship changeset - const builder = Changeset.builder(atext.text.length); + const builder = new Changeset.Builder(atext.text.length); builder.keepText(atext.text, [['author', '']], this._pad.pool); const changeset = builder.toString(); @@ -206,28 +206,26 @@ PadDiff.prototype._extendChangesetWithAuthor = (changeset, author, apool) => { // unpack const unpacked = Changeset.unpack(changeset); - const assem = Changeset.opAssembler(); - // create deleted attribs const authorAttrib = apool.putAttrib(['author', author || '']); const deletedAttrib = apool.putAttrib(['removed', true]); const attribs = `*${Changeset.numToString(authorAttrib)}*${Changeset.numToString(deletedAttrib)}`; - for (const operator of Changeset.deserializeOps(unpacked.ops)) { - if (operator.opcode === '-') { - // this is a delete operator, extend it with the author - operator.attribs = attribs; - } else if (operator.opcode === '=' && operator.attribs) { - // this is operator changes only attributes, let's mark which author did that - operator.attribs += `*${Changeset.numToString(authorAttrib)}`; + const serializedOps = Changeset.serializeOps((function* () { + for (const operator of Changeset.deserializeOps(unpacked.ops)) { + if (operator.opcode === '-') { + // this is a delete operator, extend it with the author + operator.attribs = attribs; + } else if (operator.opcode === '=' && operator.attribs) { + // this is operator changes only attributes, let's mark which author did that + operator.attribs += `*${Changeset.numToString(authorAttrib)}`; + } + yield operator; } - - // append the new operator to our assembler - assem.append(operator); - } + })()); // return the modified changeset - return Changeset.pack(unpacked.oldLen, unpacked.newLen, assem.toString(), unpacked.charBank); + return Changeset.pack(unpacked.oldLen, unpacked.newLen, serializedOps, unpacked.charBank); }; // this method is 80% like Changeset.inverse. I just changed so instead of reverting, @@ -264,7 +262,7 @@ PadDiff.prototype._createDeletionChangeset = function (cs, startAText, apool) { let curLineNextOp = new Changeset.Op('+'); const unpacked = Changeset.unpack(cs); - const builder = Changeset.builder(unpacked.newLen); + const builder = new Changeset.Builder(unpacked.newLen); const consumeAttribRuns = (numChars, func /* (len, attribs, endsLine)*/) => { if (!curLineOps || curLineOpsLine !== curLine) { @@ -330,21 +328,21 @@ PadDiff.prototype._createDeletionChangeset = function (cs, startAText, apool) { const nextText = (numChars) => { let len = 0; - const assem = Changeset.stringAssembler(); + let assem = ''; const firstString = linesGet(curLine).substring(curChar); len += firstString.length; - assem.append(firstString); + assem += firstString; let lineNum = curLine + 1; while (len < numChars) { const nextString = linesGet(lineNum); len += nextString.length; - assem.append(nextString); + assem += nextString; lineNum++; } - return assem.toString().substring(0, numChars); + return assem.substring(0, numChars); }; const cachedStrFunc = (func) => { @@ -440,7 +438,7 @@ PadDiff.prototype._createDeletionChangeset = function (cs, startAText, apool) { } } - return Changeset.checkRep(builder.toString()); + return builder.build().validate().toString(); }; // export the constructor diff --git a/src/static/js/AttributeManager.js b/src/static/js/AttributeManager.js index f508af64111..bc43d5ed212 100644 --- a/src/static/js/AttributeManager.js +++ b/src/static/js/AttributeManager.js @@ -125,7 +125,7 @@ AttributeManager.prototype = _(AttributeManager.prototype).extend({ * @param attribs an array of attributes */ _setAttributesOnRangeByLine(row, startCol, endCol, attribs) { - const builder = Changeset.builder(this.rep.lines.totalWidth()); + const builder = new Changeset.Builder(this.rep.lines.totalWidth()); ChangesetUtils.buildKeepToStartOfRange(this.rep, builder, [row, startCol]); ChangesetUtils.buildKeepRange( this.rep, builder, [row, startCol], [row, endCol], attribs, this.rep.apool); @@ -285,7 +285,7 @@ AttributeManager.prototype = _(AttributeManager.prototype).extend({ */ setAttributeOnLine(lineNum, attributeName, attributeValue) { let loc = [0, 0]; - const builder = Changeset.builder(this.rep.lines.totalWidth()); + const builder = new Changeset.Builder(this.rep.lines.totalWidth()); const hasMarker = this.lineHasMarker(lineNum); ChangesetUtils.buildKeepRange(this.rep, builder, loc, (loc = [lineNum, 0])); @@ -314,7 +314,7 @@ AttributeManager.prototype = _(AttributeManager.prototype).extend({ * @param attributeValue if given only attributes with equal value will be removed */ removeAttributeOnLine(lineNum, attributeName, attributeValue) { - const builder = Changeset.builder(this.rep.lines.totalWidth()); + const builder = new Changeset.Builder(this.rep.lines.totalWidth()); const hasMarker = this.lineHasMarker(lineNum); let found = false; diff --git a/src/static/js/Changeset.js b/src/static/js/Changeset.js index e3ae9d2863d..aef04c1b4e2 100644 --- a/src/static/js/Changeset.js +++ b/src/static/js/Changeset.js @@ -155,30 +155,159 @@ exports.Op = Op; /** * Describes changes to apply to a document. Does not include the attribute pool or the original * document. - * - * @typedef {object} Changeset - * @property {number} oldLen - The length of the base document. - * @property {number} newLen - The length of the document after applying the changeset. - * @property {string} ops - Serialized sequence of operations. Use `deserializeOps` to parse this - * string. - * @property {string} charBank - Characters inserted by insert operations. */ +class Changeset { + /** + * Parses an encoded changeset. + * + * @param {string} cs - Encoded changeset. + * @returns {Changeset} + */ + static unpack(cs) { + const headerRegex = /Z:([0-9a-z]+)([><])([0-9a-z]+)|/; + const headerMatch = headerRegex.exec(cs); + if ((!headerMatch) || (!headerMatch[0])) error(`Not a changeset: ${cs}`); + const oldLen = exports.parseNum(headerMatch[1]); + const changeSign = (headerMatch[2] === '>') ? 1 : -1; + const changeMag = exports.parseNum(headerMatch[3]); + const newLen = oldLen + changeSign * changeMag; + const opsStart = headerMatch[0].length; + let opsEnd = cs.indexOf('$'); + if (opsEnd < 0) opsEnd = cs.length; + return new Changeset(oldLen, newLen, cs.substring(opsStart, opsEnd), cs.substring(opsEnd + 1)); + } + + /** + * @param {number} oldLen - Initial value of the `oldLen` property. + * @param {number} newLen - Initial value of the `newLen` property. + * @param {string} ops - Initial value of the `ops` property. + * @param {string} charBank - Initial value of the `charBank` property. + */ + constructor(oldLen, newLen, ops, charBank) { + /** + * The length of the base document. + * + * @type {number} + * @public + */ + this.oldLen = oldLen; + + /** + * The length of the document after applying the changeset. + * + * @type {number} + * @public + */ + this.newLen = newLen; + + /** + * Serialized sequence of operations. Use `deserializeOps` to parse this string. + * + * @type {string} + * @public + */ + this.ops = ops; + + /** + * Characters inserted by insert operations. + * + * @type {string} + * @public + */ + this.charBank = charBank; + } + + /** + * @returns {string} The encoded changeset. + */ + toString() { + const lenDiff = this.newLen - this.oldLen; + const lenDiffStr = lenDiff >= 0 + ? `>${exports.numToString(lenDiff)}` + : `<${exports.numToString(-lenDiff)}`; + const a = []; + a.push('Z:', exports.numToString(this.oldLen), lenDiffStr, this.ops, '$', this.charBank); + return a.join(''); + } + + /** + * Check that this Changeset is valid. This method does not check things that require access to + * the attribute pool (e.g., attribute order) or original text (e.g., newline positions). + * + * @returns {Changeset} this (for chaining) + */ + validate() { + let charBank = this.charBank; + let oldPos = 0; + let calcNewLen = 0; + const cs = this.toString(); + const ops = (function* () { + for (const o of exports.deserializeOps(this.ops)) { + switch (o.opcode) { + case '=': + oldPos += o.chars; + calcNewLen += o.chars; + break; + case '-': + oldPos += o.chars; + assert(oldPos <= this.oldLen, `${oldPos} > ${this.oldLen} in ${cs}`); + break; + case '+': { + assert(charBank.length >= o.chars, 'Invalid changeset: not enough chars in charBank'); + const chars = charBank.slice(0, o.chars); + const nlines = (chars.match(/\n/g) || []).length; + assert(nlines === o.lines, + 'Invalid changeset: number of newlines in insert op does not match the charBank'); + assert(o.lines === 0 || chars.endsWith('\n'), + 'Invalid changeset: multiline insert op does not end with a newline'); + charBank = charBank.slice(o.chars); + calcNewLen += o.chars; + assert(calcNewLen <= this.newLen, `${calcNewLen} > ${this.newLen} in ${cs}`); + break; + } + default: + assert(false, `Invalid changeset: Unknown opcode: ${JSON.stringify(o.opcode)}`); + } + yield o; + } + }).call(this); + const serializedOps = exports.serializeOps(exports.canonicalizeOps(ops, true)); + calcNewLen += this.oldLen - oldPos; + assert(calcNewLen === this.newLen, + 'Invalid changeset: claimed length does not match actual length'); + assert(charBank === '', 'Invalid changeset: excess characters in the charBank'); + const normalized = + new Changeset(this.oldLen, calcNewLen, serializedOps, this.charBank).toString(); + assert(normalized === cs, 'Invalid changeset: not in canonical form'); + return this; + } +} /** * Returns the required length of the text before changeset can be applied. * + * @deprecated Use `Changeset.unpack(cs).oldLen` instead. * @param {string} cs - String representation of the Changeset * @returns {number} oldLen property */ -exports.oldLen = (cs) => exports.unpack(cs).oldLen; +exports.oldLen = (cs) => { + padutils.warnDeprecated( + 'Changeset.oldLen(cs) is deprecated; use Changeset.unpack(cs).oldLen instead'); + return Changeset.unpack(cs).oldLen; +}; /** * Returns the length of the text after changeset is applied. * + * @deprecated Use `Changeset.unpack(cs).newLen` instead. * @param {string} cs - String representation of the Changeset * @returns {number} newLen property */ -exports.newLen = (cs) => exports.unpack(cs).newLen; +exports.newLen = (cs) => { + padutils.warnDeprecated( + 'Changeset.newLen(cs) is deprecated; use Changeset.unpack(cs).newLen instead'); + return Changeset.unpack(cs).newLen; +}; /** * Parses a string of serialized changeset operations. @@ -291,25 +420,191 @@ exports.newOp = (optOpcode) => { */ const copyOp = (op1, op2 = new Op()) => Object.assign(op2, op1); +/** + * Converts an iterable of operation objects to wire format. + * + * @param {Iterable} ops - Iterable of operations to serialize. + * @returns {string} A string containing the encoded op data (example: '|5=2p=v*4*5+1'). + */ +exports.serializeOps = (ops) => { + let res = ''; + for (const op of ops) res += op.toString(); + return res; +}; + /** * Serializes a sequence of Ops. * - * @typedef {object} OpAssembler - * @property {Function} append - - * @property {Function} clear - - * @property {Function} toString - + * @deprecated Use `serializeOps` instead. */ +class OpAssembler { + constructor() { + this.clear(); + } + + clear() { + this._ops = []; + } + + /** + * @param {Op} op - Operation to add. Ownership remains with the caller. + */ + append(op) { + assert(op instanceof Op, 'argument must be an instance of Op'); + this._ops.push(copyOp(op)); + } + + toString() { + return exports.serializeOps(this._ops); + } +} + +/** + * Combines consecutive operations when possible. Also skips no-op changes. + * + * @param {Iterable} ops - Iterable of operations to combine. + * @param {boolean} finalize - If truthy, omits the final op if it is an attributeless keep op. + * @yields {Op} The squashed operations. + * @returns {Generator} + */ +exports.squashOps = function* (ops, finalize) { + let prevOp = new Op(); + // If we get, for example, insertions [xxx\n,yyy], those don't merge, but if we get + // [xxx\n,yyy,zzz\n], that merges to [xxx\nyyyzzz\n]. This variable stores the length of yyy and + // any other newline-less ops immediately after it. + let prevOpAdditionalCharsAfterNewline = 0; + + const flush = function* (finalize) { + if (!prevOp.opcode) return; + if (finalize && prevOp.opcode === '=' && !prevOp.attribs) { + // final merged keep, leave it implicit + } else { + yield prevOp; + if (prevOpAdditionalCharsAfterNewline) { + const op = new Op(prevOp.opcode); + op.chars = prevOpAdditionalCharsAfterNewline; + op.lines = 0; + op.attribs = prevOp.attribs; + yield op; + prevOpAdditionalCharsAfterNewline = 0; + } + } + prevOp = new Op(); + }; + + for (const op of ops) { + if (!op.opcode || op.chars <= 0) continue; + if (prevOp.opcode === op.opcode && prevOp.attribs === op.attribs) { + if (op.lines > 0) { + // bufOp and additional chars are all mergeable into a multi-line op + prevOp.chars += prevOpAdditionalCharsAfterNewline + op.chars; + prevOp.lines += op.lines; + prevOpAdditionalCharsAfterNewline = 0; + } else if (prevOp.lines === 0) { + // both prevOp and op are in-line + prevOp.chars += op.chars; + } else { + // append in-line text to multi-line prevOp + prevOpAdditionalCharsAfterNewline += op.chars; + } + } else { + yield* flush(false); + prevOp = copyOp(op); // prevOp is mutated, so make a copy to protect op. + } + } + + yield* flush(finalize); +}; /** * Efficiently merges consecutive operations that are mergeable, ignores no-ops, and drops final * pure "keeps". It does not re-order operations. - * - * @typedef {object} MergingOpAssembler - * @property {Function} append - - * @property {Function} clear - - * @property {Function} endDocument - - * @property {Function} toString - */ +class MergingOpAssembler { + constructor() { + this.clear(); + } + + clear() { + this._ops = []; + this._serialized = null; + } + + _serialize(finalize) { + this._serialized = exports.serializeOps(exports.squashOps(this._ops, finalize)); + } + + append(op) { + this._serialized = null; + this._ops.push(copyOp(op)); + } + + endDocument() { + this._serialize(true); + } + + toString() { + if (this._serialized == null) this._serialize(false); + return this._serialized; + } +} + +/** + * Canonicalizes a sequence of operations. Specifically: + * - Skips no-op changes. + * - Reorders consecutive '-' and '+' operations. + * - Combines consecutive operations when possible. + * + * @param {Iterable} ops - Iterable of operations to combine. + * @param {boolean} finalize - If truthy, omits the final op if it is an attributeless keep op. + * @yields {Op} The canonicalized operations. + * @returns {Generator} The done value indicates how much the sequence of operations + * changes the length of the document (in characters). + */ +exports.canonicalizeOps = function* (ops, finalize) { + let minusOps = []; + let plusOps = []; + let keepOps = []; + let prevOpcode = ''; + let lengthChange = 0; + + const flushPlusMinus = function* () { + yield* exports.squashOps(minusOps, false); + minusOps = []; + yield* exports.squashOps(plusOps, false); + plusOps = []; + }; + + const flushKeeps = function* (finalize) { + yield* exports.squashOps(keepOps, finalize); + keepOps = []; + }; + + for (const op of ops) { + if (!op.opcode || !op.chars) continue; + switch (op.opcode) { + case '-': + if (prevOpcode === '=') yield* flushKeeps(false); + minusOps.push(op); + lengthChange -= op.chars; + break; + case '+': + if (prevOpcode === '=') yield* flushKeeps(false); + plusOps.push(op); + lengthChange += op.chars; + break; + case '=': + if (prevOpcode !== '=') yield* flushPlusMinus(); + keepOps.push(op); + break; + } + prevOpcode = op.opcode; + } + + yield* flushPlusMinus(); + yield* flushKeeps(finalize); + return lengthChange; +}; /** * Generates operations from the given text and attributes. @@ -354,117 +649,30 @@ const opsFromText = function* (opcode, text, attribs = '', pool = null) { * - ignores 0-length changes * - reorders consecutive + and - (which MergingOpAssembler doesn't do) * - * @typedef {object} SmartOpAssembler - * @property {Function} append - - * @property {Function} appendOpWithText - - * @property {Function} clear - - * @property {Function} endDocument - - * @property {Function} getLengthChange - - * @property {Function} toString - + * @deprecated Use `canonicalizeOps` with `serializeOps` instead. */ +class SmartOpAssembler { + constructor() { + this.clear(); + } -/** - * Used to check if a Changeset is valid. This function does not check things that require access to - * the attribute pool (e.g., attribute order) or original text (e.g., newline positions). - * - * @param {string} cs - Changeset to check - * @returns {string} the checked Changeset - */ -exports.checkRep = (cs) => { - const unpacked = exports.unpack(cs); - const oldLen = unpacked.oldLen; - const newLen = unpacked.newLen; - const ops = unpacked.ops; - let charBank = unpacked.charBank; - - const assem = exports.smartOpAssembler(); - let oldPos = 0; - let calcNewLen = 0; - for (const o of exports.deserializeOps(ops)) { - switch (o.opcode) { - case '=': - oldPos += o.chars; - calcNewLen += o.chars; - break; - case '-': - oldPos += o.chars; - assert(oldPos <= oldLen, `${oldPos} > ${oldLen} in ${cs}`); - break; - case '+': - { - assert(charBank.length >= o.chars, 'Invalid changeset: not enough chars in charBank'); - const chars = charBank.slice(0, o.chars); - const nlines = (chars.match(/\n/g) || []).length; - assert(nlines === o.lines, - 'Invalid changeset: number of newlines in insert op does not match the charBank'); - assert(o.lines === 0 || chars.endsWith('\n'), - 'Invalid changeset: multiline insert op does not end with a newline'); - charBank = charBank.slice(o.chars); - calcNewLen += o.chars; - assert(calcNewLen <= newLen, `${calcNewLen} > ${newLen} in ${cs}`); - break; - } - default: - assert(false, `Invalid changeset: Unknown opcode: ${JSON.stringify(o.opcode)}`); - } - assem.append(o); - } - calcNewLen += oldLen - oldPos; - assert(calcNewLen === newLen, 'Invalid changeset: claimed length does not match actual length'); - assert(charBank === '', 'Invalid changeset: excess characters in the charBank'); - assem.endDocument(); - const normalized = exports.pack(oldLen, calcNewLen, assem.toString(), unpacked.charBank); - assert(normalized === cs, 'Invalid changeset: not in canonical form'); - return cs; -}; - -/** - * @returns {SmartOpAssembler} - */ -exports.smartOpAssembler = () => { - const minusAssem = exports.mergingOpAssembler(); - const plusAssem = exports.mergingOpAssembler(); - const keepAssem = exports.mergingOpAssembler(); - const assem = exports.stringAssembler(); - let lastOpcode = ''; - let lengthChange = 0; - - const flushKeeps = () => { - assem.append(keepAssem.toString()); - keepAssem.clear(); - }; - - const flushPlusMinus = () => { - assem.append(minusAssem.toString()); - minusAssem.clear(); - assem.append(plusAssem.toString()); - plusAssem.clear(); - }; + clear() { + this._ops = []; + this._serialized = null; + this._lengthChange = null; + } - const append = (op) => { - if (!op.opcode) return; - if (!op.chars) return; + _serialize(finalize) { + this._serialized = exports.serializeOps((function* () { + this._lengthChange = yield* exports.canonicalizeOps(this._ops, finalize); + }).call(this)); + } - if (op.opcode === '-') { - if (lastOpcode === '=') { - flushKeeps(); - } - minusAssem.append(op); - lengthChange -= op.chars; - } else if (op.opcode === '+') { - if (lastOpcode === '=') { - flushKeeps(); - } - plusAssem.append(op); - lengthChange += op.chars; - } else if (op.opcode === '=') { - if (lastOpcode !== '=') { - flushPlusMinus(); - } - keepAssem.append(op); - } - lastOpcode = op.opcode; - }; + append(op) { + this._serialized = null; + this._lengthChange = null; + this._ops.push(copyOp(op)); + } /** * Generates operations from the given text and attributes. @@ -476,212 +684,143 @@ exports.smartOpAssembler = () => { * @param {?AttributePool} pool - Attribute pool. Only required if `attribs` is an iterable of * attribute key, value pairs. */ - const appendOpWithText = (opcode, text, attribs, pool) => { - padutils.warnDeprecated('Changeset.smartOpAssembler().appendOpWithText() is deprecated; ' + - 'use opsFromText() instead.'); - for (const op of opsFromText(opcode, text, attribs, pool)) append(op); - }; + appendOpWithText(opcode, text, attribs, pool) { + padutils.warnDeprecated( + 'Changeset.SmartOpAssembler.prototype.appendOpWithText() is deprecated; ' + + 'use opsFromText() instead.'); + for (const op of opsFromText(opcode, text, attribs, pool)) this.append(op); + } - const toString = () => { - flushPlusMinus(); - flushKeeps(); - return assem.toString(); - }; + toString() { + if (this._serialized == null) this._serialize(false); + return this._serialized; + } - const clear = () => { - minusAssem.clear(); - plusAssem.clear(); - keepAssem.clear(); - assem.clear(); - lengthChange = 0; - }; + endDocument() { + this._serialize(true); + } - const endDocument = () => { - keepAssem.endDocument(); - }; + getLengthChange() { + if (this._lengthChange == null) this._serialize(false); + return this._lengthChange; + } +} - const getLengthChange = () => lengthChange; +/** + * Used to check if a Changeset is valid. This function does not check things that require access to + * the attribute pool (e.g., attribute order) or original text (e.g., newline positions). + * + * @deprecated Use `Changeset.unpack(cs).validate()` instead. + * @param {string} cs - Changeset to check + * @returns {string} the checked Changeset + */ +exports.checkRep = (cs) => { + padutils.warnDeprecated( + 'Changeset.checkRep(cs) is deprecated; use Changeset.unpack(cs).validate() instead.'); + Changeset.unpack(cs).validate(); + return cs; +}; - return { - append, - toString, - clear, - endDocument, - appendOpWithText, - getLengthChange, - }; +/** + * @deprecated Use `canonicalizeOps` with `serializeOps` instead. + * @returns {SmartOpAssembler} + */ +exports.smartOpAssembler = () => { + padutils.warnDeprecated( + 'Changeset.smartOpAssembler() is deprecated; use Changeset.canonicalizeOps() instead'); + return new SmartOpAssembler(); }; /** + * @deprecated Use `squashOps` with `serializeOps` instead. * @returns {MergingOpAssembler} */ exports.mergingOpAssembler = () => { - const assem = exports.opAssembler(); - const bufOp = new Op(); - - // If we get, for example, insertions [xxx\n,yyy], those don't merge, - // but if we get [xxx\n,yyy,zzz\n], that merges to [xxx\nyyyzzz\n]. - // This variable stores the length of yyy and any other newline-less - // ops immediately after it. - let bufOpAdditionalCharsAfterNewline = 0; - - const flush = (isEndDocument) => { - if (!bufOp.opcode) return; - if (isEndDocument && bufOp.opcode === '=' && !bufOp.attribs) { - // final merged keep, leave it implicit - } else { - assem.append(bufOp); - if (bufOpAdditionalCharsAfterNewline) { - bufOp.chars = bufOpAdditionalCharsAfterNewline; - bufOp.lines = 0; - assem.append(bufOp); - bufOpAdditionalCharsAfterNewline = 0; - } - } - bufOp.opcode = ''; - }; - - const append = (op) => { - if (op.chars <= 0) return; - if (bufOp.opcode === op.opcode && bufOp.attribs === op.attribs) { - if (op.lines > 0) { - // bufOp and additional chars are all mergeable into a multi-line op - bufOp.chars += bufOpAdditionalCharsAfterNewline + op.chars; - bufOp.lines += op.lines; - bufOpAdditionalCharsAfterNewline = 0; - } else if (bufOp.lines === 0) { - // both bufOp and op are in-line - bufOp.chars += op.chars; - } else { - // append in-line text to multi-line bufOp - bufOpAdditionalCharsAfterNewline += op.chars; - } - } else { - flush(); - copyOp(op, bufOp); - } - }; - - const endDocument = () => { - flush(true); - }; - - const toString = () => { - flush(); - return assem.toString(); - }; - - const clear = () => { - assem.clear(); - clearOp(bufOp); - }; - return { - append, - toString, - clear, - endDocument, - }; + padutils.warnDeprecated( + 'Changeset.mergingOpAssembler() is deprecated; use Changeset.squashOps() instead'); + return new MergingOpAssembler(); }; /** + * @deprecated Use `serializeOps` instead. * @returns {OpAssembler} */ exports.opAssembler = () => { - let serialized = ''; - - /** - * @param {Op} op - Operation to add. Ownership remains with the caller. - */ - const append = (op) => { - assert(op instanceof Op, 'argument must be an instance of Op'); - serialized += op.toString(); - }; - - const toString = () => serialized; - - const clear = () => { - serialized = ''; - }; - return { - append, - toString, - clear, - }; + padutils.warnDeprecated( + 'Changeset.opAssembler() is deprecated; use Changeset.serializeOps() instead'); + return new OpAssembler(); }; /** * A custom made String Iterator - * - * @typedef {object} StringIterator - * @property {Function} newlines - - * @property {Function} peek - - * @property {Function} remaining - - * @property {Function} skip - - * @property {Function} take - */ +class StringIterator { + constructor(str) { + this._str = str; + this._curIndex = 0; + // this._newLines is the number of \n between this._curIndex and this._str.length + this._newLines = this._str.split('\n').length - 1; + } -/** - * @param {string} str - String to iterate over - * @returns {StringIterator} - */ -exports.stringIterator = (str) => { - let curIndex = 0; - // newLines is the number of \n between curIndex and str.length - let newLines = str.split('\n').length - 1; - const getnewLines = () => newLines; + newlines() { + return this._newLines; + } - const assertRemaining = (n) => { - assert(n <= remaining(), `!(${n} <= ${remaining()})`); - }; + _assertRemaining(n) { + assert(n <= this.remaining(), `!(${n} <= ${this.remaining()})`); + } - const take = (n) => { - assertRemaining(n); - const s = str.substr(curIndex, n); - newLines -= s.split('\n').length - 1; - curIndex += n; + take(n) { + this._assertRemaining(n); + const s = this._str.substr(this._curIndex, n); + this._newLines -= s.split('\n').length - 1; + this._curIndex += n; return s; - }; + } - const peek = (n) => { - assertRemaining(n); - const s = str.substr(curIndex, n); + peek(n) { + this._assertRemaining(n); + const s = this._str.substr(this._curIndex, n); return s; - }; + } - const skip = (n) => { - assertRemaining(n); - curIndex += n; - }; + skip(n) { + this._assertRemaining(n); + this._curIndex += n; + } - const remaining = () => str.length - curIndex; - return { - take, - skip, - remaining, - peek, - newlines: getnewLines, - }; -}; + remaining() { + return this._str.length - this._curIndex; + } +} /** - * A custom made StringBuffer - * - * @typedef {object} StringAssembler - * @property {Function} append - - * @property {Function} toString - + * @param {string} str - String to iterate over + * @returns {StringIterator} */ +exports.stringIterator = (str) => new StringIterator(str); /** - * @returns {StringAssembler} + * A custom made StringBuffer */ -exports.stringAssembler = () => ({ - _str: '', +class StringAssembler { + constructor() { this.clear(); } + clear() { this._str = ''; } /** * @param {string} x - */ - append(x) { this._str += String(x); }, - toString() { return this._str; }, -}); + append(x) { this._str += String(x); } + toString() { return this._str; } +} + +/** + * @returns {StringAssembler} + */ +exports.stringAssembler = () => { + padutils.warnDeprecated( + 'Changeset.stringAssembler() is deprecated; build a string manually instead'); + return new StringAssembler(); +}; /** * @typedef {object} StringArrayLike @@ -1035,22 +1174,22 @@ class TextLinesMutator { * @returns {string} the integrated changeset */ const applyZip = (in1, in2, func) => { - const ops1 = exports.deserializeOps(in1); - const ops2 = exports.deserializeOps(in2); - let next1 = ops1.next(); - let next2 = ops2.next(); - const assem = exports.smartOpAssembler(); - while (!next1.done || !next2.done) { - if (!next1.done && !next1.value.opcode) next1 = ops1.next(); - if (!next2.done && !next2.value.opcode) next2 = ops2.next(); - if (next1.value == null) next1.value = new Op(); - if (next2.value == null) next2.value = new Op(); - if (!next1.value.opcode && !next2.value.opcode) break; - const opOut = func(next1.value, next2.value); - if (opOut && opOut.opcode) assem.append(opOut); - } - assem.endDocument(); - return assem.toString(); + const ops = (function* () { + const ops1 = exports.deserializeOps(in1); + const ops2 = exports.deserializeOps(in2); + let next1 = ops1.next(); + let next2 = ops2.next(); + while (!next1.done || !next2.done) { + if (!next1.done && !next1.value.opcode) next1 = ops1.next(); + if (!next2.done && !next2.value.opcode) next2 = ops2.next(); + if (next1.value == null) next1.value = new Op(); + if (next2.value == null) next2.value = new Op(); + if (!next1.value.opcode && !next2.value.opcode) break; + const opOut = func(next1.value, next2.value); + if (opOut && opOut.opcode) yield opOut; + } + })(); + return exports.serializeOps(exports.canonicalizeOps(ops, true)); }; /** @@ -1059,24 +1198,7 @@ const applyZip = (in1, in2, func) => { * @param {string} cs - The encoded changeset. * @returns {Changeset} */ -exports.unpack = (cs) => { - const headerRegex = /Z:([0-9a-z]+)([><])([0-9a-z]+)|/; - const headerMatch = headerRegex.exec(cs); - if ((!headerMatch) || (!headerMatch[0])) error(`Not a changeset: ${cs}`); - const oldLen = exports.parseNum(headerMatch[1]); - const changeSign = (headerMatch[2] === '>') ? 1 : -1; - const changeMag = exports.parseNum(headerMatch[3]); - const newLen = oldLen + changeSign * changeMag; - const opsStart = headerMatch[0].length; - let opsEnd = cs.indexOf('$'); - if (opsEnd < 0) opsEnd = cs.length; - return { - oldLen, - newLen, - ops: cs.substring(opsStart, opsEnd), - charBank: cs.substring(opsEnd + 1), - }; -}; +exports.unpack = (cs) => Changeset.unpack(cs); /** * Creates an encoded changeset. @@ -1087,14 +1209,8 @@ exports.unpack = (cs) => { * @param {string} bank - Characters for insert operations. * @returns {string} The encoded changeset. */ -exports.pack = (oldLen, newLen, opsStr, bank) => { - const lenDiff = newLen - oldLen; - const lenDiffStr = (lenDiff >= 0 ? `>${exports.numToString(lenDiff)}` - : `<${exports.numToString(-lenDiff)}`); - const a = []; - a.push('Z:', exports.numToString(oldLen), lenDiffStr, opsStr, '$', bank); - return a.join(''); -}; +exports.pack = + (oldLen, newLen, opsStr, bank) => new Changeset(oldLen, newLen, opsStr, bank).toString(); /** * Applies a Changeset to a string. @@ -1104,11 +1220,11 @@ exports.pack = (oldLen, newLen, opsStr, bank) => { * @returns {string} */ exports.applyToText = (cs, str) => { - const unpacked = exports.unpack(cs); + const unpacked = Changeset.unpack(cs); assert(str.length === unpacked.oldLen, `mismatched apply: ${str.length} / ${unpacked.oldLen}`); - const bankIter = exports.stringIterator(unpacked.charBank); - const strIter = exports.stringIterator(str); - const assem = exports.stringAssembler(); + const bankIter = new StringIterator(unpacked.charBank); + const strIter = new StringIterator(str); + let assem = ''; for (const op of exports.deserializeOps(unpacked.ops)) { switch (op.opcode) { case '+': @@ -1117,7 +1233,7 @@ exports.applyToText = (cs, str) => { if (op.lines !== bankIter.peek(op.chars).split('\n').length - 1) { throw new Error(`newline count is wrong in op +; cs:${cs} and text:${str}`); } - assem.append(bankIter.take(op.chars)); + assem += bankIter.take(op.chars); break; case '-': // op is - and op.lines 0: no newlines must be in the deleted string @@ -1133,12 +1249,12 @@ exports.applyToText = (cs, str) => { if (op.lines !== strIter.peek(op.chars).split('\n').length - 1) { throw new Error(`newline count is wrong in op =; cs:${cs} and text:${str}`); } - assem.append(strIter.take(op.chars)); + assem += strIter.take(op.chars); break; } } - assem.append(strIter.take(strIter.remaining())); - return assem.toString(); + assem += strIter.take(strIter.remaining()); + return assem; }; /** @@ -1148,8 +1264,8 @@ exports.applyToText = (cs, str) => { * @param {string[]} lines - The lines to which the changeset needs to be applied */ exports.mutateTextLines = (cs, lines) => { - const unpacked = exports.unpack(cs); - const bankIter = exports.stringIterator(unpacked.charBank); + const unpacked = Changeset.unpack(cs); + const bankIter = new StringIterator(unpacked.charBank); const mut = new TextLinesMutator(lines); for (const op of exports.deserializeOps(unpacked.ops)) { switch (op.opcode) { @@ -1272,12 +1388,12 @@ const slicerZipperFunc = (attOp, csOp, pool) => { * @returns {string} */ exports.applyToAttribution = (cs, astr, pool) => { - const unpacked = exports.unpack(cs); + const unpacked = Changeset.unpack(cs); return applyZip(astr, unpacked.ops, (op1, op2) => slicerZipperFunc(op1, op2, pool)); }; exports.mutateAttributionLines = (cs, lines, pool) => { - const unpacked = exports.unpack(cs); + const unpacked = Changeset.unpack(cs); const csOps = exports.deserializeOps(unpacked.ops); let csOpsNext = csOps.next(); const csBank = unpacked.charBank; @@ -1306,14 +1422,12 @@ exports.mutateAttributionLines = (cs, lines, pool) => { let lineAssem = null; const outputMutOp = (op) => { - if (!lineAssem) { - lineAssem = exports.mergingOpAssembler(); - } - lineAssem.append(op); + if (!lineAssem) lineAssem = []; + lineAssem.push(op); if (op.lines <= 0) return; assert(op.lines === 1, `Can't have op.lines of ${op.lines} in attribution lines`); // ship it to the mut - mut.insert(lineAssem.toString(), 1); + mut.insert(exports.serializeOps(exports.squashOps(lineAssem, false)), 1); lineAssem = null; }; @@ -1362,23 +1476,22 @@ exports.mutateAttributionLines = (cs, lines, pool) => { * @returns {string} joined Attribution lines */ exports.joinAttributionLines = (theAlines) => { - const assem = exports.mergingOpAssembler(); - for (const aline of theAlines) { - for (const op of exports.deserializeOps(aline)) assem.append(op); - } - return assem.toString(); + const ops = (function* () { + for (const aline of theAlines) yield* exports.deserializeOps(aline); + })(); + return exports.serializeOps(exports.squashOps(ops, false)); }; exports.splitAttributionLines = (attrOps, text) => { - const assem = exports.mergingOpAssembler(); + let ops = []; const lines = []; let pos = 0; const appendOp = (op) => { - assem.append(op); + ops.push(op); if (op.lines > 0) { - lines.push(assem.toString()); - assem.clear(); + lines.push(exports.serializeOps(exports.squashOps(ops, false))); + ops = []; } pos += op.chars; }; @@ -1422,15 +1535,15 @@ exports.splitTextLines = (text) => text.match(/[^\n]*(?:\n|[^\n]$)/g); * @returns {string} */ exports.compose = (cs1, cs2, pool) => { - const unpacked1 = exports.unpack(cs1); - const unpacked2 = exports.unpack(cs2); + const unpacked1 = Changeset.unpack(cs1); + const unpacked2 = Changeset.unpack(cs2); const len1 = unpacked1.oldLen; const len2 = unpacked1.newLen; assert(len2 === unpacked2.oldLen, 'mismatched composition of two changesets'); const len3 = unpacked2.newLen; - const bankIter1 = exports.stringIterator(unpacked1.charBank); - const bankIter2 = exports.stringIterator(unpacked2.charBank); - const bankAssem = exports.stringAssembler(); + const bankIter1 = new StringIterator(unpacked1.charBank); + const bankIter2 = new StringIterator(unpacked2.charBank); + let bankAssem = ''; const newOps = applyZip(unpacked1.ops, unpacked2.ops, (op1, op2) => { const op1code = op1.opcode; @@ -1440,16 +1553,12 @@ exports.compose = (cs1, cs2, pool) => { } const opOut = slicerZipperFunc(op1, op2, pool); if (opOut.opcode === '+') { - if (op2code === '+') { - bankAssem.append(bankIter2.take(opOut.chars)); - } else { - bankAssem.append(bankIter1.take(opOut.chars)); - } + bankAssem += (op2code === '+' ? bankIter2 : bankIter1).take(opOut.chars); } return opOut; }); - return exports.pack(len1, len3, newOps, bankAssem.toString()); + return new Changeset(len1, len3, newOps, bankAssem).toString(); }; /** @@ -1475,7 +1584,7 @@ exports.attributeTester = (attribPair, pool) => { * @param {number} N - length of the identity changeset * @returns {string} */ -exports.identity = (N) => exports.pack(N, N, '', ''); +exports.identity = (N) => new Changeset(N, N, '', '').toString(); /** * Creates a Changeset which works on oldFullText and removes text from spliceStart to @@ -1496,15 +1605,13 @@ exports.makeSplice = (orig, start, ndel, ins, attribs, pool) => { if (start > orig.length) start = orig.length; if (ndel > orig.length - start) ndel = orig.length - start; const deleted = orig.substring(start, start + ndel); - const assem = exports.smartOpAssembler(); const ops = (function* () { yield* opsFromText('=', orig.substring(0, start)); yield* opsFromText('-', deleted); yield* opsFromText('+', ins, attribs, pool); })(); - for (const op of ops) assem.append(op); - assem.endDocument(); - return exports.pack(orig.length, orig.length + ins.length - ndel, assem.toString(), ins); + const serializedOps = exports.serializeOps(exports.canonicalizeOps(ops, true)); + return new Changeset(orig.length, orig.length + ins.length - ndel, serializedOps, ins).toString(); }; /** @@ -1515,12 +1622,12 @@ exports.makeSplice = (orig, start, ndel, ins, attribs, pool) => { * @returns {[number, number, string][]} */ const toSplices = (cs) => { - const unpacked = exports.unpack(cs); + const unpacked = Changeset.unpack(cs); /** @type {[number, number, string][]} */ const splices = []; let oldPos = 0; - const charIter = exports.stringIterator(unpacked.charBank); + const charIter = new StringIterator(unpacked.charBank); let inSplice = false; for (const op of exports.deserializeOps(unpacked.ops)) { if (op.opcode === '=') { @@ -1626,11 +1733,8 @@ exports.moveOpsToNewPool = (cs, oldPool, newPool) => { * @param {string} text - text to insert * @returns {string} */ -exports.makeAttribution = (text) => { - const assem = exports.smartOpAssembler(); - for (const op of opsFromText('+', text)) assem.append(op); - return assem.toString(); -}; +exports.makeAttribution = + (text) => exports.serializeOps(exports.canonicalizeOps(opsFromText('+', text), false)); /** * Iterates over attributes in exports, attribution string, or attribs property of an op and runs @@ -1823,7 +1927,7 @@ exports.prepareForWire = (cs, pool) => { * @returns {boolean} */ exports.isIdentity = (cs) => { - const unpacked = exports.unpack(cs); + const unpacked = Changeset.unpack(cs); return unpacked.ops === '' && unpacked.oldLen === unpacked.newLen; }; @@ -1870,95 +1974,107 @@ exports.attribsAttributeValue = (attribs, key, pool) => { /** * Incrementally builds a Changeset. - * - * @typedef {object} Builder - * @property {Function} insert - - * @property {Function} keep - - * @property {Function} keepText - - * @property {Function} remove - - * @property {Function} toString - */ +class Builder { + /** + * @param {number} oldLen - Old length + */ + constructor(oldLen) { + this._oldLen = oldLen; + this._ops = []; + this._charBank = ''; + } -/** - * @param {number} oldLen - Old length - * @returns {Builder} - */ -exports.builder = (oldLen) => { - const assem = exports.smartOpAssembler(); - const o = new Op(); - const charBank = exports.stringAssembler(); + /** + * @param {number} N - Number of characters to keep. + * @param {number} L - Number of newlines among the `N` characters. If positive, the last + * character must be a newline. + * @param {(string|Attribute[])} [attribs] - Either [[key1,value1],[key2,value2],...] or '*0*1...' + * (no pool needed in latter case). + * @param {?AttributePool} [pool] - Attribute pool, only required if `attribs` is a list of + * attribute key, value pairs. + * @returns {Builder} this + */ + keep(N, L, attribs = '', pool = null) { + const o = new Op('='); + o.attribs = typeof attribs === 'string' + ? attribs : new AttributeMap(pool).update(attribs || []).toString(); + o.chars = N; + o.lines = (L || 0); + this._ops.push(o); + return this; + } - const self = { - /** - * @param {number} N - Number of characters to keep. - * @param {number} L - Number of newlines among the `N` characters. If positive, the last - * character must be a newline. - * @param {(string|Attribute[])} attribs - Either [[key1,value1],[key2,value2],...] or '*0*1...' - * (no pool needed in latter case). - * @param {?AttributePool} pool - Attribute pool, only required if `attribs` is a list of - * attribute key, value pairs. - * @returns {Builder} this - */ - keep: (N, L, attribs, pool) => { - o.opcode = '='; - o.attribs = typeof attribs === 'string' - ? attribs : new AttributeMap(pool).update(attribs || []).toString(); - o.chars = N; - o.lines = (L || 0); - assem.append(o); - return self; - }, + /** + * @param {string} text - Text to keep. + * @param {(string|Attribute[])} [attribs] - Either [[key1,value1],[key2,value2],...] or '*0*1...' + * (no pool needed in latter case). + * @param {?AttributePool} [pool] - Attribute pool, only required if `attribs` is a list of + * attribute key, value pairs. + * @returns {Builder} this + */ + keepText(text, attribs = '', pool = null) { + this._ops.push(...opsFromText('=', text, attribs, pool)); + return this; + } - /** - * @param {string} text - Text to keep. - * @param {(string|Attribute[])} attribs - Either [[key1,value1],[key2,value2],...] or '*0*1...' - * (no pool needed in latter case). - * @param {?AttributePool} pool - Attribute pool, only required if `attribs` is a list of - * attribute key, value pairs. - * @returns {Builder} this - */ - keepText: (text, attribs, pool) => { - for (const op of opsFromText('=', text, attribs, pool)) assem.append(op); - return self; - }, + /** + * @param {string} text - Text to insert. + * @param {(string|Attribute[])} [attribs] - Either [[key1,value1],[key2,value2],...] or '*0*1...' + * (no pool needed in latter case). + * @param {?AttributePool} [pool] - Attribute pool, only required if `attribs` is a list of + * attribute key, value pairs. + * @returns {Builder} this + */ + insert(text, attribs = '', pool = null) { + this._ops.push(...opsFromText('+', text, attribs, pool)); + this._charBank += text; + return this; + } - /** - * @param {string} text - Text to insert. - * @param {(string|Attribute[])} attribs - Either [[key1,value1],[key2,value2],...] or '*0*1...' - * (no pool needed in latter case). - * @param {?AttributePool} pool - Attribute pool, only required if `attribs` is a list of - * attribute key, value pairs. - * @returns {Builder} this - */ - insert: (text, attribs, pool) => { - for (const op of opsFromText('+', text, attribs, pool)) assem.append(op); - charBank.append(text); - return self; - }, + /** + * @param {number} N - Number of characters to remove. + * @param {number} L - Number of newlines among the `N` characters. If positive, the last + * character must be a newline. + * @returns {Builder} this + */ + remove(N, L) { + const o = new Op('-'); + o.attribs = ''; + o.chars = N; + o.lines = (L || 0); + this._ops.push(o); + return this; + } - /** - * @param {number} N - Number of characters to remove. - * @param {number} L - Number of newlines among the `N` characters. If positive, the last - * character must be a newline. - * @returns {Builder} this - */ - remove: (N, L) => { - o.opcode = '-'; - o.attribs = ''; - o.chars = N; - o.lines = (L || 0); - assem.append(o); - return self; - }, - - toString: () => { - assem.endDocument(); - const newLen = oldLen + assem.getLengthChange(); - return exports.pack(oldLen, newLen, assem.toString(), charBank.toString()); - }, - }; + /** + * @returns {Changeset} + */ + build() { + /** @type {number} */ + let lengthChange; + const serializedOps = exports.serializeOps((function* () { + lengthChange = yield* exports.canonicalizeOps(this._ops, true); + }).call(this)); + const newLen = this._oldLen + lengthChange; + return new Changeset(this._oldLen, newLen, serializedOps, this._charBank); + } + + toString() { + return this.build().toString(); + } +} +exports.Builder = Builder; - return self; +/** + * @deprecated Use the `Builder` class instead. + * @param {number} oldLen - Old length + * @returns {Builder} + */ +exports.builder = (oldLen) => { + padutils.warnDeprecated( + 'Changeset.builder() is deprecated; use the Changeset.Builder class instead'); + return new Builder(oldLen); }; /** @@ -1989,11 +2105,11 @@ exports.makeAttribsString = (opcode, attribs, pool) => { exports.subattribution = (astr, start, optEnd) => { const attOps = exports.deserializeOps(astr); let attOpsNext = attOps.next(); - const assem = exports.smartOpAssembler(); let attOp = new Op(); - const csOp = new Op(); + const csOp = new Op('-'); + csOp.chars = start; - const doCsOp = () => { + const doCsOp = function* () { if (!csOp.chars) return; while (csOp.opcode && (attOp.opcode || !attOpsNext.done)) { if (!attOp.opcode) { @@ -2005,30 +2121,25 @@ exports.subattribution = (astr, start, optEnd) => { csOp.lines++; } const opOut = slicerZipperFunc(attOp, csOp, null); - if (opOut.opcode) assem.append(opOut); + if (opOut.opcode) yield opOut; } }; - csOp.opcode = '-'; - csOp.chars = start; - - doCsOp(); - - if (optEnd === undefined) { - if (attOp.opcode) { - assem.append(attOp); - } - while (!attOpsNext.done) { - assem.append(attOpsNext.value); - attOpsNext = attOps.next(); + const ops = (function* () { + yield* doCsOp(); + if (optEnd === undefined) { + if (attOp.opcode) yield attOp; + while (!attOpsNext.done) { + yield attOpsNext.value; + attOpsNext = attOps.next(); + } + } else { + csOp.opcode = '='; + csOp.chars = optEnd - start; + yield* doCsOp(); } - } else { - csOp.opcode = '='; - csOp.chars = optEnd - start; - doCsOp(); - } - - return assem.toString(); + })(); + return exports.serializeOps(exports.canonicalizeOps(ops, false)); }; exports.inverse = (cs, lines, alines, pool) => { @@ -2063,8 +2174,8 @@ exports.inverse = (cs, lines, alines, pool) => { let curLineOpsLine; let curLineNextOp = new Op('+'); - const unpacked = exports.unpack(cs); - const builder = exports.builder(unpacked.newLen); + const unpacked = Changeset.unpack(cs); + const builder = new Builder(unpacked.newLen); const consumeAttribRuns = (numChars, func /* (len, attribs, endsLine)*/) => { if (!curLineOps || curLineOpsLine !== curLine) { @@ -2127,20 +2238,20 @@ exports.inverse = (cs, lines, alines, pool) => { const nextText = (numChars) => { let len = 0; - const assem = exports.stringAssembler(); + let assem = ''; const firstString = linesGet(curLine).substring(curChar); len += firstString.length; - assem.append(firstString); + assem += firstString; let lineNum = curLine + 1; while (len < numChars) { const nextString = linesGet(lineNum); len += nextString.length; - assem.append(nextString); + assem += nextString; lineNum++; } - return assem.toString().substring(0, numChars); + return assem.substring(0, numChars); }; const cachedStrFunc = (func) => { @@ -2187,18 +2298,18 @@ exports.inverse = (cs, lines, alines, pool) => { } } - return exports.checkRep(builder.toString()); + return builder.build().validate().toString(); }; // %CLIENT FILE ENDS HERE% exports.follow = (cs1, cs2, reverseInsertOrder, pool) => { - const unpacked1 = exports.unpack(cs1); - const unpacked2 = exports.unpack(cs2); + const unpacked1 = Changeset.unpack(cs1); + const unpacked2 = Changeset.unpack(cs2); const len1 = unpacked1.oldLen; const len2 = unpacked2.oldLen; assert(len1 === len2, 'mismatched follow - cannot transform cs1 on top of cs2'); - const chars1 = exports.stringIterator(unpacked1.charBank); - const chars2 = exports.stringIterator(unpacked2.charBank); + const chars1 = new StringIterator(unpacked1.charBank); + const chars2 = new StringIterator(unpacked2.charBank); const oldLen = unpacked1.newLen; let oldPos = 0; @@ -2329,7 +2440,7 @@ exports.follow = (cs1, cs2, reverseInsertOrder, pool) => { }); newLen += oldLen - oldPos; - return exports.pack(oldLen, newLen, newOps, unpacked2.charBank); + return new Changeset(oldLen, newLen, newOps, unpacked2.charBank).toString(); }; const followAttributes = (att1, att2, pool) => { @@ -2353,12 +2464,9 @@ const followAttributes = (att1, att2, pool) => { return ''; }); // we've only removed attributes, so they're already sorted - const buf = exports.stringAssembler(); - for (const att of atts) { - buf.append('*'); - buf.append(exports.numToString(pool.putAttrib(att))); - } - return buf.toString(); + let buf = ''; + for (const att of atts) buf += `*${exports.numToString(pool.putAttrib(att))}`; + return buf; }; exports.exportedForTestingOnly = { diff --git a/src/static/js/ace2_inner.js b/src/static/js/ace2_inner.js index 77912057e60..9549e5761e8 100644 --- a/src/static/js/ace2_inner.js +++ b/src/static/js/ace2_inner.js @@ -170,7 +170,7 @@ function Ace2Inner(editorInfo, cssManagers) { // CCCCCCCCCCCCCCCCCCCC\n // CCCC\n // end[0]: -------\n - const builder = Changeset.builder(rep.lines.totalWidth()); + const builder = new Changeset.Builder(rep.lines.totalWidth()); ChangesetUtils.buildKeepToStartOfRange(rep, builder, start); ChangesetUtils.buildRemoveRange(rep, builder, start, end); builder.insert(newText, [ @@ -522,18 +522,24 @@ function Ace2Inner(editorInfo, cssManagers) { const numLines = rep.lines.length(); const upToLastLine = rep.lines.offsetOfIndex(numLines - 1); const lastLineLength = rep.lines.atIndex(numLines - 1).text.length; - const assem = Changeset.smartOpAssembler(); - const o = new Changeset.Op('-'); - o.chars = upToLastLine; - o.lines = numLines - 1; - assem.append(o); - o.chars = lastLineLength; - o.lines = 0; - assem.append(o); - for (const op of Changeset.opsFromAText(atext)) assem.append(op); - const newLen = oldLen + assem.getLengthChange(); - const changeset = Changeset.checkRep( - Changeset.pack(oldLen, newLen, assem.toString(), atext.text.slice(0, -1))); + const ops = (function* () { + const op1 = new Changeset.Op('-'); + op1.chars = upToLastLine; + op1.lines = numLines - 1; + yield op1; + const op2 = new Changeset.Op('-'); + op2.chars = lastLineLength; + op2.lines = 0; + yield op2; + yield* Changeset.opsFromAText(atext); + })(); + let lengthChange; + const serializedOps = Changeset.serializeOps((function* () { + lengthChange = yield* Changeset.canonicalizeOps(ops, false); + })()); + const newLen = oldLen + lengthChange; + const changeset = Changeset.pack(oldLen, newLen, serializedOps, atext.text.slice(0, -1)); + Changeset.unpack(changeset).validate(); performDocumentApplyChangeset(changeset); performSelectionChange( @@ -1266,7 +1272,7 @@ function Ace2Inner(editorInfo, cssManagers) { if (shouldIndent && /[[(:{]\s*$/.exec(prevLineText)) { theIndent += THE_TAB; } - const cs = Changeset.builder(rep.lines.totalWidth()).keep( + const cs = new Changeset.Builder(rep.lines.totalWidth()).keep( rep.lines.offsetOfIndex(lineNum), lineNum).insert( theIndent, [ ['author', thisAuthor], @@ -1441,11 +1447,10 @@ function Ace2Inner(editorInfo, cssManagers) { }; const doRepApplyChangeset = (changes, insertsAfterSelection) => { - Changeset.checkRep(changes); + const cs = Changeset.unpack(changes).validate(); - if (Changeset.oldLen(changes) !== rep.alltext.length) { - const errMsg = `${Changeset.oldLen(changes)}/${rep.alltext.length}`; - throw new Error(`doRepApplyChangeset length mismatch: ${errMsg}`); + if (cs.oldLen !== rep.alltext.length) { + throw new Error(`doRepApplyChangeset length mismatch: ${cs.oldLen}/${rep.alltext.length}`); } const editEvent = currentCallStack.editEvent; @@ -1740,7 +1745,7 @@ function Ace2Inner(editorInfo, cssManagers) { const spliceStartLineStart = rep.lines.offsetOfIndex(spliceStartLine); const startBuilder = () => { - const builder = Changeset.builder(oldLen); + const builder = new Changeset.Builder(oldLen); builder.keep(spliceStartLineStart, spliceStartLine); builder.keep(spliceStart - spliceStartLineStart); return builder; @@ -2291,7 +2296,7 @@ function Ace2Inner(editorInfo, cssManagers) { // 3-renumber every list item of the same level from the beginning, level 1 // IMPORTANT: never skip a level because there imbrication may be arbitrary - const builder = Changeset.builder(rep.lines.totalWidth()); + const builder = new Changeset.Builder(rep.lines.totalWidth()); let loc = [0, 0]; const applyNumberList = (line, level) => { // init diff --git a/src/static/js/changesettracker.js b/src/static/js/changesettracker.js index 30c70aa748f..cfe82f4a3d2 100644 --- a/src/static/js/changesettracker.js +++ b/src/static/js/changesettracker.js @@ -143,22 +143,22 @@ const makeChangesetTracker = (scheduler, apool, aceCallbacksProvider) => { // Sanitize authorship: Replace all author attributes with this user's author ID in case the // text was copied from another author. const cs = Changeset.unpack(userChangeset); - const assem = Changeset.mergingOpAssembler(); - - for (const op of Changeset.deserializeOps(cs.ops)) { - if (op.opcode === '+') { - const attribs = AttributeMap.fromString(op.attribs, apool); - const oldAuthorId = attribs.get('author'); - if (oldAuthorId != null && oldAuthorId !== authorId) { - attribs.set('author', authorId); - op.attribs = attribs.toString(); + const ops = (function* () { + for (const op of Changeset.deserializeOps(cs.ops)) { + if (op.opcode === '+') { + const attribs = AttributeMap.fromString(op.attribs, apool); + const oldAuthorId = attribs.get('author'); + if (oldAuthorId != null && oldAuthorId !== authorId) { + attribs.set('author', authorId); + op.attribs = attribs.toString(); + } } + yield op; } - assem.append(op); - } - assem.endDocument(); - userChangeset = Changeset.pack(cs.oldLen, cs.newLen, assem.toString(), cs.charBank); - Changeset.checkRep(userChangeset); + })(); + const serializedOps = Changeset.serializeOps(Changeset.squashOps(ops, true)); + userChangeset = Changeset.pack(cs.oldLen, cs.newLen, serializedOps, cs.charBank); + Changeset.unpack(userChangeset).validate(); if (Changeset.isIdentity(userChangeset)) toSubmit = null; else toSubmit = userChangeset; @@ -167,7 +167,7 @@ const makeChangesetTracker = (scheduler, apool, aceCallbacksProvider) => { let cs = null; if (toSubmit) { submittedChangeset = toSubmit; - userChangeset = Changeset.identity(Changeset.newLen(toSubmit)); + userChangeset = Changeset.identity(Changeset.unpack(toSubmit).newLen); cs = toSubmit; } diff --git a/src/static/js/contentcollector.js b/src/static/js/contentcollector.js index 7dd70e51287..266400d7a6b 100644 --- a/src/static/js/contentcollector.js +++ b/src/static/js/contentcollector.js @@ -82,31 +82,30 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author) const lines = (() => { const textArray = []; const attribsArray = []; - let attribsBuilder = null; - const op = new Changeset.Op('+'); + let ops = null; const self = { length: () => textArray.length, atColumnZero: () => textArray[textArray.length - 1] === '', startNew: () => { textArray.push(''); self.flush(true); - attribsBuilder = Changeset.smartOpAssembler(); + ops = []; }, textOfLine: (i) => textArray[i], appendText: (txt, attrString = '') => { textArray[textArray.length - 1] += txt; + const op = new Changeset.Op('+'); op.attribs = attrString; op.chars = txt.length; - attribsBuilder.append(op); + ops.push(op); }, textLines: () => textArray.slice(), attribLines: () => attribsArray, // call flush only when you're done flush: (withNewline) => { - if (attribsBuilder) { - attribsArray.push(attribsBuilder.toString()); - attribsBuilder = null; - } + if (ops == null) return; + attribsArray.push(Changeset.serializeOps(Changeset.canonicalizeOps(ops, false))); + ops = null; }, }; self.startNew(); diff --git a/src/tests/frontend/easysync-helper.js b/src/tests/frontend/easysync-helper.js index 6b4bfc95982..cec78d5c31a 100644 --- a/src/tests/frontend/easysync-helper.js +++ b/src/tests/frontend/easysync-helper.js @@ -20,29 +20,19 @@ const poolOrArray = (attribs) => { exports.poolOrArray = poolOrArray; const randomInlineString = (len) => { - const assem = Changeset.stringAssembler(); - for (let i = 0; i < len; i++) { - assem.append(String.fromCharCode(randInt(26) + 97)); - } - return assem.toString(); + let assem = ''; + for (let i = 0; i < len; i++) assem += String.fromCharCode(randInt(26) + 97); + return assem; }; const randomMultiline = (approxMaxLines, approxMaxCols) => { const numParts = randInt(approxMaxLines * 2) + 1; - const txt = Changeset.stringAssembler(); - txt.append(randInt(2) ? '\n' : ''); + let txt = ''; + txt += randInt(2) ? '\n' : ''; for (let i = 0; i < numParts; i++) { - if ((i % 2) === 0) { - if (randInt(10)) { - txt.append(randomInlineString(randInt(approxMaxCols) + 1)); - } else { - txt.append('\n'); - } - } else { - txt.append('\n'); - } + txt += i % 2 === 0 && randInt(10) ? randomInlineString(randInt(approxMaxCols) + 1) : '\n'; } - return txt.toString(); + return txt; }; exports.randomMultiline = randomMultiline; @@ -165,29 +155,25 @@ const randomTwoPropAttribs = (opcode) => { }; const randomTestChangeset = (origText, withAttribs) => { - const charBank = Changeset.stringAssembler(); + let charBank = ''; let textLeft = origText; // always keep final newline - const outTextAssem = Changeset.stringAssembler(); - const opAssem = Changeset.smartOpAssembler(); + let outTextAssem = ''; + const ops = []; const oldLen = origText.length; - const nextOp = new Changeset.Op(); - const appendMultilineOp = (opcode, txt) => { - nextOp.opcode = opcode; - if (withAttribs) { - nextOp.attribs = randomTwoPropAttribs(opcode); - } + const attribs = withAttribs ? randomTwoPropAttribs(opcode) : ''; txt.replace(/\n|[^\n]+/g, (t) => { + const nextOp = new Changeset.Op(opcode); + nextOp.attribs = attribs; if (t === '\n') { nextOp.chars = 1; nextOp.lines = 1; - opAssem.append(nextOp); } else { nextOp.chars = t.length; nextOp.lines = 0; - opAssem.append(nextOp); } + ops.push(nextOp); return ''; }); }; @@ -196,13 +182,13 @@ const randomTestChangeset = (origText, withAttribs) => { const o = randomStringOperation(textLeft.length); if (o.insert) { const txt = o.insert; - charBank.append(txt); - outTextAssem.append(txt); + charBank += txt; + outTextAssem += txt; appendMultilineOp('+', txt); } else if (o.skip) { const txt = textLeft.substring(0, o.skip); textLeft = textLeft.substring(o.skip); - outTextAssem.append(txt); + outTextAssem += txt; appendMultilineOp('=', txt); } else if (o.remove) { const txt = textLeft.substring(0, o.remove); @@ -213,10 +199,10 @@ const randomTestChangeset = (origText, withAttribs) => { while (textLeft.length > 1) doOp(); for (let i = 0; i < 5; i++) doOp(); // do some more (only insertions will happen) - const outText = `${outTextAssem.toString()}\n`; - opAssem.endDocument(); - const cs = Changeset.pack(oldLen, outText.length, opAssem.toString(), charBank.toString()); - Changeset.checkRep(cs); + const outText = `${outTextAssem}\n`; + const serializedOps = Changeset.serializeOps(Changeset.canonicalizeOps(ops, true)); + const cs = Changeset.pack(oldLen, outText.length, serializedOps, charBank); + Changeset.unpack(cs).validate(); return [cs, outText]; }; exports.randomTestChangeset = randomTestChangeset; diff --git a/src/tests/frontend/specs/easysync-assembler.js b/src/tests/frontend/specs/easysync-assembler.js index d9ce04ae2ac..fe15ee605c5 100644 --- a/src/tests/frontend/specs/easysync-assembler.js +++ b/src/tests/frontend/specs/easysync-assembler.js @@ -3,27 +3,23 @@ const Changeset = require('../../../static/js/Changeset'); describe('easysync-assembler', function () { - it('opAssembler', async function () { + it('deserialize and serialize', async function () { const x = '-c*3*4+6|3=az*asdf0*1*2*3+1=1-1+1*0+1=1-1+1|c=c-1'; - const assem = Changeset.opAssembler(); - for (const op of Changeset.deserializeOps(x)) assem.append(op); - expect(assem.toString()).to.equal(x); + expect(Changeset.serializeOps(Changeset.deserializeOps(x))).to.equal(x); }); - it('smartOpAssembler', async function () { + it('canonicalizeOps', async function () { const x = '-c*3*4+6|3=az*asdf0*1*2*3+1=1-1+1*0+1=1-1+1|c=c-1'; - const assem = Changeset.smartOpAssembler(); - for (const op of Changeset.deserializeOps(x)) assem.append(op); - assem.endDocument(); - expect(assem.toString()).to.equal(x); + expect(Changeset.serializeOps(Changeset.canonicalizeOps(Changeset.deserializeOps(x), true))) + .to.equal(x); }); describe('append atext to assembler', function () { const testAppendATextToAssembler = (testId, atext, correctOps) => { it(`testAppendATextToAssembler#${testId}`, async function () { - const assem = Changeset.smartOpAssembler(); - for (const op of Changeset.opsFromAText(atext)) assem.append(op); - expect(assem.toString()).to.equal(correctOps); + const serializedOps = + Changeset.serializeOps(Changeset.canonicalizeOps(Changeset.opsFromAText(atext), false)); + expect(serializedOps).to.equal(correctOps); }); }; diff --git a/src/tests/frontend/specs/easysync-compose.js b/src/tests/frontend/specs/easysync-compose.js index 69757763c6c..25dcf9c8066 100644 --- a/src/tests/frontend/specs/easysync-compose.js +++ b/src/tests/frontend/specs/easysync-compose.js @@ -24,10 +24,14 @@ describe('easysync-compose', function () { const change3 = x3[0]; const text3 = x3[1]; - const change12 = Changeset.checkRep(Changeset.compose(change1, change2, p)); - const change23 = Changeset.checkRep(Changeset.compose(change2, change3, p)); - const change123 = Changeset.checkRep(Changeset.compose(change12, change3, p)); - const change123a = Changeset.checkRep(Changeset.compose(change1, change23, p)); + const change12 = Changeset.compose(change1, change2, p); + Changeset.unpack(change12).validate(); + const change23 = Changeset.compose(change2, change3, p); + Changeset.unpack(change23).validate(); + const change123 = Changeset.compose(change12, change3, p); + Changeset.unpack(change123).validate(); + const change123a = Changeset.compose(change1, change23, p); + Changeset.unpack(change123a).validate(); expect(change123a).to.equal(change123); expect(Changeset.applyToText(change12, startText)).to.equal(text2); @@ -44,9 +48,12 @@ describe('easysync-compose', function () { const p = new AttributePool(); p.putAttrib(['bold', '']); p.putAttrib(['bold', 'true']); - const cs1 = Changeset.checkRep('Z:2>1*1+1*1=1$x'); - const cs2 = Changeset.checkRep('Z:3>0*0|1=3$'); - const cs12 = Changeset.checkRep(Changeset.compose(cs1, cs2, p)); + const cs1 = 'Z:2>1*1+1*1=1$x'; + Changeset.unpack(cs1).validate(); + const cs2 = 'Z:3>0*0|1=3$'; + Changeset.unpack(cs2).validate(); + const cs12 = Changeset.compose(cs1, cs2, p); + Changeset.unpack(cs12).validate(); expect(cs12).to.equal('Z:2>1+1*0|1=2$x'); }); }); diff --git a/src/tests/frontend/specs/easysync-follow.js b/src/tests/frontend/specs/easysync-follow.js index 9ec5a7e8301..efc02dc1758 100644 --- a/src/tests/frontend/specs/easysync-follow.js +++ b/src/tests/frontend/specs/easysync-follow.js @@ -15,11 +15,15 @@ describe('easysync-follow', function () { const cs1 = randomTestChangeset(startText)[0]; const cs2 = randomTestChangeset(startText)[0]; - const afb = Changeset.checkRep(Changeset.follow(cs1, cs2, false, p)); - const bfa = Changeset.checkRep(Changeset.follow(cs2, cs1, true, p)); + const afb = Changeset.follow(cs1, cs2, false, p); + Changeset.unpack(afb).validate(); + const bfa = Changeset.follow(cs2, cs1, true, p); + Changeset.unpack(bfa).validate(); - const merge1 = Changeset.checkRep(Changeset.compose(cs1, afb)); - const merge2 = Changeset.checkRep(Changeset.compose(cs2, bfa)); + const merge1 = Changeset.compose(cs1, afb); + Changeset.unpack(merge1).validate(); + const merge2 = Changeset.compose(cs2, bfa); + Changeset.unpack(merge2).validate(); expect(merge2).to.equal(merge1); }); @@ -60,7 +64,7 @@ describe('easysync-follow', function () { describe('chracterRangeFollow', function () { const testCharacterRangeFollow = (testId, cs, oldRange, insertionsAfter, correctNewRange) => { it(`testCharacterRangeFollow#${testId}`, async function () { - cs = Changeset.checkRep(cs); + Changeset.unpack(cs).validate(); expect(Changeset.characterRangeFollow(cs, oldRange[0], oldRange[1], insertionsAfter)) .to.eql(correctNewRange); }); diff --git a/src/tests/frontend/specs/easysync-inverseRandom.js b/src/tests/frontend/specs/easysync-inverseRandom.js index 41ef86d5779..12f84ab982e 100644 --- a/src/tests/frontend/specs/easysync-inverseRandom.js +++ b/src/tests/frontend/specs/easysync-inverseRandom.js @@ -41,7 +41,8 @@ describe('easysync-inverseRandom', function () { const testInverse = (testId, cs, lines, alines, pool, correctOutput) => { it(`testInverse#${testId}`, async function () { pool = poolOrArray(pool); - const str = Changeset.inverse(Changeset.checkRep(cs), lines, alines, pool); + Changeset.unpack(cs).validate(); + const str = Changeset.inverse(cs, lines, alines, pool); expect(str).to.equal(correctOutput); }); }; diff --git a/src/tests/frontend/specs/easysync-mutations.js b/src/tests/frontend/specs/easysync-mutations.js index 7cf43c8b7e5..567f134193d 100644 --- a/src/tests/frontend/specs/easysync-mutations.js +++ b/src/tests/frontend/specs/easysync-mutations.js @@ -15,37 +15,36 @@ describe('easysync-mutations', function () { }; const mutationsToChangeset = (oldLen, arrayOfArrays) => { - const assem = Changeset.smartOpAssembler(); - const op = new Changeset.Op(); - const bank = Changeset.stringAssembler(); + let bank = ''; let oldPos = 0; let newLen = 0; - arrayOfArrays.forEach((a) => { - if (a[0] === 'skip') { - op.opcode = '='; - op.chars = a[1]; - op.lines = (a[2] || 0); - assem.append(op); - oldPos += op.chars; - newLen += op.chars; - } else if (a[0] === 'remove') { - op.opcode = '-'; - op.chars = a[1]; - op.lines = (a[2] || 0); - assem.append(op); - oldPos += op.chars; - } else if (a[0] === 'insert') { - op.opcode = '+'; - bank.append(a[1]); - op.chars = a[1].length; - op.lines = (a[2] || 0); - assem.append(op); - newLen += op.chars; + const ops = (function* () { + for (const a of arrayOfArrays) { + const op = new Changeset.Op(); + if (a[0] === 'skip') { + op.opcode = '='; + op.chars = a[1]; + op.lines = (a[2] || 0); + oldPos += op.chars; + newLen += op.chars; + } else if (a[0] === 'remove') { + op.opcode = '-'; + op.chars = a[1]; + op.lines = (a[2] || 0); + oldPos += op.chars; + } else if (a[0] === 'insert') { + op.opcode = '+'; + bank += a[1]; + op.chars = a[1].length; + op.lines = (a[2] || 0); + newLen += op.chars; + } + yield op; } - }); + })(); + const serializedOps = Changeset.serializeOps(Changeset.canonicalizeOps(ops, true)); newLen += oldLen - oldPos; - assem.endDocument(); - return Changeset.pack(oldLen, newLen, assem.toString(), bank.toString()); + return Changeset.pack(oldLen, newLen, serializedOps, bank); }; const runMutationTest = (testId, origLines, muts, correct) => { @@ -205,7 +204,8 @@ describe('easysync-mutations', function () { it(`runMutateAttributionTest#${testId}`, async function () { const p = poolOrArray(attribs); const alines2 = Array.prototype.slice.call(alines); - Changeset.mutateAttributionLines(Changeset.checkRep(cs), alines2, p); + Changeset.unpack(cs).validate(); + Changeset.mutateAttributionLines(cs, alines2, p); expect(alines2).to.eql(outCorrect); const removeQuestionMarks = (a) => a.replace(/\?/g, ''); diff --git a/src/tests/frontend/specs/easysync-other.js b/src/tests/frontend/specs/easysync-other.js index af4580835c8..75c41ce19c3 100644 --- a/src/tests/frontend/specs/easysync-other.js +++ b/src/tests/frontend/specs/easysync-other.js @@ -78,7 +78,8 @@ describe('easysync-other', function () { }); it('testToSplices', async function () { - const cs = Changeset.checkRep('Z:z>9*0=1=4-3+9=1|1-4-4+1*0+a$123456789abcdefghijk'); + const cs = 'Z:z>9*0=1=4-3+9=1|1-4-4+1*0+a$123456789abcdefghijk'; + Changeset.unpack(cs).validate(); const correctSplices = [ [5, 8, '123456789'], [9, 17, 'abcdefghijk'], @@ -112,7 +113,8 @@ describe('easysync-other', function () { const runApplyToAttributionTest = (testId, attribs, cs, inAttr, outCorrect) => { it(`applyToAttribution#${testId}`, async function () { const p = poolOrArray(attribs); - const result = Changeset.applyToAttribution(Changeset.checkRep(cs), inAttr, p); + Changeset.unpack(cs).validate(); + const result = Changeset.applyToAttribution(cs, inAttr, p); expect(result).to.equal(outCorrect); }); }; @@ -129,16 +131,17 @@ describe('easysync-other', function () { describe('split/join attribution lines', function () { const testSplitJoinAttributionLines = (randomSeed) => { const stringToOps = (str) => { - const assem = Changeset.mergingOpAssembler(); - const o = new Changeset.Op('+'); - o.chars = 1; - for (let i = 0; i < str.length; i++) { - const c = str.charAt(i); - o.lines = (c === '\n' ? 1 : 0); - o.attribs = (c === 'a' || c === 'b' ? `*${c}` : ''); - assem.append(o); - } - return assem.toString(); + const ops = (function* () { + for (let i = 0; i < str.length; i++) { + const c = str.charAt(i); + const o = new Changeset.Op('+'); + o.chars = 1; + o.lines = (c === '\n' ? 1 : 0); + o.attribs = (c === 'a' || c === 'b' ? `*${c}` : ''); + yield o; + } + })(); + return Changeset.serializeOps(Changeset.squashOps(ops, false)); }; it(`testSplitJoinAttributionLines#${randomSeed}`, async function () {