Procházet zdrojové kódy

[overleaf-editor-core] Introduce ScanOp and subclasses (#16695)

* [overleaf-editor-core] Introduce ScanOp and subclasses

* [overleaf-editor-core] Remove unused methods

* [overleaf-editor-core] Add tests for ScanOps

* [overleaf-editor-core] Simplify merge

* [overleaf-editor-core] Make ApplyContexts mutable

* [overleaf-editor-core] Remove unnecessary containsNonBmpChars check

* [overleaf-editor-core] Revert to using reduce

* [overleaf-editor-core] Modify inputCursor after using it

* [overleaf-editor-core] Rename DeleteOp to RemoveOp

* [overleaf-editor-core] Remove useless constructor

* [overleaf-editor-core] Mutate in mergeWith

* [overleaf-editor-core] Add check for out-of-bounds retain

* [overleaf-editor-core] Move error import

GitOrigin-RevId: d07bd58177579638551257d56f087e8967e5b52f
Mathias Jakobsen před 2 roky
rodič
revize
63ff16843c

+ 37 - 0
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,
+}

+ 364 - 0
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,
+}

+ 173 - 232
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
 }

+ 3 - 2
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

+ 1 - 1
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 () {

+ 295 - 0
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)
+  })
+})

+ 7 - 6
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 () {