// std/diff - line diff, unified diff, and colored diff renderers.
import { ansi_dim, ansi_error, ansi_info, ansi_success } from "std/ansi"
import { render_table } from "std/table"
type DiffOptions = {
path?: string,
from_label?: string,
to_label?: string,
context?: int,
color?: bool,
color_mode?: string,
}
fn __diff_lines_of(text) {
return split(text ?? "", "\n")
}
fn __diff_op(kind, line, old_line, new_line) {
return {kind: kind, line: line, old_line: old_line, new_line: new_line}
}
fn __diff_key(i, j) -> string {
return to_string(i) + "," + to_string(j)
}
fn __diff_matrix(a, b) {
let n = len(a)
let m = len(b)
var dp = {}
var i = n - 1
while i >= 0 {
var j = m - 1
while j >= 0 {
if a[i] == b[j] {
let next = dp[__diff_key(i + 1, j + 1)] ?? 0
dp = dp + {[__diff_key(i, j)]: next + 1}
} else {
let down = dp[__diff_key(i + 1, j)] ?? 0
let right = dp[__diff_key(i, j + 1)] ?? 0
dp = dp + {[__diff_key(i, j)]: max(down, right)}
}
j -= 1
}
i -= 1
}
return dp
}
fn __diff_ops(a, b) {
let dp = __diff_matrix(a, b)
var ops = []
var i = 0
var j = 0
while i < len(a) && j < len(b) {
if a[i] == b[j] {
ops = ops.push(__diff_op("equal", a[i], i + 1, j + 1))
i += 1
j += 1
} else {
let delete_score = dp[__diff_key(i + 1, j)] ?? 0
let insert_score = dp[__diff_key(i, j + 1)] ?? 0
if delete_score >= insert_score {
ops = ops.push(__diff_op("delete", a[i], i + 1, j + 1))
i += 1
} else {
ops = ops.push(__diff_op("insert", b[j], i + 1, j + 1))
j += 1
}
}
}
while i < len(a) {
ops = ops.push(__diff_op("delete", a[i], i + 1, j + 1))
i += 1
}
while j < len(b) {
ops = ops.push(__diff_op("insert", b[j], i + 1, j + 1))
j += 1
}
return ops
}
fn __diff_stats(ops) {
var insertions = 0
var deletions = 0
for op in ops {
if op.kind == "insert" {
insertions += 1
} else if op.kind == "delete" {
deletions += 1
}
}
return {insertions: insertions, deletions: deletions, changed: insertions > 0 || deletions > 0}
}
fn __diff_compact_ops(ops, context) {
if context == nil || context < 0 {
return ops
}
var keep = {}
var idx = 0
while idx < len(ops) {
if ops[idx].kind != "equal" {
let start = max(0, idx - context)
let end = min(len(ops), idx + context + 1)
var cursor = start
while cursor < end {
keep = keep + {[to_string(cursor)]: true}
cursor += 1
}
}
idx += 1
}
var out = []
var skipped = false
idx = 0
while idx < len(ops) {
if keep[to_string(idx)] {
if skipped {
out = out.push({kind: "skip"})
skipped = false
}
out = out.push(ops[idx])
} else {
skipped = true
}
idx += 1
}
return out
}
fn __diff_label(prefix, path, fallback) {
if path == nil || trim(path) == "" {
return fallback
}
return prefix + path
}
fn __diff_color_line(line, options) {
if !(options?.color ?? false) {
return line
}
let color_opts = {mode: options?.color_mode ?? "always"}
if starts_with(line, "@@") {
return ansi_info(line, color_opts)
}
if starts_with(line, "+++") || starts_with(line, "---") || starts_with(line, "diff ")
|| starts_with(line, "index ") {
return ansi_dim(line, color_opts)
}
if starts_with(line, "+") {
return ansi_success(line, color_opts)
}
if starts_with(line, "-") {
return ansi_error(line, color_opts)
}
return line
}
/** Return a structured line diff `{changed, insertions, deletions, ops}`. */
pub fn diff_lines(before: string, after: string) -> dict {
let old_lines = __diff_lines_of(before)
let new_lines = __diff_lines_of(after)
let ops = __diff_ops(old_lines, new_lines)
return __diff_stats(ops)
+ {ops: ops, old_lines: len(old_lines), new_lines: len(new_lines)}
}
/** Render a unified diff for two text values. */
pub fn unified_diff(before: string, after: string, options: DiffOptions = {}) -> string {
let opts = options ?? {}
let path = opts.path
let old_label = opts.from_label ?? __diff_label("a/", path, "before")
let new_label = opts.to_label ?? __diff_label("b/", path, "after")
let result = diff_lines(before, after)
if !result.changed {
return ""
}
let context = opts.context
let ops = __diff_compact_ops(result.ops, context)
var lines = [
"--- " + old_label,
"+++ " + new_label,
"@@ -1," + to_string(result.old_lines) + " +1," + to_string(result.new_lines) + " @@",
]
for op in ops {
if op.kind == "skip" {
lines = lines.push("...")
} else if op.kind == "equal" {
lines = lines.push(" " + op.line)
} else if op.kind == "delete" {
lines = lines.push("-" + op.line)
} else if op.kind == "insert" {
lines = lines.push("+" + op.line)
}
}
return join(lines.map({ line -> __diff_color_line(line, opts) }), "\n")
}
/** Apply ANSI coloring to an existing unified diff. */
pub fn colorize_diff(diff_text: string, options: DiffOptions = {}) -> string {
let base = options ?? {}
let opts = base + {color: true}
return join(split(diff_text ?? "", "\n").map({ line -> __diff_color_line(line, opts) }), "\n")
}
/** Return a compact stat dict for two text values. */
pub fn diff_summary(before: string, after: string) -> dict {
let result = diff_lines(before, after)
return {
changed: result.changed,
insertions: result.insertions,
deletions: result.deletions,
old_lines: result.old_lines,
new_lines: result.new_lines,
}
}
/** Render per-file diff stats from entries with `{path, before, after}` or stat fields. */
pub fn render_diff_stat(entries: list, options = {}) -> string {
var rows = []
for entry in entries ?? [] {
let stat = if entry?.before != nil || entry?.after != nil {
diff_summary(entry?.before ?? "", entry?.after ?? "")
} else {
entry
}
rows = rows
.push(
{
path: entry?.path ?? entry?.file ?? "(text)",
insertions: stat?.insertions ?? 0,
deletions: stat?.deletions ?? 0,
},
)
}
return render_table(
rows,
{
columns: [
{key: "path", header: "Path"},
{key: "insertions", header: "+", align: "right"},
{key: "deletions", header: "-", align: "right"},
],
format: options?.format ?? "plain",
empty: options?.empty ?? "",
},
)
}