Changeset: Move changeset logic to a new Changeset class

This commit is contained in:
Richard Hansen 2021-10-25 06:07:51 -04:00
parent a470253779
commit b718d88157
11 changed files with 193 additions and 127 deletions

View file

@ -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 -

View file

@ -438,7 +438,7 @@ PadDiff.prototype._createDeletionChangeset = function (cs, startAText, apool) {
}
}
return Changeset.checkRep(builder.toString());
return builder.build().validate().toString();
};
// export the constructor

View file

@ -155,14 +155,133 @@ 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.
@ -170,7 +289,7 @@ exports.Op = Op;
* @param {string} cs - String representation of the Changeset
* @returns {number} oldLen property
*/
exports.oldLen = (cs) => exports.unpack(cs).oldLen;
exports.oldLen = (cs) => Changeset.unpack(cs).oldLen;
/**
* Returns the length of the text after changeset is applied.
@ -178,7 +297,7 @@ exports.oldLen = (cs) => exports.unpack(cs).oldLen;
* @param {string} cs - String representation of the Changeset
* @returns {number} newLen property
*/
exports.newLen = (cs) => exports.unpack(cs).newLen;
exports.newLen = (cs) => Changeset.unpack(cs).newLen;
/**
* Parses a string of serialized changeset operations.
@ -581,53 +700,14 @@ class SmartOpAssembler {
* 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) => {
const unpacked = exports.unpack(cs);
const oldLen = unpacked.oldLen;
const newLen = unpacked.newLen;
let charBank = unpacked.charBank;
let oldPos = 0;
let calcNewLen = 0;
const ops = (function* () {
for (const o of exports.deserializeOps(unpacked.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)}`);
}
yield o;
}
})();
const serializedOps = exports.serializeOps(exports.canonicalizeOps(ops, true));
calcNewLen += oldLen - oldPos;
assert(calcNewLen === newLen, 'Invalid changeset: claimed length does not match actual length');
assert(charBank === '', 'Invalid changeset: excess characters in the charBank');
const normalized = exports.pack(oldLen, calcNewLen, serializedOps, unpacked.charBank);
assert(normalized === cs, 'Invalid changeset: not in canonical form');
padutils.warnDeprecated(
'Changeset.checkRep(cs) is deprecated; use Changeset.unpack(cs).validate() instead.');
Changeset.unpack(cs).validate();
return cs;
};
@ -1108,24 +1188,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.
@ -1136,14 +1199,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.
@ -1153,7 +1210,7 @@ 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 = new StringIterator(unpacked.charBank);
const strIter = new StringIterator(str);
@ -1197,7 +1254,7 @@ 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 unpacked = Changeset.unpack(cs);
const bankIter = new StringIterator(unpacked.charBank);
const mut = new TextLinesMutator(lines);
for (const op of exports.deserializeOps(unpacked.ops)) {
@ -1321,12 +1378,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;
@ -1468,8 +1525,8 @@ 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');
@ -1491,7 +1548,7 @@ exports.compose = (cs1, cs2, pool) => {
return opOut;
});
return exports.pack(len1, len3, newOps, bankAssem);
return new Changeset(len1, len3, newOps, bankAssem).toString();
};
/**
@ -1517,7 +1574,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
@ -1544,7 +1601,7 @@ exports.makeSplice = (orig, start, ndel, ins, attribs, pool) => {
yield* opsFromText('+', ins, attribs, pool);
})();
const serializedOps = exports.serializeOps(exports.canonicalizeOps(ops, true));
return exports.pack(orig.length, orig.length + ins.length - ndel, serializedOps, ins);
return new Changeset(orig.length, orig.length + ins.length - ndel, serializedOps, ins).toString();
};
/**
@ -1555,7 +1612,7 @@ 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 = [];
@ -1860,7 +1917,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;
};
@ -1989,17 +2046,12 @@ class Builder {
const serializedOps = exports.serializeOps((function* () {
lengthChange = yield* exports.canonicalizeOps(this._ops, true);
}).call(this));
return {
oldLen: this._oldLen,
newLen: this._oldLen + lengthChange,
ops: serializedOps,
charBank: this._charBank,
};
const newLen = this._oldLen + lengthChange;
return new Changeset(this._oldLen, newLen, serializedOps, this._charBank);
}
toString() {
const {oldLen, newLen, ops, charBank} = this.build();
return exports.pack(oldLen, newLen, ops, charBank);
return this.build().toString();
}
}
exports.Builder = Builder;
@ -2112,7 +2164,7 @@ exports.inverse = (cs, lines, alines, pool) => {
let curLineOpsLine;
let curLineNextOp = new Op('+');
const unpacked = exports.unpack(cs);
const unpacked = Changeset.unpack(cs);
const builder = new Builder(unpacked.newLen);
const consumeAttribRuns = (numChars, func /* (len, attribs, endsLine)*/) => {
@ -2236,13 +2288,13 @@ 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');
@ -2378,7 +2430,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) => {

View file

@ -538,8 +538,8 @@ function Ace2Inner(editorInfo, cssManagers) {
lengthChange = yield* Changeset.canonicalizeOps(ops, false);
})());
const newLen = oldLen + lengthChange;
const changeset =
Changeset.checkRep(Changeset.pack(oldLen, newLen, serializedOps, atext.text.slice(0, -1)));
const changeset = Changeset.pack(oldLen, newLen, serializedOps, atext.text.slice(0, -1));
Changeset.unpack(changeset).validate();
performDocumentApplyChangeset(changeset);
performSelectionChange(
@ -1447,7 +1447,7 @@ function Ace2Inner(editorInfo, cssManagers) {
};
const doRepApplyChangeset = (changes, insertsAfterSelection) => {
Changeset.checkRep(changes);
Changeset.unpack(changes).validate();
if (Changeset.oldLen(changes) !== rep.alltext.length) {
const errMsg = `${Changeset.oldLen(changes)}/${rep.alltext.length}`;

View file

@ -158,7 +158,7 @@ const makeChangesetTracker = (scheduler, apool, aceCallbacksProvider) => {
})();
const serializedOps = Changeset.serializeOps(Changeset.squashOps(ops, true));
userChangeset = Changeset.pack(cs.oldLen, cs.newLen, serializedOps, cs.charBank);
Changeset.checkRep(userChangeset);
Changeset.unpack(userChangeset).validate();
if (Changeset.isIdentity(userChangeset)) toSubmit = null;
else toSubmit = userChangeset;

View file

@ -202,7 +202,7 @@ const randomTestChangeset = (origText, withAttribs) => {
const outText = `${outTextAssem}\n`;
const serializedOps = Changeset.serializeOps(Changeset.canonicalizeOps(ops, true));
const cs = Changeset.pack(oldLen, outText.length, serializedOps, charBank);
Changeset.checkRep(cs);
Changeset.unpack(cs).validate();
return [cs, outText];
};
exports.randomTestChangeset = randomTestChangeset;

View file

@ -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');
});
});

View file

@ -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);
});

View file

@ -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);
});
};

View file

@ -204,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, '');

View file

@ -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);
});
};