import * as Diff from 'diff'
import type { Change } from 'diff'
import { DIFF_DELETE, DIFF_INSERT, diff_match_patch as DiffMatchPatch } from 'diff-match-patch'
import hljs from './highlight'
import { DiffType } from './types'
import type { DiffLine, DiffStat, SplitLineChange, SplitLineUnchanges, SplitViewerChange, UnifiedLineChange, UnifiedLineUnchanges, UnifiedViewerChange } from './types'
const MODIFIED_START_TAG = ''
const MODIFIED_CLOSE_TAG = ''
const startEntity = MODIFIED_START_TAG.replace('<', '<').replace('>', '>')
const closeEntity = MODIFIED_CLOSE_TAG.replace('<', '<').replace('>', '>')
function lineType(diff: Diff.Change): DiffType {
if (diff === undefined)
return DiffType.EQUAL
if (diff.added)
return DiffType.ADD
if (diff.removed)
return DiffType.DELETE
return DiffType.EQUAL
}
function renderWords(prev?: string, current?: string, diffStyle = 'word'): string {
if (typeof prev === 'undefined')
return current!
if (typeof current === 'undefined')
return prev!
type RenderFunctionType = (prev: string, old: string) => Change[]
const func: RenderFunctionType = diffStyle === 'char' ? Diff.diffChars : Diff.diffWords
return func(prev, current)
.filter(word => lineType(word) !== DiffType.DELETE)
.map(word =>
lineType(word) === DiffType.ADD ? `${MODIFIED_START_TAG}${word.value}${MODIFIED_CLOSE_TAG}` : word.value,
)
.join('')
}
function diffLines(prev: string, current: string) {
const dmp = new DiffMatchPatch()
const a = dmp.diff_linesToChars_(prev, current)
const linePrev = a.chars1
const lineCurrent = a.chars2
const lineArray = a.lineArray
const diffs: any[] = dmp.diff_main(linePrev, lineCurrent, false)
dmp.diff_charsToLines_(diffs, lineArray)
return diffs.map((x) => {
const [type, text] = x
const count = text.replace(/\n$/, '').split('\n').length
const change: Diff.Change = {
count,
value: text,
removed: type === DIFF_DELETE,
added: type === DIFF_INSERT,
}
return change
})
}
function getHighlightCode(language: string, code: string) {
const hasModifiedTags = code.match(new RegExp(`(${MODIFIED_START_TAG}|${MODIFIED_CLOSE_TAG})`, 'g'))
if (!hasModifiedTags)
return hljs.highlight(code, { language }).value
/**
* Explore highlight DOM extracted from pure code and compare the text with the original code to generate the highlight code
*/
let originalCode = code // original code with modified tags
const pureCode = code.replace(new RegExp(`(${MODIFIED_START_TAG}|${MODIFIED_CLOSE_TAG})`, 'g'), '') // Without modified tags
const pureElement = document.createElement('div')
pureElement.innerHTML = hljs.highlight(pureCode, { language }).value // Highlight DOM without modified tags
// Modified span is created per highlight operator and causes it to continue
let innerModifiedTag = false
const diffElements = (node: HTMLElement) => {
node.childNodes.forEach((child) => {
if (child.nodeType === Node.ELEMENT_NODE)
diffElements(child as HTMLElement)
// Compare text nodes and check changed text
if (child.nodeType === Node.TEXT_NODE) {
if (!child.textContent)
return
let oldContent = child.textContent
let newContent = ''
if (innerModifiedTag) {
// If it continues within the modified range
newContent = newContent + MODIFIED_START_TAG
}
while (oldContent.length) {
if (originalCode.startsWith(MODIFIED_START_TAG)) {
// Add modified start tag
originalCode = originalCode.slice(MODIFIED_START_TAG.length)
newContent = newContent + MODIFIED_START_TAG
innerModifiedTag = true // Start modified
continue
}
if (originalCode.startsWith(MODIFIED_CLOSE_TAG)) {
// Add modified close tag
originalCode = originalCode.slice(MODIFIED_CLOSE_TAG.length)
newContent = newContent + MODIFIED_CLOSE_TAG
innerModifiedTag = false // End modified
continue
}
// Add words before modified tag
const hasModifiedTag = originalCode.match(new RegExp(`(${MODIFIED_START_TAG}|${MODIFIED_CLOSE_TAG})`))
const originalCodeDiffLength = (hasModifiedTag && hasModifiedTag.index) ? hasModifiedTag.index : originalCode.length
const nextDiffsLength = Math.min(originalCodeDiffLength, oldContent.length)
newContent = newContent + originalCode.substring(0, nextDiffsLength)
originalCode = originalCode.slice(nextDiffsLength)
oldContent = oldContent.slice(nextDiffsLength)
}
if (innerModifiedTag) {
// If the loop is finished without a modified close, it is still within the modified range.
newContent = newContent + MODIFIED_CLOSE_TAG
}
child.textContent = newContent // put as entity code because change textContent
}
})
}
diffElements(pureElement)
return pureElement.innerHTML
.replace(new RegExp(startEntity, 'g'), '')
.replace(new RegExp(closeEntity, 'g'), '')
}
function calcDiffStat(changes: Change[], ignoreRegex?: RegExp): DiffStat {
const count = (s: string, c: string) => (s.match(new RegExp(c, 'g')) || []).length
const ignoreCount = (lines: string[]) => lines.filter(line => ignoreRegex?.test(line)).length
let additionsNum = 0
let deletionsNum = 0
let ignoreAdditionsNum = 0
let ignoreDeletionsNum = 0
for (const change of changes) {
if (change.added) {
const ignoreNum = ignoreCount(change.value.trim().split('\n'))
additionsNum += count(change.value.trim(), '\n') + 1 - ignoreNum
ignoreAdditionsNum += ignoreNum
continue
}
if (change.removed) {
const ignoreNum = ignoreCount(change.value.trim().split('\n'))
deletionsNum += count(change.value.trim(), '\n') + 1 - ignoreNum
ignoreDeletionsNum += ignoreNum
continue
}
}
return {
additionsNum,
deletionsNum,
ignoreAdditionsNum,
ignoreDeletionsNum,
}
}
export function createSplitDiff(
oldString: string,
newString: string,
language = 'plaintext',
diffStyle = 'word',
forceInlineComparison = false,
context = 10,
ignoreMatchingLines?: string,
): SplitViewerChange {
const newEmptySplitDiff = (): DiffLine => ({ type: DiffType.EMPTY })
const newSplitDiff = (type: DiffType, num: number, code: string): DiffLine => ({ type, num, code })
const changes = diffLines(oldString, newString)
const ignoreRegex = ignoreMatchingLines ? new RegExp(ignoreMatchingLines) : undefined
let delNum = 0
let addNum = 0
let skip = false
const rawChanges: SplitLineChange[] = []
const result: SplitViewerChange = {
changes: rawChanges,
collector: [],
stat: calcDiffStat(changes, ignoreRegex),
}
for (let i = 0; i < changes.length; i++) {
if (skip) {
skip = false
continue
}
const [cur, next] = [changes[i], changes[i + 1]]
const [curType, nextType] = [lineType(cur), lineType(next)]
const curLines = cur.value.replace(/\n$/, '').split('\n')
// 最后一处 diff 的特殊处理
if (next === undefined) {
for (const line of curLines) {
let left: DiffLine = newEmptySplitDiff()
let right: DiffLine = newEmptySplitDiff()
const highlightCode = getHighlightCode(language, line)
if (curType === DiffType.EQUAL) {
delNum++
addNum++
left = newSplitDiff(DiffType.EQUAL, delNum, highlightCode)
right = newSplitDiff(DiffType.EQUAL, addNum, highlightCode)
}
if (curType === DiffType.DELETE) {
delNum++
left = newSplitDiff(DiffType.DELETE, delNum, highlightCode)
right = newEmptySplitDiff()
}
if (curType === DiffType.ADD) {
addNum++
left = newEmptySplitDiff()
right = newSplitDiff(DiffType.ADD, addNum, highlightCode)
}
rawChanges.push({ left, right })
}
break
}
// 正常逻辑
// 处理当前 diff 为相等的情况
if (curType === DiffType.EQUAL) {
for (const line of curLines) {
delNum++
addNum++
const highlightCode = getHighlightCode(language, line)
rawChanges.push({
left: newSplitDiff(DiffType.EQUAL, delNum, highlightCode),
right: newSplitDiff(DiffType.EQUAL, addNum, highlightCode),
})
}
}
const nextLines = next.value.replace(/\n$/, '').split('\n')
// 处理当前 diff 为删除的情况
if (curType === DiffType.DELETE) {
if (nextType === DiffType.EQUAL) {
for (const line of curLines) {
delNum++
rawChanges.push({
left: newSplitDiff(DiffType.DELETE, delNum, getHighlightCode(language, line)),
right: newEmptySplitDiff(),
})
}
}
if (nextType === DiffType.ADD) {
skip = true
const maxCount = Math.max(cur.count!, next.count!)
for (let j = 0; j < maxCount; j++) {
if (j < cur.count!)
delNum++
if (j < next.count!)
addNum++
const [curLine, nextLine] = [curLines[j], nextLines[j]]
const shouldRenderWords = forceInlineComparison || curLines.length === nextLines.length
const leftLine = shouldRenderWords ? renderWords(nextLine, curLine, diffStyle) : curLine
const rightLine = shouldRenderWords ? renderWords(curLine, nextLine, diffStyle) : nextLine
// 忽略匹配的行等价于相等
const leftDiffType = ignoreRegex?.test(curLine) ? DiffType.EQUAL : DiffType.DELETE
const rightDiffType = ignoreRegex?.test(nextLine) ? DiffType.EQUAL : DiffType.ADD
const left
= j < cur.count!
? newSplitDiff(leftDiffType, delNum, getHighlightCode(language, leftLine))
: newEmptySplitDiff()
const right
= j < next.count!
? newSplitDiff(rightDiffType, addNum, getHighlightCode(language, rightLine))
: newEmptySplitDiff()
rawChanges.push({ left, right })
}
}
}
// 处理当前 diff 为添加的情况
if (curType === DiffType.ADD) {
for (const line of curLines) {
addNum++
rawChanges.push({
left: newEmptySplitDiff(),
right: newSplitDiff(DiffType.ADD, addNum, getHighlightCode(language, line)),
})
}
}
}
if (oldString === newString) {
for (let i = 0; i < rawChanges.length; i++)
rawChanges[i].fold = false
return result
}
for (let i = 0; i < rawChanges.length; i++) {
const line = rawChanges[i]
if (line.left.type === DiffType.DELETE || line.right.type === DiffType.ADD) {
const [start, end] = [Math.max(i - context, 0), Math.min(i + context + 1, rawChanges.length)]
for (let j = start; j < end; j++)
rawChanges[j].fold = false
}
if (line.fold === undefined)
line.fold = true
}
const processedChanges: SplitViewerChange['changes'] = []
let unchanges: SplitLineUnchanges['lines'] = [] // collector for unchanged lines.
for (let i = 0; i < rawChanges.length; i++) {
const line = rawChanges[i]
if (line.fold === false) {
if (unchanges.length) {
unchanges[0].hideIndex = result.collector.length
result.collector.push({
lines: unchanges,
fold: true,
})
unchanges = []
}
processedChanges.push(line)
continue
}
line.hide = true
unchanges.push(line)
processedChanges.push(line)
}
if (unchanges.length) {
unchanges[0].hideIndex = result.collector.length
result.collector.push({
lines: unchanges,
fold: true,
})
unchanges = []
}
result.changes = processedChanges
return result
}
export function createUnifiedDiff(
oldString: string,
newString: string,
language = 'plaintext',
diffStyle = 'word',
forceInlineComparison = false,
context = 10,
ignoreMatchingLines?: string,
): UnifiedViewerChange {
const changes = diffLines(oldString, newString)
const ignoreRegex = ignoreMatchingLines ? new RegExp(ignoreMatchingLines) : undefined
let delNum = 0
let addNum = 0
let skip = false
const rawChanges: UnifiedLineChange[] = []
const result: UnifiedViewerChange = {
changes: rawChanges,
collector: [],
stat: calcDiffStat(changes, ignoreRegex),
}
for (let i = 0; i < changes.length; i++) {
if (skip) {
skip = false
continue
}
const [cur, next] = [changes[i], changes[i + 1]]
const [curType, nextType] = [lineType(cur), lineType(next)]
const curLines = cur.value.replace(/\n$/, '').split('\n')
// 最后一行的特殊处理
if (next === undefined) {
for (const line of curLines) {
if (curType === DiffType.EQUAL) {
delNum++
addNum++
}
if (curType === DiffType.DELETE)
delNum++
if (curType === DiffType.ADD)
addNum++
const code = getHighlightCode(language, line)
rawChanges.push({
type: curType,
code,
addNum: curType === DiffType.DELETE ? undefined : addNum,
delNum: curType === DiffType.ADD ? undefined : delNum,
})
}
break
}
// 正常逻辑
// 处理当前 diff 为相等的情况
if (curType === DiffType.EQUAL) {
for (const line of curLines) {
delNum++
addNum++
const code = getHighlightCode(language, line)
rawChanges.push({ type: DiffType.EQUAL, code, delNum, addNum })
}
}
const nextLines = next.value.replace(/\n$/, '').split('\n')
// 处理当前 diff 为删除的情况
if (curType === DiffType.DELETE) {
// 下一处差异为新增,且删除与新增行数相同时,对每行依次 diff
if (nextType === DiffType.ADD && (curLines.length === nextLines.length || forceInlineComparison)) {
for (let j = 0; j < curLines.length; j++) {
const curLine = curLines[j]
const nextLine = nextLines[j]
delNum++
const code = getHighlightCode(language, renderWords(nextLine, curLine, diffStyle))
rawChanges.push({
type: ignoreRegex?.test(curLine) ? DiffType.EQUAL : DiffType.DELETE,
code,
delNum,
})
}
for (let j = 0; j < nextLines.length; j++) {
const curLine = curLines[j]
const nextLine = nextLines[j]
addNum++
const code = getHighlightCode(language, renderWords(curLine, nextLine, diffStyle))
rawChanges.push({
type: ignoreRegex?.test(nextLine) ? DiffType.EQUAL : DiffType.ADD,
code,
addNum,
})
}
skip = true
}
else {
// 否则单独渲染每行
for (const line of curLines) {
delNum++
const code = getHighlightCode(language, line)
rawChanges.push({ type: DiffType.DELETE, code, delNum })
}
}
}
// 处理当前 diff 为添加的情况
if (curType === DiffType.ADD) {
for (const line of curLines) {
addNum++
const code = getHighlightCode(language, line)
rawChanges.push({ type: DiffType.ADD, code, addNum })
}
}
}
for (let i = 0; i < rawChanges.length; i++) {
const line = rawChanges[i]
if (line.type === DiffType.DELETE || line.type === DiffType.ADD) {
const [start, end] = [Math.max(i - context, 0), Math.min(i + context + 1, rawChanges.length)]
for (let j = start; j < end; j++)
rawChanges[j].fold = false
}
if (line.fold === undefined)
line.fold = true
}
if (oldString === newString) {
for (let i = 0; i < rawChanges.length; i++)
rawChanges[i].fold = false
return result
}
const processedChanges = []
let unchanges: UnifiedLineUnchanges['lines'] = [] // collector for unchanged lines.
for (let i = 0; i < rawChanges.length; i++) {
const line = rawChanges[i]
if (line.fold === false) {
if (unchanges.length) {
unchanges[0].hideIndex = result.collector.length
// Keeps "hideIndex" in first element of collector
// for delegating lines to expand.
result.collector.push({
lines: unchanges,
fold: true,
})
unchanges = []
}
processedChanges.push(line)
continue
}
if (line.type === 'equal') {
line.hide = true
unchanges.push(line)
}
processedChanges.push(line)
}
if (unchanges.length) {
unchanges[0].hideIndex = result.collector.length
result.collector.push({
lines: unchanges,
fold: true,
})
unchanges = []
}
result.changes = processedChanges
return result
}