瀏覽代碼

The `fastdiff()` function now also accepts arrays as input.

Krzysztof Krztoń 7 年之前
父節點
當前提交
63528fa9f0
共有 2 個文件被更改,包括 560 次插入214 次删除
  1. 143 62
      packages/ckeditor5-utils/src/fastdiff.js
  2. 417 152
      packages/ckeditor5-utils/tests/fastdiff.js

+ 143 - 62
packages/ckeditor5-utils/src/fastdiff.js

@@ -8,8 +8,7 @@
  */
 
 /**
- * Finds position of the first and last change in the given strings and generates set of changes. Set of changes
- * can be applied to the input text in order to transform it into the output text, for example:
+ * Finds position of the first and last change in the given string/array and generates a set of changes:
  *
  *		fastDiff( '12a', '12xyza' );
  *		// [ { index: 2, type: 'insert', values: [ 'x', 'y', 'z' ] } ]
@@ -20,13 +19,27 @@
  *		fastDiff( '12xyza', '12a' );
  *		// [ { index: 2, type: 'delete', howMany: 3 } ]
  *
- *		fastDiff( '12aa', '12a' );
+ *		fastDiff( [ '1', '2', 'a', 'a' ], [ '1', '2', 'a' ] );
  *		// [ { index: 3, type: 'delete', howMany: 1 } ]
  *
- *		fastDiff( '12abc3', '2ab' );
+ *		fastDiff( [ '1', '2', 'a', 'b', 'c', '3' ], [ '2', 'a', 'b' ] );
  *		// [ { index: 0, type: 'insert', values: [ '2', 'a', 'b' ] }, { index: 3, type: 'delete', howMany: 6 } ]
  *
- * Using returned results you can modify `oldText` to transform it into `newText`:
+ * Passed arrays can contain any type of data, however to compare them correctly custom comparator function
+ * should be passed as a third parameter:
+ *
+ * 		fastDiff( [ { value: 1 }, { value: 2 } ], [ { value: 1 }, { value: 3 } ], ( a, b ) => {
+ * 			return a.value === b.value;
+ * 		} );
+ * 		// [ { index: 1, type: 'insert', values: [ { value: 3 } ] }, { index: 2, type: 'delete', howMany: 1 } ]
+ *
+ * 	By passing `true` as a fourth parameter (`linearChanges`) the output compatible with
+ * 	{@link module:utils/diff~diff diff()} function will be returned:
+ *
+ *		fastDiff( '12a', '12xyza' );
+ *		// [ 'equal', 'equal', 'insert', 'insert', 'insert', 'equal' ]
+ *
+ * The resulted set of changes can be applied to the input in order to transform it into the output, for example:
  *
  * 		let input = '12abc3';
  * 		const output = '2ab';
@@ -40,101 +53,136 @@
  *			}
  *		} );
  *
- *		input === output; // -> true
+ *		// input equals output now
+ *
+ * or in case of arrays:
+ *
+ *		let input = [ '1', '2', 'a', 'b', 'c', '3' ];
+ * 		const output = [ '2', 'a', 'b' ];
+ * 		const changes = fastDiff( input, output );
+ *
+ *		changes.forEach( change => {
+ *			if ( change.type == 'insert' ) {
+ *				input = input.slice( 0, change.index ).concat( change.values, input.slice( change.index ) );
+ *			} else if ( change.type == 'delete' ) {
+ *				input = input.slice( 0, change.index ).concat( input.slice( change.index + change.howMany ) );
+ *			}
+ *		} );
+ *
+ *		// input equals output now
  *
- * The output format of this function is compatible with {@link module:utils/difftochanges~diffToChanges} output format.
+ * The output format of this function is compatible with {@link module:utils/difftochanges~diffToChanges diffToChanges()}
+ * function output format.
  *
- * @param {String} oldText Input string.
- * @param {String} newText Input string.
+ * @param {Array|String} a Input array or string.
+ * @param {Array|String} b Input array or string.
+ * @param {Function} [cmp] Optional function used to compare array values, by default === is used.
+ * @param {Boolean} [linearChanges=false] Whether array of `inset|delete|equal` operations should be returned or changes set.
  * @returns {Array} Array of changes.
  */
