@plugjs/plug
Version:
PlugJS Build System ===================
356 lines (312 loc) • 11 kB
text/typescript
import { fail } from 'node:assert'
import { inspect, isDeepStrictEqual } from 'node:util'
import { assert } from '../asserts'
import { $grn, $red, logOptions } from '../logging'
import type { InspectOptions } from 'node:util'
/* ========================================================================== *
* EXPORTED INTERFACES *
* ========================================================================== */
/** Identifies a single change */
export interface Change {
/** The position in the Left-Hand side where items were deleted */
lhsPos: number;
/** The number of items deleted from the Left-Hand side */
lhsDel: number;
/** The position in the Right-Hand side where items were added */
rhsPos: number;
/** The number of items added to the Right-Hand side */
rhsAdd: number;
}
/* ========================================================================== *
* MYERS IMPLEMENTATION *
* Lifted from https://github.com/wickedest/myers-diff/ (Apache 2.0) *
* ========================================================================== */
function compareLongestCommonSubsequence(lhsCtx: Context, rhsCtx: Context): Change[] {
let lhsStart = 0
let rhsStart = 0
let lhsItem = 0
let rhsItem = 0
const changes: Change[] = []
while (lhsItem < lhsCtx.length || rhsItem < rhsCtx.length) {
if (
(lhsItem < lhsCtx.length) && (!lhsCtx.modified[lhsItem]) &&
(rhsItem < rhsCtx.length) && (!rhsCtx.modified[rhsItem])
) {
// equal lines
lhsItem++
rhsItem++
continue
}
// maybe deleted and/or inserted lines
lhsStart = lhsItem
rhsStart = rhsItem
while ((lhsItem < lhsCtx.length) &&
(rhsItem >= rhsCtx.length || lhsCtx.modified[lhsItem])) {
lhsItem++
}
while ((rhsItem < rhsCtx.length) &&
(lhsItem >= lhsCtx.length || rhsCtx.modified[rhsItem])) {
rhsItem++
}
if ((lhsStart < lhsItem) || (rhsStart < rhsItem)) {
const lat = Math.min(lhsStart, (lhsCtx.length) ? lhsCtx.length - 1 : 0)
const rat = Math.min(rhsStart, (rhsCtx.length) ? rhsCtx.length - 1 : 0)
changes.push({
lhsPos: lat,
lhsDel: lhsItem - lhsStart,
rhsPos: rat,
rhsAdd: rhsItem - rhsStart,
})
}
}
return changes
}
function getShortestMiddleSnake(
lhsCtx: Context, lhsLower: number, lhsUpper: number,
rhsCtx: Context, rhsLower: number, rhsUpper: number,
vectorU: number[], vectorD: number[],
): { x: number, y: number } {
const max = lhsCtx.length + rhsCtx.length + 1
const kdown = lhsLower - rhsLower
const kup = lhsUpper - rhsUpper
const delta = (lhsUpper - lhsLower) - (rhsUpper - rhsLower)
const odd = (delta & 1) != 0
const offsetDown = max - kdown
const offsetUp = max - kup
const maxd = ((lhsUpper - lhsLower + rhsUpper - rhsLower) / 2) + 1
const ret = { x: 0, y: 0 }
let d: number
let k: number
let x: number
let y: number
vectorD[offsetDown + kdown + 1] = lhsLower
vectorU[offsetUp + kup - 1] = lhsUpper
for (d = 0; d <= maxd; ++d) {
for (k = kdown - d; k <= kdown + d; k += 2) {
if (k === kdown - d) {
x = vectorD[offsetDown + k + 1]! // down
} else {
x = vectorD[offsetDown + k - 1]! + 1 // right
if ((k < (kdown + d)) && (vectorD[offsetDown + k + 1]! >= x)) {
x = vectorD[offsetDown + k + 1]! // down
}
}
y = x - k
// find the end of the furthest reaching forward D-path in diagonal k.
while ((x < lhsUpper) &&
(y < rhsUpper) &&
(lhsCtx.codes[x] === rhsCtx.codes[y])
) {
x++; y++
}
vectorD[offsetDown + k]! = x
// overlap ?
if (odd && (kup - d < k) && (k < kup + d)) {
if (vectorU[offsetUp + k]! <= vectorD[offsetDown + k]!) {
ret.x = vectorD[offsetDown + k]!
ret.y = vectorD[offsetDown + k]! - k
return (ret)
}
}
}
// Extend the reverse path.
for (k = kup - d; k <= kup + d; k += 2) {
// find the only or better starting point
if (k === kup + d) {
x = vectorU[offsetUp + k - 1]! // up
} else {
x = vectorU[offsetUp + k + 1]! - 1 // left
if ((k > kup - d) && (vectorU[offsetUp + k - 1]! < x)) {
x = vectorU[offsetUp + k - 1]!
} // up
}
y = x - k
while ((x > lhsLower) &&
(y > rhsLower) &&
(lhsCtx.codes[x - 1] === rhsCtx.codes[y - 1])
) {
// diagonal
x--
y--
}
vectorU[offsetUp + k] = x
// overlap ?
if (!odd && (kdown - d <= k) && (k <= kdown + d)) {
if (vectorU[offsetUp + k]! <= vectorD[offsetDown + k]!) {
ret.x = vectorD[offsetDown + k]!
ret.y = vectorD[offsetDown + k]! - k
return (ret)
}
}
}
}
// coverage ignore next // we should never get here
fail('Unexpected state computing diff')
}
function getLongestCommonSubsequence(
lhsCtx: Context, lhsLower: number, lhsUpper: number,
rhsCtx: Context, rhsLower: number, rhsUpper: number,
vectorU = [], vectorD = [],
): void {
// trim off the matching items at the beginning
while ( (lhsLower < lhsUpper) &&
(rhsLower < rhsUpper) &&
(lhsCtx.codes[lhsLower] === rhsCtx.codes[rhsLower]) ) {
++lhsLower
++rhsLower
}
// trim off the matching items at the end
while ( (lhsLower < lhsUpper) &&
(rhsLower < rhsUpper) &&
(lhsCtx.codes[lhsUpper - 1] === rhsCtx.codes[rhsUpper - 1]) ) {
--lhsUpper
--rhsUpper
}
if (lhsLower === lhsUpper) {
while (rhsLower < rhsUpper) {
rhsCtx.modified[rhsLower++] = true
}
} else if (rhsLower === rhsUpper) {
while (lhsLower < lhsUpper) {
lhsCtx.modified[lhsLower++] = true
}
} else {
const { x, y } = getShortestMiddleSnake(
lhsCtx, lhsLower, lhsUpper,
rhsCtx, rhsLower, rhsUpper,
vectorU, vectorD)
getLongestCommonSubsequence(
lhsCtx, lhsLower, x,
rhsCtx, rhsLower, y,
vectorU, vectorD)
getLongestCommonSubsequence(
lhsCtx, x, lhsUpper,
rhsCtx, y, rhsUpper,
vectorU, vectorD)
}
}
/* ========================================================================== *
* INTERNAL CLASSES *
* ========================================================================== */
/** The context to use while executing the diff */
class Context {
/** Keep a tab on modified items */
readonly modified: (true | undefined)[]
/** A _code table_ for all the items in this context */
readonly codes: readonly number[]
/** The number of item held by this context */
readonly length: number
/** Construct with a _code table_ */
constructor(codes: number[]) {
const length = this.length = codes.length
this.modified = new Array(length)
this.codes = codes
}
}
/** A codec producing _code tables_ */
class Coder<T, I extends Iterable<T> = Iterable<T>> {
private _primitives = new Map<T, number>()
private _objects: [ T, number ][] = []
private _index = 1
private _getObjectCode(item: T): number {
for (const [ object, code ] of this._objects) {
if (isDeepStrictEqual(item, object)) return code
}
const code = ++ this._index
this._objects.push([ item, code ])
return code
}
private _getPrimitiveCode(item: T): number {
let code = this._primitives.get(item)
if (code) return code
code = ++ this._index
this._primitives.set(item, code)
return code
}
/** Get the code table for an {@link Iterable} */
getCodes(iterable: I): number[] {
const codes: number[] = []
for (const item of iterable) {
const type = item === null ? 'null' : typeof item
const code = type === 'object' ?
this._getObjectCode(item) :
this._getPrimitiveCode(item)
codes.push(code)
}
return codes
}
}
/** Shared constant for inspect options */
const inspectOptions: InspectOptions = {
showHidden: false,
depth: 10,
colors: false,
maxArrayLength: 100,
maxStringLength: 250,
breakLength: Infinity,
compact: false,
sorted: true,
getters: true,
}
/* ========================================================================== *
* EXPORTED FUNCTIONS *
* ========================================================================== */
/**
* Compare the _Left-Hand side_ {@link Iterable} to _Right-Hand side_ one,
* producing an array of {@link Change | Changes} identifying the differences.
*/
export function diff<T, I extends Iterable<T> = Iterable<T>>(
lhs: I,
rhs: I,
): Change[] {
assert(lhs !== undefined, 'Left-Hand side undefined')
assert(rhs !== undefined, 'Right-Hand side undefined')
const codec = new Coder<T>()
const lhsCtx = new Context(codec.getCodes(lhs))
const rhsCtx = new Context(codec.getCodes(rhs))
getLongestCommonSubsequence(
lhsCtx, 0, lhsCtx.length,
rhsCtx, 0, rhsCtx.length,
)
return compareLongestCommonSubsequence(lhsCtx, rhsCtx)
}
/** Produce a textual diff between two values. */
export function textDiff(
lhs: any,
rhs: any,
add?: (s: string) => string,
del?: (s: string) => string,
not?: (s: string) => string,
): string {
// Defaults for our "add" "del" and "not" functions (depending on colorization)
const _add = add || (logOptions.colors ? $grn : (s: string): string => `+ ${s}`)
const _del = del || (logOptions.colors ? $red : (s: string): string => `- ${s}`)
const _not = not || (logOptions.colors ? (s: string): string => s : (s: string): string => ` ${s}`)
// We'll need two array of string lines to compare...
let lhsLines: string[]
let rhsLines: string[]
// If _both_ arguments are strings, then just split and compare, otherwise
// we nuse NodeJS' inspect to prep their string version
if ((typeof lhs === 'string') && (typeof rhs === 'string')) {
lhsLines = lhs.split('\n')
rhsLines = rhs.split('\n')
} else {
lhsLines = inspect(lhs, inspectOptions).split('\n')
rhsLines = inspect(rhs, inspectOptions).split('\n')
}
// Calculate the difference between the two arrays of strings
const changes = diff(lhsLines, rhsLines)
if (changes.length === 0) return ''
// Go through changes and highlight
let offset = 0
const result: string[] = []
changes.forEach(({ lhsPos, lhsDel, rhsPos, rhsAdd }) => {
if (offset != lhsPos) result.push(...lhsLines.slice(offset, lhsPos).map(_not))
if (lhsDel) result.push(...lhsLines.slice(lhsPos, lhsPos + lhsDel).map(_del))
if (rhsAdd) result.push(...rhsLines.slice(rhsPos, rhsPos + rhsAdd).map(_add))
offset = lhsPos + lhsDel
})
if (offset < lhsLines.length) result.push(...lhsLines.slice(offset).map(_not))
// Join our results and return
return result.join('\n')
}