@sanity/diff
Version:
Generates diffs between documents and primitive types
394 lines (335 loc) • 9.92 kB
text/typescript
import {replaceProperty} from '../helpers'
import {type ArrayDiff, type ArrayInput, type DiffOptions, type ItemDiff} from '../types'
import {addedInput, diffInput, removedInput} from './diffInput'
import {getLongestCommonSubsequence} from './lcs'
export function diffArray<A>(
fromInput: ArrayInput<A>,
toInput: ArrayInput<A>,
options: DiffOptions,
): ArrayDiff<A> {
if (fromInput === toInput) {
const fromValue = fromInput.value
const toValue = toInput.value
return {
type: 'array',
action: 'unchanged',
isChanged: false,
fromValue,
toValue,
get items(): ItemDiff<A>[] {
const items = diffExactByPosition(fromInput, toInput, options)
if (!items) throw new Error('invariant broken: equivalent input, but diff detected')
return replaceProperty(this, 'items', items)
},
}
}
// The key-ed approach should handle most cases (_key'ed objects, primitives):
const keyedA = indexByKey(fromInput)
const keyedB = indexByKey(toInput)
if (keyedA && keyedB) {
return diffArrayByKey(fromInput, keyedA, toInput, keyedB, options)
}
// Check if they are 100% equivalent:
const items = diffExactByPosition(fromInput, toInput, options)
if (items) return buildArrayDiff(fromInput, toInput, items, false)
// Otherwise we create a diff where we model it as removing the from-items and adding the to-items.
return diffArrayByReinsert(fromInput, toInput, options)
}
function buildArrayDiff<A>(
fromInput: ArrayInput<A>,
toInput: ArrayInput<A>,
items: ItemDiff<A>[],
isChanged: boolean,
): ArrayDiff<A> {
const fromValue = fromInput.value
const toValue = toInput.value
return isChanged
? {
type: 'array',
action: 'changed',
isChanged: true,
fromValue,
toValue,
items,
annotation: toInput.annotation,
}
: {
type: 'array',
action: 'unchanged',
isChanged: false,
fromValue,
toValue,
items,
}
}
/**
* Diffes the two arrays by position. Returns an `items` array if they are unchanged, or undefined
* if there are any changes anywhere.
*/
function diffExactByPosition<A>(
fromInput: ArrayInput<A>,
toInput: ArrayInput<A>,
options: DiffOptions,
): ItemDiff<A>[] | undefined {
if (fromInput.length !== toInput.length) {
return undefined
}
const items: ItemDiff<A>[] = []
for (let idx = 0; idx < fromInput.length; idx++) {
const diff = diffInput(fromInput.at(idx), toInput.at(idx), options)
if (diff.isChanged) {
return undefined
}
items.push({
fromIndex: idx,
toIndex: idx,
hasMoved: false,
diff,
annotation: toInput.annotationAt(idx),
})
}
return items
}
function diffArrayByReinsert<A>(
fromInput: ArrayInput<A>,
toInput: ArrayInput<A>,
options: DiffOptions,
): ArrayDiff<A> {
const items: ItemDiff<A>[] = []
for (let idx = 0; idx < toInput.length; idx++) {
const input = toInput.at(idx)
items.push({
fromIndex: undefined,
toIndex: idx,
hasMoved: false,
diff: addedInput(input, undefined, options),
annotation: input.annotation,
})
}
for (let idx = 0; idx < fromInput.length; idx++) {
const input = fromInput.at(idx)
items.push({
fromIndex: idx,
toIndex: undefined,
hasMoved: false,
diff: removedInput(input, undefined, options),
annotation: input.annotation,
})
}
return buildArrayDiff(fromInput, toInput, items, true)
}
type Key = string | number | boolean
/**
* Diff an array when all the elements have _key in the same position.
*/
function diffArrayByKey<A>(
fromArray: ArrayInput<A>,
fromKeyIndex: KeyIndex,
toArray: ArrayInput<A>,
toKeyIndex: KeyIndex,
options: DiffOptions,
): ArrayDiff<A> {
const items: ItemDiff<A>[] = []
let isChanged = false
function diffCommon(key: Key, fromIndex: number, toIndex: number, hasMoved: boolean) {
deletePositionInIndex(fromKeyIndex.index, key, fromIndex)
deletePositionInIndex(toKeyIndex.index, key, toIndex)
const fromInput = fromArray.at(fromIndex)
const toInput = toArray.at(toIndex)
const diff = diffInput(fromInput, toInput)
items.push({
fromIndex,
toIndex,
hasMoved,
diff,
annotation: toArray.annotationAt(toIndex),
})
if (diff.isChanged || fromIndex !== toIndex) {
isChanged = true
}
}
const lcs = getLongestCommonSubsequence(fromKeyIndex.keys, toKeyIndex.keys)
for (let fromIndex = 0; fromIndex < fromKeyIndex.keys.length; fromIndex++) {
const key = fromKeyIndex.keys[fromIndex]
const subsequenceIdx = lcs.prevIndices.indexOf(fromIndex)
if (subsequenceIdx !== -1) {
// Part of the common subsequence => hasMoved:false
diffCommon(key, fromIndex, lcs.nextIndices[subsequenceIdx], false)
continue
}
// Not a part of the subsequence. Try to find another item which has the same key
// and also is not part of the common subsequence.
const toIndexes = toKeyIndex.index.get(key)
const toIndex = toIndexes && toIndexes.find((idx) => !lcs.nextIndices.includes(idx))
if (toIndex !== undefined) {
diffCommon(key, fromIndex, toIndex, true)
continue
}
const input = fromArray.at(fromIndex)
items.push({
fromIndex,
toIndex: undefined,
hasMoved: false,
diff: removedInput(input, undefined, options),
annotation: fromArray.annotationAt(fromIndex),
})
isChanged = true
}
// The remaining data in toKeyIndex are the new elements which has been added
for (const positions of toKeyIndex.index.values()) {
for (const toIndex of positions) {
const input = toArray.at(toIndex)
items.push({
fromIndex: undefined,
toIndex,
hasMoved: false,
diff: addedInput(input, undefined, options),
annotation: toArray.annotationAt(toIndex),
})
}
isChanged = true
}
items.sort(compareItemDiff)
return buildArrayDiff(fromArray, toArray, items, isChanged)
}
function compareItemDiff<A>(a: ItemDiff<A>, b: ItemDiff<A>): number {
if (a.toIndex !== undefined && b.toIndex !== undefined) {
return a.toIndex - b.toIndex
}
if (a.fromIndex !== undefined && b.fromIndex !== undefined) {
return a.fromIndex - b.fromIndex
}
if (a.fromIndex !== undefined && b.toIndex !== undefined) {
// A was removed and B was added. Prefer to sort removals last.
return -1
}
if (a.toIndex !== undefined && b.fromIndex !== undefined) {
// A was added and B was removed. Prefer to sort removals last.
return 1
}
throw new Error('invalid item diff comparison')
}
function deletePositionInIndex(index: Map<Key, number[]>, key: Key, pos: number) {
const positions = index.get(key)!
deleteArrayValue(positions, pos)
if (positions.length === 0) {
index.delete(key)
}
}
function deleteArrayValue<E>(arr: E[], value: E) {
const idx = arr.indexOf(value)
if (idx === -1) throw new Error('value not found')
arr.splice(idx, 1)
}
type KeyIndex = {
keys: Key[]
index: Map<Key, number[]>
}
/**
* Indexes the array by a key. This handles cases where the items are:
*
* - Objects with _key
* - Strings
* - Numbers
*/
function indexByKey<A>(arr: ArrayInput<A>): KeyIndex | undefined {
const index = new Map<Key, number[]>()
const keys: Key[] = []
const length = arr.length
for (let i = 0; i < length; i++) {
const item = arr.at(i)
let key: Key | null = null
switch (item.type) {
case 'string':
key = `s${item.value}`
break
case 'number':
key = item.value
break
case 'boolean':
key = item.value
break
case 'null':
key = 'n'
break
case 'object':
{
const keyField = item.get('_key')
if (keyField && keyField.type === 'string') {
key = `k${keyField.value}`
// We do not handle duplicate _key
if (index.has(key)) return undefined
}
}
break
default:
}
// No key => abort
if (key === null) return undefined
keys.push(key)
let positions = index.get(key)
if (!positions) {
positions = []
index.set(key, positions)
}
positions.push(i)
}
// All is good.
return {keys, index}
}
export function removedArray<A>(
input: ArrayInput<A>,
toValue: null | undefined,
options: DiffOptions,
): ArrayDiff<A> & {action: 'removed'} {
return {
type: 'array',
action: 'removed',
isChanged: true,
fromValue: input.value,
toValue,
annotation: input.annotation,
get items(): ArrayDiff<A>['items'] {
const items: ArrayDiff<A>['items'] = []
for (let i = 0; i < input.length; i++) {
const item = input.at(i)
items.push({
fromIndex: i,
toIndex: undefined,
hasMoved: false,
diff: removedInput(item, undefined, options),
annotation: input.annotationAt(i),
})
}
return replaceProperty(this, 'items', items)
},
}
}
export function addedArray<A>(
input: ArrayInput<A>,
fromValue: null | undefined,
options: DiffOptions,
): ArrayDiff<A> & {action: 'added'} {
return {
type: 'array',
action: 'added',
isChanged: true,
fromValue,
toValue: input.value,
annotation: input.annotation,
get items(): ArrayDiff<A>['items'] {
const items: ArrayDiff<A>['items'] = []
for (let i = 0; i < input.length; i++) {
const item = input.at(i)
items.push({
fromIndex: undefined,
toIndex: i,
hasMoved: false,
diff: addedInput(item, undefined, options),
annotation: input.annotationAt(i),
})
}
return replaceProperty(this, 'items', items)
},
}
}