-export default function fastDiff( oldText, newText ) {
-	// Check if both texts are equal.
-	if ( oldText === newText ) {
-		return [];
+export default function fastDiff( a, b, cmp, linearChanges = false ) {
+	// Set the comparator function.
+	cmp = cmp || function( a, b ) {
+		return a === b;
+	};
+
+	// Transform text into arrays for easier, consistent processing.
+	if ( typeof a === 'string' ) {
+		a = a.split( '' );
 	}
 
-	const changeIndexes = findChangeBoundaryIndexes( oldText, newText );
+	if ( typeof b === 'string' ) {
+		b = b.split( '' );
+	}
 
-	return changeIndexesToChanges( newText, changeIndexes );
+	// Find first and last change.
+	const changeIndexes = findChangeBoundaryIndexes( a, b, cmp );
+
+	// Transform into changes array.
+	return linearChanges ? changeIndexesToLinearChanges( changeIndexes, b.length ) : changeIndexesToChanges( b, changeIndexes );
 }
 
-// Finds position of the first and last change in the given strings. For example:
+// Finds position of the first and last change in the given arrays. For example:
 //
-//		const indexes = findChangeBoundaryIndexes( '1234', '13424' );
+//		const indexes = findChangeBoundaryIndexes( [ '1', '2', '3', '4' ], [ '1', '3', '4', '2', '4' ] );
 //		console.log( indexes ); // { firstIndex: 1, lastIndexOld: 3, lastIndexNew: 4 }
 //
-// The above indexes means that in `oldText` modified part is `1[23]4` and in the `newText` it is `1[342]4`.
-// Based on such indexes, array with `insert`/`delete` operations which allows transforming
-// old text to the new one can be generated.
-//
-// It is expected that `oldText` and `newText` are different.
+// The above indexes means that in the first array the modified part is `1[23]4` and in the second array it is `1[342]4`.
+// Based on such indexes, array with `insert`/`delete` operations which allows transforming first value into the second one
+// can be generated.
 //
-// @param {String} oldText
-// @param {String} newText
+// @param {Array} arr1
+// @param {Array} arr2
+// @param {Function} cmp Comparator function.
 // @returns {Object}
-// @returns {Number} return.firstIndex Index of the first change in both strings (always the same for both).
-// @returns {Number} result.lastIndexOld Index of the last common character in `oldText` string.
-// @returns {Number} result.lastIndexNew Index of the last common character in `newText` string.
-function findChangeBoundaryIndexes( oldText, newText ) {
-	// Find the first difference between texts.
-	const firstIndex = findFirstDifferenceIndex( oldText, newText );
-
-	// Remove the common part of texts and reverse them to make it simpler to find the last difference between texts.
-	const oldTextReversed = cutAndReverse( oldText, firstIndex );
-	const newTextReversed = cutAndReverse( newText, firstIndex );
-
-	// Find the first difference between reversed texts.
-	// It should be treated as "how many characters from the end the last difference occurred".
+// @returns {Number} return.firstIndex Index of the first change in both values (always the same for both).
+// @returns {Number} result.lastIndexOld Index of the last common value in `arr1`.
+// @returns {Number} result.lastIndexNew Index of the last common value in `arr2`.
+function findChangeBoundaryIndexes( arr1, arr2, cmp ) {
+	// Find the first difference between passed values.
+	const firstIndex = findFirstDifferenceIndex( arr1, arr2, cmp );
+
+	// If arrays are equal return -1 indexes object.
+	if ( firstIndex === -1 ) {
+		return { firstIndex: -1, lastIndexOld: -1, lastIndexNew: -1 };
+	}
+
+	// Remove the common part of each value and reverse them to make it simpler to find the last difference between them.
+	const oldTextReversed = cutAndReverse( arr1, firstIndex );
+	const newTextReversed = cutAndReverse( arr2, firstIndex );
+
+	// Find the first difference between reversed values.
+	// It should be treated as "how many elements from the end the last difference occurred".
 	//
 	// For example:
 	//
-	// 			initial	->	after cut	-> reversed:
-	// oldText:	'321ba'	->	'21ba'		-> 'ab12'
-	// newText:	'31xba'	->	'1xba'		-> 'abx1'
-	// lastIndex:						->    2
+	// 				initial	->	after cut	-> reversed:
+	// oldValue:	'321ba'	->	'21ba'		-> 'ab12'
+	// newValue:	'31xba'	->	'1xba'		-> 'abx1'
+	// lastIndex:							-> 2
 	//
-	// So the last change occurred two characters from the end of the texts.
-	const lastIndex = findFirstDifferenceIndex( oldTextReversed, newTextReversed );
+	// So the last change occurred two characters from the end of the arrays.
+	const lastIndex = findFirstDifferenceIndex( oldTextReversed, newTextReversed, cmp );
 
 	// Use `lastIndex` to calculate proper offset, starting from the beginning (`lastIndex` kind of starts from the end).
-	const lastIndexOld = oldText.length - lastIndex;
-	const lastIndexNew = newText.length - lastIndex;
+	const lastIndexOld = arr1.length - lastIndex;
+	const lastIndexNew = arr2.length - lastIndex;
 
 	return { firstIndex, lastIndexOld, lastIndexNew };
 }
 
-// Returns a first index on which `oldText` and `newText` differ.
+// Returns a first index on which given arrays differ. If both arrays are the same, -1 is returned.
 //
-// @param {String} oldText
-// @param {String} newText
+// @param {Array} arr1
+// @param {Array} arr2
+// @param {Function} cmp Comparator function.
 // @returns {Number}
-function findFirstDifferenceIndex( oldText, newText ) {
-	for ( let i = 0; i < Math.max( oldText.length, newText.length ); i++ ) {
-		if ( oldText[ i ] !== newText[ i ] ) {
+function findFirstDifferenceIndex( arr1, arr2, cmp ) {
+	for ( let i = 0; i < Math.max( arr1.length, arr2.length ); i++ ) {
+		if ( arr1[ i ] === undefined || arr2[ i ] === undefined || !cmp( arr1[ i ], arr2[ i ] ) ) {
 			return i;
 		}
 	}
-	// No "backup" return cause we assume that `oldText` and `newText` differ. This means that they either have a
-	// difference or they have a different lengths. This means that the `if` condition will always be met eventually.
+
+	return -1; // Return -1 if arrays are equal.
 }
 
-// Removes `howMany` characters from the given `text` string starting from the beginning, then reverses and returns it.
+// Returns a copy of the given array with `howMany` elements removed starting from the beginning and in reversed order.
 //
-// @param {String} text Text to be processed.
-// @param {Number} howMany How many characters from text beginning to cut.
-// @returns {String} Shortened and reversed text.
-function cutAndReverse( text, howMany ) {
-	return text.substring( howMany ).split( '' ).reverse().join( '' );
+// @param {Array} arr Array to be processed.
+// @param {Number} howMany How many elements from text beginning to remove.
+// @returns {Array} Shortened and reversed array.
+function cutAndReverse( arr, howMany ) {
+	return arr.slice( howMany ).reverse();
 }
 
 // Generates changes array based on change indexes from `findChangeBoundaryIndexes` function. This function will
 // generate array with 0 (no changes), 1 (deletion or insertion) or 2 records (insertion and deletion).
 //
-// @param {String} newText New text for which change indexes were calculated.
+// @param {Array} newArray New array for which change indexes were calculated.
 // @param {Object} changeIndexes Change indexes object from `findChangeBoundaryIndexes` function.
 // @returns {Array.<Object>} Array of changes compatible with {@link module:utils/difftochanges~diffToChanges} format.
-function changeIndexesToChanges( newText, changeIndexes ) {
+function changeIndexesToChanges( newArray, changeIndexes ) {
 	const result = [];
 	const { firstIndex, lastIndexOld, lastIndexNew } = changeIndexes;
 
@@ -145,7 +193,7 @@ function changeIndexesToChanges( newText, changeIndexes ) {
 		result.push( {
 			index: firstIndex,
 			type: 'insert',
-			values: newText.substring( firstIndex, lastIndexNew ).split( '' )
+			values: newArray.slice( firstIndex, lastIndexNew )
 		} );
 	}
 
@@ -159,3 +207,36 @@ function changeIndexesToChanges( newText, changeIndexes ) {
 
 	return result;
 }
+
+// Generates array with set `equal|insert|delete` operations based on change indexes from `findChangeBoundaryIndexes` function.
+//
+// @param {Object} changeIndexes Change indexes object from `findChangeBoundaryIndexes` function.
+// @param {Number} newLength Length of the new array/string on which `findChangeBoundaryIndexes` calculated change indexes.
+// @returns {Array.<String>} Array of changes compatible with {@link module:utils/diff~diff} format.
+function changeIndexesToLinearChanges( changeIndexes, newLength ) {
+	const { firstIndex, lastIndexOld, lastIndexNew } = changeIndexes;
+
+	// No changes.
+	if ( firstIndex === -1 ) {
+		return Array( newLength ).fill( 'equal' );
+	}
+
+	let result = [];
+	if ( firstIndex > 0 ) {
+		result = result.concat( Array( firstIndex ).fill( 'equal' ) );
+	}
+
+	if ( lastIndexNew - firstIndex > 0 ) {
+		result = result.concat( Array( lastIndexNew - firstIndex ).fill( 'insert' ) );
+	}
+
+	if ( lastIndexOld - firstIndex > 0 ) {
+		result = result.concat( Array( lastIndexOld - firstIndex ).fill( 'delete' ) );
+	}
+
+	if ( lastIndexNew < newLength ) {
+		result = result.concat( Array( newLength - lastIndexNew ).fill( 'equal' ) );
+	}
+
+	return result;
+}

+ 417 - 152
packages/ckeditor5-utils/tests/fastdiff.js

@@ -8,205 +8,470 @@ import diff from '../src/diff';
 import diffToChanges from '../src/difftochanges';
 
 describe( 'fastDiff', () => {
-	it( 'should diff identical texts', () => {
-		expectDiff( '123', '123', [] );
-	} );
-
-	describe( 'insertion', () => {
-		it( 'should diff if old text is empty', () => {
-			expectDiff( '', '123', [ { index: 0, type: 'insert', values: [ '1', '2', '3' ] } ] );
+	describe( 'changes object', () => {
+		it( 'should diff identical texts', () => {
+			expectDiff( '123', '123', [] );
 		} );
 
-		it( 'should diff insertion on the beginning', () => {
-			expectDiff( '123', 'abc123', [ { index: 0, type: 'insert', values: [ 'a', 'b', 'c' ] } ] );
+		it( 'should diff identical arrays', () => {
+			expectDiff( [ '1', '2', '3' ], [ '1', '2', '3' ], [] );
 		} );
 
-		it( 'should diff insertion on the beginning (repetitive substring)', () => {
-			// Do not check compatibility with 'diffToChanges' as it generates:
-			// [ { index: 0, type: 'insert', values: [ 'a', 'b' ] }, { index: 5, type: 'insert', values: [ 'c', '1', '2', '3' ] } ]
-			expectDiff( '123', 'ab123c123', [ { index: 0, type: 'insert', values: [ 'a', 'b', '1', '2', '3', 'c' ] } ], false );
+		it( 'should diff arrays with custom comparator', () => {
+			expectDiff( [ 'a', 'b', 'c' ], [ 'A', 'B', 'C' ], [], true, ( a, b ) => a.toLowerCase() === b.toLowerCase() );
 		} );
 
-		it( 'should diff insertion on the end', () => {
-			expectDiff( '123', '123abc', [ { index: 3, type: 'insert', values: [ 'a', 'b', 'c' ] } ] );
+		describe( 'insertion', () => {
+			it( 'should diff if old text is empty', () => {
+				expectDiff( '', '123', [ { index: 0, type: 'insert', values: [ '1', '2', '3' ] } ] );
+			} );
+
+			it( 'should diff if old array is empty', () => {
+				expectDiff( [], [ '1', '2', '3' ], [ { index: 0, type: 'insert', values: [ '1', '2', '3' ] } ] );
+			} );
+
+			it( 'should diff insertion on the beginning', () => {
+				expectDiff( '123', 'abc123', [ { index: 0, type: 'insert', values: [ 'a', 'b', 'c' ] } ] );
+			} );
+
+			it( 'should diff insertion on the beginning (repetitive substring)', () => {
+				// Do not check compatibility with 'diffToChanges' as it generates:
+				// [ { index: 0, type: 'insert', values: [ 'a', 'b' ] }, { index: 5, type: 'insert', values: [ 'c', '1', '2', '3' ] } ]
+				expectDiff( '123', 'ab123c123', [ { index: 0, type: 'insert', values: [ 'a', 'b', '1', '2', '3', 'c' ] } ], false );
+			} );
+
+			it( 'should diff insertion on the end', () => {
+				expectDiff( '123', '123abc', [ { index: 3, type: 'insert', values: [ 'a', 'b', 'c' ] } ] );
+			} );
+
+			it( 'should diff insertion on the end (repetitive substring)', () => {
+				expectDiff( '123', '123ab123c', [ { index: 3, type: 'insert', values: [ 'a', 'b', '1', '2', '3', 'c' ] } ] );
+			} );
+
+			it( 'should diff insertion in the middle', () => {
+				expectDiff( '123', '12abc3', [ { index: 2, type: 'insert', values: [ 'a', 'b', 'c' ] } ] );
+			} );
+
+			it( 'should diff insertion in the middle (repetitive substring)', () => {
+				// Do not check compatibility with 'diffToChanges' as it generates:
+				// [ { index: 2, type: 'insert', values: [ 'a', 'b', '1', '2' ] }, { index: 7, type: 'insert', values: [ 'c', '3' ] } ]
+				expectDiff( '123', '12ab123c3', [ { index: 2, type: 'insert', values: [ 'a', 'b', '1', '2', '3', 'c' ] } ], false );
+			} );
+
+			it( 'should diff insertion of duplicated content', () => {
+				expectDiff( '123', '123123', [ { index: 3, type: 'insert', values: [ '1', '2', '3' ] } ] );
+			} );
+
+			it( 'should diff insertion of partially duplicated content', () => {
+				expectDiff( '123', '12323', [ { index: 3, type: 'insert', values: [ '2', '3' ] } ] );
+			} );
+
+			it( 'should diff insertion on both boundaries', () => {
+				// Do not check compatibility with 'diffToChanges' as it generates:
+				// [ { index: 2, type: 'insert', values: [ 'a', 'b' ] }, { index: 5, type: 'insert', values: [ 'c' ] } ]
+				expectDiff( '123', 'ab123c', [
+					{ index: 0, type: 'insert', values: [ 'a', 'b', '1', '2', '3', 'c' ] },
+					{ index: 6, type: 'delete', howMany: 3 }
+				], false );
+			} );
+
+			it( 'should diff insertion in array of objects', () => {
+				const o1 = { foo: 1 };
+				const o2 = { bar: 2 };
+
+				expectDiff( [ o1, o2 ], [ o1, o2, { baz: 3 } ], [
+					{ index: 2, type: 'insert', values: [ { baz: 3 } ] }
+				] );
+			} );
+
+			it( 'should diff insertion in array of objects with comparator', () => {
+				expectDiff( [ { text: 'foo' }, { text: 'bar' } ], [ { text: 'foo' }, { text: 'bar' }, { text: 'baz' } ], [
+					{ index: 2, type: 'insert', values: [ { text: 'baz' } ] }
+				], true, ( a, b ) => a.text === b.text );
+			} );
 		} );
 
-		it( 'should diff insertion on the end (repetitive substring)', () => {
-			expectDiff( '123', '123ab123c', [ { index: 3, type: 'insert', values: [ 'a', 'b', '1', '2', '3', 'c' ] } ] );
+		describe( 'deletion', () => {
+			it( 'should diff if new text is empty', () => {
+				expectDiff( '123', '', [ { index: 0, type: 'delete', howMany: 3 } ] );
+			} );
+
+			it( 'should diff if new array is empty', () => {
+				expectDiff( [ '1', '2', '3' ], [], [ { index: 0, type: 'delete', howMany: 3 } ] );
+			} );
+
+			it( 'should diff deletion on the beginning', () => {
+				expectDiff( 'abc123', '123', [ { index: 0, type: 'delete', howMany: 3 } ] );
+			} );
+
+			it( 'should diff deletion on the beginning (repetitive substring)', () => {
+				// Do not check compatibility with 'diffToChanges' as it generates:
+				// [ { index: 0, type: 'delete', howMany: 2 }, { index: 3, type: 'delete', howMany: 4 } ]
+				expectDiff( 'ab123c123', '123', [ { index: 0, type: 'delete', howMany: 6 } ], false );
+			} );
+
+			it( 'should diff deletion on the end', () => {
+				expectDiff( '123abc', '123', [ { index: 3, type: 'delete', howMany: 3 } ] );
+			} );
+
+			it( 'should diff deletion on the end (repetitive substring)', () => {
+				expectDiff( '123ab123c', '123', [ { index: 3, type: 'delete', howMany: 6 } ] );
+			} );
+
+			it( 'should diff deletion in the middle', () => {
+				expectDiff( '12abc3', '123', [ { index: 2, type: 'delete', howMany: 3 } ] );
+			} );
+
+			it( 'should diff deletion in the middle (repetitive substring)', () => {
+				// Do not check compatibility with 'diffToChanges' as it generates:
+				// [ { index: 2, type: 'delete', howMany: 4 }, { index: 3, type: 'delete', howMany: 2 } ]
+				expectDiff( '12ab123c3', '123', [ { index: 2, type: 'delete', howMany: 6 } ], false );
+			} );
+
+			it( 'should diff deletion on both boundaries', () => {
+				// Do not check compatibility with 'diffToChanges' as it generates:
+				// [ { index: 0, type: 'delete', howMany: 1 }, { index: 3, type: 'delete', howMany: 2 } ]
+				expectDiff( '12abc3', '2ab', [
+					{ index: 0, type: 'insert', values: [ '2', 'a', 'b' ] },
+					{ index: 3, type: 'delete', howMany: 6 }
+				], false );
+			} );
+
+			it( 'should diff deletion of duplicated content', () => {
+				expectDiff( '123123', '123', [ { index: 3, type: 'delete', howMany: 3 } ] );
+			} );
+
+			it( 'should diff deletion of partially duplicated content', () => {
+				expectDiff( '12323', '123', [ { index: 3, type: 'delete', howMany: 2 } ] );
+			} );
+
+			it( 'should diff deletion of partially duplicated content 2', () => {
+				// Do not check compatibility with 'diffToChanges' as it generates:
+				// [ { index: 1, type: 'delete', howMany: 2 }, { index: 2, type: 'delete', howMany: 1 } ]
+				expectDiff( '11233', '13', [ { index: 1, type: 'delete', howMany: 3 } ], false );
+			} );
+
+			it( 'should diff deletion in array of objects', () => {
+				const o1 = { foo: 1 };
+				const o2 = { bar: 2 };
+
+				expectDiff( [ o1, o2 ], [ o2 ], [
+					{ index: 0, type: 'delete', howMany: 1 }
+				] );
+			} );
+
+			it( 'should diff insertion in array of objects with comparator', () => {
+				expectDiff( [ { text: 'foo' }, { text: 'bar' } ], [ { text: 'bar' } ], [
+					{ index: 0, type: 'delete', howMany: 1 }
+				], true, ( a, b ) => a.text === b.text );
+			} );
 		} );
 
-		it( 'should diff insertion in the middle', () => {
-			expectDiff( '123', '12abc3', [ { index: 2, type: 'insert', values: [ 'a', 'b', 'c' ] } ] );
+		describe( 'replacement', () => {
+			it( 'should diff replacement of entire text', () => {
+				// Do not check compatibility with 'diffToChanges' as it has changes in reveres order ('delete', 'insert') here.
+				expectDiff( '12345', 'abcd', [
+					{ index: 0, type: 'insert', values: [ 'a', 'b', 'c', 'd' ] },
+					{ index: 4, type: 'delete', howMany: 5 }
+				], false );
+			} );
+
+			it( 'should diff replacement on the beginning', () => {
+				expectDiff( '12345', 'abcd345', [
+					{ index: 0, type: 'insert', values: [ 'a', 'b', 'c', 'd' ] },
+					{ index: 4, type: 'delete', howMany: 2 }
+				] );
+			} );
+
+			it( 'should diff replacement on the beginning (repetitive substring)', () => {
+				// Do not check compatibility with 'diffToChanges' as it has changes in reveres order ('delete', 'insert') here.
+				expectDiff( '12345', '345345', [
+					{ index: 0, type: 'insert', values: [ '3', '4', '5' ] },
+					{ index: 3, type: 'delete', howMany: 2 }
+				], false );
+			} );
+
+			it( 'should diff replacement on the end', () => {
+				// Do not check compatibility with 'diffToChanges' as it has changes in reveres order ('delete', 'insert') here.
+				expectDiff( '12345', '12ab', [
+					{ index: 2, type: 'insert', values: [ 'a', 'b' ] },
+					{ index: 4, type: 'delete', howMany: 3 }
+				], false );
+			} );
+
+			it( 'should diff replacement on the end (repetitive substring)', () => {
+				// Do not check compatibility with 'diffToChanges' as it generates:
+				// [ { index: 3, type: 'insert', values: [ '1', '2', '3' ] }, { index: 7, type: 'delete', howMany: 1 } ]
+				expectDiff( '12345', '1231234', [
+					{ index: 3, type: 'insert', values: [ '1', '2', '3', '4' ] },
+					{ index: 7, type: 'delete', howMany: 2 }
+				], false );
+			} );
+
+			it( 'should diff insertion of duplicated content', () => {
+				expectDiff( '1234', '123123', [
+					{ index: 3, type: 'insert', values: [ '1', '2', '3' ] },
+					{ index: 6, type: 'delete', howMany: 1 }
+				], false );
+			} );
+
+			it( 'should diff insertion of duplicated content', () => {
+				expectDiff( '1234', '13424', [
+					{ index: 1, type: 'insert', values: [ '3', '4', '2' ] },
+					{ index: 4, type: 'delete', howMany: 2 }
+				], false );
+			} );
+
+			it( 'should diff replacement in the middle', () => {
+				expectDiff( '12345', '12ab5', [
+					{ index: 2, type: 'insert', values: [ 'a', 'b' ] },
+					{ index: 4, type: 'delete', howMany: 2 }
+				] );
+			} );
+
+			it( 'should diff replacement in the middle (repetitive substring)', () => {
+				// Do not check compatibility with 'diffToChanges' as it generates:
+				// [ { index: 2, type: 'insert', values: [ '1', '2' ] }, { index: 7, type: 'insert', values: [ '5' ] } ]
+				expectDiff( '12345', '12123455', [
+					{ index: 2, type: 'insert', values: [ '1', '2', '3', '4', '5' ] },
+					{ index: 7, type: 'delete', howMany: 2 }
+				], false );
+			} );
+
+			it( 'should diff replacement of duplicated content', () => {
+				// Do not check compatibility with 'diffToChanges' as it has changes in reveres order ('delete', 'insert') here.
+				expectDiff( '123123', '123333', [
+					{ index: 3, type: 'insert', values: '33'.split( '' ) },
+					{ index: 5, type: 'delete', howMany: 2 }
+				], false );
+			} );
+
+			it( 'should diff replacement in array of objects', () => {
+				const o1 = { foo: 1 };
+				const o2 = { bar: 2 };
+
+				expectDiff( [ o1, o2 ], [ o1, { baz: 3 } ], [
+					{ index: 1, type: 'insert', values: [ { baz: 3 } ] },
+					{ index: 2, type: 'delete', howMany: 1 }
+				] );
+			} );
+
+			it( 'should diff insertion in array of objects with comparator', () => {
+				expectDiff( [ { text: 'foo' }, { text: 'bar' } ], [ { text: 'foo' }, { text: 'baz' } ], [
+					{ index: 1, type: 'insert', values: [ { text: 'baz' } ] },
+					{ index: 2, type: 'delete', howMany: 1 }
+				], true, ( a, b ) => a.text === b.text );
+			} );
 		} );
+	} );
 
-		it( 'should diff insertion in the middle (repetitive substring)', () => {
-			// Do not check compatibility with 'diffToChanges' as it generates:
-			// [ { index: 2, type: 'insert', values: [ 'a', 'b', '1', '2' ] }, { index: 7, type: 'insert', values: [ 'c', '3' ] } ]
-			expectDiff( '123', '12ab123c3', [ { index: 2, type: 'insert', values: [ 'a', 'b', '1', '2', '3', 'c' ] } ], false );
+	describe( 'changes linear', () => {
+		it( 'should diff identical texts', () => {
+			expectDiffLinear( '123', '123', 'eee' );
 		} );
 
-		it( 'should diff insertion of duplicated content', () => {
-			expectDiff( '123', '123123', [ { index: 3, type: 'insert', values: [ '1', '2', '3' ] } ] );
+		it( 'should diff identical arrays', () => {
+			expectDiffLinear( [ '1', '2', '3' ], [ '1', '2', '3' ], 'eee' );
 		} );
 
-		it( 'should diff insertion of partially duplicated content', () => {
-			expectDiff( '123', '12323', [ { index: 3, type: 'insert', values: [ '2', '3' ] } ] );
+		it( 'should diff arrays with custom comparator', () => {
+			expectDiffLinear( [ 'a', 'b', 'c' ], [ 'A', 'B', 'C' ], 'eee', true, ( a, b ) => a.toLowerCase() === b.toLowerCase() );
 		} );
 
-		it( 'should diff insertion on both boundaries', () => {
-			// Do not check compatibility with 'diffToChanges' as it generates:
-			// [ { index: 2, type: 'insert', values: [ 'a', 'b' ] }, { index: 5, type: 'insert', values: [ 'c' ] } ]
-			expectDiff( '123', 'ab123c', [
-				{ index: 0, type: 'insert', values: [ 'a', 'b', '1', '2', '3', 'c' ] },
-				{ index: 6, type: 'delete', howMany: 3 }
-			], false );
-		} );
-	} );
+		describe( 'insertion', () => {
+			it( 'should diff if old text is empty', () => {
+				expectDiffLinear( '', '123', 'iii' );
+			} );
 
-	describe( 'deletion', () => {
-		it( 'should diff if new text is empty', () => {
-			expectDiff( '123', '', [ { index: 0, type: 'delete', howMany: 3 } ] );
-		} );
+			it( 'should diff if old array is empty', () => {
+				expectDiffLinear( [], [ '1', '2', '3' ], 'iii' );
+			} );
 
-		it( 'should diff deletion on the beginning', () => {
-			expectDiff( 'abc123', '123', [ { index: 0, type: 'delete', howMany: 3 } ] );
-		} );
+			it( 'should diff insertion on the beginning', () => {
+				expectDiffLinear( '123', 'abc123', 'iiieee' );
+			} );
 
-		it( 'should diff deletion on the beginning (repetitive substring)', () => {
-			// Do not check compatibility with 'diffToChanges' as it generates:
-			// [ { index: 0, type: 'delete', howMany: 2 }, { index: 3, type: 'delete', howMany: 4 } ]
-			expectDiff( 'ab123c123', '123', [ { index: 0, type: 'delete', howMany: 6 } ], false );
-		} );
+			it( 'should diff insertion on the beginning (repetitive substring)', () => {
+				expectDiffLinear( '123', 'ab123c123', 'iiiiiieee', false );
+			} );
 
-		it( 'should diff deletion on the end', () => {
-			expectDiff( '123abc', '123', [ { index: 3, type: 'delete', howMany: 3 } ] );
-		} );
+			it( 'should diff insertion on the end', () => {
+				expectDiffLinear( '123', '123abc', 'eeeiii' );
+			} );
 
-		it( 'should diff deletion on the end (repetitive substring)', () => {
-			expectDiff( '123ab123c', '123', [ { index: 3, type: 'delete', howMany: 6 } ] );
-		} );
+			it( 'should diff insertion on the end (repetitive substring)', () => {
+				expectDiffLinear( '123', '123ab123c', 'eeeiiiiii' );
+			} );
 
-		it( 'should diff deletion in the middle', () => {
-			expectDiff( '12abc3', '123', [ { index: 2, type: 'delete', howMany: 3 } ] );
-		} );
+			it( 'should diff insertion in the middle', () => {
+				expectDiffLinear( '123', '12abc3', 'eeiiie' );
+			} );
 
-		it( 'should diff deletion in the middle (repetitive substring)', () => {
-			// Do not check compatibility with 'diffToChanges' as it generates:
-			// [ { index: 2, type: 'delete', howMany: 4 }, { index: 3, type: 'delete', howMany: 2 } ]
-			expectDiff( '12ab123c3', '123', [ { index: 2, type: 'delete', howMany: 6 } ], false );
-		} );
+			it( 'should diff insertion in the middle (repetitive substring)', () => {
+				expectDiffLinear( '123', '12ab123c3', 'eeiiiiiie', false );
+			} );
 
-		it( 'should diff deletion on both boundaries', () => {
-			// Do not check compatibility with 'diffToChanges' as it generates:
-			// [ { index: 0, type: 'delete', howMany: 1 }, { index: 3, type: 'delete', howMany: 2 } ]
-			expectDiff( '12abc3', '2ab', [
-				{ index: 0, type: 'insert', values: [ '2', 'a', 'b' ] },
-				{ index: 3, type: 'delete', howMany: 6 }
-			], false );
-		} );
+			it( 'should diff insertion of duplicated content', () => {
+				expectDiffLinear( '123', '123123', 'eeeiii' );
+			} );
 
-		it( 'should diff deletion of duplicated content', () => {
-			expectDiff( '123123', '123', [ { index: 3, type: 'delete', howMany: 3 } ] );
-		} );
+			it( 'should diff insertion of partially duplicated content', () => {
+				expectDiffLinear( '123', '12323', 'eeeii' );
+			} );
 
-		it( 'should diff deletion of partially duplicated content', () => {
-			expectDiff( '12323', '123', [ { index: 3, type: 'delete', howMany: 2 } ] );
-		} );
+			it( 'should diff insertion on both boundaries', () => {
+				expectDiffLinear( '123', 'ab123c', 'iiiiiiddd', false );
+			} );
 
-		it( 'should diff deletion of partially duplicated content 2', () => {
-			// Do not check compatibility with 'diffToChanges' as it generates:
-			// [ { index: 1, type: 'delete', howMany: 2 }, { index: 2, type: 'delete', howMany: 1 } ]
-			expectDiff( '11233', '13', [ { index: 1, type: 'delete', howMany: 3 } ], false );
-		} );
-	} );
+			it( 'should diff insertion in array of objects', () => {
+				const o1 = { foo: 1 };
+				const o2 = { bar: 2 };
 
-	describe( 'replacement', () => {
-		it( 'should diff replacement of entire text', () => {
-			// Do not check compatibility with 'diffToChanges' as it has changes in reveres order ('delete', 'insert') here.
-			expectDiff( '12345', 'abcd', [
-				{ index: 0, type: 'insert', values: [ 'a', 'b', 'c', 'd' ] },
-				{ index: 4, type: 'delete', howMany: 5 }
-			], false );
-		} );
+				expectDiffLinear( [ o1, o2 ], [ o1, o2, { baz: 3 } ], 'eei' );
+			} );
 
-		it( 'should diff replacement on the beginning', () => {
-			expectDiff( '12345', 'abcd345', [
-				{ index: 0, type: 'insert', values: [ 'a', 'b', 'c', 'd' ] },
-				{ index: 4, type: 'delete', howMany: 2 }
-			] );
+			it( 'should diff insertion in array of objects with comparator', () => {
+				expectDiffLinear( [ { text: 'foo' }, { text: 'bar' } ], [ { text: 'foo' }, { text: 'bar' }, { text: 'baz' } ],
+					'eei', true, ( a, b ) => a.text === b.text );
+			} );
 		} );
 
-		it( 'should diff replacement on the beginning (repetitive substring)', () => {
-			// Do not check compatibility with 'diffToChanges' as it has changes in reveres order ('delete', 'insert') here.
-			expectDiff( '12345', '345345', [
-				{ index: 0, type: 'insert', values: [ '3', '4', '5' ] },
-				{ index: 3, type: 'delete', howMany: 2 }
-			], false );
-		} );
+		describe( 'deletion', () => {
+			it( 'should diff if new text is empty', () => {
+				expectDiffLinear( '123', '', 'ddd' );
+			} );
 
-		it( 'should diff replacement on the end', () => {
-			// Do not check compatibility with 'diffToChanges' as it has changes in reveres order ('delete', 'insert') here.
-			expectDiff( '12345', '12ab', [
-				{ index: 2, type: 'insert', values: [ 'a', 'b' ] },
-				{ index: 4, type: 'delete', howMany: 3 }
-			], false );
-		} );
+			it( 'should diff if new array is empty', () => {
+				expectDiffLinear( [ '1', '2', '3' ], [], 'ddd' );
+			} );
 
-		it( 'should diff replacement on the end (repetitive substring)', () => {
-			// Do not check compatibility with 'diffToChanges' as it generates:
-			// [ { index: 3, type: 'insert', values: [ '1', '2', '3' ] }, { index: 7, type: 'delete', howMany: 1 } ]
-			expectDiff( '12345', '1231234', [
-				{ index: 3, type: 'insert', values: [ '1', '2', '3', '4' ] },
-				{ index: 7, type: 'delete', howMany: 2 }
-			], false );
-		} );
+			it( 'should diff deletion on the beginning', () => {
+				expectDiffLinear( 'abc123', '123', 'dddeee' );
+			} );
 
-		it( 'should diff insertion of duplicated content', () => {
-			expectDiff( '1234', '123123', [
-				{ index: 3, type: 'insert', values: [ '1', '2', '3' ] },
-				{ index: 6, type: 'delete', howMany: 1 }
-			], false );
-		} );
+			it( 'should diff deletion on the beginning (repetitive substring)', () => {
+				expectDiffLinear( 'ab123c123', '123', 'ddddddeee', false );
+			} );
 
-		it( 'should diff insertion of duplicated content', () => {
-			expectDiff( '1234', '13424', [
-				{ index: 1, type: 'insert', values: [ '3', '4', '2' ] },
-				{ index: 4, type: 'delete', howMany: 2 }
-			], false );
-		} );
+			it( 'should diff deletion on the end', () => {
+				expectDiffLinear( '123abc', '123', 'eeeddd' );
+			} );
 
-		it( 'should diff replacement in the middle', () => {
-			expectDiff( '12345', '12ab5', [
-				{ index: 2, type: 'insert', values: [ 'a', 'b' ] },
-				{ index: 4, type: 'delete', howMany: 2 }
-			] );
-		} );
+			it( 'should diff deletion on the end (repetitive substring)', () => {
+				expectDiffLinear( '123ab123c', '123', 'eeedddddd' );
+			} );
+
+			it( 'should diff deletion in the middle', () => {
+				expectDiffLinear( '12abc3', '123', 'eeddde' );
+			} );
 
-		it( 'should diff replacement in the middle (repetitive substring)', () => {
-			// Do not check compatibility with 'diffToChanges' as it generates:
-			// [ { index: 2, type: 'insert', values: [ '1', '2' ] }, { index: 7, type: 'insert', values: [ '5' ] } ]
-			expectDiff( '12345', '12123455', [
-				{ index: 2, type: 'insert', values: [ '1', '2', '3', '4', '5' ] },
-				{ index: 7, type: 'delete', howMany: 2 }
-			], false );
+			it( 'should diff deletion in the middle (repetitive substring)', () => {
+				expectDiffLinear( '12ab123c3', '123', 'eedddddde', false );
+			} );
+
+			it( 'should diff deletion on both boundaries', () => {
+				expectDiffLinear( '12abc3', '2ab', 'iiidddddd', false );
+			} );
+
+			it( 'should diff deletion of duplicated content', () => {
+				expectDiffLinear( '123123', '123', 'eeeddd' );
+			} );
+
+			it( 'should diff deletion of partially duplicated content', () => {
+				expectDiffLinear( '12323', '123', 'eeedd' );
+			} );
+
+			it( 'should diff deletion of partially duplicated content 2', () => {
+				expectDiffLinear( '11233', '13', 'eddde', false );
+			} );
+
+			it( 'should diff deletion in array of objects', () => {
+				const o1 = { foo: 1 };
+				const o2 = { bar: 2 };
+
+				expectDiffLinear( [ o1, o2 ], [ o2 ], 'de' );
+			} );
+
+			it( 'should diff insertion in array of objects with comparator', () => {
+				expectDiffLinear( [ { text: 'foo' }, { text: 'bar' } ], [ { text: 'bar' } ], 'de', true, ( a, b ) => a.text === b.text );
+			} );
 		} );
 
-		it( 'should diff replacement of duplicated content', () => {
-			// Do not check compatibility with 'diffToChanges' as it has changes in reveres order ('delete', 'insert') here.
-			expectDiff( '123123', '123333', [
-				{ index: 3, type: 'insert', values: '33'.split( '' ) },
-				{ index: 5, type: 'delete', howMany: 2 }
-			], false );
+		describe( 'replacement', () => {
+			it( 'should diff replacement of entire text', () => {
+				expectDiffLinear( '12345', 'abcd', 'iiiiddddd', false );
+			} );
+
+			it( 'should diff replacement on the beginning', () => {
+				expectDiffLinear( '12345', 'abcd345', 'iiiiddeee' );
+			} );
+
+			it( 'should diff replacement on the beginning (repetitive substring)', () => {
+				expectDiffLinear( '12345', '345345', 'iiiddeee', false );
+			} );
+
+			it( 'should diff replacement on the end', () => {
+				expectDiffLinear( '12345', '12ab', 'eeiiddd', false );
+			} );
+
+			it( 'should diff replacement on the end (repetitive substring)', () => {
+				expectDiffLinear( '12345', '1231234', 'eeeiiiidd', false );
+			} );
+
+			it( 'should diff insertion of duplicated content', () => {
+				expectDiffLinear( '1234', '123123', 'eeeiiid' );
+			} );
+
+			it( 'should diff insertion of duplicated content', () => {
+				expectDiffLinear( '1234', '13424', 'eiiidde', false );
+			} );
+
+			it( 'should diff replacement in the middle', () => {
+				expectDiffLinear( '12345', '12ab5', 'eeiidde' );
+			} );
+
+			it( 'should diff replacement in the middle (repetitive substring)', () => {
+				expectDiffLinear( '12345', '12123455', 'eeiiiiidde', false );
+			} );
+
+			it( 'should diff replacement of duplicated content', () => {
+				expectDiffLinear( '123123', '123333', 'eeeiidde', false );
+			} );
+
+			it( 'should diff replacement in array of objects', () => {
+				const o1 = { foo: 1 };
+				const o2 = { bar: 2 };
+
+				expectDiffLinear( [ o1, o2 ], [ o1, { baz: 3 } ], 'eid' );
+			} );
+
+			it( 'should diff insertion in array of objects with comparator', () => {
+				expectDiffLinear( [ { text: 'foo' }, { text: 'bar' } ], [ { text: 'foo' }, { text: 'baz' } ], 'eid',
+					true, ( a, b ) => a.text === b.text );
+			} );
 		} );
 	} );
 } );
 
-function expectDiff( oldText, newText, expected, checkDiffToChangesCompatibility = true ) {
-	const result = fastDiff( oldText, newText );
+function expectDiff( oldText, newText, expected, checkDiffToChangesCompatibility = true, comparator = null ) {
+	const result = fastDiff( oldText, newText, comparator );
 
-	expect( result ).to.deep.equals( expected );
+	expect( result ).to.deep.equals( expected, 'fastDiff changes failed' );
 
 	if ( checkDiffToChangesCompatibility ) {
-		expect( result ).to.deep.equals( diffToChanges( diff( oldText, newText ), newText ), 'diffToChanges compatibility' );
+		expect( result ).to.deep.equals(
+			diffToChanges( diff( oldText, newText, comparator ), newText ), 'diffToChanges compatibility failed' );
+	}
+}
+
+function expectDiffLinear( oldText, newText, expected, checkDiffCompatibility = true, comparator = null ) {
+	const actions = { d: 'delete', e: 'equal', i: 'insert' };
+	const expectedArray = expected.split( '' ).map( item => actions[ item ] );
+	const result = fastDiff( oldText, newText, comparator, true );
+
+	expect( result ).to.deep.equals( expectedArray, 'fastDiff linear result failed' );
+
+	if ( checkDiffCompatibility ) {
+		expect( result ).to.deep.equals( diff( oldText, newText, comparator ), 'diff compatibility failed' );
 	}
 }