[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
This commit is contained in:
Mathias Jakobsen 2024-02-01 11:32:18 +00:00 committed by Copybot
parent b87dad77d9
commit 63ff16843c
7 changed files with 880 additions and 241 deletions

View file

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

View file

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

View file

@ -10,39 +10,24 @@
'use strict' 'use strict'
const containsNonBmpChars = require('../util').containsNonBmpChars const containsNonBmpChars = require('../util').containsNonBmpChars
const OError = require('@overleaf/o-error')
const EditOperation = require('./edit_operation') 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 */ /** @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. * Create an empty text operation.
* @extends EditOperation * @extends EditOperation
@ -58,9 +43,6 @@ class TextOperation extends EditOperation {
static ApplyError = ApplyError static ApplyError = ApplyError
static InvalidInsertionError = InvalidInsertionError static InvalidInsertionError = InvalidInsertionError
static TooLongError = TooLongError static TooLongError = TooLongError
static isRetain = isRetain
static isInsert = isInsert
static isRemove = isRemove
constructor() { constructor() {
super() super()
@ -68,6 +50,7 @@ class TextOperation extends EditOperation {
// if an imaginary cursor runs over the entire string and skips over some // if an imaginary cursor runs over the entire string and skips over some
// parts, removes some parts and inserts characters at some positions. These // parts, removes some parts and inserts characters at some positions. These
// actions (skip/remove/insert) are stored as an array in the "ops" property. // actions (skip/remove/insert) are stored as an array in the "ops" property.
/** @type {ScanOp[]} */
this.ops = [] this.ops = []
// An operation's baseLength is the length of every string the operation // An operation's baseLength is the length of every string the operation
// can be applied to. // can be applied to.
@ -88,7 +71,7 @@ class TextOperation extends EditOperation {
return false return false
} }
for (let i = 0; i < this.ops.length; i++) { 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 return false
} }
} }
@ -101,64 +84,76 @@ class TextOperation extends EditOperation {
/** /**
* Skip over a given number of characters. * Skip over a given number of characters.
* @param {number | {r: number}} n
*/ */
retain(n) { retain(n) {
if (typeof n !== 'number') {
throw new Error('retain expects an integer')
}
if (n === 0) { if (n === 0) {
return this return this
} }
this.baseLength += n
this.targetLength += n if (!isRetain(n)) {
if (isRetain(this.ops[this.ops.length - 1])) { 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. // 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 { } else {
// Create a new op. // Create a new op.
this.ops.push(n) this.ops.push(newOp)
} }
return this return this
} }
/** /**
* Insert a string at the current position. * Insert a string at the current position.
* @param {string | {i: string}} insertValue
*/ */
insert(str) { insert(insertValue) {
if (typeof str !== 'string') { if (!isInsert(insertValue)) {
throw new Error('insert expects a string') throw new Error('insert expects a string or an insert object')
} }
if (containsNonBmpChars(str)) { const newOp = InsertOp.fromJSON(insertValue)
throw new TextOperation.InvalidInsertionError(str) if (newOp.insertion === '') {
}
if (str === '') {
return this return this
} }
this.targetLength += str.length this.targetLength += newOp.insertion.length
const ops = this.ops 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. // Merge insert op.
ops[ops.length - 1] += str lastOp.mergeWith(newOp)
} else if (isRemove(ops[ops.length - 1])) { } else if (lastOp instanceof RemoveOp) {
// It doesn't matter when an operation is applied whether the operation // It doesn't matter when an operation is applied whether the operation
// is remove(3), insert("something") or insert("something"), remove(3). // is remove(3), insert("something") or insert("something"), remove(3).
// Here we enforce that in this case, the insert op always comes first. // 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 // 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. // a document of the right length equal in respect to the `equals` method.
if (isInsert(ops[ops.length - 2])) { const secondToLastOp = ops[ops.length - 2]
ops[ops.length - 2] += str if (secondToLastOp?.canMergeWith(newOp)) {
secondToLastOp.mergeWith(newOp)
} else { } else {
ops[ops.length] = ops[ops.length - 1] ops[ops.length] = ops[ops.length - 1]
ops[ops.length - 2] = str ops[ops.length - 2] = newOp
} }
} else { } else {
ops.push(str) ops.push(newOp)
} }
return this return this
} }
/** /**
* Remove a string at the current position. * Remove a string at the current position.
* @param {number | string} n
*/ */
remove(n) { remove(n) {
if (typeof n === 'string') { if (typeof n === 'string') {
@ -173,11 +168,13 @@ class TextOperation extends EditOperation {
if (n > 0) { if (n > 0) {
n = -n n = -n
} }
const newOp = RemoveOp.fromJSON(n)
this.baseLength -= n this.baseLength -= n
if (isRemove(this.ops[this.ops.length - 1])) { const lastOp = this.ops[this.ops.length - 1]
this.ops[this.ops.length - 1] += n if (lastOp?.canMergeWith(newOp)) {
lastOp.mergeWith(newOp)
} else { } else {
this.ops.push(n) this.ops.push(newOp)
} }
return this return this
} }
@ -187,7 +184,8 @@ class TextOperation extends EditOperation {
*/ */
isNoop() { isNoop() {
return ( 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. * Pretty printing.
*/ */
toString() { toString() {
return this.ops return this.ops.map(op => op.toString()).join(', ')
.map(op => {
if (isRetain(op)) {
return 'retain ' + op
} else if (isInsert(op)) {
return "insert '" + op + "'"
} else {
return 'remove ' + -op
}
})
.join(', ')
} }
/** /**
* @inheritdoc * @inheritdoc
*/ */
toJSON() { 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 }) { static fromJSON = function ({ textOperation: ops }) {
const o = new TextOperation() const o = new TextOperation()
for (let i = 0, l = ops.length; i < l; i++) { for (const op of ops) {
const op = ops[i]
if (isRetain(op)) { if (isRetain(op)) {
o.retain(op) o.retain(op)
} else if (isInsert(op)) { } else if (isInsert(op)) {
@ -229,12 +216,7 @@ class TextOperation extends EditOperation {
} else if (isRemove(op)) { } else if (isRemove(op)) {
o.remove(op) o.remove(op)
} else { } else {
throw new Error( throw new UnprocessableError('unknown operation: ' + JSON.stringify(op))
'unknown operation: ' +
JSON.stringify(op) +
' in ' +
JSON.stringify(ops)
)
} }
} }
return o 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 const ops = this.ops
for (let i = 0, l = ops.length; i < l; i++) { const { inputCursor, result } = ops.reduce(
const op = ops[i] (intermediate, op) => op.apply(str, intermediate),
if (isRetain(op)) { { result: '', inputCursor: 0 }
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. if (inputCursor !== str.length) {
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) {
throw new TextOperation.ApplyError( throw new TextOperation.ApplyError(
"The operation didn't operate on the whole string.", "The operation didn't operate on the whole string.",
operation, operation,
@ -321,31 +280,13 @@ class TextOperation extends EditOperation {
length length
) )
} }
let newLength = 0
let strIndex = 0 const { length: newLength, inputCursor } = this.ops.reduce(
const ops = this.ops (intermediate, op) => op.applyToLength(intermediate),
for (let i = 0, l = ops.length; i < l; i++) { { length: 0, inputCursor: 0, inputLength: length }
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. if (inputCursor !== length) {
newLength += op
strIndex += op
} else if (isInsert(op)) {
// Insert string.
newLength += op.length
} else {
// remove op
strIndex -= op
}
}
if (strIndex !== length) {
throw new TextOperation.ApplyError( throw new TextOperation.ApplyError(
"The operation didn't operate on the whole string.", "The operation didn't operate on the whole string.",
operation, operation,
@ -369,15 +310,17 @@ class TextOperation extends EditOperation {
const ops = this.ops const ops = this.ops
for (let i = 0, l = ops.length; i < l; i++) { for (let i = 0, l = ops.length; i < l; i++) {
const op = ops[i] const op = ops[i]
if (isRetain(op)) { if (op instanceof RetainOp) {
inverse.retain(op) inverse.retain(op.length)
strIndex += op strIndex += op.length
} else if (isInsert(op)) { } else if (op instanceof InsertOp) {
inverse.remove(op.length) inverse.remove(op.insertion.length)
} else { } else if (op instanceof RemoveOp) {
// remove op // remove op
inverse.insert(str.slice(strIndex, strIndex - op)) inverse.insert(str.slice(strIndex, strIndex + op.length))
strIndex -= op strIndex += op.length
} else {
throw new UnprocessableError('unknown scanop during inversion')
} }
} }
return inverse return inverse
@ -404,14 +347,14 @@ class TextOperation extends EditOperation {
return false return false
} }
if (isInsert(simpleA) && isInsert(simpleB)) { if (simpleA instanceof InsertOp && simpleB instanceof InsertOp) {
return startA + simpleA.length === startB 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 // there are two possibilities to delete: with backspace and with the
// delete key. // delete key.
return startB - simpleB === startA || startA === startB return startB + simpleB.length === startA || startA === startB
} }
return false return false
@ -460,13 +403,14 @@ class TextOperation extends EditOperation {
break break
} }
if (isRemove(op1)) { if (op1 instanceof RemoveOp) {
operation.remove(op1) operation.remove(-op1.length)
op1 = ops1[i1++] op1 = ops1[i1++]
continue continue
} }
if (isInsert(op2)) {
operation.insert(op2) if (op2 instanceof InsertOp) {
operation.insert(op2.insertion)
op2 = ops2[i2++] op2 = ops2[i2++]
continue continue
} }
@ -482,57 +426,57 @@ class TextOperation extends EditOperation {
) )
} }
if (isRetain(op1) && isRetain(op2)) { if (op1 instanceof RetainOp && op2 instanceof RetainOp) {
if (op1 > op2) { if (op1.length > op2.length) {
operation.retain(op2) operation.retain(op2.length)
op1 = op1 - op2 op1 = ScanOp.fromJSON(op1.length - op2.length)
op2 = ops2[i2++] op2 = ops2[i2++]
} else if (op1 === op2) { } else if (op1.length === op2.length) {
operation.retain(op1) operation.retain(op1.length)
op1 = ops1[i1++] op1 = ops1[i1++]
op2 = ops2[i2++] op2 = ops2[i2++]
} else { } else {
operation.retain(op1) operation.retain(op1.length)
op2 = op2 - op1 op2 = ScanOp.fromJSON(op2.length - op1.length)
op1 = ops1[i1++] op1 = ops1[i1++]
} }
} else if (isInsert(op1) && isRemove(op2)) { } else if (op1 instanceof InsertOp && op2 instanceof RemoveOp) {
if (op1.length > -op2) { if (op1.insertion.length > op2.length) {
op1 = op1.slice(-op2) op1 = ScanOp.fromJSON(op1.insertion.slice(op2.length))
op2 = ops2[i2++] op2 = ops2[i2++]
} else if (op1.length === -op2) { } else if (op1.insertion.length === op2.length) {
op1 = ops1[i1++] op1 = ops1[i1++]
op2 = ops2[i2++] op2 = ops2[i2++]
} else { } else {
op2 = op2 + op1.length op2 = ScanOp.fromJSON(-op2.length + op1.insertion.length)
op1 = ops1[i1++] op1 = ops1[i1++]
} }
} else if (isInsert(op1) && isRetain(op2)) { } else if (op1 instanceof InsertOp && op2 instanceof RetainOp) {
if (op1.length > op2) { if (op1.insertion.length > op2.length) {
operation.insert(op1.slice(0, op2)) operation.insert(op1.insertion.slice(0, op2.length))
op1 = op1.slice(op2) op1 = ScanOp.fromJSON(op1.insertion.slice(op2.length))
op2 = ops2[i2++] op2 = ops2[i2++]
} else if (op1.length === op2) { } else if (op1.insertion.length === op2.length) {
operation.insert(op1) operation.insert(op1.insertion)
op1 = ops1[i1++] op1 = ops1[i1++]
op2 = ops2[i2++] op2 = ops2[i2++]
} else { } else {
operation.insert(op1) operation.insert(op1.insertion)
op2 = op2 - op1.length op2 = ScanOp.fromJSON(op2.length - op1.insertion.length)
op1 = ops1[i1++] op1 = ops1[i1++]
} }
} else if (isRetain(op1) && isRemove(op2)) { } else if (op1 instanceof RetainOp && op2 instanceof RemoveOp) {
if (op1 > -op2) { if (op1.length > op2.length) {
operation.remove(op2) operation.remove(-op2.length)
op1 = op1 + op2 op1 = ScanOp.fromJSON(op1.length - op2.length)
op2 = ops2[i2++] op2 = ops2[i2++]
} else if (op1 === -op2) { } else if (op1.length === op2.length) {
operation.remove(op2) operation.remove(-op2.length)
op1 = ops1[i1++] op1 = ops1[i1++]
op2 = ops2[i2++] op2 = ops2[i2++]
} else { } else {
operation.remove(op1) operation.remove(op1.length)
op2 = op2 + op1 op2 = ScanOp.fromJSON(-op2.length + op1.length)
op1 = ops1[i1++] op1 = ops1[i1++]
} }
} else { } else {
@ -581,15 +525,15 @@ class TextOperation extends EditOperation {
// next two cases: one or both ops are insert ops // next two cases: one or both ops are insert ops
// => insert the string in the corresponding prime operation, skip it in // => insert the string in the corresponding prime operation, skip it in
// the other one. If both op1 and op2 are insert ops, prefer op1. // the other one. If both op1 and op2 are insert ops, prefer op1.
if (isInsert(op1)) { if (op1 instanceof InsertOp) {
operation1prime.insert(op1) operation1prime.insert(op1.insertion)
operation2prime.retain(op1.length) operation2prime.retain(op1.insertion.length)
op1 = ops1[i1++] op1 = ops1[i1++]
continue continue
} }
if (isInsert(op2)) { if (op2 instanceof InsertOp) {
operation1prime.retain(op2.length) operation1prime.retain(op2.insertion.length)
operation2prime.insert(op2) operation2prime.insert(op2.insertion)
op2 = ops2[i2++] op2 = ops2[i2++]
continue continue
} }
@ -606,65 +550,65 @@ class TextOperation extends EditOperation {
} }
let minl let minl
if (isRetain(op1) && isRetain(op2)) { if (op1 instanceof RetainOp && op2 instanceof RetainOp) {
// Simple case: retain/retain // Simple case: retain/retain
if (op1 > op2) { if (op1.length > op2.length) {
minl = op2 minl = op2.length
op1 = op1 - op2 op1 = ScanOp.fromJSON(op1.length - op2.length)
op2 = ops2[i2++] op2 = ops2[i2++]
} else if (op1 === op2) { } else if (op1.length === op2.length) {
minl = op2 minl = op2.length
op1 = ops1[i1++] op1 = ops1[i1++]
op2 = ops2[i2++] op2 = ops2[i2++]
} else { } else {
minl = op1 minl = op1.length
op2 = op2 - op1 op2 = ScanOp.fromJSON(op2.length - op1.length)
op1 = ops1[i1++] op1 = ops1[i1++]
} }
operation1prime.retain(minl) operation1prime.retain(minl)
operation2prime.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 // 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 // need to produce any operations, we just skip over the remove ops and
// handle the case that one operation removes more than the other. // handle the case that one operation removes more than the other.
if (-op1 > -op2) { if (op1.length > op2.length) {
op1 = op1 - op2 op1 = ScanOp.fromJSON(-op1.length - -op2.length)
op2 = ops2[i2++] op2 = ops2[i2++]
} else if (op1 === op2) { } else if (op1.length === op2.length) {
op1 = ops1[i1++] op1 = ops1[i1++]
op2 = ops2[i2++] op2 = ops2[i2++]
} else { } else {
op2 = op2 - op1 op2 = ScanOp.fromJSON(-op2.length - -op1.length)
op1 = ops1[i1++] op1 = ops1[i1++]
} }
// next two cases: remove/retain and retain/remove // next two cases: remove/retain and retain/remove
} else if (isRemove(op1) && isRetain(op2)) { } else if (op1 instanceof RemoveOp && op2 instanceof RetainOp) {
if (-op1 > op2) { if (op1.length > op2.length) {
minl = op2 minl = op2.length
op1 = op1 + op2 op1 = ScanOp.fromJSON(-op1.length + op2.length)
op2 = ops2[i2++] op2 = ops2[i2++]
} else if (-op1 === op2) { } else if (op1.length === op2.length) {
minl = op2 minl = op2.length
op1 = ops1[i1++] op1 = ops1[i1++]
op2 = ops2[i2++] op2 = ops2[i2++]
} else { } else {
minl = -op1 minl = op1.length
op2 = op2 + op1 op2 = ScanOp.fromJSON(op2.length + -op1.length)
op1 = ops1[i1++] op1 = ops1[i1++]
} }
operation1prime.remove(minl) operation1prime.remove(minl)
} else if (isRetain(op1) && isRemove(op2)) { } else if (op1 instanceof RetainOp && op2 instanceof RemoveOp) {
if (op1 > -op2) { if (op1.length > op2.length) {
minl = -op2 minl = op2.length
op1 = op1 + op2 op1 = ScanOp.fromJSON(op1.length + -op2.length)
op2 = ops2[i2++] op2 = ops2[i2++]
} else if (op1 === -op2) { } else if (op1.length === op2.length) {
minl = op1 minl = op1.length
op1 = ops1[i1++] op1 = ops1[i1++]
op2 = ops2[i2++] op2 = ops2[i2++]
} else { } else {
minl = op1 minl = op1.length
op2 = op2 + op1 op2 = ScanOp.fromJSON(-op2.length + op1.length)
op1 = ops1[i1++] op1 = ops1[i1++]
} }
operation2prime.remove(minl) operation2prime.remove(minl)
@ -685,27 +629,24 @@ class TextOperation extends EditOperation {
// Represented by strings. // Represented by strings.
// * Remove ops: Remove the next n characters. Represented by negative ints. // * Remove ops: Remove the next n characters. Represented by negative ints.
function isRetain(op) { /**
return typeof op === 'number' && op > 0 *
} * @param {TextOperation} operation
* @returns {ScanOp | null}
function isInsert(op) { */
return typeof op === 'string' function getSimpleOp(operation) {
}
function isRemove(op) {
return typeof op === 'number' && op < 0
}
function getSimpleOp(operation, fn) {
const ops = operation.ops const ops = operation.ops
switch (ops.length) { switch (ops.length) {
case 1: case 1:
return ops[0] return ops[0]
case 2: 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: case 3:
if (isRetain(ops[0]) && isRetain(ops[2])) { if (ops[0] instanceof RetainOp && ops[2] instanceof RetainOp) {
return ops[1] return ops[1]
} }
} }
@ -713,8 +654,8 @@ function getSimpleOp(operation, fn) {
} }
function getStartIndex(operation) { function getStartIndex(operation) {
if (isRetain(operation.ops[0])) { if (operation.ops[0] instanceof RetainOp) {
return operation.ops[0] return operation.ops[0].length
} }
return 0 return 0
} }

View file

@ -4,8 +4,9 @@
'use strict' '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) { exports.containsNonBmpChars = function utilContainsNonBmpChars(str) {
// check for first (high) surrogate in a non-BMP character // check for first (high) surrogate in a non-BMP character

View file

@ -31,7 +31,7 @@ describe('EditOperationBuilder', function () {
} }
const op = EditOperationBuilder.fromJSON(raw) const op = EditOperationBuilder.fromJSON(raw)
expect(op).to.be.an.instanceof(TextOperation) 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 () { it('Throws error for unsupported operation', function () {

View file

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

View file

@ -13,6 +13,7 @@ const randomOperation = require('./support/random_text_operation')
const ot = require('..') const ot = require('..')
const TextOperation = ot.TextOperation const TextOperation = ot.TextOperation
const StringFileData = require('../lib/file_data/string_file_data') const StringFileData = require('../lib/file_data/string_file_data')
const { RetainOp, InsertOp, RemoveOp } = require('../lib/operation/scan_op')
describe('TextOperation', function () { describe('TextOperation', function () {
const numTrials = 500 const numTrials = 500
@ -73,22 +74,22 @@ describe('TextOperation', function () {
expect(o.ops.length).to.equal(0) expect(o.ops.length).to.equal(0)
o.retain(2) o.retain(2)
expect(o.ops.length).to.equal(1) 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) o.retain(3)
expect(o.ops.length).to.equal(1) 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') o.insert('abc')
expect(o.ops.length).to.equal(2) 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') o.insert('xyz')
expect(o.ops.length).to.equal(2) 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') o.remove('d')
expect(o.ops.length).to.equal(3) 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') o.remove('d')
expect(o.ops.length).to.equal(3) 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 () { it('checks for no-ops', function () {