///|
/// Converts a range to the "ed" format used by unified diffs.
fn format_range_unified(start : Int, stop : Int) -> String {
// Per the diff spec at http://www.unix.org/single_unix_specification/
let mut beginning = start + 1 // lines start numbering with one
let length = stop - start
if length == 1 {
return "\{beginning}"
}
if length == 0 {
beginning -= 1 // empty ranges begin at line just before the range
}
"\{beginning},\{length}"
}
///|
/// ANSI escape sequences used for `color=true` unified diffs (the defaults of
/// Python's `_colorize` difflib theme).
priv struct DiffTheme {
header : String
hunk : String
added : String
removed : String
context : String
reset : String
}
///|
let color_theme : DiffTheme = {
header: "\u{1b}[1m",
hunk: "\u{1b}[36m",
added: "\u{1b}[32m",
removed: "\u{1b}[31m",
context: "\u{1b}[0m",
reset: "\u{1b}[0m",
}
///|
let no_color_theme : DiffTheme = {
header: "",
hunk: "",
added: "",
removed: "",
context: "",
reset: "",
}
///|
/// Compares two sequences of lines and returns the delta as a unified diff.
///
/// The lines are generated lazily, like Python's generator: nothing is
/// computed until the first line is requested, and the returned iterator can
/// be consumed only once.
///
/// - `n` (default 3): the number of context lines; must not be negative
/// (it panics otherwise).
/// - `lineterm` (default `"\n"`): appended to the control lines (`---`,
/// `+++`, `@@`). Set it to `""` for inputs without trailing newlines.
/// - `fromfile`, `tofile`, `fromfiledate`, `tofiledate`: header fields.
/// - `color`: emit ANSI colors similar to `git diff --color`. Unlike Python,
/// the environment (`NO_COLOR`, tty detection, ...) is not consulted.
/// - `autojunk`: the automatic junk heuristic of [SequenceMatcher].
///
/// # Example
/// ```mbt check
/// test {
/// let diff = @difflib.unified_diff(
/// ["one", "two", "three", "four"],
/// ["zero", "one", "tree", "four"],
/// fromfile="Original",
/// tofile="Current",
/// fromfiledate="2005-01-26 23:30:50",
/// tofiledate="2010-04-02 10:20:52",
/// lineterm="",
/// )
/// inspect(
/// diff.join("\n"),
/// content=(
/// #|--- Original 2005-01-26 23:30:50
/// #|+++ Current 2010-04-02 10:20:52
/// #|@@ -1,4 +1,4 @@
/// #|+zero
/// #| one
/// #|-two
/// #|-three
/// #|+tree
/// #| four
/// ),
/// )
/// }
/// ```
pub fn unified_diff(
a : Array[String],
b : Array[String],
fromfile? : String = "",
tofile? : String = "",
fromfiledate? : String = "",
tofiledate? : String = "",
n? : Int = 3,
lineterm? : String = "\n",
autojunk? : Bool = true,
color? : Bool = false,
) -> Iter[String] {
check_context_size(n)
let t = if color { color_theme } else { no_color_theme }
deferred(() => {
let groups = SequenceMatcher::new(a~, b~, autojunk~).get_grouped_opcodes(n~)
groups
.mapi((index, group) => {
let header : Iter[String] = if index == 0 {
let fromdate = if fromfiledate != "" { "\t\{fromfiledate}" } else { "" }
let todate = if tofiledate != "" { "\t\{tofiledate}" } else { "" }
[|
"\{t.header}--- \{fromfile}\{fromdate}\{lineterm}\{t.reset}",
"\{t.header}+++ \{tofile}\{todate}\{lineterm}\{t.reset}",
|]
} else {
Iter::empty()
}
let first = group[0]
let last = group[group.length() - 1]
let file1_range = format_range_unified(first.i1, last.i2)
let file2_range = format_range_unified(first.j1, last.j2)
let removed = (op : Opcode) => {
[|
for line in a[op.i1:op.i2] => "\{t.removed}-\{line}\{t.reset}"
|]
}
let added = (op : Opcode) => {
[|
for line in b[op.j1:op.j2] => "\{t.added}+\{line}\{t.reset}"
|]
}
header +
[|"\{t.hunk}@@ -\{file1_range} +\{file2_range} @@\{lineterm}\{t.reset}"|] +
group
.iter()
.flat_map(op => {
match op.tag {
Equal =>
[|
for line in a[op.i1:op.i2] => "\{t.context} \{line}\{t.reset}"
|]
Delete => removed(op)
Insert => added(op)
Replace => removed(op) + added(op)
}
})
})
.flatten()
})
}
///|
fn check_context_size(n : Int) -> Unit {
if n < 0 {
abort("the number of context lines must be >= 0, got \{n}")
}
}
///|
/// Converts a range to the "ed" format used by context diffs.
fn format_range_context(start : Int, stop : Int) -> String {
// Per the diff spec at http://www.unix.org/single_unix_specification/
let mut beginning = start + 1 // lines start numbering with one
let length = stop - start
if length == 0 {
beginning -= 1 // empty ranges begin at line just before the range
}
if length <= 1 {
return "\{beginning}"
}
"\{beginning},\{beginning + length - 1}"
}
///|
fn context_prefix(tag : Tag) -> String {
match tag {
Insert => "+ "
Delete => "- "
Replace => "! "
Equal => " "
}
}
///|
/// Compares two sequences of lines and returns the delta as a context diff.
///
/// The parameters have the same meaning as for [unified_diff], and the lines
/// are likewise generated lazily.
///
/// # Example
/// ```mbt check
/// test {
/// let diff = @difflib.context_diff(
/// ["one\n", "two\n", "three\n", "four\n"],
/// ["zero\n", "one\n", "tree\n", "four\n"],
/// fromfile="Original",
/// tofile="Current",
/// )
/// inspect(
/// diff.join(""),
/// content=(
/// #|*** Original
/// #|--- Current
/// #|***************
/// #|*** 1,4 ****
/// #| one
/// #|! two
/// #|! three
/// #| four
/// #|--- 1,4 ----
/// #|+ zero
/// #| one
/// #|! tree
/// #| four
/// #|
/// ),
/// )
/// }
/// ```
pub fn context_diff(
a : Array[String],
b : Array[String],
fromfile? : String = "",
tofile? : String = "",
fromfiledate? : String = "",
tofiledate? : String = "",
n? : Int = 3,
lineterm? : String = "\n",
autojunk? : Bool = true,
) -> Iter[String] {
check_context_size(n)
deferred(() => {
let groups = SequenceMatcher::new(a~, b~, autojunk~).get_grouped_opcodes(n~)
groups
.mapi((index, group) => {
let header : Iter[String] = if index == 0 {
let fromdate = if fromfiledate != "" { "\t\{fromfiledate}" } else { "" }
let todate = if tofiledate != "" { "\t\{tofiledate}" } else { "" }
[|
"*** \{fromfile}\{fromdate}\{lineterm}",
"--- \{tofile}\{todate}\{lineterm}",
|]
} else {
Iter::empty()
}
let first = group[0]
let last = group[group.length() - 1]
let file1_range = format_range_context(first.i1, last.i2)
let file2_range = format_range_context(first.j1, last.j2)
// the "from" side lists no lines when the group only inserts, and the
// "to" side lists none when it only deletes
let from_lines : Iter[String] = if group
.iter()
.any(op => op.tag is (Replace | Delete)) {
group
.iter()
.filter(op => !(op.tag is Insert))
.flat_map(op => {
[|
for line in a[op.i1:op.i2] => context_prefix(op.tag) + line
|]
})
} else {
Iter::empty()
}
let to_lines : Iter[String] = if group
.iter()
.any(op => op.tag is (Replace | Insert)) {
group
.iter()
.filter(op => !(op.tag is Delete))
.flat_map(op => {
[|
for line in b[op.j1:op.j2] => context_prefix(op.tag) + line
|]
})
} else {
Iter::empty()
}
header +
[|"***************" + lineterm, "*** \{file1_range} ****\{lineterm}"|] +
from_lines +
[|"--- \{file2_range} ----\{lineterm}"|] +
to_lines
})
.flatten()
})
}
///|
/// The output format used by [diff_bytes].
pub(all) enum DiffFormat {
/// [unified_diff]
Unified
/// [context_diff]
Context
} derive(Eq, Debug)
///|
/// Decodes bytes losslessly into a string, one character per byte.
fn bytes_to_latin1(bs : BytesView) -> String {
let buf = StringBuilder()
for byte in bs {
buf.write_char(byte.to_int().unsafe_to_char())
}
buf.to_string()
}
///|
/// Inverse of [bytes_to_latin1].
fn latin1_to_bytes(s : String) -> Bytes {
let arr : Array[Byte] = []
for c in s {
arr.push(c.to_int().to_byte())
}
Bytes::from_array(arr)
}
///|
/// Compares `a` and `b`, two sequences of lines represented as bytes rather
/// than strings, producing a unified or context diff (see `format`) whose
/// lines are also bytes.
///
/// Inputs are losslessly converted to strings (one character per byte) so
/// that files with unknown or inconsistent encodings can be compared, and the
/// output is encoded back to bytes. This mirrors Python's `diff_bytes`;
/// `autojunk` is forwarded to the underlying diff function.
///
/// # Example
/// ```mbt check
/// test {
/// let diff = @difflib.diff_bytes(
/// Unified,
/// [b"\xa3odz is a city in Poland."],
/// [b"\xc5\x81odz is a city in Poland."],
/// fromfile=b"\xb3odz.txt",
/// tofile=b"\xc5\x82odz.txt",
/// lineterm=b"",
/// )
/// let expected : Array[Bytes] = [
/// b"--- \xb3odz.txt", b"+++ \xc5\x82odz.txt", b"@@ -1 +1 @@", b"-\xa3odz is a city in Poland.",
/// b"+\xc5\x81odz is a city in Poland.",
/// ]
/// assert_eq(diff.to_array(), expected)
/// }
/// ```
pub fn diff_bytes(
format : DiffFormat,
a : Array[Bytes],
b : Array[Bytes],
fromfile? : Bytes = b"",
tofile? : Bytes = b"",
fromfiledate? : Bytes = b"",
tofiledate? : Bytes = b"",
n? : Int = 3,
lineterm? : Bytes = b"\n",
autojunk? : Bool = true,
) -> Iter[Bytes] {
check_context_size(n)
deferred(() => {
let a = a.map(line => bytes_to_latin1(line))
let b = b.map(line => bytes_to_latin1(line))
let fromfile = bytes_to_latin1(fromfile)
let tofile = bytes_to_latin1(tofile)
let fromfiledate = bytes_to_latin1(fromfiledate)
let tofiledate = bytes_to_latin1(tofiledate)
let lineterm = bytes_to_latin1(lineterm)
let lines = match format {
Unified =>
unified_diff(
a,
b,
fromfile~,
tofile~,
fromfiledate~,
tofiledate~,
n~,
lineterm~,
autojunk~,
)
Context =>
context_diff(
a,
b,
fromfile~,
tofile~,
fromfiledate~,
tofiledate~,
n~,
lineterm~,
autojunk~,
)
}
lines.map(latin1_to_bytes)
})
}