diff --git a/libraries/overleaf-editor-core/lib/errors.js b/libraries/overleaf-editor-core/lib/errors.js new file mode 100644 index 0000000000..952875f69f --- /dev/null +++ b/libraries/overleaf-editor-core/lib/errors.js @@ -0,0 +1,37 @@ +const OError = require('@overleaf/o-error') + +class UnprocessableError extends OError {} + +class ApplyError extends UnprocessableError { + constructor(message, operation, operand) { + super(message, { operation, operand }) + this.operation = operation + this.operand = operand + } +} + +class InvalidInsertionError extends UnprocessableError { + constructor(str, operation) { + super('inserted text contains non BMP characters', { str, operation }) + this.str = str + this.operation = operation + } +} + +class TooLongError extends UnprocessableError { + constructor(operation, resultLength) { + super(`resulting string would be too long: ${resultLength}`, { + operation, + resultLength, + }) + this.operation = operation + this.resultLength = resultLength + } +} + +module.exports = { + UnprocessableError, + ApplyError, + InvalidInsertionError, + TooLongError, +} diff --git a/libraries/overleaf-editor-core/lib/operation/scan_op.js b/libraries/overleaf-editor-core/lib/operation/scan_op.js new file mode 100644 index 0000000000..9c9c0df132 --- /dev/null +++ b/libraries/overleaf-editor-core/lib/operation/scan_op.js @@ -0,0 +1,364 @@ +// @ts-check +const { containsNonBmpChars } = require('../util') +const { + ApplyError, + InvalidInsertionError, + UnprocessableError, +} = require('../errors') + +/** @typedef {{ result: string, inputCursor: number}} ApplyContext */ +/** @typedef {{ length: number, inputCursor: number, readonly inputLength: number}} LengthApplyContext */ + +class ScanOp { + constructor() { + if (this.constructor === ScanOp) { + throw new Error('Cannot instantiate abstract class') + } + } + + /** + * Applies an operation to a string + * @param {string} input + * @param {ApplyContext} current + * @returns {ApplyContext} + */ + apply(input, current) { + throw new Error('abstract method') + } + + /** + * Applies an operation to a length + * @param {LengthApplyContext} current + * @returns {LengthApplyContext} + */ + applyToLength(current) { + throw new Error('abstract method') + } + + toJSON() { + throw new Error('abstract method') + } + + /** + * @param {object} raw + * @returns {ScanOp} + */ + static fromJSON(raw) { + if (isRetain(raw)) { + return RetainOp.fromJSON(raw) + } else if (isInsert(raw)) { + return InsertOp.fromJSON(raw) + } else if (isRemove(raw)) { + return RemoveOp.fromJSON(raw) + } + throw new UnprocessableError(`Invalid ScanOp ${JSON.stringify(raw)}`) + } + + /** + * Tests whether two ScanOps are equal + * @param {ScanOp} _other + * @returns {boolean} + */ + equals(_other) { + return false + } + + /** + * Tests whether two ScanOps can be merged into a single operation + * @param {ScanOp} other + * @returns + */ + canMergeWith(other) { + return false + } + + /** + * Merge two ScanOps into a single operation + * @param {ScanOp} _other + * @returns {void} + */ + mergeWith(_other) { + throw new Error('abstract method') + } + + toString() { + 'ScanOp' + } +} + +class InsertOp extends ScanOp { + constructor(insertion) { + super() + if (typeof insertion !== 'string') { + throw new InvalidInsertionError('insertion must be a string') + } + if (containsNonBmpChars(insertion)) { + throw new InvalidInsertionError('insertion contains non-BMP characters') + } + this.insertion = insertion + } + + /** + * + * @param {{i: string} | string} op + * @returns {InsertOp} + */ + static fromJSON(op) { + if (typeof op === 'string') { + return new InsertOp(op) + } + // It must be an object with an 'i' property. + if (typeof op.i !== 'string') { + throw new InvalidInsertionError( + 'insert operation must have a string property' + ) + } + return new InsertOp(op.i) + } + + /** + * @inheritdoc + * @param {string} input + * @param {ApplyContext} current + * @returns {ApplyContext} + * */ + apply(input, current) { + if (containsNonBmpChars(this.insertion)) { + throw new InvalidInsertionError(input, this.toJSON()) + } + current.result += this.insertion + return current + } + + /** + * @inheritdoc + * @param {LengthApplyContext} current + * @returns {LengthApplyContext} + */ + applyToLength(current) { + current.length += this.insertion.length + return current + } + + /** @inheritdoc */ + equals(other) { + if (!(other instanceof InsertOp)) { + return false + } + return this.insertion === other.insertion + } + + canMergeWith(other) { + return other instanceof InsertOp + } + + mergeWith(other) { + if (!(other instanceof InsertOp)) { + throw new Error('Cannot merge with incompatible operation') + } + this.insertion += other.insertion + } + + toJSON() { + // TODO: Once we add metadata to the operation, generate an object rather + // than the compact representation. + return this.insertion + } + + toString() { + return `insert '${this.insertion}'` + } +} + +class RetainOp extends ScanOp { + constructor(length) { + super() + if (length < 0) { + throw new Error('length must be non-negative') + } + this.length = length + } + + /** + * @inheritdoc + * @param {string} input + * @param {ApplyContext} current + * @returns {ApplyContext} + * */ + apply(input, current) { + if (current.inputCursor + this.length > input.length) { + throw new ApplyError( + "Operation can't retain more chars than are left in the string.", + this.toJSON(), + input + ) + } + current.result += input.slice( + current.inputCursor, + current.inputCursor + this.length + ) + current.inputCursor += this.length + return current + } + + /** + * @inheritdoc + * @param {LengthApplyContext} current + * @returns {LengthApplyContext} + */ + applyToLength(current) { + if (current.inputCursor + this.length > current.inputLength) { + throw new ApplyError( + "Operation can't retain more chars than are left in the string.", + this.toJSON(), + current.inputLength + ) + } + current.length += this.length + current.inputCursor += this.length + return current + } + + /** + * + * @param {number | {r: number}} op + * @returns + */ + static fromJSON(op) { + if (typeof op === 'number') { + return new RetainOp(op) + } + // It must be an object with a 'r' property. + if (typeof op.r !== 'number') { + throw new Error('retain operation must have a number property') + } + return new RetainOp(op.r) + } + + /** @inheritdoc */ + equals(other) { + if (!(other instanceof RetainOp)) { + return false + } + return this.length === other.length + } + + canMergeWith(other) { + return other instanceof RetainOp + } + + mergeWith(other) { + if (!(other instanceof RetainOp)) { + throw new Error('Cannot merge with incompatible operation') + } + this.length += other.length + } + + toJSON() { + // TODO: Once we add metadata to the operation, generate an object rather + // than the compact representation. + return this.length + } + + toString() { + return `retain ${this.length}` + } +} + +class RemoveOp extends ScanOp { + constructor(length) { + super() + if (length < 0) { + throw new Error('length must be non-negative') + } + this.length = length + } + + /** + * @inheritdoc + * @param {string} _input + * @param {ApplyContext} current + * @returns {ApplyContext} + */ + apply(_input, current) { + current.inputCursor += this.length + return current + } + + /** + * @inheritdoc + * @param {LengthApplyContext} current + * @returns {LengthApplyContext} + */ + applyToLength(current) { + current.inputCursor += this.length + return current + } + + /** + * + * @param {number} op + * @returns {RemoveOp} + */ + static fromJSON(op) { + if (typeof op !== 'number' || op > 0) { + throw new Error('delete operation must be a negative number') + } + return new RemoveOp(-op) + } + + /** @inheritdoc */ + equals(other) { + if (!(other instanceof RemoveOp)) { + return false + } + return this.length === other.length + } + + canMergeWith(other) { + return other instanceof RemoveOp + } + + mergeWith(other) { + if (!(other instanceof RemoveOp)) { + throw new Error('Cannot merge with incompatible operation') + } + this.length += other.length + } + + toJSON() { + return -this.length + } + + toString() { + return `remove ${this.length}` + } +} + +function isRetain(op) { + return ( + (typeof op === 'number' && op > 0) || + (typeof op === 'object' && typeof op.r === 'number' && op.r > 0) + ) +} + +function isInsert(op) { + return ( + typeof op === 'string' || + (typeof op === 'object' && typeof op.i === 'string') + ) +} + +function isRemove(op) { + return typeof op === 'number' && op < 0 +} + +module.exports = { + ScanOp, + InsertOp, + RetainOp, + RemoveOp, + isRetain, + isInsert, + isRemove, +} diff --git a/libraries/overleaf-editor-core/lib/operation/text_operation.js b/libraries/overleaf-editor-core/lib/operation/text_operation.js index 2fbf21f5d9..c0d2748cf5 100644 --- a/libraries/overleaf-editor-core/lib/operation/text_operation.js +++ b/libraries/overleaf-editor-core/lib/operation/text_operation.js @@ -10,39 +10,24 @@ 'use strict' const containsNonBmpChars = require('../util').containsNonBmpChars -const OError = require('@overleaf/o-error') const EditOperation = require('./edit_operation') +const { + ScanOp, + RetainOp, + InsertOp, + RemoveOp, + isRetain, + isInsert, + isRemove, +} = require('./scan_op') +const { + UnprocessableError, + ApplyError, + InvalidInsertionError, + TooLongError, +} = require('../errors') /** @typedef {import('../file_data/string_file_data')} StringFileData */ -class UnprocessableError extends OError {} - -class ApplyError extends UnprocessableError { - constructor(message, operation, operand) { - super(message, { operation, operand }) - this.operation = operation - this.operand = operand - } -} - -class InvalidInsertionError extends UnprocessableError { - constructor(str, operation) { - super('inserted text contains non BMP characters', { str, operation }) - this.str = str - this.operation = operation - } -} - -class TooLongError extends UnprocessableError { - constructor(operation, resultLength) { - super(`resulting string would be too long: ${resultLength}`, { - operation, - resultLength, - }) - this.operation = operation - this.resultLength = resultLength - } -} - /** * Create an empty text operation. * @extends EditOperation @@ -58,9 +43,6 @@ class TextOperation extends EditOperation { static ApplyError = ApplyError static InvalidInsertionError = InvalidInsertionError static TooLongError = TooLongError - static isRetain = isRetain - static isInsert = isInsert - static isRemove = isRemove constructor() { super() @@ -68,6 +50,7 @@ class TextOperation extends EditOperation { // if an imaginary cursor runs over the entire string and skips over some // parts, removes some parts and inserts characters at some positions. These // actions (skip/remove/insert) are stored as an array in the "ops" property. + /** @type {ScanOp[]} */ this.ops = [] // An operation's baseLength is the length of every string the operation // can be applied to. @@ -88,7 +71,7 @@ class TextOperation extends EditOperation { return false } for (let i = 0; i < this.ops.length; i++) { - if (this.ops[i] !== other.ops[i]) { + if (!this.ops[i].equals(other.ops[i])) { return false } } @@ -101,64 +84,76 @@ class TextOperation extends EditOperation { /** * Skip over a given number of characters. + * @param {number | {r: number}} n */ retain(n) { - if (typeof n !== 'number') { - throw new Error('retain expects an integer') - } if (n === 0) { return this } - this.baseLength += n - this.targetLength += n - if (isRetain(this.ops[this.ops.length - 1])) { + + if (!isRetain(n)) { + throw new Error('retain expects an integer or a retain object') + } + const newOp = RetainOp.fromJSON(n) + + if (newOp.length === 0) { + return this + } + + this.baseLength += newOp.length + this.targetLength += newOp.length + + const lastOperation = this.ops[this.ops.length - 1] + if (lastOperation?.canMergeWith(newOp)) { // The last op is a retain op => we can merge them into one op. - this.ops[this.ops.length - 1] += n + lastOperation.mergeWith(newOp) } else { // Create a new op. - this.ops.push(n) + this.ops.push(newOp) } return this } /** * Insert a string at the current position. + * @param {string | {i: string}} insertValue */ - insert(str) { - if (typeof str !== 'string') { - throw new Error('insert expects a string') + insert(insertValue) { + if (!isInsert(insertValue)) { + throw new Error('insert expects a string or an insert object') } - if (containsNonBmpChars(str)) { - throw new TextOperation.InvalidInsertionError(str) - } - if (str === '') { + const newOp = InsertOp.fromJSON(insertValue) + if (newOp.insertion === '') { return this } - this.targetLength += str.length + this.targetLength += newOp.insertion.length const ops = this.ops - if (isInsert(ops[ops.length - 1])) { + const lastOp = this.ops[this.ops.length - 1] + if (lastOp?.canMergeWith(newOp)) { // Merge insert op. - ops[ops.length - 1] += str - } else if (isRemove(ops[ops.length - 1])) { + lastOp.mergeWith(newOp) + } else if (lastOp instanceof RemoveOp) { // It doesn't matter when an operation is applied whether the operation // is remove(3), insert("something") or insert("something"), remove(3). // Here we enforce that in this case, the insert op always comes first. // This makes all operations that have the same effect when applied to // a document of the right length equal in respect to the `equals` method. - if (isInsert(ops[ops.length - 2])) { - ops[ops.length - 2] += str + const secondToLastOp = ops[ops.length - 2] + if (secondToLastOp?.canMergeWith(newOp)) { + secondToLastOp.mergeWith(newOp) } else { ops[ops.length] = ops[ops.length - 1] - ops[ops.length - 2] = str + ops[ops.length - 2] = newOp } } else { - ops.push(str) + ops.push(newOp) } return this } /** * Remove a string at the current position. + * @param {number | string} n */ remove(n) { if (typeof n === 'string') { @@ -173,11 +168,13 @@ class TextOperation extends EditOperation { if (n > 0) { n = -n } + const newOp = RemoveOp.fromJSON(n) this.baseLength -= n - if (isRemove(this.ops[this.ops.length - 1])) { - this.ops[this.ops.length - 1] += n + const lastOp = this.ops[this.ops.length - 1] + if (lastOp?.canMergeWith(newOp)) { + lastOp.mergeWith(newOp) } else { - this.ops.push(n) + this.ops.push(newOp) } return this } @@ -187,7 +184,8 @@ class TextOperation extends EditOperation { */ isNoop() { return ( - this.ops.length === 0 || (this.ops.length === 1 && isRetain(this.ops[0])) + this.ops.length === 0 || + (this.ops.length === 1 && this.ops[0] instanceof RetainOp) ) } @@ -195,24 +193,14 @@ class TextOperation extends EditOperation { * Pretty printing. */ toString() { - return this.ops - .map(op => { - if (isRetain(op)) { - return 'retain ' + op - } else if (isInsert(op)) { - return "insert '" + op + "'" - } else { - return 'remove ' + -op - } - }) - .join(', ') + return this.ops.map(op => op.toString()).join(', ') } /** * @inheritdoc */ toJSON() { - return { textOperation: this.ops } + return { textOperation: this.ops.map(op => op.toJSON()) } } /** @@ -220,8 +208,7 @@ class TextOperation extends EditOperation { */ static fromJSON = function ({ textOperation: ops }) { const o = new TextOperation() - for (let i = 0, l = ops.length; i < l; i++) { - const op = ops[i] + for (const op of ops) { if (isRetain(op)) { o.retain(op) } else if (isInsert(op)) { @@ -229,12 +216,7 @@ class TextOperation extends EditOperation { } else if (isRemove(op)) { o.remove(op) } else { - throw new Error( - 'unknown operation: ' + - JSON.stringify(op) + - ' in ' + - JSON.stringify(ops) - ) + throw new UnprocessableError('unknown operation: ' + JSON.stringify(op)) } } return o @@ -265,36 +247,13 @@ class TextOperation extends EditOperation { ) } - // Build up the result string directly by concatenation (which is actually - // faster than joining arrays because it is optimised in v8). - let result = '' - let strIndex = 0 const ops = this.ops - for (let i = 0, l = ops.length; i < l; i++) { - const op = ops[i] - if (isRetain(op)) { - if (strIndex + op > str.length) { - throw new TextOperation.ApplyError( - "Operation can't retain more chars than are left in the string.", - operation, - str - ) - } - // Copy skipped part of the old string. - result += str.slice(strIndex, strIndex + op) - strIndex += op - } else if (isInsert(op)) { - if (containsNonBmpChars(op)) { - throw new TextOperation.InvalidInsertionError(str, operation) - } - // Insert string. - result += op - } else { - // remove op - strIndex -= op - } - } - if (strIndex !== str.length) { + const { inputCursor, result } = ops.reduce( + (intermediate, op) => op.apply(str, intermediate), + { result: '', inputCursor: 0 } + ) + + if (inputCursor !== str.length) { throw new TextOperation.ApplyError( "The operation didn't operate on the whole string.", operation, @@ -321,31 +280,13 @@ class TextOperation extends EditOperation { length ) } - let newLength = 0 - let strIndex = 0 - const ops = this.ops - for (let i = 0, l = ops.length; i < l; i++) { - const op = ops[i] - if (isRetain(op)) { - if (strIndex + op > length) { - throw new TextOperation.ApplyError( - "Operation can't retain more chars than are left in the string.", - operation, - length - ) - } - // Copy skipped part of the old string. - newLength += op - strIndex += op - } else if (isInsert(op)) { - // Insert string. - newLength += op.length - } else { - // remove op - strIndex -= op - } - } - if (strIndex !== length) { + + const { length: newLength, inputCursor } = this.ops.reduce( + (intermediate, op) => op.applyToLength(intermediate), + { length: 0, inputCursor: 0, inputLength: length } + ) + + if (inputCursor !== length) { throw new TextOperation.ApplyError( "The operation didn't operate on the whole string.", operation, @@ -369,15 +310,17 @@ class TextOperation extends EditOperation { const ops = this.ops for (let i = 0, l = ops.length; i < l; i++) { const op = ops[i] - if (isRetain(op)) { - inverse.retain(op) - strIndex += op - } else if (isInsert(op)) { - inverse.remove(op.length) - } else { + if (op instanceof RetainOp) { + inverse.retain(op.length) + strIndex += op.length + } else if (op instanceof InsertOp) { + inverse.remove(op.insertion.length) + } else if (op instanceof RemoveOp) { // remove op - inverse.insert(str.slice(strIndex, strIndex - op)) - strIndex -= op + inverse.insert(str.slice(strIndex, strIndex + op.length)) + strIndex += op.length + } else { + throw new UnprocessableError('unknown scanop during inversion') } } return inverse @@ -404,14 +347,14 @@ class TextOperation extends EditOperation { return false } - if (isInsert(simpleA) && isInsert(simpleB)) { - return startA + simpleA.length === startB + if (simpleA instanceof InsertOp && simpleB instanceof InsertOp) { + return startA + simpleA.insertion.length === startB } - if (isRemove(simpleA) && isRemove(simpleB)) { + if (simpleA instanceof RemoveOp && simpleB instanceof RemoveOp) { // there are two possibilities to delete: with backspace and with the // delete key. - return startB - simpleB === startA || startA === startB + return startB + simpleB.length === startA || startA === startB } return false @@ -460,13 +403,14 @@ class TextOperation extends EditOperation { break } - if (isRemove(op1)) { - operation.remove(op1) + if (op1 instanceof RemoveOp) { + operation.remove(-op1.length) op1 = ops1[i1++] continue } - if (isInsert(op2)) { - operation.insert(op2) + + if (op2 instanceof InsertOp) { + operation.insert(op2.insertion) op2 = ops2[i2++] continue } @@ -482,57 +426,57 @@ class TextOperation extends EditOperation { ) } - if (isRetain(op1) && isRetain(op2)) { - if (op1 > op2) { - operation.retain(op2) - op1 = op1 - op2 + if (op1 instanceof RetainOp && op2 instanceof RetainOp) { + if (op1.length > op2.length) { + operation.retain(op2.length) + op1 = ScanOp.fromJSON(op1.length - op2.length) op2 = ops2[i2++] - } else if (op1 === op2) { - operation.retain(op1) + } else if (op1.length === op2.length) { + operation.retain(op1.length) op1 = ops1[i1++] op2 = ops2[i2++] } else { - operation.retain(op1) - op2 = op2 - op1 + operation.retain(op1.length) + op2 = ScanOp.fromJSON(op2.length - op1.length) op1 = ops1[i1++] } - } else if (isInsert(op1) && isRemove(op2)) { - if (op1.length > -op2) { - op1 = op1.slice(-op2) + } else if (op1 instanceof InsertOp && op2 instanceof RemoveOp) { + if (op1.insertion.length > op2.length) { + op1 = ScanOp.fromJSON(op1.insertion.slice(op2.length)) op2 = ops2[i2++] - } else if (op1.length === -op2) { + } else if (op1.insertion.length === op2.length) { op1 = ops1[i1++] op2 = ops2[i2++] } else { - op2 = op2 + op1.length + op2 = ScanOp.fromJSON(-op2.length + op1.insertion.length) op1 = ops1[i1++] } - } else if (isInsert(op1) && isRetain(op2)) { - if (op1.length > op2) { - operation.insert(op1.slice(0, op2)) - op1 = op1.slice(op2) + } else if (op1 instanceof InsertOp && op2 instanceof RetainOp) { + if (op1.insertion.length > op2.length) { + operation.insert(op1.insertion.slice(0, op2.length)) + op1 = ScanOp.fromJSON(op1.insertion.slice(op2.length)) op2 = ops2[i2++] - } else if (op1.length === op2) { - operation.insert(op1) + } else if (op1.insertion.length === op2.length) { + operation.insert(op1.insertion) op1 = ops1[i1++] op2 = ops2[i2++] } else { - operation.insert(op1) - op2 = op2 - op1.length + operation.insert(op1.insertion) + op2 = ScanOp.fromJSON(op2.length - op1.insertion.length) op1 = ops1[i1++] } - } else if (isRetain(op1) && isRemove(op2)) { - if (op1 > -op2) { - operation.remove(op2) - op1 = op1 + op2 + } else if (op1 instanceof RetainOp && op2 instanceof RemoveOp) { + if (op1.length > op2.length) { + operation.remove(-op2.length) + op1 = ScanOp.fromJSON(op1.length - op2.length) op2 = ops2[i2++] - } else if (op1 === -op2) { - operation.remove(op2) + } else if (op1.length === op2.length) { + operation.remove(-op2.length) op1 = ops1[i1++] op2 = ops2[i2++] } else { - operation.remove(op1) - op2 = op2 + op1 + operation.remove(op1.length) + op2 = ScanOp.fromJSON(-op2.length + op1.length) op1 = ops1[i1++] } } else { @@ -581,15 +525,15 @@ class TextOperation extends EditOperation { // next two cases: one or both ops are insert ops // => insert the string in the corresponding prime operation, skip it in // the other one. If both op1 and op2 are insert ops, prefer op1. - if (isInsert(op1)) { - operation1prime.insert(op1) - operation2prime.retain(op1.length) + if (op1 instanceof InsertOp) { + operation1prime.insert(op1.insertion) + operation2prime.retain(op1.insertion.length) op1 = ops1[i1++] continue } - if (isInsert(op2)) { - operation1prime.retain(op2.length) - operation2prime.insert(op2) + if (op2 instanceof InsertOp) { + operation1prime.retain(op2.insertion.length) + operation2prime.insert(op2.insertion) op2 = ops2[i2++] continue } @@ -606,65 +550,65 @@ class TextOperation extends EditOperation { } let minl - if (isRetain(op1) && isRetain(op2)) { + if (op1 instanceof RetainOp && op2 instanceof RetainOp) { // Simple case: retain/retain - if (op1 > op2) { - minl = op2 - op1 = op1 - op2 + if (op1.length > op2.length) { + minl = op2.length + op1 = ScanOp.fromJSON(op1.length - op2.length) op2 = ops2[i2++] - } else if (op1 === op2) { - minl = op2 + } else if (op1.length === op2.length) { + minl = op2.length op1 = ops1[i1++] op2 = ops2[i2++] } else { - minl = op1 - op2 = op2 - op1 + minl = op1.length + op2 = ScanOp.fromJSON(op2.length - op1.length) op1 = ops1[i1++] } operation1prime.retain(minl) operation2prime.retain(minl) - } else if (isRemove(op1) && isRemove(op2)) { + } else if (op1 instanceof RemoveOp && op2 instanceof RemoveOp) { // Both operations remove the same string at the same position. We don't // need to produce any operations, we just skip over the remove ops and // handle the case that one operation removes more than the other. - if (-op1 > -op2) { - op1 = op1 - op2 + if (op1.length > op2.length) { + op1 = ScanOp.fromJSON(-op1.length - -op2.length) op2 = ops2[i2++] - } else if (op1 === op2) { + } else if (op1.length === op2.length) { op1 = ops1[i1++] op2 = ops2[i2++] } else { - op2 = op2 - op1 + op2 = ScanOp.fromJSON(-op2.length - -op1.length) op1 = ops1[i1++] } // next two cases: remove/retain and retain/remove - } else if (isRemove(op1) && isRetain(op2)) { - if (-op1 > op2) { - minl = op2 - op1 = op1 + op2 + } else if (op1 instanceof RemoveOp && op2 instanceof RetainOp) { + if (op1.length > op2.length) { + minl = op2.length + op1 = ScanOp.fromJSON(-op1.length + op2.length) op2 = ops2[i2++] - } else if (-op1 === op2) { - minl = op2 + } else if (op1.length === op2.length) { + minl = op2.length op1 = ops1[i1++] op2 = ops2[i2++] } else { - minl = -op1 - op2 = op2 + op1 + minl = op1.length + op2 = ScanOp.fromJSON(op2.length + -op1.length) op1 = ops1[i1++] } operation1prime.remove(minl) - } else if (isRetain(op1) && isRemove(op2)) { - if (op1 > -op2) { - minl = -op2 - op1 = op1 + op2 + } else if (op1 instanceof RetainOp && op2 instanceof RemoveOp) { + if (op1.length > op2.length) { + minl = op2.length + op1 = ScanOp.fromJSON(op1.length + -op2.length) op2 = ops2[i2++] - } else if (op1 === -op2) { - minl = op1 + } else if (op1.length === op2.length) { + minl = op1.length op1 = ops1[i1++] op2 = ops2[i2++] } else { - minl = op1 - op2 = op2 + op1 + minl = op1.length + op2 = ScanOp.fromJSON(-op2.length + op1.length) op1 = ops1[i1++] } operation2prime.remove(minl) @@ -685,27 +629,24 @@ class TextOperation extends EditOperation { // Represented by strings. // * Remove ops: Remove the next n characters. Represented by negative ints. -function isRetain(op) { - return typeof op === 'number' && op > 0 -} - -function isInsert(op) { - return typeof op === 'string' -} - -function isRemove(op) { - return typeof op === 'number' && op < 0 -} - -function getSimpleOp(operation, fn) { +/** + * + * @param {TextOperation} operation + * @returns {ScanOp | null} + */ +function getSimpleOp(operation) { const ops = operation.ops switch (ops.length) { case 1: return ops[0] case 2: - return isRetain(ops[0]) ? ops[1] : isRetain(ops[1]) ? ops[0] : null + return ops[0] instanceof RetainOp + ? ops[1] + : ops[1] instanceof RetainOp + ? ops[0] + : null case 3: - if (isRetain(ops[0]) && isRetain(ops[2])) { + if (ops[0] instanceof RetainOp && ops[2] instanceof RetainOp) { return ops[1] } } @@ -713,8 +654,8 @@ function getSimpleOp(operation, fn) { } function getStartIndex(operation) { - if (isRetain(operation.ops[0])) { - return operation.ops[0] + if (operation.ops[0] instanceof RetainOp) { + return operation.ops[0].length } return 0 } diff --git a/libraries/overleaf-editor-core/lib/util.js b/libraries/overleaf-editor-core/lib/util.js index 7647425ce9..52b1ab8e82 100644 --- a/libraries/overleaf-editor-core/lib/util.js +++ b/libraries/overleaf-editor-core/lib/util.js @@ -4,8 +4,9 @@ 'use strict' -/* - * return true/false if the given string contains non-BMP chars +/** + * @param {string} str + * @returns {boolean} true if the given string contains non-BMP chars otherwise false */ exports.containsNonBmpChars = function utilContainsNonBmpChars(str) { // check for first (high) surrogate in a non-BMP character diff --git a/libraries/overleaf-editor-core/test/edit_operation.test.js b/libraries/overleaf-editor-core/test/edit_operation.test.js index aef8fcad84..b7cc673526 100644 --- a/libraries/overleaf-editor-core/test/edit_operation.test.js +++ b/libraries/overleaf-editor-core/test/edit_operation.test.js @@ -31,7 +31,7 @@ describe('EditOperationBuilder', function () { } const op = EditOperationBuilder.fromJSON(raw) expect(op).to.be.an.instanceof(TextOperation) - expect(op.ops).to.deep.equal([1, 'foo', 3]) + expect(op.toJSON()).to.deep.equal(raw) }) it('Throws error for unsupported operation', function () { diff --git a/libraries/overleaf-editor-core/test/scan_op.test.js b/libraries/overleaf-editor-core/test/scan_op.test.js new file mode 100644 index 0000000000..dc1e9d466d --- /dev/null +++ b/libraries/overleaf-editor-core/test/scan_op.test.js @@ -0,0 +1,295 @@ +// @ts-check +const { expect } = require('chai') +const { + RetainOp, + ScanOp, + InsertOp, + RemoveOp, +} = require('../lib/operation/scan_op') +const { UnprocessableError, ApplyError } = require('../lib/errors') + +describe('ScanOp', function () { + describe('fromJSON', function () { + it('constructs a RetainOp from object', function () { + const op = ScanOp.fromJSON({ r: 1 }) + expect(op).to.be.instanceOf(RetainOp) + expect(/** @type {RetainOp} */ (op).length).to.equal(1) + }) + + it('constructs a RetainOp from number', function () { + const op = ScanOp.fromJSON(2) + expect(op).to.be.instanceOf(RetainOp) + expect(/** @type {RetainOp} */ (op).length).to.equal(2) + }) + + it('constructs an InsertOp from string', function () { + const op = ScanOp.fromJSON('abc') + expect(op).to.be.instanceOf(InsertOp) + expect(/** @type {InsertOp} */ (op).insertion).to.equal('abc') + }) + + it('constructs an InsertOp from object', function () { + const op = ScanOp.fromJSON({ i: 'abc' }) + expect(op).to.be.instanceOf(InsertOp) + expect(/** @type {InsertOp} */ (op).insertion).to.equal('abc') + }) + + it('constructs a RemoveOp from number', function () { + const op = ScanOp.fromJSON(-2) + expect(op).to.be.instanceOf(RemoveOp) + expect(/** @type {RemoveOp} */ (op).length).to.equal(2) + }) + + it('throws an error for invalid input', function () { + expect(() => ScanOp.fromJSON({})).to.throw(UnprocessableError) + }) + + it('throws an error for zero', function () { + expect(() => ScanOp.fromJSON(0)).to.throw(UnprocessableError) + }) + }) +}) + +describe('RetainOp', function () { + it('is equal to another RetainOp with the same length', function () { + const op1 = new RetainOp(1) + const op2 = new RetainOp(1) + expect(op1.equals(op2)).to.be.true + }) + + it('is not equal to another RetainOp with a different length', function () { + const op1 = new RetainOp(1) + const op2 = new RetainOp(2) + expect(op1.equals(op2)).to.be.false + }) + + it('is not equal to an InsertOp', function () { + const op1 = new RetainOp(1) + const op2 = new InsertOp('a') + expect(op1.equals(op2)).to.be.false + }) + + it('is not equal to a RemoveOp', function () { + const op1 = new RetainOp(1) + const op2 = new RemoveOp(1) + expect(op1.equals(op2)).to.be.false + }) + + it('can merge with another RetainOp', function () { + const op1 = new RetainOp(1) + const op2 = new RetainOp(2) + expect(op1.canMergeWith(op2)).to.be.true + op1.mergeWith(op2) + expect(op1.equals(new RetainOp(3))).to.be.true + }) + + it('cannot merge with an InsertOp', function () { + const op1 = new RetainOp(1) + const op2 = new InsertOp('a') + expect(op1.canMergeWith(op2)).to.be.false + expect(() => op1.mergeWith(op2)).to.throw(Error) + }) + + it('cannot merge with a RemoveOp', function () { + const op1 = new RetainOp(1) + const op2 = new RemoveOp(1) + expect(op1.canMergeWith(op2)).to.be.false + expect(() => op1.mergeWith(op2)).to.throw(Error) + }) + + it('can be converted to JSON', function () { + const op = new RetainOp(3) + expect(op.toJSON()).to.equal(3) + }) + + it('adds to the length and cursor when applied to length', function () { + const op = new RetainOp(3) + const { length, inputCursor } = op.applyToLength({ + length: 10, + inputCursor: 10, + inputLength: 30, + }) + expect(length).to.equal(13) + expect(inputCursor).to.equal(13) + }) + + it('adds from the input to the result when applied', function () { + const op = new RetainOp(3) + const { result, inputCursor } = op.apply('abcdefghi', { + result: 'xyz', + inputCursor: 3, + }) + expect(result).to.equal('xyzdef') + expect(inputCursor).to.equal(6) + }) +}) + +describe('InsertOp', function () { + it('is equal to another InsertOp with the same insertion', function () { + const op1 = new InsertOp('a') + const op2 = new InsertOp('a') + expect(op1.equals(op2)).to.be.true + }) + + it('is not equal to another InsertOp with a different insertion', function () { + const op1 = new InsertOp('a') + const op2 = new InsertOp('b') + expect(op1.equals(op2)).to.be.false + }) + + it('is not equal to a RetainOp', function () { + const op1 = new InsertOp('a') + const op2 = new RetainOp(1) + expect(op1.equals(op2)).to.be.false + }) + + it('is not equal to a RemoveOp', function () { + const op1 = new InsertOp('a') + const op2 = new RemoveOp(1) + expect(op1.equals(op2)).to.be.false + }) + + it('can merge with another InsertOp', function () { + const op1 = new InsertOp('a') + const op2 = new InsertOp('b') + expect(op1.canMergeWith(op2)).to.be.true + op1.mergeWith(op2) + expect(op1.equals(new InsertOp('ab'))).to.be.true + }) + + it('cannot merge with a RetainOp', function () { + const op1 = new InsertOp('a') + const op2 = new RetainOp(1) + expect(op1.canMergeWith(op2)).to.be.false + expect(() => op1.mergeWith(op2)).to.throw(Error) + }) + + it('cannot merge with a RemoveOp', function () { + const op1 = new InsertOp('a') + const op2 = new RemoveOp(1) + expect(op1.canMergeWith(op2)).to.be.false + expect(() => op1.mergeWith(op2)).to.throw(Error) + }) + + it('can be converted to JSON', function () { + const op = new InsertOp('a') + expect(op.toJSON()).to.equal('a') + }) + + it('adds to the length when applied to length', function () { + const op = new InsertOp('abc') + const { length, inputCursor } = op.applyToLength({ + length: 10, + inputCursor: 20, + inputLength: 40, + }) + expect(length).to.equal(13) + expect(inputCursor).to.equal(20) + }) + + it('adds from the insertion to the result when applied', function () { + const op = new InsertOp('ghi') + const { result, inputCursor } = op.apply('abcdef', { + result: 'xyz', + inputCursor: 3, + }) + expect(result).to.equal('xyzghi') + expect(inputCursor).to.equal(3) + }) + + it('can apply a retain of the rest of the input', function () { + const op = new RetainOp(10) + const { length, inputCursor } = op.applyToLength({ + length: 10, + inputCursor: 5, + inputLength: 15, + }) + expect(length).to.equal(20) + expect(inputCursor).to.equal(15) + }) + + it('cannot apply to length if the input cursor is at the end', function () { + const op = new RetainOp(10) + expect(() => + op.applyToLength({ + length: 10, + inputCursor: 10, + inputLength: 10, + }) + ).to.throw(ApplyError) + }) +}) + +describe('RemoveOp', function () { + it('is equal to another RemoveOp with the same length', function () { + const op1 = new RemoveOp(1) + const op2 = new RemoveOp(1) + expect(op1.equals(op2)).to.be.true + }) + + it('is not equal to another RemoveOp with a different length', function () { + const op1 = new RemoveOp(1) + const op2 = new RemoveOp(2) + expect(op1.equals(op2)).to.be.false + }) + + it('is not equal to a RetainOp', function () { + const op1 = new RemoveOp(1) + const op2 = new RetainOp(1) + expect(op1.equals(op2)).to.be.false + }) + + it('is not equal to an InsertOp', function () { + const op1 = new RemoveOp(1) + const op2 = new InsertOp('a') + expect(op1.equals(op2)).to.be.false + }) + + it('can merge with another RemoveOp', function () { + const op1 = new RemoveOp(1) + const op2 = new RemoveOp(2) + expect(op1.canMergeWith(op2)).to.be.true + op1.mergeWith(op2) + expect(op1.equals(new RemoveOp(3))).to.be.true + }) + + it('cannot merge with a RetainOp', function () { + const op1 = new RemoveOp(1) + const op2 = new RetainOp(1) + expect(op1.canMergeWith(op2)).to.be.false + expect(() => op1.mergeWith(op2)).to.throw(Error) + }) + + it('cannot merge with an InsertOp', function () { + const op1 = new RemoveOp(1) + const op2 = new InsertOp('a') + expect(op1.canMergeWith(op2)).to.be.false + expect(() => op1.mergeWith(op2)).to.throw(Error) + }) + + it('can be converted to JSON', function () { + const op = new RemoveOp(3) + expect(op.toJSON()).to.equal(-3) + }) + + it('adds to the input cursor when applied to length', function () { + const op = new RemoveOp(3) + const { length, inputCursor } = op.applyToLength({ + length: 10, + inputCursor: 10, + inputLength: 30, + }) + expect(length).to.equal(10) + expect(inputCursor).to.equal(13) + }) + + it('does not change the result and adds to the cursor when applied', function () { + const op = new RemoveOp(3) + const { result, inputCursor } = op.apply('abcdefghi', { + result: 'xyz', + inputCursor: 3, + }) + expect(result).to.equal('xyz') + expect(inputCursor).to.equal(6) + }) +}) diff --git a/libraries/overleaf-editor-core/test/text_operation.test.js b/libraries/overleaf-editor-core/test/text_operation.test.js index 7985a8f77b..5a44b34eb9 100644 --- a/libraries/overleaf-editor-core/test/text_operation.test.js +++ b/libraries/overleaf-editor-core/test/text_operation.test.js @@ -13,6 +13,7 @@ const randomOperation = require('./support/random_text_operation') const ot = require('..') const TextOperation = ot.TextOperation const StringFileData = require('../lib/file_data/string_file_data') +const { RetainOp, InsertOp, RemoveOp } = require('../lib/operation/scan_op') describe('TextOperation', function () { const numTrials = 500 @@ -73,22 +74,22 @@ describe('TextOperation', function () { expect(o.ops.length).to.equal(0) o.retain(2) expect(o.ops.length).to.equal(1) - expect(last(o.ops)).to.equal(2) + expect(last(o.ops).equals(new RetainOp(2))).to.be.true o.retain(3) expect(o.ops.length).to.equal(1) - expect(last(o.ops)).to.equal(5) + expect(last(o.ops).equals(new RetainOp(5))).to.be.true o.insert('abc') expect(o.ops.length).to.equal(2) - expect(last(o.ops)).to.equal('abc') + expect(last(o.ops).equals(new InsertOp('abc'))).to.be.true o.insert('xyz') expect(o.ops.length).to.equal(2) - expect(last(o.ops)).to.equal('abcxyz') + expect(last(o.ops).equals(new InsertOp('abcxyz'))).to.be.true o.remove('d') expect(o.ops.length).to.equal(3) - expect(last(o.ops)).to.equal(-1) + expect(last(o.ops).equals(new RemoveOp(1))).to.be.true o.remove('d') expect(o.ops.length).to.equal(3) - expect(last(o.ops)).to.equal(-2) + expect(last(o.ops).equals(new RemoveOp(2))).to.be.true }) it('checks for no-ops', function () {