lib0
Version:
isomorphic utility functions
262 lines (251 loc) • 8.73 kB
JavaScript
/**
* A very simple diff algorithm. Slightly adapted to support splitting at different stages (e.g.
* first diff lines, then diff words)
*
* https://bramcohen.livejournal.com/73318.html
*
* @experiemantal This API will likely change.
*/
import * as map from '../map.js'
import * as math from '../math.js'
import * as array from '../array.js'
/**
* Implementation of patience diff. Expects that content is pre-split (e.g. by newline).
*
* A hunk inside a run of equal items is ambiguous (`aaa` → `aa` may delete any of the `a`s). By default
* the common prefix is stripped first, so such a hunk lands at the *rightmost* slot of its run. With
* `leftmost` the first hunk lands at the *leftmost* slot instead: nothing is stripped up front and the
* gap before the first anchor strips its common suffix before its prefix (see {@link lcs}). Unambiguous
* input diffs identically either way.
*
* @param {Array<string>} as
* @param {Array<string>} bs
* @param {boolean} [leftmost]
* @return {Array<{ index: number, remove: Array<string>, insert: Array<string>}>} changeset
*/
export const diff = (as, bs, leftmost = false) => {
if (leftmost) return lcs(as, bs, 0, true)
const {
middleAs,
middleBs,
commonPrefix
} = removeCommonPrefixAndSuffix(as, bs)
return lcs(middleAs, middleBs, commonPrefix, false)
}
/**
* @param {string} a
* @param {string} b
* @param {RegExp|string} regexp
*/
export const diffSplitBy = (a, b, regexp) => {
const isStringSeparator = typeof regexp === 'string'
const separator = /** @type {string} */ (isStringSeparator ? regexp : '')
if (isStringSeparator) {
regexp = new RegExp(regexp, 'g')
}
const as = splitByRegexp(a, /** @type {RegExp} */ (regexp), !isStringSeparator)
const bs = splitByRegexp(b, /** @type {RegExp} */ (regexp), !isStringSeparator)
const changes = diff(as, bs)
let prevSplitIndex = 0
let prevStringIndex = 0
return changes.map(change => {
for (; prevSplitIndex < change.index; prevSplitIndex++) {
prevStringIndex += as[prevSplitIndex].length
}
return {
index: prevStringIndex,
remove: change.remove.join(separator),
insert: change.insert.join(separator)
}
})
}
/**
* Idea: match all "non-word" characters and keep them separate (spaces, comma, exclamation marks, ..).
* Anything that can be classivied as a word (emoji, char, number) will be bundled together. All other tokens are kept
* individually. "hello, world!!!!" => ['hello', ',', ' ', 'world', '!', '!']
*
* @experimental
*/
export const smartSplitRegex = /[^\p{L}\p{N}\p{Extended_Pictographic}\p{Emoji_Modifier}\u200D\uFE0F]/gu // eslint-disable-line no-misleading-character-class
/**
* Sensible default for diffing strings using patience (it's fast though).
*
* Perform different types of patience diff on the content. Diff first by newline, then paragraphs, then by word
* (split by space, brackets, punctuation)
*
* @param {string} a
* @param {string} b
*/
export const diffAuto = (a, b) =>
diffSplitBy(a, b, '\n').map(d =>
diffSplitBy(d.remove, d.insert, smartSplitRegex).map(dd => ({ // old regex: /\. |[a-zA-Z0-9]+|[. ()[\],;{}]/g
insert: dd.insert,
remove: dd.remove,
index: dd.index + d.index
}))
).flat(1)
/**
* Strip the common prefix and suffix. Whichever end is matched first claims the overlap, i.e. decides
* where a hunk inside a run of equal items lands: prefix first (default) ⇒ rightmost slot, `suffixFirst`
* ⇒ leftmost slot.
*
* @param {Array<string>} as
* @param {Array<string>} bs
* @param {boolean} [suffixFirst]
*/
const removeCommonPrefixAndSuffix = (as, bs, suffixFirst = false) => {
const commonLen = math.min(as.length, bs.length)
let commonPrefix = 0
let commonSuffix = 0
if (suffixFirst) {
for (; commonSuffix < commonLen && as[as.length - 1 - commonSuffix] === bs[bs.length - 1 - commonSuffix]; commonSuffix++) { /* nop */ }
}
// match start
for (; commonPrefix < commonLen - commonSuffix && as[commonPrefix] === bs[commonPrefix]; commonPrefix++) { /* nop */ }
// match end
for (; commonSuffix < commonLen - commonPrefix && as[as.length - 1 - commonSuffix] === bs[bs.length - 1 - commonSuffix]; commonSuffix++) { /* nop */ }
const middleAs = as.slice(commonPrefix, as.length - commonSuffix)
const middleBs = bs.slice(commonPrefix, bs.length - commonSuffix)
return {
middleAs, middleBs, commonPrefix, commonSuffix
}
}
/**
* Splits string by regex and returns all strings as an array. The matched parts are also returned.
*
* @param {string} str
* @param {RegExp} regexp global; must not match the empty string (an empty match would never advance)
* @param {boolean} includeSeparator
*/
export const splitByRegexp = (str, regexp, includeSeparator) => {
/**
* @type {Array<string>}
*/
const res = []
let prevIndex = 0
regexp.lastIndex = 0
for (let m = regexp.exec(str); m !== null; m = regexp.exec(str)) {
prevIndex < m.index && res.push(str.slice(prevIndex, m.index))
includeSeparator && res.push(m[0]) // is always non-empty
prevIndex = m.index + m[0].length
}
prevIndex < str.length && res.push(str.slice(prevIndex))
return res
}
/**
* An item may have multiple occurances (not when matching unique entries). It also may have a
* reference to the stack of other items (from as to bs).
*/
class Item {
constructor () {
/**
* @type {Array<number>}
*/
this.indexes = []
/**
* The matching item from the other side
* @type {Item?}
*/
this.match = null
/**
* For patience sort. Reference (index of the stack) to the previous pile.
*
* @type {Item?}
*/
this.ref = null
}
}
/**
* @param {Array<string>} xs
*/
const partition = xs => {
/**
* @type {Map<string,Item>}
*/
const refs = map.create()
xs.forEach((x, index) => {
map.setIfUndefined(refs, x, () => new Item()).indexes.push(index)
})
return refs
}
/**
* Find the longest common subsequence of items using patience sort.
*
* @param {Array<string>} as
* @param {Array<string>} bs
* @param {number} indexAdjust
* @param {boolean} leftmost the gap before the first anchor strips its suffix first (see {@link diff})
*/
const lcs = (as, bs, indexAdjust, leftmost) => {
if (as.length === 0 && bs.length === 0) return []
const aParts = partition(as)
const bParts = partition(bs)
/**
* @type {Array<Array<Item>>} I.e. Array<Pile<Item>>
*/
const piles = []
aParts.forEach((aItem, aKey) => {
// skip if no match or if either item is not unique
if (aItem.indexes.length > 1 || (aItem.match = bParts.get(aKey) || null) == null || aItem.match.indexes.length > 1) return
// pile tops ascend: binary search for the first pile whose top lies beyond this match
const bIndex = aItem.match.indexes[0]
let lo = 0
let hi = piles.length
while (lo < hi) {
const mid = (lo + hi) >> 1
if (bIndex < /** @type {Item} */ (array.last(piles[mid]).match).indexes[0]) {
hi = mid
} else {
lo = mid + 1
}
}
if (lo > 0) aItem.ref = array.last(piles[lo - 1])
if (lo < piles.length) {
piles[lo].push(aItem)
} else {
piles.push([aItem])
}
})
/**
* References to all matched items
*
* @type {Array<Item>}
*/
const matches = []
/**
* @type {Item?}
*/
let currPileItem = piles[piles.length - 1]?.[0]
while (currPileItem != null) {
matches.push(currPileItem)
currPileItem = currPileItem.ref
}
matches.reverse()
// add pseude match (assume the string terminal always matches)
const pseudoA = new Item()
const pseudoB = new Item()
pseudoA.match = pseudoB
pseudoA.indexes.push(as.length)
pseudoB.indexes.push(bs.length)
matches.push(pseudoA)
/**
* @type {Array<{ index: number, remove: Array<string>, insert: Array<string>}>}
*/
const changeset = []
let diffAStart = 0
let diffBStart = 0
for (let i = 0; i < matches.length; i++) {
const m = matches[i]
const delLength = m.indexes[0] - diffAStart
const insLength = /** @type {Item} */ (m.match).indexes[0] - diffBStart
if (delLength !== 0 || insLength !== 0) {
const stripped = removeCommonPrefixAndSuffix(as.slice(diffAStart, diffAStart + delLength), bs.slice(diffBStart, diffBStart + insLength), leftmost && diffAStart === 0)
if (stripped.middleAs.length !== 0 || stripped.middleBs.length !== 0) {
changeset.push({ index: diffAStart + indexAdjust + stripped.commonPrefix, remove: stripped.middleAs, insert: stripped.middleBs })
}
}
diffAStart = m.indexes[0] + 1
diffBStart = /** @type {Item} */ (m.match).indexes[0] + 1
}
return changeset
}