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.append(tags[i]);
- assem.append('>');
- }
+ assem += spanClass || spanWithData ? '' : `${tags[i]}>`;
};
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 () {