/**
* The `TextDiff` module computes edit scripts for arrays and text, applies and
* inverts them, and formats line differences as unified diffs.
*
* Scripts are computed with Myers' O(ND) algorithm in linear space, after
* trimming common prefixes and suffixes. Scripts are minimal unless the
* optional `maxEdits` search cap is exceeded. Word diffs, semantic cleanup
* and collaborative editing protocols are out of scope.
*
* @stability experimental
* @since 4.0.6
*/
import * as Data from "./Data.ts"
import * as Equal from "./Equal.ts"
import { hasProperty } from "./Predicate.ts"
import * as Result from "./Result.ts"
const TextDiffErrorTypeId = "~effect/TextDiff/TextDiffError"
/**
* One run of an edit script.
*
* **Details**
*
* `Equal` runs carry items kept from `before`, `Delete` runs carry items
* removed from `before`, and `Insert` runs carry items added from `after`.
* Scripts produced by this module never contain empty runs or two adjacent
* runs with the same tag, and within each change the `Delete` run comes before
* the `Insert` run.
*
* @see {@link diff} to compute an edit script
* @stability experimental
* @category models
* @since 4.0.6
*/
export type Edit = EditEqual | EditInsert | EditDelete
/**
* Run of items present in both sequences.
*
* @stability experimental
* @category models
* @since 4.0.6
*/
export interface EditEqual {
readonly _tag: "Equal"
readonly items: ReadonlyArray
}
/**
* Run of items present only in the `after` sequence.
*
* @stability experimental
* @category models
* @since 4.0.6
*/
export interface EditInsert {
readonly _tag: "Insert"
readonly items: ReadonlyArray
}
/**
* Run of items present only in the `before` sequence.
*
* @stability experimental
* @category models
* @since 4.0.6
*/
export interface EditDelete {
readonly _tag: "Delete"
readonly items: ReadonlyArray
}
/**
* Error returned when an edit script does not fit the input it is applied to.
*
* **Example** (Rejecting a script made for other input)
*
* ```ts import.meta.vitest
* import { Result, TextDiff } from "effect"
*
* const edits = TextDiff.diff([1, 2], [1])
*
* Result.mapError(TextDiff.apply([1], edits), TextDiff.isTextDiffError) // => Result.fail(true)
* ```
*
* @see {@link apply} and {@link applyText} for the operations that return it
* @see {@link isTextDiffError} for narrowing unknown values
* @stability experimental
* @category errors
* @since 4.0.6
*/
export class TextDiffError extends Data.TaggedError("TextDiffError")<{
readonly message: string
}> {
readonly [TextDiffErrorTypeId]: typeof TextDiffErrorTypeId = TextDiffErrorTypeId
}
/**
* Checks whether a value is a `TextDiffError`.
*
* @stability experimental
* @category guards
* @since 4.0.6
*/
export const isTextDiffError = (u: unknown): u is TextDiffError => hasProperty(u, TextDiffErrorTypeId)
/**
* Creates a function that computes a minimal edit script between two arrays
* using a custom equivalence.
*
* **When to use**
*
* Use when items should match by key or by a structural comparison other than
* `Equal.equals`.
*
* **Details**
*
* Unless the `maxEdits` cap is exceeded, the script has the
* fewest possible inserted plus deleted items. `Equal` runs carry the items
* from `before`, not necessarily the exact values from `after`.
*
* **Gotchas**
*
* Without `maxEdits`, work grows with the product of the input size and the
* number of differences, which is quadratic for unrelated inputs. Set
* `maxEdits` for large or untrusted inputs.
*
* **Example** (Matching items by id)
*
* ```ts import.meta.vitest
* import { TextDiff } from "effect"
*
* const diffById = TextDiff.diffWith<{ readonly id: number }>((a, b) => a.id === b.id)
*
* diffById([{ id: 1 }, { id: 2 }], [{ id: 2 }]) // => [{ _tag: "Delete", items: [{ id: 1 }] }, { _tag: "Equal", items: [{ id: 2 }] }]
* ```
*
* @see {@link diff} for the `Equal.equals` variant
* @stability experimental
* @category constructors
* @since 4.0.6
*/
export const diffWith = (isEquivalent: (self: A, that: A) => boolean) =>
(
before: ReadonlyArray,
after: ReadonlyArray,
options?: {
/**
* Largest number of inserted plus deleted items searched for in each
* changed region. Common prefixes, suffixes and matching runs are free.
* When a region needs more edits, the search splits it at the furthest
* point it reached and continues on the rest, and a part that still cannot
* be matched becomes a deletion followed by an insertion. The script stays
* valid, and is minimal whenever the whole diff needs at most `maxEdits`
* edits. For a finite cap, work is O((N + M) * (maxEdits + 1)).
*
* @default Infinity
*/
readonly maxEdits?: number | undefined
}
): Array> => editScript(before, after, (i, j) => isEquivalent(before[i], after[j]), options?.maxEdits)
/**
* Computes a minimal edit script between two arrays, comparing items with
* `Equal.equals`.
*
* **Details**
*
* Unless the `maxEdits` cap is exceeded, the script has the
* fewest possible inserted plus deleted items. The choice is deterministic,
* and each change lists its deletions before its insertions.
*
* **Gotchas**
*
* Without `maxEdits`, work is quadratic for unrelated inputs. Set `maxEdits`
* when diffing large or untrusted arrays.
*
* **Example** (Diffing two arrays)
*
* ```ts import.meta.vitest
* import { TextDiff } from "effect"
*
* TextDiff.diff([1, 2, 3, 4], [1, 3, 4, 5]) // => [{ _tag: "Equal", items: [1] }, { _tag: "Delete", items: [2] }, { _tag: "Equal", items: [3, 4] }, { _tag: "Insert", items: [5] }]
* ```
*
* @see {@link diffWith} to compare items with a custom equivalence
* @see {@link apply} to replay a script
* @stability experimental
* @category constructors
* @since 4.0.6
*/
export const diff = (
before: ReadonlyArray,
after: ReadonlyArray,
options?: {
/**
* Largest number of inserted plus deleted items searched for in each
* changed region. Common prefixes, suffixes and matching runs are free.
* When a region needs more edits, the search splits it at the furthest
* point it reached and continues on the rest, and a part that still cannot
* be matched becomes a deletion followed by an insertion. The script stays
* valid, and is minimal whenever the whole diff needs at most `maxEdits`
* edits. For a finite cap, work is O((N + M) * (maxEdits + 1)).
*
* @default Infinity
*/
readonly maxEdits?: number | undefined
}
): Array> => diffWith(Equal.equals)(before, after, options)
/**
* Computes a minimal line-level edit script between two strings.
*
* **Details**
*
* Each line keeps its `\n` terminator, so `\n` versus `\r\n` endings and a
* missing final newline are reported as changes. Joining the items of a run
* gives the exact text it covers.
*
* **Gotchas**
*
* Without `maxEdits`, work is quadratic for unrelated inputs. Set `maxEdits`
* when diffing large or untrusted text.
*
* **Example** (Diffing lines)
*
* ```ts import.meta.vitest
* import { TextDiff } from "effect"
*
* TextDiff.lines("a\nb\n", "a\nc\n") // => [{ _tag: "Equal", items: ["a\n"] }, { _tag: "Delete", items: ["b\n"] }, { _tag: "Insert", items: ["c\n"] }]
* ```
*
* @see {@link codePoints} for a code point diff
* @see {@link unified} to format a line diff
* @stability experimental
* @category constructors
* @since 4.0.6
*/
export const lines = (before: string, after: string, options?: {
/**
* Largest number of inserted plus deleted items searched for in each
* changed region. Common prefixes, suffixes and matching runs are free.
* When a region needs more edits, the search splits it at the furthest
* point it reached and continues on the rest, and a part that still cannot
* be matched becomes a deletion followed by an insertion. The script stays
* valid, and is minimal whenever the whole diff needs at most `maxEdits`
* edits. For a finite cap, work is O((N + M) * (maxEdits + 1)).
*
* @default Infinity
*/
readonly maxEdits?: number | undefined
}): Array> => diffStrings(splitLines(before), splitLines(after), options?.maxEdits)
/**
* Computes a minimal code point edit script between two strings.
*
* **Details**
*
* Items are Unicode code points, so a surrogate pair is never split between
* runs. Grapheme clusters, such as an emoji with modifiers or a letter with
* combining marks, can still be split. The UTF-16 length of a run is the sum
* of its items' lengths.
*
* **Gotchas**
*
* Without `maxEdits`, work is quadratic for unrelated inputs. Set `maxEdits`
* when diffing large or untrusted text.
*
* **Example** (Diffing code points)
*
* ```ts import.meta.vitest
* import { TextDiff } from "effect"
*
* TextDiff.codePoints("cat", "cut") // => [{ _tag: "Equal", items: ["c"] }, { _tag: "Delete", items: ["a"] }, { _tag: "Insert", items: ["u"] }, { _tag: "Equal", items: ["t"] }]
* ```
*
* @see {@link lines} for a line diff
* @see {@link applyText} to replay a script on a string
* @stability experimental
* @category constructors
* @since 4.0.6
*/
export const codePoints = (before: string, after: string, options?: {
/**
* Largest number of inserted plus deleted items searched for in each
* changed region. Common prefixes, suffixes and matching runs are free.
* When a region needs more edits, the search splits it at the furthest
* point it reached and continues on the rest, and a part that still cannot
* be matched becomes a deletion followed by an insertion. The script stays
* valid, and is minimal whenever the whole diff needs at most `maxEdits`
* edits. For a finite cap, work is O((N + M) * (maxEdits + 1)).
*
* @default Infinity
*/
readonly maxEdits?: number | undefined
}): Array> => diffStrings(Array.from(before), Array.from(after), options?.maxEdits)
/**
* Applies an edit script to an array.
*
* **Details**
*
* `Equal` and `Delete` runs consume items from `before` by count, `Equal` runs
* copy them to the result, and `Insert` runs add their items. Run contents are
* not compared with `before`, since under a custom equivalence `Equal` items
* can differ from the input.
*
* **Gotchas**
*
* Fails with a {@link TextDiffError} when the runs do not consume exactly
* `before.length` items.
*
* **Example** (Replaying a script)
*
* ```ts import.meta.vitest
* import { Result, TextDiff } from "effect"
*
* const edits = TextDiff.diff([1, 2, 3], [1, 3, 4])
*
* TextDiff.apply([1, 2, 3], edits) // => Result.succeed([1, 3, 4])
* ```
*
* @see {@link applyText} for the string variant
* @see {@link invert} to apply a script in reverse
* @stability experimental
* @category combinators
* @since 4.0.6
*/
export const apply = (
before: ReadonlyArray,
edits: ReadonlyArray>
): Result.Result, TextDiffError> => {
const out: Array = []
let index = 0
for (const edit of edits) {
if (edit._tag === "Insert") {
for (const item of edit.items) out.push(item)
continue
}
const end = index + edit.items.length
if (end > before.length) return fail("Edit script consumes more items than the input contains")
if (edit._tag === "Equal") {
for (let i = index; i < end; i++) out.push(before[i])
}
index = end
}
if (index !== before.length) return fail("Edit script consumes fewer items than the input contains")
return Result.succeed(out)
}
/**
* Applies a text edit script to a string.
*
* **Details**
*
* Works with scripts from both {@link lines} and {@link codePoints}. The
* joined items of each `Equal` and `Delete` run must match `before` at the
* current position, so a script made for different text is rejected rather
* than applied.
*
* **Gotchas**
*
* Fails with a {@link TextDiffError} when a run does not match `before`, or
* when the runs do not cover all of it.
*
* **Example** (Replaying a code point diff)
*
* ```ts import.meta.vitest
* import { Result, TextDiff } from "effect"
*
* const edits = TextDiff.codePoints("cat", "cut")
*
* TextDiff.applyText("cat", edits) // => Result.succeed("cut")
* ```
*
* @see {@link apply} for the array variant
* @stability experimental
* @category combinators
* @since 4.0.6
*/
export const applyText = (
before: string,
edits: ReadonlyArray>
): Result.Result => {
let out = ""
let index = 0
for (const edit of edits) {
const text = edit.items.join("")
if (edit._tag === "Insert") {
out += text
continue
}
if (!before.startsWith(text, index)) return fail(`Edit script does not match the input at index ${index}`)
if (edit._tag === "Equal") out += text
index += text.length
}
if (index !== before.length) return fail("Edit script covers less text than the input contains")
return Result.succeed(out)
}
/**
* Inverts an edit script, so it turns `after` back into `before`.
*
* **Details**
*
* Swaps the items of each change's `Delete` and `Insert` runs, keeping
* deletions before insertions. `Equal` runs are unchanged.
*
* **Example** (Undoing a change)
*
* ```ts import.meta.vitest
* import { Result, TextDiff } from "effect"
*
* const edits = TextDiff.diff([1, 2], [1, 3])
*
* TextDiff.apply([1, 3], TextDiff.invert(edits)) // => Result.succeed([1, 2])
* ```
*
* @stability experimental
* @category combinators
* @since 4.0.6
*/
export const invert = (edits: ReadonlyArray>): Array> => {
const out: Array> = []
let deleted: ReadonlyArray = []
let inserted: ReadonlyArray = []
const flush = () => {
if (inserted.length > 0) out.push({ _tag: "Delete", items: inserted })
if (deleted.length > 0) out.push({ _tag: "Insert", items: deleted })
deleted = []
inserted = []
}
for (const edit of edits) {
if (edit._tag === "Delete") {
deleted = deleted.concat(edit.items)
} else if (edit._tag === "Insert") {
inserted = inserted.concat(edit.items)
} else {
flush()
out.push(edit)
}
}
flush()
return out
}
/**
* Formats a line edit script as a unified diff.
*
* **Details**
*
* The script must come from {@link lines}, or otherwise hold one line per
* item. The output follows `diff -u`: hunks whose context would touch or
* overlap are merged, and a final line without a newline is followed by
* `\ No newline at end of file`. When `fromFile` or `toFile` is set, both
* header lines are emitted and an unset name falls back to the other. A script
* without changes produces an empty string.
*
* **Gotchas**
*
* Among equally small diffs, hunks can differ from those of GNU diff or Git,
* which apply extra heuristics.
*
* **Example** (Formatting a unified diff)
*
* ```ts import.meta.vitest
* import { TextDiff } from "effect"
*
* TextDiff.unified(TextDiff.lines("a\nb\n", "a\nc\n")) // => "@@ -1,2 +1,2 @@\n a\n-b\n+c\n"
* ```
*
* @see {@link lines} to compute a line diff
* @stability experimental
* @category formatting
* @since 4.0.6
*/
export const unified = (edits: ReadonlyArray>, options?: {
/**
* Number of unchanged lines shown around each change.
*
* @default 3
*/
readonly context?: number | undefined
/**
* Name shown in the `---` header line.
*/
readonly fromFile?: string | undefined
/**
* Name shown in the `+++` header line.
*/
readonly toFile?: string | undefined
}): string => {
const a: Array = []
const b: Array = []
// Group consecutive deletions and insertions between Equal runs.
const changes: Array = []
let change: Change | undefined
for (const edit of edits) {
if (edit._tag === "Equal") {
change = undefined
for (const line of edit.items) {
a.push(line)
b.push(line)
}
continue
}
if (edit.items.length === 0) continue
if (change === undefined) {
change = { a0: a.length, a1: a.length, b0: b.length, b1: b.length }
changes.push(change)
}
if (edit._tag === "Delete") {
for (const line of edit.items) a.push(line)
change.a1 = a.length
} else {
for (const line of edit.items) b.push(line)
change.b1 = b.length
}
}
if (changes.length === 0) return ""
const context = Math.max(0, Math.floor(options?.context ?? 3))
let out = ""
if (options?.fromFile !== undefined || options?.toFile !== undefined) {
out += `--- ${options.fromFile ?? options.toFile}\n+++ ${options.toFile ?? options.fromFile}\n`
}
let start = 0
while (start < changes.length) {
let end = start + 1
while (end < changes.length && changes[end].a0 - changes[end - 1].a1 <= 2 * context) end++
const first = changes[start]
const last = changes[end - 1]
const a0 = Math.max(0, first.a0 - context)
const b0 = first.b0 - (first.a0 - a0)
const a1 = Math.min(a.length, last.a1 + context)
const b1 = last.b1 + (a1 - last.a1)
out += `@@ -${range(a0, a1 - a0)} +${range(b0, b1 - b0)} @@\n`
let line = a0
for (let c = start; c < end; c++) {
const change = changes[c]
for (; line < change.a0; line++) out += formatLine(" ", a[line])
for (let k = change.a0; k < change.a1; k++) out += formatLine("-", a[k])
for (let k = change.b0; k < change.b1; k++) out += formatLine("+", b[k])
line = change.a1
}
for (; line < a1; line++) out += formatLine(" ", a[line])
start = end
}
return out
}
// -----------------------------------------------------------------------------
// internal
// -----------------------------------------------------------------------------
const fail = (message: string) => Result.fail(new TextDiffError({ message }))
interface Block {
readonly a: number
readonly b: number
readonly length: number
}
interface Change {
a0: number
a1: number
b0: number
b1: number
}
interface Region {
readonly aLo: number
readonly aHi: number
readonly bLo: number
readonly bHi: number
}
// A diagonal run from (x, y) to (u, v)
interface Snake {
readonly x: number
readonly y: number
readonly u: number
readonly v: number
}
const splitLines = (text: string): Array => text.match(/[^\n]*\n|[^\n]+$/g) ?? []
const range = (start: number, count: number): string =>
count === 0 ? `${start},0` : count === 1 ? `${start + 1}` : `${start + 1},${count}`
const formatLine = (prefix: string, line: string): string =>
line.endsWith("\n") ? prefix + line : `${prefix}${line}\n\\ No newline at end of file\n`
// Interns the tokens so the search compares integers instead of strings
const diffStrings = (a: ReadonlyArray, b: ReadonlyArray, maxEdits?: number) => {
const ids = new Map()
const intern = (token: string) => {
let id = ids.get(token)
if (id === undefined) {
id = ids.size
ids.set(token, id)
}
return id
}
const ai = Int32Array.from(a, intern)
const bi = Int32Array.from(b, intern)
return editScript(a, b, (i, j) => ai[i] === bi[j], maxEdits)
}
const editScript = (
a: ReadonlyArray,
b: ReadonlyArray,
equals: (i: number, j: number) => boolean,
maxEdits?: number
): Array> => {
const blocks = matchingBlocks(a.length, b.length, equals, maxEdits)
// A final empty block flushes the trailing changes
blocks.push({ a: a.length, b: b.length, length: 0 })
const out: Array> = []
let i = 0
let j = 0
for (const block of blocks) {
if (block.a > i) out.push({ _tag: "Delete", items: a.slice(i, block.a) })
if (block.b > j) out.push({ _tag: "Insert", items: b.slice(j, block.b) })
if (block.length > 0) out.push({ _tag: "Equal", items: a.slice(block.a, block.a + block.length) })
i = block.a + block.length
j = block.b + block.length
}
return out
}
/**
* Returns ordered, merged matching runs for sequences of lengths n and m,
* using the index comparator. Linear-space Myers search splits each region
* at the middle snake of a minimal path, or at the furthest point reached
* when the search cap is exceeded.
*/
const matchingBlocks = (
n: number,
m: number,
equals: (i: number, j: number) => boolean,
maxEdits = Infinity
): Array => {
const blocks: Array = []
const push = (a: number, b: number, length: number) => {
if (length === 0) return
const last = blocks[blocks.length - 1]
if (last !== undefined && last.a + last.length === a && last.b + last.length === b) {
blocks[blocks.length - 1] = { a: last.a, b: last.b, length: last.length + length }
} else {
blocks.push({ a, b, length })
}
}
// Forward and reverse furthest-reaching x per diagonal, offset by n + m + 1
const offset = n + m + 1
const vf = new Int32Array(2 * offset + 1)
const vb = new Int32Array(2 * offset + 1)
// The searches meet after half the edits of a region, so a region needing at
// most `maxEdits` edits is solved within `maxD` steps of each search
const maxD = Math.ceil(Math.max(0, maxEdits) / 2)
// Returns the middle snake of a minimal path in absolute coordinates, or an
// empty snake at the furthest point reached when the region needs more than
// `maxEdits` edits, or undefined when no point inside the region was reached
const findSplit = (aLo: number, aHi: number, bLo: number, bHi: number): Snake | undefined => {
const N = aHi - aLo
const M = bHi - bLo
const delta = N - M
const odd = (delta & 1) === 1
const limit = Math.min((N + M + 1) >> 1, maxD)
vf[offset + 1] = 0
vb[offset + 1] = 0
for (let d = 0; d <= limit; d++) {
for (let k = -d; k <= d; k += 2) {
let x = k === -d || (k !== d && vf[offset + k - 1] < vf[offset + k + 1])
? vf[offset + k + 1]
: vf[offset + k - 1] + 1
let y = x - k
const x0 = x
const y0 = y
while (x < N && y < M && equals(aLo + x, bLo + y)) {
x++
y++
}
vf[offset + k] = x
if (odd && k >= delta - (d - 1) && k <= delta + (d - 1) && x + vb[offset + delta - k] >= N) {
return { x: aLo + x0, y: bLo + y0, u: aLo + x, v: bLo + y }
}
}
for (let k = -d; k <= d; k += 2) {
let x = k === -d || (k !== d && vb[offset + k - 1] < vb[offset + k + 1])
? vb[offset + k + 1]
: vb[offset + k - 1] + 1
let y = x - k
const x0 = x
const y0 = y
while (x < N && y < M && equals(aHi - x - 1, bHi - y - 1)) {
x++
y++
}
vb[offset + k] = x
if (!odd && k >= delta - d && k <= delta + d && x + vf[offset + delta - k] >= N) {
return { x: aHi - x, y: bHi - y, u: aHi - x0, v: bHi - y0 }
}
}
}
// Over the cap: split at the reached point that covers the most items. It
// must lie strictly inside the region so that both parts shrink
let best = 0
let x = 0
let y = 0
for (let k = -limit; k <= limit; k += 2) {
const xf = vf[offset + k]
const yf = xf - k
if (xf <= N && yf >= 0 && yf <= M && xf + yf > best) {
best = xf + yf
x = aLo + xf
y = bLo + yf
}
const xb = vb[offset + k]
const yb = xb - k
if (xb <= N && yb >= 0 && yb <= M && xb + yb > best) {
best = xb + yb
x = aHi - xb
y = bHi - yb
}
}
return best > 0 && best < N + M ? { x, y, u: x, v: y } : undefined
}
// Regions are processed in order from an explicit stack, since splits over
// the cap can nest deeper than the call stack allows
const stack: Array = [{ aLo: 0, aHi: n, bLo: 0, bHi: m }]
while (stack.length > 0) {
const task = stack.pop()!
if ("length" in task) {
push(task.a, task.b, task.length)
continue
}
let { aHi, aLo, bHi, bLo } = task
let prefix = 0
while (aLo + prefix < aHi && bLo + prefix < bHi && equals(aLo + prefix, bLo + prefix)) prefix++
push(aLo, bLo, prefix)
aLo += prefix
bLo += prefix
let suffix = 0
while (aLo < aHi - suffix && bLo < bHi - suffix && equals(aHi - suffix - 1, bHi - suffix - 1)) suffix++
aHi -= suffix
bHi -= suffix
stack.push({ a: aHi, b: bHi, length: suffix })
// With a common prefix and suffix removed, a non-empty side on both ends
// means at least two edits, so both halves below are strictly smaller
const snake = aLo < aHi && bLo < bHi ? findSplit(aLo, aHi, bLo, bHi) : undefined
if (snake !== undefined) {
stack.push(
{ aLo: snake.u, aHi, bLo: snake.v, bHi },
{ a: snake.x, b: snake.y, length: snake.u - snake.x },
{ aLo, aHi: snake.x, bLo, bHi: snake.y }
)
}
}
return blocks
}